copy Semantics Surprises

The built-in copy(dst, src) copies elements from a source slice into a destination slice and returns the number of elements actually copied, which is min(len(dst), len(src))not len(src), and not cap(dst). The single most common copy bug is allocating the destination with make([]T, 0, n) and then calling copy(dst, src): nothing is copied, because len(dst) is 0. copy writes into the length of the destination, never its capacity. A second surprise: copy accepts a string as its source when the destination is a []byte, copying the string’s bytes. Both behaviors are spec-defined and deliberate, but both routinely violate a naive mental model of “copy duplicates the source.”

Mental Model

Think of copy as filling existing slots, not creating them. The destination slice already has len(dst) real, addressable element slots; copy overwrites however many of them it can from the source, stopping when either slice runs out. It never grows the destination, never allocates, never touches slots beyond len(dst) even if cap(dst) is larger. If you want “duplicate this slice into a fresh one,” you must first create the slots — with make([]T, len(src)) — and only then copy.

The governing equation is:

n = copy(dst, src) where n = min(len(dst), len(src))

Read min literally: it is the minimum of two lengths. len(dst) == 0 forces n == 0 regardless of how big src is. len(src) == 0 forces n == 0 regardless of dst. Capacity appears nowhere in this equation.

flowchart LR
    subgraph SRC["src — len 5"]
        s0["a"] --- s1["b"] --- s2["c"] --- s3["d"] --- s4["e"]
    end
    subgraph DST["dst — len 3, cap 10"]
        d0["·"] --- d1["·"] --- d2["·"]
    end
    s0 -->|copied| d0
    s1 -->|copied| d1
    s2 -->|copied| d2
    s3 -.->|"NOT copied — dst length exhausted"| X1[" "]
    s4 -.->|"NOT copied"| X2[" "]
    NOTE["copy returns 3 = min(len dst, len src)<br/>cap 10 is irrelevant"]

Diagram: copying a length-5 source into a length-3 destination copies exactly 3 elements and returns 3. The destination’s capacity of 10 is never used — copy writes only within len(dst). The insight: it is the length of the destination, not its capacity, that bounds the copy.

Mechanical Walk-through

The spec definition

The Go spec’s “Appending to and copying slices” section defines copy precisely: it copies slice elements from a source src to a destination dst and returns the number of elements copied, and “the number of elements copied is the minimum of len(src) and len(dst)” (Go spec). Source and destination may overlap. As a special case, “if the destination’s core type is []byte, copy also accepts a source argument with core type bytestring” — i.e. a string (Go spec). The builtin package documents the same signature: func copy(dst, src []Type) int (builtin docs).

What the runtime does

copy lowers to runtime.memmove (or a typed variant) — a single, optimized block memory move of n * sizeof(T) bytes, where n is the computed minimum length (src/runtime/slice.go). Because it is memmove, not memcpy, overlapping source and destination are handled correctly: copying a slice onto a shifted view of itself does the right thing. The cost is O(n) in the number of bytes moved, with no per-element loop overhead and no allocation.

The crucial mechanical fact: copy reads len(dst) and len(src) from the slice headers, takes the minimum, and moves exactly that many elements. It never reads cap. A destination with len 0, cap 1000 presents len 0 to copy, so copy moves zero bytes — the 1000-element backing array is simply not its concern.

Why make([]T, 0, n) then copy silently does nothing

make([]T, 0, n) creates a slice header with len = 0 and cap = n. copy(dst, src) computes min(0, len(src)) = 0 and moves zero bytes. The destination still has len 0; the data was never copied; copy’s return value is 0, but the return value is usually ignored, so the bug is silent. The destination then gets used as if it were populated — full of zero-length emptiness.

Code Examples

The classic zero-length destination bug

src := []int{1, 2, 3, 4, 5}
 
dst := make([]int, 0, len(src))   // len 0, cap 5
n := copy(dst, src)               // n == 0 — nothing copied!
fmt.Println(n, dst)               // 0 []
 
// CORRECT — destination must have LENGTH:
dst2 := make([]int, len(src))     // len 5, cap 5
n2 := copy(dst2, src)             // n2 == 5
fmt.Println(n2, dst2)             // 5 [1 2 3 4 5]

make([]int, 0, len(src)) reads as “an empty slice with room for 5” — and that room is capacity, not length. copy ignores capacity, sees len 0, copies nothing. The fix is make([]int, len(src)): the second argument to make for a slice is the length, and that is what copy fills. This is the number-one copy mistake; always check the return value or len(dst) after copy in code review.

Short destination silently truncates

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)             // len 3
n := copy(dst, src)               // n == 3
fmt.Println(n, dst)               // 3 [1 2 3]  — 4 and 5 dropped, no error

copy does not panic, does not warn, does not grow dst. It copies min(3, 5) = 3 and returns 3. If the caller assumed the whole source was copied, elements 4 and 5 are silently lost. The return value is the only signal — ignoring it hides the truncation.

append for “grow”, copy for “fill”

// Want: a fresh, independent duplicate of src.
src := []int{1, 2, 3}
 
// Idiom A — copy into a pre-sized destination:
dupA := make([]int, len(src))
copy(dupA, src)
 
// Idiom B — append onto nil (Go grows it for you):
dupB := append([]int(nil), src...)
 
// Idiom C — the slices package (Go 1.21+):
dupC := slices.Clone(src)

copy never resizes; append does. Mixing them up — expecting copy to grow the destination, or expecting append to overwrite in place — is a frequent confusion. For “I want a duplicate,” prefer slices.Clone (slices package, Go 1.21+), which is exactly make + copy with the lengths handled for you. Note that append([]int(nil), src...) of an empty src yields nil, not an empty non-nil slice — a corner case where slices.Clone and make+copy differ.

Copying a string into a byte slice

s := "héllo"                      // 6 bytes (é is 2 bytes in UTF-8)
buf := make([]byte, len(s))       // len 6
n := copy(buf, s)                 // string source — special case
fmt.Println(n, buf)               // 6 [104 195 169 108 108 111]

When the destination is []byte, copy accepts a string directly and copies its raw UTF-8 bytes — no per-rune iteration, no conversion allocation. len(s) here is the byte length (6), not the rune count (5), because len on a string counts bytes (see String Internals and Rune and Byte Semantics). This is the one place copy takes a non-slice source. It is also the idiom for getting a mutable copy of a string’s bytes, since strings themselves are immutable.

Overlapping copy — left-shift in place

s := []int{1, 2, 3, 4, 5}
copy(s, s[1:])                    // shift everything left by one
fmt.Println(s)                    // [2 3 4 5 5]  — last element duplicated
s = s[:len(s)-1]
fmt.Println(s)                    // [2 3 4 5]

copy(s, s[1:]) overlaps the destination s and the source s[1:]. Because copy is memmove, the overlap is handled correctly: n = min(5, 4) = 4 elements are copied, shifting 2,3,4,5 left by one position. The last slot still holds the stale 5; the trailing re-slice drops it. This is the standard “delete element 0” trick and relies entirely on copy’s overlap safety.

Failure Modes and Common Misunderstandings

make([]T, 0, n) + copy copies nothing. The number-one bug. copy fills length, not capacity. Use make([]T, n).

Ignoring the return value hides truncation. If len(dst) < len(src), copy silently drops the tail. The return value is the only evidence. Code that ignores it can lose data without any error.

copy makes a deep copy.” It does not. copy does a shallow, element-wise memory move. If T is a pointer, a slice, a map, or a struct containing those, the copy and the original share the pointed-to data. Mutating dst[i].SomeSlice mutates src[i].SomeSlice too. copy duplicates the top-level elements only.

Confusing copy with append. append may allocate a new backing array and grow the slice; copy never allocates and never grows. Expecting copy(dst, src) to extend dst to len(src) is wrong — dst keeps its original length.

copy between different element types. copy(dst, src) requires dst and src to have identical element types (the sole exception being []bytestring). copy([]int{...}, []int32{...}) does not compile. There is no element conversion.

Assuming copy zero-fills extra destination slots. If len(dst) > len(src), copy writes only the first len(src) slots; the remaining slots keep whatever they already held (zero values if freshly maked, stale data otherwise). copy does not clear the tail.

copy of nil slices. copy(nil, src) and copy(dst, nil) are both legal and both copy zero elements — len(nil) == 0. No panic. This means a forgotten make produces silent no-ops rather than a crash.

Alternatives and When to Choose Them

  • copy(dst, src) — use when the destination already exists with the right length and you want to overwrite its contents in place, or for in-place shifts (overlap-safe). No allocation. The right tool for buffer reuse, ring buffers, and element deletion.
  • slices.Clone(src) (Go 1.21+) — use when you want an independent duplicate of a slice. It is make + copy done correctly, with no chance of the zero-length-destination bug. Preferred over hand-rolled make+copy for clarity. Note it returns nil for a nil/empty input.
  • append([]T(nil), src...) — an older duplicate idiom; functionally slices.Clone minus the helper. Slightly less obvious in intent.
  • append(dst, src...) — use when you want to grow dst by the contents of src, allocating a bigger backing array if needed. The opposite of copy’s fixed-length fill.
  • bytes.Clone / maps.Clone — the byte-slice and map equivalents of slices.Clone.
  • s = s[:len(s)-1] after copy(s, s[i+1:]) — the canonical in-place element-deletion pattern, or just slices.Delete(s, i, i+1) (Go 1.21+), which encapsulates it.

Production Notes

copy’s “fill the length, return the minimum” contract is the foundation of zero-allocation buffer reuse — the pattern behind bufio, io.CopyBuffer, and most high-throughput Go I/O code: a fixed-size []byte is maked once at the right length, and copy (or Read, which has the same length-bounded semantics) refills it each iteration without touching the allocator. Getting the length right once at make time is what makes the rest correct.

The zero-length-destination bug is common enough that it is a standard item on Go code-review checklists and is one of the first things linters and reviewers look for around any make+copy pair. The introduction of slices.Clone in Go 1.21 (slices package) was partly motivated by removing the foot-gun: a single, named, correct function for “duplicate this slice” so that fewer hand-written make([]T, 0, n) + copy pairs exist to get wrong. When reviewing legacy code, treat every copy whose return value is discarded as suspect until you have confirmed len(dst) >= len(src) by construction.

See Also