DEV Community

Cover image for Go's slices Package: 11 Functions That Replace Half Your Helpers
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go's slices Package: 11 Functions That Replace Half Your Helpers


Open internal/util/slice.go in any Go service that was bootstrapped before 1.21. You'll find ContainsString, IndexOfInt, a hand-rolled removeAt, two versions of uniq, and a mergeSlices helper that someone copy-pasted from Stack Overflow in 2019.

Most of that file can go. Go 1.21 shipped the slices package in August 2023, and 1.22 filled in the last gap with Concat. Eleven of those functions cover the work those helpers were doing. The migration is mechanical: import "slices", delete the helper, replace the call.

There's one catch worth knowing before you swap. Delete and Compact quietly changed behavior between 1.21 and 1.22 in a way that affects pointer-heavy slices.

A note on versions

Everything below is in slices from Go 1.21 onward, except Concat, which arrived in 1.22. If you're on 1.20 or earlier, none of this works without golang.org/x/exp/slices, and you should be planning the upgrade anyway.

1. slices.Contains — the helper everyone wrote

The pre-1.21 version, written once per type:

func ContainsString(s []string, target string) bool {
    for _, v := range s {
        if v == target {
            return true
        }
    }
    return false
}

if ContainsString(allowed, user.Role) { ... }
Enter fullscreen mode Exit fullscreen mode

After:

import "slices"

if slices.Contains(allowed, user.Role) { ... }
Enter fullscreen mode Exit fullscreen mode

Contains works on any slice of comparable. No more per-type variants. If you need a predicate instead of equality, reach for slices.ContainsFunc.

2. slices.Index: same trick, position included

When you need the position, not just the boolean:

i := slices.Index(events, "user.deleted")
if i >= 0 {
    events = append(events[:i], events[i+1:]...)
}
Enter fullscreen mode Exit fullscreen mode

Returns -1 when nothing matches — same shape as strings.Index.

The before-version was usually a for loop with a manual counter. Less interesting, equally redundant.

3. slices.Sort: drop the sort.Slice closure

sort.Slice and sort.SliceStable still work, but they take a closure that has to capture the slice and compare by index. The new form is direct.

nums := []int{3, 1, 4, 1, 5, 9, 2, 6}
slices.Sort(nums)
// [1 1 2 3 4 5 6 9]
Enter fullscreen mode Exit fullscreen mode

No closure to capture the slice, no func(i, j int) bool { return nums[i] < nums[j] } boilerplate. slices.Sort works on any cmp.Ordered type: integers, floats, strings, named types whose underlying type is one of those.

4. slices.SortFunc — when you need a comparator

For struct slices and anything where natural ordering doesn't apply.

type User struct {
    ID   int
    Name string
    Age  int
}

users := []User{...}
slices.SortFunc(users, func(a, b User) int {
    return cmp.Compare(a.Age, b.Age)
})
Enter fullscreen mode Exit fullscreen mode

Two things are different from sort.Slice. The comparator takes the values, not indices, so it reads more directly. And it returns an int (negative, zero, positive) instead of a bool, which means three-way comparison is one line instead of a chained if/else. Pair it with cmp.Compare from the cmp package (also new in 1.21) and most sort comparators become a one-liner.

For tiebreakers, cmp.Or chains comparisons:

slices.SortFunc(users, func(a, b User) int {
    return cmp.Or(
        cmp.Compare(a.Age, b.Age),
        cmp.Compare(a.Name, b.Name),
    )
})
Enter fullscreen mode Exit fullscreen mode

5. slices.BinarySearch: O(log n) when your slice is already sorted

If your slice is sorted and you're calling Contains in a loop, you're doing O(n) work where O(log n) would do. BinarySearch returns the position and a boolean for "found":

ids := []int{102, 318, 421, 559, 770, 803}
i, found := slices.BinarySearch(ids, 559)
// i=3, found=true
Enter fullscreen mode Exit fullscreen mode

The position is useful even when found is false. It's where you'd insert the value to keep the slice sorted. So the pattern "insert into a sorted slice, no duplicates" is:

i, found := slices.BinarySearch(ids, newID)
if !found {
    ids = slices.Insert(ids, i, newID)
}
Enter fullscreen mode Exit fullscreen mode

That's two stdlib calls replacing a hand-rolled function with the off-by-one bug you've shipped twice.

6. slices.Equal — element-wise comparison without reflect

Two slices, same length, every element equal in order. Comparable element types.

slices.Equal([]int{1, 2, 3}, []int{1, 2, 3}) // true
slices.Equal([]string{"a"}, []string{"b"})   // false
Enter fullscreen mode Exit fullscreen mode

The pre-slices version was either a hand-rolled loop or a reflect.DeepEqual call. reflect.DeepEqual works, but it's reflection — slow, allocating, no compile-time type guarantee. slices.Equal is generic, type-checked, and short enough to read end-to-end. It's a nine-line function.

For non-comparable elements (slices of slices, structs containing maps), use slices.EqualFunc and pass your own equality predicate.

7. slices.Compact: dedupe consecutive runs (read the doc carefully)

Compact removes consecutive duplicate elements. It does NOT do a full set-style dedupe. That's a different problem.

xs := []int{1, 1, 2, 3, 3, 3, 4}
xs = slices.Compact(xs)
// [1 2 3 4]
Enter fullscreen mode Exit fullscreen mode

If you want the full dedupe, sort first, then compact:

slices.Sort(xs)
xs = slices.Compact(xs)
Enter fullscreen mode Exit fullscreen mode

The naming throws people the first time. "Compact" sounds like full dedupe. It is not.

Compact also got the same 1.22 tail-zeroing fix as Delete (more on that next), so dropping references on shrink works correctly on 1.22+ without you doing anything.

8. slices.Delete — and the 1.22 gotcha worth knowing

This is the one that changed behavior between 1.21 and 1.22, and the change matters if your slices hold pointers, maps, channels, slices, or strings.

The API:

xs := []int{10, 20, 30, 40, 50}
xs = slices.Delete(xs, 1, 3) // remove indices 1..2
// [10 40 50]
Enter fullscreen mode Exit fullscreen mode

In Go 1.21, Delete shifted the surviving elements left and resliced. Whatever sat in the unused tail of the backing array stayed there. If those tail elements held pointers, the GC couldn't collect what they pointed at. The slice no longer referenced them, but the backing array still did.

In Go 1.22, the behavior changed: Delete now zeroes the tail elements before returning. Same for DeleteFunc, Compact, CompactFunc, and Replace. The fix exists because the old behavior caused real memory leaks in long-lived services holding slices of *Image, *Connection, or anything that points at memory the GC needs to free.

What this means practically:

  • On 1.22+, you don't have to think about it.
  • On 1.21, you were responsible for nilling out the tail yourself if your elements were reference types. Most codebases didn't.
  • For value-only slices ([]int, []float64, structs with no pointers), the change is a no-op. The zeroing is cheap and there's nothing to leak.

If you're upgrading from 1.20-and-earlier and your codebase has hand-rolled delete helpers that DO zero the tail, you can drop them. If your helpers DON'T zero the tail and you're on Go 1.22+, switching to slices.Delete is a quiet bug fix.

9. slices.Insert: the typed splice

The inverse of Delete. Insert one or more values at index i, shift the rest right.

xs := []int{10, 20, 40, 50}
xs = slices.Insert(xs, 2, 30)
// [10 20 30 40 50]

ys := []string{"a", "d"}
ys = slices.Insert(ys, 1, "b", "c")
// [a b c d]
Enter fullscreen mode Exit fullscreen mode

Variadic, so you can splice in a single value or a small batch. For inserting a whole slice, use Concat (next).

A 1.22 detail: Insert now panics consistently when i is out of range, even when there are no values to insert. In 1.21 it would silently succeed in the no-values case. This is a small thing but it means your bounds checks behave the same regardless of input.

10. slices.Concat: the late arrival from 1.22

The only function in this list that didn't ship in 1.21. Concatenate any number of slices into a new one:

a := []int{1, 2}
b := []int{3, 4}
c := []int{5, 6}

all := slices.Concat(a, b, c)
// [1 2 3 4 5 6]
Enter fullscreen mode Exit fullscreen mode

The pre-Concat version was either:

all := append(a, b...)
all = append(all, c...)
Enter fullscreen mode Exit fullscreen mode

…which has the classic slice-aliasing trap: if a had spare capacity, the first append mutates it. slices.Concat allocates a fresh slice with the right capacity and copies into it. No aliasing, no surprises. For two slices it's not much shorter. For five or six it cleans up the call site significantly.

11. slices.Min and slices.Max: one entry, two functions

Counted as one because they ship together and you reach for them in the same situations.

prices := []float64{12.5, 9.99, 14.0, 7.50}
cheapest := slices.Min(prices) // 7.50
priciest := slices.Max(prices) // 14.0
Enter fullscreen mode Exit fullscreen mode

Important: both panic on empty slices. There's no (value, ok) form. If your input might be empty, check len first or use slices.MinFunc/MaxFunc with a custom comparator. Those panic on empty too. The panic on empty input is the contract; the only choice is whether you want a custom comparator on top.

The built-in min/max (also new in 1.21) are different. They work on a fixed list of arguments, not a slice. min(a, b, c) versus slices.Min([]int{a, b, c}). Use the built-ins for argument lists, the slice form for collections.

What to delete from your codebase tomorrow

In rough order of how often it pays off:

  1. Per-type Contains, Index helpers. One line of slices.Contains per call site.
  2. sort.Slice calls with simple comparators. slices.Sort for ordered, slices.SortFunc with cmp.Compare otherwise.
  3. reflect.DeepEqual on slices of comparable elements. slices.Equal is faster and type-safe.
  4. Hand-rolled "remove element at index" code. slices.Delete, with the bonus tail-zeroing on 1.22+.
  5. Linear "is this in this sorted slice" loops. slices.BinarySearch.

Run gopls after the migration; it flags most of these old patterns now.

The slices package isn't really about shorter code. It's that slice-handling stops being your problem. The Go team maintains the test suite for these functions, and the 1.22 zeroing change landed in your Delete calls without you doing anything.


If this was useful

If the slice patterns above were the kind of thing you wished someone had explained in your first year of Go, The Complete Guide to Go Programming covers the language at the same level. What the spec actually says, what the runtime actually does, and which of your habits from other languages don't translate. The hexagonal-architecture book picks up where that one leaves off, for when the codebase is large enough that "where does this slice helper live" stops being a small question.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)