Go Arrays, Slices, Properly: From Basics to Production Pitfalls
Slices are everywhere in Go.
You use them when working with API payloads, database results, file contents, queues, buffers, and batch operations. Because of that, slices show up very early in real Go code.
But to understand slices properly, it helps to start one step earlier: arrays.
Slices are built on top of arrays. That relationship explains some of the most important slice behaviors in Go, including shared backing storage, len vs cap, and why append can sometimes reuse existing memory and sometimes allocate a new array.
So in this article, we will build the topic in order:
- first arrays
- then slices
- then the production pitfalls and subtle behaviors that are easy to miss
The goal here is not just to learn the syntax.
The goal is to build a mental model that still holds up when the code gets real.
1. Arrays: the foundation slices are built on
An array in Go is a fixed-size sequence of elements.
var a [3]int
fmt.Println(a) // [0 0 0]
The important detail is that the size is part of the type.
That means these are different types:
var a [3]int
var b [4]int
[3]int and [4]int are not compatible just because both hold integers. In Go, the length is part of the array’s identity.
That makes arrays predictable and simple, but also less flexible for everyday application code.
Arrays are still worth learning because slices are built on top of them. If that part is not clear, slice behavior starts to feel magical when it is actually quite mechanical.
Array literals
You can initialize arrays with literals:
nums := [5]int{10, 20, 30, 40, 50}
fmt.Println(nums) // [10 20 30 40 50]
You can also let Go count the size for you:
nums := [...]int{10, 20, 30, 40, 50}
That is still an array. Go just infers the length.
Indexing arrays
Arrays are indexed the same way you would expect:
nums := [3]int{10, 20, 30}
fmt.Println(nums[0]) // 10
fmt.Println(nums[1]) // 20
fmt.Println(nums[2]) // 30
And like other indexed structures in Go, indexing out of range is a runtime panic.
Arrays are values
This is where arrays differ from what many developers expect.
When you assign an array to another variable, you copy the whole array.
a := [3]int{1, 2, 3}
b := a
b[0] = 999
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [999 2 3]
Same with function calls. If you pass an array to a function, the function receives a copy unless you explicitly pass a pointer.
func update(arr [3]int) {
arr[0] = 42
}
func main() {
a := [3]int{1, 2, 3}
update(a)
fmt.Println(a) // [1 2 3]
}
That value semantics is clean and predictable, but it is also one reason arrays are not the main tool for most dynamic data handling in Go.
2. Why arrays alone are not enough
Arrays are useful, but fixed-size structures are not always a natural fit for application code.
In real systems, you often do not know the final size in advance.
A few common examples:
- rows returned from a database query
- IDs collected during a batch job
- log lines read from a file
- parsed records from an API response
- queued jobs waiting to be processed
You could manually manage arrays for all of that, but it would be awkward fast.
What you usually want is something that still has array-backed performance characteristics, but with more flexibility.
That is where slices come in.
3. Slices: a flexible view over arrays
A slice is not the underlying data itself.
A slice is a small descriptor that points to a section of an underlying array.
Conceptually, a slice contains:
- a pointer to the underlying array
- a length
- a capacity
You can think about it like this:
type sliceHeader struct {
ptr *T
len int
cap int
}
That is not the runtime definition you should use in code, but it is the right mental model.
Here is a simple slice:
nums := []int{10, 20, 30, 40, 50}
fmt.Println(nums) // [10 20 30 40 50]
fmt.Println(len(nums)) // 5
Unlike arrays, slices do not include their size in the type.
That makes them much more natural for general-purpose data handling.
Slices can be created from arrays
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4]
fmt.Println(s) // [20 30 40]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4
That cap(s) == 4 catches many people the first time.
Why 4?
Because s starts at index 1 of the underlying array, and from there there are 4 accessible elements left in that backing array: 20, 30, 40, 50.
This is the first key distinction:
lenis how many elements the slice currently exposescapis how much room exists in the backing array from the slice’s start position
4. Slice syntax is easy, slice behavior is the real topic
Basic slicing syntax:
s := []int{0, 1, 2, 3, 4, 5}
fmt.Println(s[1:4]) // [1 2 3]
fmt.Println(s[:3]) // [0 1 2]
fmt.Println(s[2:]) // [2 3 4 5]
fmt.Println(s[:]) // [0 1 2 3 4 5]
The rule is simple:
- start is inclusive
- end is exclusive
The syntax is not the hard part.
The hard part is remembering that slicing does not copy the underlying data.
It creates another slice pointing into the same backing array.
nums := []int{10, 20, 30, 40}
part := nums[1:3]
part[0] = 999
fmt.Println(nums) // [10 999 30 40]
fmt.Println(part) // [999 30]
This is one of the most important facts about slices.
If two slices point to the same backing array, mutations can be shared.
5. Passing slices to functions: what actually gets passed
People often loosely say “slices are reference types.”
That is not the most precise phrase, but it leads to the right practical behavior: when you pass a slice to a function, the function receives a copy of the slice header, not a copy of all the elements.
That means both sides still refer to the same backing array unless a reallocation happens.
func updateFirst(s []int) {
s[0] = 42
}
func main() {
nums := []int{1, 2, 3}
updateFirst(nums)
fmt.Println(nums) // [42 2 3]
}
No pointer parameter was needed.
That is because the underlying array was shared.
But there is a second part people often misunderstand:
func addItem(s []int) {
s = append(s, 4)
}
func main() {
nums := []int{1, 2, 3}
addItem(nums)
fmt.Println(nums) // [1 2 3]
}
Why did the caller not see the new element?
Because changing existing elements and changing the slice itself are different things.
- element mutation can be visible through shared backing storage
- slice growth updates the local slice header, and the caller will not see that unless you return the new slice
Correct version:
func addItem(s []int) []int {
return append(s, 4)
}
That distinction matters a lot in real code.
6. len and cap: not trivia, actual behavior
Here is a simple example:
s := make([]int, 3, 5)
fmt.Println(s) // [0 0 0]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5
This means:
- the slice currently contains 3 elements
- the backing array has room for 5 elements from this slice’s starting point
So this works without reallocation:
s = append(s, 10, 20)
fmt.Println(s) // [0 0 0 10 20]
But one more append likely forces growth:
s = append(s, 30)
Whether that growth causes a new allocation is not just a performance detail. It also changes aliasing behavior.
If another slice pointed into the same backing array before, reallocation may break that shared relationship.
So cap is not an academic number. It directly influences behavior.
7. What append really does
This line looks simple:
s = append(s, 4)
But under the hood, one of two things happens:
Case 1: capacity is available
Go writes into the existing backing array.
Case 2: capacity is exhausted
Go allocates a new backing array, copies the old data, appends the new item, and returns a new slice header.
That is why this rule matters:
Always use the returned slice from append.
Bad:
append(s, 4)
Correct:
s = append(s, 4)
Ignoring the returned value is a real bug, not just a style issue.
A production-shaped append bug
Consider this:
base := make([]int, 3, 5)
base[0], base[1], base[2] = 1, 2, 3
a := append(base, 4)
b := append(base, 5)
fmt.Println(a)
fmt.Println(b)
Many developers expect a and b to be independent.
But if base still had spare capacity, both appends can reuse the same backing array.
So a and b may interfere with each other.
You can end up with both slices reflecting the later append.
If you want independence, copy deliberately:
a := append([]int(nil), base...)
a = append(a, 4)
b := append([]int(nil), base...)
b = append(b, 5)
This topic is not theoretical. It shows up in production when code assumes “new slice variable” means “new storage.” It does not.
8. copy: one of the most useful built-ins
append gets more attention, but copy is often the tool you actually need when correctness depends on ownership.
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
dst[0] = 999
fmt.Println(src) // [1 2 3]
fmt.Println(dst) // [999 2 3]
Use copy when:
- you want to avoid shared backing arrays
- you need stable ownership
- you want to keep only a small part of a larger buffer
- your API should not mutate caller-owned memory
A surprising number of slice bugs are actually ownership bugs.
copy is often the cleanest fix.
9. Tiny subslices can keep huge memory alive
This is one of the most important production slice topics.
Suppose you read a 100 MB file into memory:
data := readHugeFile()
header := data[:10]
header is only 10 bytes long.
But it still points to the same backing array.
As long as header is alive, the large underlying buffer may also stay alive.
That creates silent memory retention.
This appears in real systems like:
- parsers
- HTTP request processing
- file readers
- log ingestion pipelines
- CSV and JSON processing
- queue consumers
You think you kept a tiny field. In reality, you kept the entire original buffer reachable.
If the small slice must outlive the big buffer, copy it:
headerCopy := append([]byte(nil), data[:10]...)
or:
headerCopy := make([]byte, 10)
copy(headerCopy, data[:10])
That one small decision can save a lot of memory in production.
10. Nil slice vs empty slice
This comes up in interviews, but it also matters in APIs.
var a []int // nil slice
b := []int{} // empty slice
c := make([]int, 0) // empty slice
Differences:
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false
fmt.Println(len(a), cap(a)) // 0 0
fmt.Println(len(b), cap(b)) // 0 0
fmt.Println(len(c), cap(c)) // 0 0
Operationally, many things still work the same:
- you can range over both
lenworksappendworks
a = append(a, 1)
No issue there.
Why the distinction matters
At API boundaries, behavior can differ.
For example with JSON:
type Payload struct {
Items []string `json:"items"`
}
A nil slice may serialize as:
{"items":null}
An empty slice may serialize as:
{"items":[]}
That difference matters if your clients expect an array and not null.
So this is not just interview trivia. It is contract design.
A practical rule:
- inside your code, nil slices are often fine
- at API boundaries, be explicit about whether you want
nullor[]
11. Range loops: copying values vs mutating elements
This is another very common source of bugs.
nums := []int{10, 20, 30}
for _, v := range nums {
v *= 2
}
fmt.Println(nums) // [10 20 30]
Why unchanged?
Because v is a copy of each element.
If you want to mutate the actual slice elements, use indexes:
for i := range nums {
nums[i] *= 2
}
Now the original slice changes.
This is basic syntax on the surface, but it causes real bugs when someone assumes range variables are references into the original data.
They are not.
12. Appending while ranging: risky unless you are very deliberate
This pattern looks tempting:
for _, v := range s {
if shouldAdd(v) {
s = append(s, newValue(v))
}
}
It can be very hard to reason about correctly.
Why?
Because range evaluates the slice at the start of the loop, and append may or may not reallocate during iteration.
So you end up mixing:
- iteration over one logical slice
- mutation of a slice that may change backing storage
- assumptions about loop growth that may not hold
Can you make code like this work? Yes.
Should it be your default pattern in production? Usually not.
A clearer approach is to build a result slice:
result := make([]int, 0, len(s))
for _, v := range s {
result = append(result, v)
if shouldAdd(v) {
result = append(result, newValue(v))
}
}
Much easier to read. Much easier to trust.
13. Deleting from a slice
A common delete pattern is:
s = append(s[:i], s[i+1:]...)
That works, and you will see it often.
But it helps to understand what it does:
- elements after
ishift left - the visible length shrinks
- the backing array may still retain data beyond the new logical length
That last point matters when the slice holds pointers or large referenced objects.
If the removed element still remains in the backing storage past the new length, it may continue to keep memory alive longer than expected.
For pointer-heavy slices, a more careful version can help:
copy(s[i:], s[i+1:])
s[len(s)-1] = nil
s = s[:len(s)-1]
For plain value types like int, the simpler append form is usually fine.
For long-lived slices holding references, this detail can matter.
14. Reusing slices with [:0]
A useful performance pattern:
buf := make([]string, 0, 1000)
// use buf
buf = buf[:0]
This resets the length to zero while keeping the backing array for reuse.
Very practical in:
- batch processing
- parsers
- request-scoped buffers
- encoders
- log aggregation
- high-throughput workers
But there is a condition: ownership must be clear.
If other code still holds slices pointing into that same backing array, reusing it can overwrite data in surprising ways.
This is a good pattern when the lifetime is controlled. It is a dangerous pattern when the buffer escapes further than you think.
15. Preallocation: useful, but easy to misuse
You will often hear advice like “preallocate slices for performance.”
That is good advice when used carefully.
If you know the approximate final size and will build the slice with append, this is a solid pattern:
items := make([]int, 0, expectedCount)
for _, x := range input {
items = append(items, process(x))
}
If you know the exact final length and will fill by index, this is even better:
items := make([]int, len(input))
for i, x := range input {
items[i] = process(x)
}
A common bug
This mistake shows up often:
items := make([]int, len(input))
for _, x := range input {
items = append(items, process(x))
}
Now items already contains len(input) zero values, and append adds more elements after them.
So you accidentally created a slice with both the initial zero-filled length and the appended values.
The rule is simple:
- if you plan to fill by index, use
len - if you plan to build with
append, usually use length0and capacityN
16. Slices of bytes deserve extra respect
In backend code, []byte shows up everywhere.
That is good for performance, but it also makes aliasing bugs more likely.
Example:
func parsePrefix(buf []byte) []byte {
return buf[:5]
}
Looks harmless.
But if buf came from a reusable read buffer, the returned slice is not stable data. It is just a view into mutable storage.
Later reads can change what that slice appears to contain.
If the returned data must remain stable, copy it:
func parsePrefix(buf []byte) []byte {
out := make([]byte, 5)
copy(out, buf[:5])
return out
}
Yes, that allocates.
But correctness comes first. Then you measure.
A lot of backend bugs around slices happen in []byte handling, not []int.
17. What interviews often test around slices
If an interviewer asks about slices well, they are usually not testing syntax. They are testing whether you understand the memory model well enough to avoid subtle bugs.
Common good questions:
What is the difference between an array and a slice?
A strong answer mentions:
- arrays have fixed size
- array size is part of the type
- slices are descriptors over array-backed storage
Does slicing copy data?
No. It creates another slice referencing the same backing array.
Why must you assign the result of append?
Because append may return a new slice header, potentially pointing to a new backing array.
Can a function modify a slice without returning it?
Yes, it can modify existing elements through shared backing storage. But if it grows the slice and the caller needs the new length or storage, it should return the new slice.
What is the difference between nil and empty slices?
They often behave similarly in code, but they differ in nil checks and can differ at boundaries like JSON serialization.
Why can a small subslice keep a large buffer alive?
Because the slice still references the same underlying array.
When can two slices unexpectedly affect each other?
When they share the same backing array.
Those are good questions because they reveal whether someone really understands slices or just knows the syntax.
18. Practical rules that hold up in production
A few slice rules are worth keeping close:
- Always use the returned value from
append
If you ignore it, you are gambling with correctness.
- Assume slicing shares memory unless you explicitly copy
That should be your default mental model.
- Use
copywhen ownership matters
Especially across function, goroutine, parser, or API boundaries.
- Watch out for tiny slices that keep huge buffers alive
This matters more often than people expect.
- Do not confuse
lenwithcap
They answer different questions, and both affect behavior.
- Be intentional about nil vs empty slices at boundaries
Especially for JSON responses and public APIs.
- Preallocate when size is known or predictable
But match the pattern to the way you fill the slice.
- Be careful when deleting reference types
The logical delete may not be enough to release memory promptly.
19. Final thought
Slices look simple because the syntax is simple.
That is exactly why they cause trouble.
Most slice bugs do not come from forgetting how a[low:high] works. They come from weak mental models around:
- shared backing arrays
- ownership
- mutation
- memory retention
- and how
appendchanges the storage story
If you remember one thing, remember this:
A slice is not just “a dynamic array.”
It is a view over array-backed storage, and that storage model affects correctness, performance, and memory behavior.
Once that clicks, slice-heavy Go code becomes much easier to reason about.
And a lot of subtle bugs become much easier to avoid.