Finding the largest integer in a Go slice is easy. The problem gets more interesting when the slice contains structs and “largest” means the build with the longest duration, the newest release, or the highest application-specific score.
slices.MaxFunc is designed for that case. It scans a slice and returns the maximal element according to a comparator you provide, so the ordering rule can live next to the selection instead of being hidden in a separate sort.
What slices.MaxFunc returns
The function accepts a slice of any element type:
func MaxFunc[S ~[]E, E any](x S, cmp func(a, b E) int) EThe comparator returns a negative value when a comes before b, zero when they compare equally, and a positive value when a comes after b. MaxFunc uses that ordering to choose the greatest element.
Suppose a CI service records completed builds and needs the slowest one:
package main
import (
"cmp"
"fmt"
"slices"
)
type Build struct {
ID string
Duration int
}
func main() {
builds := []Build{
{ID: "api", Duration: 42},
{ID: "worker", Duration: 71},
{ID: "web", Duration: 38},
}
slowest := slices.MaxFunc(builds, func(a, b Build) int {
return cmp.Compare(a.Duration, b.Duration)
})
fmt.Println(slowest.ID) // worker
}The original slice isn’t sorted or rearranged. MaxFunc only selects a value from it.
Use slices.MaxFunc when structs need an ordering rule
For ordered built-in values, slices.Max is simpler:
highest := slices.Max([]int{42, 71, 38})Structs don’t have a built-in greater-than relationship. Even if every field is individually ordered, Go can’t know whether a Build should be compared by duration, ID, timestamp, or some combination of fields.
A comparator makes that decision explicit:
type Release struct {
Version string
Build int
}
latest := slices.MaxFunc(releases, func(a, b Release) int {
return cmp.Compare(a.Build, b.Build)
})The useful detail here is that the result is the entire Release. There’s no need to extract build numbers into another slice, find the maximum number, and then search the original data again.
Equal maximum values return the first match
If more than one element is maximal according to the comparator, slices.MaxFunc returns the first one.
builds := []Build{
{ID: "api", Duration: 71},
{ID: "worker", Duration: 42},
{ID: "web", Duration: 71},
}
slowest := slices.MaxFunc(builds, func(a, b Build) int {
return cmp.Compare(a.Duration, b.Duration)
})
fmt.Println(slowest.ID) // apiBoth api and web have the maximum duration, but api appears first. That behavior can be useful when input order already represents a preference.
If input order is incidental, don’t leave the tie unresolved. Add another comparison so the result follows a rule visible in the code.
Add a tie-breaker when maximum values need deterministic ordering
A multi-field comparator can compare the primary field first and only inspect another field when the first comparison is equal:
selected := slices.MaxFunc(builds, func(a, b Build) int {
if n := cmp.Compare(a.Duration, b.Duration); n != 0 {
return n
}
return cmp.Compare(a.ID, b.ID)
})With this comparator, a duration tie is broken by ID. Which tie-breaker makes sense depends on the data. A deployment queue might use creation time; a version record might use a revision number.
Be deliberate about the direction of each comparison. Because MaxFunc chooses the greatest element under the comparator, reversing the arguments also reverses which value wins:
// Chooses the smallest Duration, despite calling MaxFunc.
return cmp.Compare(b.Duration, a.Duration)That code is legal, but it makes the intent harder to read. If you need a minimum, slices.MinFunc communicates that directly.
Prefer cmp.Compare to subtracting integers
A common comparator shortcut is subtraction:
return a.Duration - b.DurationIt looks compact, but it can overflow when values are near the limits of the integer type. Overflow can flip the sign of the result, which breaks the ordering the comparator is supposed to describe.
Use cmp.Compare instead:
return cmp.Compare(a.Duration, b.Duration)The comparator should also be consistent for the duration of the call. Avoid rules that depend on mutable external state and can give different answers for the same pair while MaxFunc is scanning the slice. If comparison depends on configuration or derived data, capture stable values before starting the selection.
Empty and nil slices panic
slices.MaxFunc needs at least one candidate. It panics when the input slice is empty, including when it is nil:
var builds []Build
slowest := slices.MaxFunc(builds, func(a, b Build) int {
return cmp.Compare(a.Duration, b.Duration)
}) // panicSometimes that panic correctly exposes a broken invariant. If an empty slice is an expected state, check it at the boundary and represent “no maximum” explicitly:
func slowestBuild(builds []Build) (Build, bool) {
if len(builds) == 0 {
return Build{}, false
}
build := slices.MaxFunc(builds, func(a, b Build) int {
return cmp.Compare(a.Duration, b.Duration)
})
return build, true
}The boolean prevents a zero-value Build from being mistaken for a real result.
MaxFunc returns the element, not its index
MaxFunc is a good fit when the caller needs the selected value. It doesn’t return the position of that value in the original slice.
That distinction matters if the next step is an in-place update. In that case, tracking the maximum index directly is clearer:
if len(builds) == 0 {
// handle empty input
}
maxIndex := 0
for i := 1; i < len(builds); i++ {
if builds[i].Duration > builds[maxIndex].Duration {
maxIndex = i
}
}
builds[maxIndex].Duration = 0Calling MaxFunc and then searching for the returned value adds another pass and can be ambiguous when duplicate values exist. Pick the operation based on what the caller needs after selection.
Don’t sort a slice just to read its maximum
Sorting can produce the same maximum, but it solves a larger problem. It orders every element and changes the slice unless you copy it first.
If later code genuinely needs all builds ordered by duration, sorting is appropriate. If the only question is “which build took longest?”, slices.MaxFunc expresses that question directly and leaves the existing order alone.
Use a small comparator that matches the domain rule, define tie-breakers when ties shouldn’t depend on input order, and guard empty input when emptiness is valid. That keeps maximum selection focused on the value the program actually needs.