- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You need to drop one element out of a slice. You reach for the
first thing that comes to mind, copy a one-liner off an old Stack
Overflow answer, and move on. Three weeks later a coworker files a
bug: a struct that should have been garbage-collected is still
pinned in memory, and a slice that was supposed to be independent is
mutating a caller's data.
Both bugs come from the same place. Go slices are cheap views over
a shared backing array, and the classic "slice tricks" one-liners
say nothing about who else is looking at that array. The Go wiki
kept a SliceTricks page for years
precisely because these operations aren't obvious. Since Go 1.21 the
standard slices package covers most of them, but you still need to
know what the underlying operations do, because the traps didn't go
away — they moved.
Here's each operation, the manual version, the trap, and the
stdlib version where one exists. Target is Go 1.23+.
Delete: the append trick and the pointer it forgets
The manual in-place delete looks like this. Remove index i by
copying the tail over it:
func deleteAt(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}
This works for int, and for any element type that holds no
pointers. It shifts the tail left by one and returns a slice one
shorter. Length drops, capacity stays.
Now switch the element type to something with a pointer in it and
the same code leaks:
type Job struct {
Name string
Data []byte // holds memory
}
func deleteAt(s []Job, i int) []Job {
return append(s[:i], s[i+1:]...)
}
Say the slice had length 5. After deleting index 2, the length is
- But the backing array still has 5 slots. Slot 4 (the old last
element) still holds a
Jobwith itsDatabyte slice. Nothing zeroed it. The length says 4, so you can't reach it, but the garbage collector can — the pointer is live in the array, so theDatait points at never gets freed.
The fix is to zero the tail slot after the shift:
func deleteAt(s []Job, i int) []Job {
copy(s[i:], s[i+1:])
var zero Job
s[len(s)-1] = zero // clear the freed slot
return s[:len(s)-1]
}
You don't have to write that. slices.Delete does exactly this,
including the zeroing, since Go 1.21:
s = slices.Delete(s, 2, 3) // remove index 2 (half-open [2,3))
slices.Delete takes a range, not a single index, and it clears
the vacated tail so pointer elements can be collected. This is the
whole reason to prefer it over the hand-rolled append trick. It
isn't shorter for the sake of shortness — it plugs the memory leak
the one-liner leaves open.
Delete unordered: the swap-and-truncate
If you don't care about order, you can delete in O(1) by moving the
last element into the hole and truncating:
func deleteUnordered(s []Job, i int) []Job {
s[i] = s[len(s)-1]
var zero Job
s[len(s)-1] = zero
return s[:len(s)-1]
}
Same zeroing rule applies. This is the right tool when the slice is
a set or a work queue where order carries no meaning. slices.Delete
preserves order and pays the copy cost to do it, so keep the manual
swap when order genuinely doesn't matter and the slice is large.
Insert: making room in the middle
Inserting at index i means shifting everything from i onward to
the right. The old idiom does it with two appends:
func insertAt(s []int, i, v int) []int {
s = append(s, 0) // grow by one
copy(s[i+1:], s[i:]) // shift tail right
s[i] = v
return s
}
The copy of overlapping regions works because Go's built-in
copy handles overlap correctly when copying to a lower or higher
index. Get the order wrong and you smear one value across the tail,
so this is easy to break by hand.
Since Go 1.21, slices.Insert does it, variadic, for any number of
elements:
s = slices.Insert(s, 2, 99) // one element at index 2
s = slices.Insert(s, 2, 7, 8, 9) // three elements at index 2
It grows the backing array once if needed, shifts once, and writes
the new values. For inserting into the middle of a large slice this
is both shorter and less error-prone than the manual dance.
Filter: reuse the backing array or don't
Filtering in place is the trick people love because it allocates
nothing. You walk the slice, keep a write cursor, and copy survivors
forward:
func filterInPlace(s []int, keep func(int) bool) []int {
n := 0
for _, v := range s {
if keep(v) {
s[n] = v
n++
}
}
return s[:n]
}
For int this is fine. For a slice of pointers or pointer-holding
structs it has the same tail problem as delete: the slots between
n and the old length still hold references. Zero them if the
elements point at memory you want collected:
func filterInPlace(s []*Job, keep func(*Job) bool) []*Job {
n := 0
for _, v := range s {
if keep(v) {
s[n] = v
n++
}
}
clear(s[n:]) // Go 1.21 builtin: zero the tail
return s[:n]
}
clear on a slice sets every element to its zero value. It's the
one-line way to release the tail after any compaction. Reach for it
whenever you shrink a slice of pointers in place.
There is no slices.Filter in the standard library. If you want a
non-mutating version, slices.DeleteFunc (Go 1.21) removes the
elements that match a predicate, in place, with the zeroing handled:
// keep evens -> delete odds
s = slices.DeleteFunc(s, func(v int) bool {
return v%2 != 0
})
Note the inverted logic: DeleteFunc removes what the predicate
returns true for, so the predicate describes what you throw away,
not what you keep.
Reverse: stop writing the loop
The two-pointer reverse is muscle memory:
func reverse(s []int) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
It's correct and it's fine. It's also in the standard library as
slices.Reverse, generic over any element type, since Go 1.21:
slices.Reverse(s) // in place, any slice type
There is no reason to hand-roll this one anymore unless you're
targeting a Go version older than 1.21.
The aliasing trap under all of it
Every operation above returns a slice that may share a backing array
with its input. That is the single fact that turns these tricks into
production bugs. Watch what a low-capacity re-slice does:
func main() {
all := []int{1, 2, 3, 4, 5}
head := all[:3] // len 3, cap 5, shares backing
head = slices.Insert(head, 1, 99)
fmt.Println(head) // [1 99 2 3]
fmt.Println(all) // [1 99 2 3 5] <- all[1:4] clobbered
}
head had spare capacity borrowed from all, so slices.Insert
wrote into the shared array and stomped on all. The stdlib
functions do not save you from this — they operate on the backing
array they're handed. If a slice needs to be independent of its
parent, cut the tie before you mutate:
head := slices.Clone(all[:3]) // fresh backing array
head = slices.Insert(head, 1, 99)
// all is untouched
Or force a fresh allocation with a three-index slice that caps
capacity at the length, so the next append or Insert has to
grow into new memory:
head := all[:3:3] // len 3, cap 3
The all[:3:3] form is the low-tech guard. Any write that needs
more room than the length allows will allocate rather than reach
into the parent. Use it when you hand a sub-slice to code you don't
control.
What to check on Monday
Four things to grep for in the code you already have:
-
append(s[:i], s[i+1:]...)on a slice of pointers or pointer-holding structs. That's a memory leak. Replace withslices.Delete. - In-place filter loops that truncate with
s[:n]and neverclearthe tail. Same leak. Addclear(s[n:]). - Sub-slices (
s[:k],s[i:j]) handed to functions thatappendorInsert. Those can clobber the parent. Clone or three-index them. - Hand-rolled
reverseand delete helpers that predate Go 1.21. Swap them for theslicespackage and delete the code.
The slices package didn't make these operations trivial. It made
the safe version the default, so you stop shipping the leaky
one-liner. Know what it does underneath, because the day you need
the manual version (a hot path, an odd element type, an unordered
delete), the aliasing and the tail slot are still yours to handle.
Slices sit right on top of Go's memory model, and the traps here are
really questions about who owns a backing array and when it gets
freed. The Complete Guide to Go Programming works through slices,
the append growth rules, and the garbage collector in the depth
you need to reason about this without guessing. Hexagonal
Architecture in Go is the companion for keeping this kind of
low-level detail behind a boundary, so a leaky slice trick can't
leak past the adapter that owns it.

Top comments (0)