The Copy Built-in Copies Min(len(src), len(dst)) Elements
You are reading a stream of network packets into a fixed-size buffer. The incoming data is larger than your buffer. You write a for loop to copy bytes one by one, but you forget to check the bounds. The program panics. Or worse, it silently writes past the end of the array and corrupts adjacent memory. Go prevents this by giving you a built-in copy function that handles the math for you. It looks at both slices, picks the smaller length, moves exactly that many elements, and stops. No overflow. No panic.
How slices and memory movement work
Slices in Go are not arrays. They are lightweight headers that point to an underlying array. Each header stores three values: a pointer to the first element, a length, and a capacity. When you pass a slice to a function, you are passing a copy of that header. The underlying data stays in one place. When you copy between slices, you are moving data from one backing array to another.
The copy built-in treats this like pouring water between two glasses. If the destination glass is smaller, it fills up and stops. The function calculates min(len(dst), len(src)) internally, performs the memory transfer, and returns the exact number of elements moved. You never have to write the boundary check yourself.
Slices are views. copy respects the bounds.
Minimal example
Here is the simplest way to use it. The function works on any slice type, not just bytes.
package main
import "fmt"
func main() {
// Destination has room for three bytes
dst := make([]byte, 3)
// Source holds five bytes
src := []byte("hello")
// copy figures out the smaller length automatically
n := copy(dst, src)
fmt.Println(n) // prints: 3
fmt.Println(string(dst)) // prints: hel
}
What happens at runtime
When the compiler sees copy(dst, src), it does not generate a generic loop. It reads the slice headers, compares the lengths, and emits a highly optimized memory move. Under the hood, the Go runtime uses a memmove-style operation that handles overlapping memory regions safely. If dst and src point to the same underlying array and their ranges overlap, copy still produces the correct result. A naive for loop would overwrite data before reading it, but the built-in knows how to shift memory in the right direction.
The function returns an int representing the number of elements actually transferred. This return value exists because truncation is common and often intentional. If you pass a five-element source into a three-element destination, copy returns 3. The source remains untouched. The destination gets overwritten up to its length. Everything else stays exactly as it was.
Check the return value. Silent truncation is a quiet bug.
Realistic buffering pattern
In production code, you often deal with fixed-size buffers for performance. Allocating new slices inside a hot loop triggers garbage collection. Pre-allocating a buffer and reusing it is the standard pattern. copy fits perfectly here.
package main
import (
"fmt"
"net"
)
// extractHeader pulls the first 16 bytes of a raw packet
// into a stack-allocated buffer for zero-allocation parsing.
func extractHeader(rawPacket []byte) []byte {
// Stack array avoids heap allocation on every call
var headerBuf [16]byte
// copy safely handles packets smaller than 16 bytes
n := copy(headerBuf[:], rawPacket)
// Return only the valid portion of the buffer
return headerBuf[:n]
}
func main() {
packet := []byte("short")
header := extractHeader(packet)
fmt.Printf("copied %d bytes: %q\n", len(header), header)
}
The headerBuf[:] syntax converts the array into a slice so copy can accept it. The function returns a slice pointing to the stack-allocated array, but only up to n. This pattern appears everywhere in networking, file parsing, and protocol handling. You get zero allocations, safe bounds, and exact control over how much data moves.
Match the tool to the data flow. Don't overcomplicate memory movement.
Strings as sources
You can copy from a string directly into a byte slice. Strings in Go are immutable sequences of bytes. The compiler allows copy(dst []byte, src string) because it knows the string data lives in read-only memory and will not change. This saves you from converting the string to a slice first.
package main
import "fmt"
func main() {
// Pre-allocate buffer for the string length
buf := make([]byte, 5)
msg := "hello world"
// copy accepts a string as the source argument
n := copy(buf, msg)
fmt.Println(n) // prints: 5
fmt.Println(string(buf)) // prints: hello
}
The compiler rejects the reverse operation. You cannot copy into a string because strings are immutable. If you try, you get cannot use msg (variable of type string) as []byte value in argument to copy. Convert the string to a slice first, or use a byte slice from the start.
Pitfalls and compiler errors
The built-in is forgiving, but it enforces type safety. Both arguments must be slices of the exact same element type. If you try to mix types, the compiler rejects the program with cannot use src (variable of type []int) as []byte value in argument to copy. Go does not perform implicit type conversions on slices.
The most common runtime mistake involves confusing length and capacity. copy uses len, not cap. If you create a slice with extra capacity but only set the length to three, copy will only overwrite those three slots. The extra capacity sits unused. If you want to utilize the full capacity, you must reslice the destination first: copy(dst[:cap(dst)], src). Forgetting to reslice leads to truncated data that looks like a bug until you inspect the slice header.
Another trap is assuming copy appends data. It overwrites. If dst already contains data, the first n elements get replaced. The remaining elements stay exactly as they were. If you need to grow a slice, use append. If you need to replace a chunk, use copy.
The compiler also catches mismatched types at compile time, but it will not warn you if you ignore the return value. The Go community convention is to capture the return value when truncation could mask a logic error. Write n := copy(dst, src) and check n if your downstream logic depends on a full transfer. If you truly do not care about the count, discard it with the underscore: _, _ = copy(dst, src). Using _ signals to readers that you considered the return value and chose to drop it.
Trust the bounds. Verify the count.
When to use copy versus alternatives
Memory movement in Go follows a few clear patterns. Pick the right tool based on what you actually need to happen.
Use copy when you need to transfer data between two existing slices without allocating new memory. Use a manual for loop when you must transform, filter, or validate elements during the transfer. Use slice assignment (dst = src) when you want both variables to reference the same underlying array and avoid copying entirely. Use append when you need to grow a slice dynamically rather than overwriting fixed slots. Use make with a larger capacity when you are building a buffer from scratch and want to reserve space for future growth.