1. The copy signature: what it copies, how much, and what it returns
Honestly, beginners have a very reasonable question: “Why do I need copy if I have append? I can just build everything with append.” And yes — sometimes you can. But as soon as you want to control slice contents predictably (especially inside a single backing array), append starts to feel like a friend helping you move… by rearranging your boxes at random.
copy is good because it does one simple thing: it overwrites elements. No magic, no length changes, no “suddenly new memory was allocated”. This makes copy an ideal tool when you need to “move furniture around in a room” rather than “build a new house”.
By the way, copy is one of Go’s basic built-in functions (along with append), introduced as a major simplification for working with slices.
Let’s start with the main formula of the day. In Go there is a built-in function:
copy(dst, src)
It copies elements from src to dst. It’s important to remember the argument order exactly like this: to where (dst), then from where (src). If you swap them, the code will compile, but the logic will be “Friday-release style”.
How many elements are copied? Exactly as many as possible without going out of bounds:
min(len(dst), len(src))
And copy returns the number of elements actually copied. This is not a decorative number: it’s often needed when you do shifts and compute the new “logical length” of the result.
A very important detail: copy does not change len for either dst or src. It simply overwrites elements. If after copying you need to “make it shorter/longer” — that’s a separate step via reslice (for example, s = s[:...]) or append.
A mini example to understand “how many it copies”:
package main
import "fmt"
func main() {
dst := make([]int, 2) // len=2
src := []int{10, 20, 30} // len=3
n := copy(dst, src)
fmt.Println("n =", n) // n = 2
fmt.Println(dst) // [10 20]
}
3. Pattern #1: a real slice copy
When you write:
b := a
you are not copying the elements — you are copying the slice header (pointer+len+cap). That means a and b continue to point to the same backing array. Change b[0] — and a[0] changes too. Sometimes this is convenient, but often it’s the source of “paranormal bugs”.
To get an independent copy of the data (with a new backing array), use the combination make + copy:
package main
import "fmt"
func main() {
tasks := []string{"buy milk", "read book", "write code"}
snapshot := make([]string, len(tasks)) // new backing array
copy(snapshot, tasks)
snapshot[0] = "buy coffee"
fmt.Println(tasks) // [buy milk read book write code]
fmt.Println(snapshot) // [buy coffee read book write code]
}
Here snapshot has a different backing array, so changes don’t “leak” back.
This pattern is so fundamental that it’s worth thinking of it as “take a snapshot of state”. In real code you will often clone a slice before sorting, filtering, or returning it outward (for example, from a function), so you don’t allow the caller to accidentally corrupt your data.
4. Nuance: len matters more than cap for copy
Here’s a typical trap. A beginner thinks: “I made make([]int, 0, 10), so I have lots of space; now copy will copy everything there.” But copy looks at len, not cap. If len(dst) == 0, there is nowhere to copy to.
A bad (but illustrative) example:
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, 0, 10) // len=0, cap=10
n := copy(dst, src)
fmt.Println("n =", n) // n = 0
fmt.Println(dst) // []
}
For the copy to happen, dst must have length. The most straightforward option is to make dst the required length:
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, len(src)) // len=3
copy(dst, src)
fmt.Println(dst) // [1 2 3]
}
And if you intentionally want to use already allocated capacity (cap), you can extend the slice length via reslice, but only if cap allows it:
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, 0, 10)
dst = dst[:len(src)] // now len=3
copy(dst, src)
fmt.Println(dst) // [1 2 3]
}
5. copy within a single slice: overlaps and shifts
Now the most interesting part: copy is useful not only for “make a clone”, but also for “move a chunk around”.
The key idea: in Go, copy works correctly even when dst and src overlap. This is exactly what you need for shifts inside the same backing array: we take the tail and pull it left or push it right.
This approach often appears in standard library code and in real streaming-processing examples: a chunk of data is copied, then the slice is “shifted” via reslice. For example, an article about a GIF decoder shows a characteristic technique: take a buffer slice, copy it into p, and then “cut off” the already used part via reslice.
For us, this means: copy is the “engine”, while changing len is the “gearbox”, and mixing them up is pretty easy.
Where copy lives in these patterns, and where length changes live
To avoid confusion, it helps to keep a simple mental model in mind: copy always performs an “overwrite”, and length changes only happen as a separate step.
flowchart TD
A[You have a slice s] --> B{What do you need to do?}
B -->|"Make an independent copy"| C["make a new slice with the required len"]
C --> D["copy(dst, src)"]
D --> E[Done: a different backing array]
B -->|Shift left| F["copy(s[i:], s[j:])"]
F --> G[len is unchanged for now]
G --> H[Later: reslice/deletion logic]
B -->|"Shift right"| I[First increase len: append]
I --> J["copy(s[i+k:], s[i:])"]
J --> K[Later: write the inserted values]
This diagram intentionally emphasizes: after a shift, you often end up in an “intermediate state”, and that’s normal. The sequence of steps is what matters.
6. Shift left: pull the tail forward
Imagine we have a task list, and we want to remove an element from the middle. We’ll formalize full removal later, but right now we’ll learn the key step: close the gap by shifting the tail to the left.
A left shift looks like this:
copy(s[i:], s[i+1:])
That is, we copy elements starting from i+1 into position i.
Mini example:
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4}
copy(s[1:], s[2:])
fmt.Println(s) // [1 3 4 4]
}
Notice the result: the “gap” between 2 and 3 is closed, but a duplicate appears at the end. This is normal and expected because len did not change. We simply overwrote elements.
If you’re thinking “why didn’t Go shorten the slice automatically?” — because copy has no right to change the length at all: it’s a low-level overwrite operation. Shortening is a separate step, and it’s deliberate program logic.
7. Shift right: push the tail apart
A right shift is needed when you want to insert an element into the middle. The important point here: you can’t shift right if you don’t have room in the length. So the first step is to increase len. The simplest way is append (yes, we’re still friends with append — we just use it carefully).
The “big picture” insertion steps look like this:
- increase the length by 1
- shift the tail to the right via copy
- write the value into the newly freed slot
An example where we “free up space” at position i:
package main
import "fmt"
func main() {
s := []int{10, 20, 30}
i := 1
s = append(s, 0) // len+1, space created
copy(s[i+1:], s[i:]) // tail right by 1
s[i] = 99 // insert
fmt.Println(s) // [10 99 20 30]
}
Here copy overlaps the ranges: source and destination partially coincide, and that’s fine — copy can handle it.
8. A mini task manager on []string
Now we’ll write a small piece of code that feels like part of an application rather than separate abstract examples. Let’s say we have a minimal task list []string, and we want to be able to:
- take a “snapshot” of the list (to safely show it to the user without risking that someone changes the original),
- shift a block of tasks (for now without full delete/insert, only the shifting mechanics).
Let’s start with two small functions: cloneStrings and shiftLeftByOne.
package main
import "fmt"
// cloneStrings makes an independent copy of a slice.
func cloneStrings(src []string) []string {
dst := make([]string, len(src))
copy(dst, src)
return dst
}
// shiftLeftByOne shifts elements left by 1 starting at position i.
// This is a "semi-finished" operation: we do not change len, we just overwrite.
func shiftLeftByOne(s []string, i int) {
if i < 0 || i >= len(s)-1 {
return
}
copy(s[i:], s[i+1:])
}
func main() {
tasks := []string{"buy milk", "read book", "write code"}
view := cloneStrings(tasks)
view[0] = "buy coffee"
fmt.Println("tasks:", tasks) // tasks: [buy milk read book write code]
fmt.Println("view: ", view) // view: [buy coffee read book write code]
shiftLeftByOne(tasks, 1)
fmt.Println("shift:", tasks) // shift: [buy milk write code write code]
}
There are two important “practical” takeaways here.
First, cloneStrings is real protection against aliasing: you return a copy to the outside, and external code can’t accidentally corrupt your internal state.
Second, shiftLeftByOne demonstrates the honest mechanics: we performed the shift, but we still have an “extra tail”. Until we change len, that tail is visible. In the next lectures today we’ll learn how to turn such semi-finished steps into a complete operation (delete/insert), but the foundation is already here.
9. Common mistakes when working with copy
Mistake #1: expecting copy to change len.
Usually it looks like this: you did copy(s[i:], s[i+1:]), printed s, and were surprised: “Why did the last element get duplicated?” Because copy only overwrites. Length is changed by append or reslice. If you keep the rule “copy doesn’t touch length” in mind, half the confusion disappears.
Mistake #2: swapping dst and src.
Visually copy(a, b) looks symmetric, but the arguments have different meaning. If you swap them, you can corrupt the source data instead of filling a buffer. A helpful mnemonic: “first is where to, second is from where”. If you’re unsure, temporarily rename your variables to dst and src right in the code — not poetic literature, but it works.
Mistake #3: trying to copy into a slice with len=0, hoping cap will help.
make([]T, 0, 10) gives you space in memory, but does not give you elements by length. For copy, that means “there is nowhere to copy to”. The fix is simple: either create dst with the needed length right away, or carefully extend dst via dst = dst[:n] (and only if cap(dst) >= n).
Mistake #4: shifting right without increasing length.
If you try to “push the tail apart” with copy(s[i+1:], s[i:]) but you didn’t increase len(s), you’ll either get the wrong result or (more often) nothing useful at all. Insertion logic always starts by creating space in length — usually via append.
Mistake #5: forgetting that subslices can share the same backing array.
Sometimes you do head := s[:k], then do shifts in s, and suddenly head “changes by itself”. It doesn’t: it’s looking at the same underlying array as s. If you need an independent head, you’ll have to clone it via make + copy. And yes, this is exactly the case where an extra allocation is not a sin — it’s the price of predictability.
GO TO FULL VERSION