DEV Community

Cover image for From sort.Slice to slices.Sort in Go: Cleaner, Faster, Type-Safe
Gabriel Anhaia
Gabriel Anhaia

Posted on

From sort.Slice to slices.Sort in Go: Cleaner, Faster, Type-Safe


You open a Go file from 2021 and there it is again. A sort.Slice
call with a closure that indexes into the slice twice, compares two
fields, and returns a bool. It works. It has always worked. But the
closure captures the slice by reference, the comparison reads
s[i] and s[j] on every call, and nothing about the code tells
you what type is even being sorted.

Go 1.21 shipped the slices and cmp packages in the standard
library. They are not a rewrite of the sort engine. They are a
thinner, typed surface over it. If your codebase still leans on
sort.Slice for everything, this is the migration that cleans up
the most call sites for the least risk.

The old shape and why it grated

Here is the pattern you have written a hundred times.

sort.Slice(users, func(i, j int) bool {
    return users[i].Age < users[j].Age
})
Enter fullscreen mode Exit fullscreen mode

Three things are off. The closure takes indices, not values, so
you index back into users yourself. The compiler has no idea the
elements are User — the whole thing runs through any and
reflection under the hood. And there is no compile-time check that
the two sides of the comparison are the same field or even the same
type.

For a plain slice of ordered values it was worse, because you still
had to write the closure:

sort.Slice(nums, func(i, j int) bool {
    return nums[i] < nums[j]
})
Enter fullscreen mode Exit fullscreen mode

That is a lot of ceremony to say "sort these ascending."

slices.Sort for ordered types

If the element type satisfies cmp.Ordered (every integer, float,
and string), you get a one-liner with no closure at all.

import "slices"

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

slices.Sort is generic. The signature is
Sort[S ~[]E, E cmp.Ordered](x S), so the compiler knows E is
int here and inlines a direct < comparison. No func(i, j int),
no reflection dispatch on each compare. It reads as what it does.

The same package gives you slices.IsSorted and
slices.BinarySearch that share the exact same ordering, so you
stop re-deriving "sorted ascending" in three different spots.

slices.SortFunc for everything else

Structs and custom orderings move to slices.SortFunc. The
difference from sort.Slice is the callback: it receives the two
elements, not their indices, and it returns an int (negative,
zero, positive) instead of a bool.

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

cmp.Compare is the helper that makes this pleasant. It takes two
cmp.Ordered values and returns -1, 0, or +1, so you rarely write
the sign arithmetic by hand. Compare that to the old
return a.Age < b.Age: you now hand back a three-way result, which
is what a comparator has always wanted to express.

Multi-key sorting stops being a nest of if branches. cmp.Or
returns its first non-zero argument, so you chain comparisons in
priority order:

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

Last name first, then first name, then age. Each line is one key.
Read top to bottom, that is the sort order. The old bool version of
this was a chain of if a.LastName != b.LastName { return ... }
blocks that everyone copied and nobody enjoyed.

Reverse order is just swapped arguments, or wrap with -:

// descending by age
slices.SortFunc(users, func(a, b User) int {
    return cmp.Compare(b.Age, a.Age)
})
Enter fullscreen mode Exit fullscreen mode

The stability decision

Here is the part people skip and then get a flaky test out of.

slices.Sort and slices.SortFunc are not stable. Elements
that compare equal can come out in any relative order. That matches
the old sort.Slice, which was also unstable. So a straight
sort.Sliceslices.SortFunc swap keeps the same behavior. Good.

But if the code you are replacing used sort.SliceStable, you must
map it to slices.SortStableFunc, not slices.SortFunc. Miss that
and equal elements silently reorder.

// preserves original order among equal-priority items
slices.SortStableFunc(tasks, func(a, b Task) int {
    return cmp.Compare(a.Priority, b.Priority)
})
Enter fullscreen mode Exit fullscreen mode

When does stability actually matter? When your comparator does not
look at every field that distinguishes two records. If you sort a
list of tasks by priority only, two tasks with the same priority are
"equal" to the sort, and stability decides whether they stay in
insertion order. UIs that show a list the user already ordered care
about this. Pipelines that sort by a partial key care about this.

The safe rule for the migration: match what was there. Slice maps
to SortFunc, SliceStable maps to SortStableFunc. Do not
upgrade an unstable sort to stable "to be safe" — stable sort does
extra work, and if the old behavior was fine, changing it is a
different decision than the one you are making today.

There is a subtle catch with equal keys and SortFunc you should
know about. If your comparator returns 0 for two elements you
actually consider different, an unstable sort may swap them across
runs and across Go versions. The compiler will not warn you. Return
0 only when you genuinely mean "order between these does not
matter."

What actually gets faster

Two things, and it helps to be precise so you do not oversell it.

First, slices.Sort on ordered types drops the interface
indirection. The old sort.Slice routed comparisons through a
sort.Interface and reflection-driven swaps. The generic version
lets the compiler specialize the comparison, so for big slices of
int or string you cut a real chunk of per-comparison overhead.
The 1.19 release also moved the internal sort to a pattern-defeating
quicksort (pdqsort), which the slices package inherits.

Second, SortFunc passes values to your comparator instead of
making you index back into the slice twice per call. For small
element types that difference is minor. For call sites that were
doing extra work inside the closure, tightening the comparator to a
single cmp.Compare line removes it.

Do not quote a fixed speedup number. It depends on element type,
slice size, and how expensive your comparator is. Benchmark your own
hot path with testing.B if it matters:

func BenchmarkSort(b *testing.B) {
    base := makeUsers(10000)
    for b.Loop() {
        s := slices.Clone(base)
        slices.SortFunc(s, func(a, b User) int {
            return cmp.Compare(a.Age, b.Age)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

b.Loop() (Go 1.24+) keeps the setup outside the timed region and
stops the compiler from optimizing the work away. Clone inside the
loop so each iteration sorts unsorted data, otherwise you benchmark
"sort an already-sorted slice," which is the fast path and not what
you ship.

A migration you can run this week

The mechanical mapping is small enough to grep for:

  • sort.Ints, sort.Strings, sort.Float64sslices.Sort
  • sort.Slice with a pure < on an ordered type → slices.Sort
  • sort.Slice with a struct/custom compare → slices.SortFunc plus cmp.Compare
  • sort.SliceStableslices.SortStableFunc
  • hand-rolled multi-key if chains → cmp.Or of cmp.Compare

sort is not deprecated, and you do not have to touch code that
already works. Custom types that implement sort.Interface for a
real reason can stay. This is about the closure-heavy call sites,
which is most of them.

One more idiom worth adopting. If a type has a natural ordering,
give it a method and pass it directly:

func (u User) Compare(o User) int {
    return cmp.Or(
        cmp.Compare(u.LastName, o.LastName),
        cmp.Compare(u.Age, o.Age),
    )
}

slices.SortFunc(users, User.Compare)
Enter fullscreen mode Exit fullscreen mode

User.Compare is a method value with signature
func(User, User) int, which is exactly what SortFunc wants. The
sort order lives on the type, next to the data, instead of being
copy-pasted into every call site.

Sorting is a small corner of Go, but it is the kind of corner where
the standard library quietly got better and a lot of code never
caught up. The Complete Guide to Go Programming digs into how
generics and the cmp/slices packages fit the language's type
model, and why the comparator returns an int instead of a bool.
Hexagonal Architecture in Go is about keeping ordering logic like
User.Compare on the domain type where it belongs, instead of
smeared across the adapters that happen to sort.

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

Top comments (0)