Sorting a slice of structs usually starts with a field: priority, timestamp, name, score, or some combination of them. slices.Sort can’t handle a struct because a struct has no built-in ordering relation. slices.SortFunc fills that gap by taking a comparator that defines the order for the element type.
The call is compact, but the comparator is part of the program’s correctness. It has to describe a consistent ordering, and equal comparisons need deliberate handling when records have multiple fields. This article builds that comparator from simple cases to multi-field ordering and covers the mutation and stability details that tend to cause surprises.
Sort a struct slice with slices.SortFunc
Suppose a task list should be ordered by numeric priority:
package main
import (
"cmp"
"fmt"
"slices"
)
type Task struct {
Name string
Priority int
}
func main() {
tasks := []Task{
{Name: "backup", Priority: 3},
{Name: "deploy", Priority: 1},
{Name: "report", Priority: 2},
}
slices.SortFunc(tasks, func(a, b Task) int {
return cmp.Compare(a.Priority, b.Priority)
})
fmt.Println(tasks)
}The result places priority 1 first, then 2, then 3:
[{deploy 1} {report 2} {backup 3}]The comparator receives two elements and returns a negative value when a belongs before b, a positive value when a belongs after b, and zero when they compare equal. cmp.Compare already follows that contract for ordered values, so it’s a good building block for numeric and string fields.
slices.SortFunc changes the supplied slice in place. It doesn’t return a new slice.
Write the comparator as an ordering rule
A comparator is easier to review when each return value corresponds directly to the intended order. For ascending priority, this is enough:
func(a, b Task) int {
return cmp.Compare(a.Priority, b.Priority)
}For descending priority, reverse the operands:
func(a, b Task) int {
return cmp.Compare(b.Priority, a.Priority)
}That form is safer than subtracting integers:
// Avoid using subtraction as a general comparator.
return a.Priority - b.PrioritySubtraction can overflow for sufficiently distant integer values. A comparator only needs to report negative, zero, or positive; cmp.Compare does that without turning the comparison into arithmetic that can overflow.
The function also needs to define a strict weak ordering. In practical terms, don’t make the result depend on changing state, random values, or contradictory pairwise rules. If a is ordered before b and b before c, the comparator can’t place c before a without breaking the ordering assumptions used by the sort.
Sort by multiple struct fields
Real records often need a tie-breaker. A task list might sort by priority first and name second:
slices.SortFunc(tasks, func(a, b Task) int {
if n := cmp.Compare(a.Priority, b.Priority); n != 0 {
return n
}
return cmp.Compare(a.Name, b.Name)
})The first comparison decides the result whenever priorities differ. The name comparison runs only for equal priorities.
For this input:
tasks := []Task{
{Name: "test", Priority: 2},
{Name: "deploy", Priority: 1},
{Name: "build", Priority: 2},
}the order becomes:
[{deploy 1} {build 2} {test 2}]This pattern scales cleanly to more fields. Compare the most significant field first, return when it differs, then continue to the next field.
A common mistake is to combine several comparisons into one numeric expression. That tends to hide precedence rules and can introduce overflow. Sequential comparisons make the ordering contract visible.
Mix ascending and descending fields
Not every field needs the same direction. Imagine ranking build results by score from highest to lowest, then by name from A to Z:
type Result struct {
Name string
Score int
}
slices.SortFunc(results, func(a, b Result) int {
if n := cmp.Compare(b.Score, a.Score); n != 0 {
return n
}
return cmp.Compare(a.Name, b.Name)
})Only the score operands are reversed. The name comparison keeps its ordinary ascending direction.
This is more explicit than sorting ascending and then reversing the whole slice. Reversing would also reverse the secondary name order, producing Z-to-A names within equal scores.
Custom string rules belong in the comparator
Normal string comparison is case-sensitive. If a user-facing list needs case-insensitive ordering, transform the values used for comparison rather than changing the stored values:
slices.SortFunc(names, func(a, b string) int {
return strings.Compare(
strings.ToLower(a),
strings.ToLower(b),
)
})This leaves the original strings intact while ordering them by lowercase forms.
There is a nuance here: two distinct strings can compare equal after normalization. "ALPHA" and "alpha" both produce the same lowercase comparison key. If their relative order matters, either add a second comparison using the original strings or use a stable sort when preserving input order is the desired behavior.
For large slices or expensive normalization, repeatedly computing comparison keys inside the comparator can also cost more than expected because the comparator runs many times. In that situation, precompute keys in the records before sorting or store a normalized field when that fits the data model.
Equal comparisons don’t imply stable output
slices.SortFunc isn’t guaranteed to be stable. When the comparator returns zero for two elements, their original relative order isn’t part of the function’s contract.
Consider records sorted only by department:
type Employee struct {
Name string
Department string
}
slices.SortFunc(employees, func(a, b Employee) int {
return cmp.Compare(a.Department, b.Department)
})Two employees in the same department compare equal. If their incoming order must be retained, use slices.SortStableFunc with the same comparator:
slices.SortStableFunc(employees, func(a, b Employee) int {
return cmp.Compare(a.Department, b.Department)
})Another option is to add a tie-breaker to SortFunc, such as employee ID or name. These choices express different requirements. A stable sort preserves the prior order of equal records; a tie-breaker creates a more specific ordering.
Sorting mutates shared slice storage
Copying a slice variable doesn’t copy its elements. If two slice values refer to the same backing array, sorting through either one changes the order observed through both:
tasks := []Task{
{Name: "backup", Priority: 3},
{Name: "deploy", Priority: 1},
}
alias := tasks
slices.SortFunc(alias, func(a, b Task) int {
return cmp.Compare(a.Priority, b.Priority)
})
fmt.Println(tasks)tasks is now reordered too.
If a function promises to preserve its input order, clone the slice before sorting:
func sortedTasks(tasks []Task) []Task {
result := slices.Clone(tasks)
slices.SortFunc(result, func(a, b Task) int {
return cmp.Compare(a.Priority, b.Priority)
})
return result
}The clone adds an allocation and copies the slice elements. That’s appropriate when preserving caller-owned order is part of the function contract; it’s unnecessary overhead when in-place mutation is already expected.
For structs containing pointers, maps, slices, or other reference-like fields, remember that slices.Clone is a shallow copy. It separates the outer slice storage but doesn’t recursively duplicate data referenced by each element.
Keep comparator behavior consistent
A sort can only produce meaningful output if the comparator describes a coherent order. Avoid comparators that read mutable external state during the sort:
direction := 1
slices.SortFunc(tasks, func(a, b Task) int {
direction = -direction
return direction * cmp.Compare(a.Priority, b.Priority)
})The same pair can receive different answers at different moments. The sorting algorithm isn’t required to compensate for that inconsistency.
Also be careful when a domain has values that don’t fit a simple total order. Floating-point NaN values, partially populated records, and application-specific “unknown” states need an explicit policy. Decide where those values belong, then encode that policy consistently rather than letting an incidental field comparison decide it.
Comparator code is small enough that focused tests pay off. Test records with equal primary fields, reversed input, already ordered input, and any special values your domain permits. Those cases exercise the ordering contract more directly than a single happy-path example.
Use slices.SortFunc when the data owns the ordering rule
slices.SortFunc fits struct slices and other element types that need an explicit comparison rule. Build the comparator one field at a time, use cmp.Compare where possible, and make ascending or descending direction visible in the operand order.
Choose slices.SortStableFunc when equal records must retain their incoming sequence. Clone before sorting when callers must keep their original order. With those contracts decided up front, the sort call stays small and the behavior remains clear at the places that consume the result.