How to use sort package

Use the sort package to arrange slices of integers, strings, or custom types in ascending or descending order.

The problem with unordered data

You pull a list of user records from a database. The query returns them in insertion order, which is useless for a leaderboard. You need them ranked by score, then by join date. In Python, you slap .sort(key=...) on the list. In JavaScript, you pass a callback to .sort(). Go takes a different path. It gives you a sort package that works in place, mutates your slice directly, and expects you to be explicit about what less than means.

There is no magic method attached to slices. Slices in Go are just headers containing a pointer, a length, and a capacity. They do not carry behavior. The sort package lives in the standard library and operates on those headers. You hand it a slice and a rule, and it rearranges the underlying array until the rule holds true for every adjacent pair.

How Go sorts under the hood

Sorting in Go is like organizing a deck of cards on a table. You do not get a new deck handed back. You rearrange the cards right where they sit. The standard library provides a few pre-made hands for common types, and a flexible rulebook for everything else. The core idea is simple: tell the sorter how to compare two elements, and it will shuffle them until the whole slice is ordered.

Under the hood, Go uses a variant of introsort. Introsort starts with quicksort for speed, switches to heapsort if the recursion depth gets too large, and falls back to insertion sort for tiny slices. This hybrid approach guarantees O(n log n) worst-case performance while keeping the average case fast. The algorithm is iterative rather than recursive, which avoids stack overflow on massive slices.

Go sorts in place. If you need the original order, copy the slice first. This is a deliberate design choice. Allocating a new slice for every sort would waste memory and trigger unnecessary garbage collection. The convention is clear: mutation is expected, and copying is your responsibility when you need immutability.

The built-in shortcuts

The standard library ships with optimized functions for the three most common primitive types. They skip the overhead of custom comparison logic and use direct type-specific comparisons.

Here is the simplest way to sort integers and strings:

package main

import (
	"fmt"
	"sort"
)

func main() {
	// Direct mutation of the underlying array
	nums := []int{5, 2, 9, 1, 4}
	sort.Ints(nums)

	// Strings compare lexicographically by UTF-8 code points
	words := []string{"banana", "apple", "cherry"}
	sort.Strings(words)

	fmt.Println(nums)  // [1 2 4 5 9]
	fmt.Println(words) // [apple banana cherry]
}

The functions return nothing. They modify the slice you pass in. If you chain them or expect a new slice, the compiler will reject your code with cannot use sort.Ints(nums) as type []int in assignment. The return type is void by design. This forces you to acknowledge that the data has changed.

Float64s follow the same pattern with sort.Float64s. They handle IEEE 754 special values correctly, placing NaN values at the end of the sorted sequence. The built-ins are fast because they avoid function call indirection. The compiler can inline the comparison logic and optimize the swap operations.

Custom comparisons with sort.Slice

Primitive shortcuts cover the basics. Real applications sort structs, pointers, or nested slices. For those cases, sort.Slice accepts a closure that defines the ordering rule. The closure receives two indices, i and j, and must return true if the element at i should come before the element at j.

Here is how you sort a slice of custom structs by a single field:

package main

import (
	"fmt"
	"sort"
)

type Task struct {
	Title string
	Priority int
}

func main() {
	tasks := []Task{
		{"Deploy", 3},
		{"Write docs", 1},
		{"Fix bug", 2},
	}

	// The closure captures the slice by reference
	// It compares the Priority field of two elements
	// Returning true means i comes before j
	sort.Slice(tasks, func(i, j int) bool {
		return tasks[i].Priority < tasks[j].Priority
	})

	fmt.Println(tasks)
}

The closure captures the slice by reference, not by value. This keeps memory usage low and allows the sorter to read the data without copying it. The indices i and j are always valid positions within the slice bounds. The algorithm guarantees that i and j will never be out of range, so you do not need bounds checking inside the closure.

The comparison function must be consistent. If a is less than b, and b is less than c, then a must be less than c. Violating transitivity will cause the sorter to panic with sort: Slice comparison is inconsistent. The panic happens at runtime because the compiler cannot verify logical consistency across arbitrary closure bodies.

When order of ties matters

Standard sorting is unstable. When two elements compare as equal, their relative order after sorting is undefined. They might swap, stay put, or scatter depending on the internal algorithm state. This is fine for most data. It breaks when you sort by multiple fields sequentially.

Picture a leaderboard. You want players ranked by score. If two players tie, you want the one who joined earlier to appear first. If you sort by join date first, then sort by score using sort.Slice, the second sort will scramble the join date order for tied scores. The algorithm does not remember the previous arrangement.

Stable sorting preserves the original relative order of equal elements. Go provides sort.SliceStable for this exact scenario. It uses a variant of merge sort that guarantees stability at the cost of slightly higher memory allocation and a small performance penalty.

Here is how you chain stable sorts for multi-key ordering:

package main

import (
	"fmt"
	"sort"
	"time"
)

type Player struct {
	Name string
	Score int
	Joined time.Time
}

func main() {
	players := []Player{
		{"Alice", 100, time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
		{"Bob", 100, time.Date(2022, 6, 1, 0, 0, 0, 0, time.UTC)},
		{"Charlie", 90, time.Date(2023, 3, 1, 0, 0, 0, 0, time.UTC)},
	}

	// Sort by secondary key first to establish tie-breaking order
	sort.SliceStable(players, func(i, j int) bool {
		return players[i].Joined.Before(players[j].Joined)
	})

	// Sort by primary key. Ties keep the join-date order
	sort.SliceStable(players, func(i, j int) bool {
		return players[i].Score > players[j].Score
	})

	fmt.Println(players)
}

The first sort arranges players by join date. The second sort arranges them by score. Because the second sort is stable, Alice and Bob keep their original relative order when their scores match. Charlie drops to the bottom because his score is lower. Stability is cheap. Use it when ties matter.

Common traps and compiler feedback

The sort package is straightforward, but a few patterns trip up newcomers. The comparison closure is the most common source of bugs.

Never mutate the slice inside the comparison function. The algorithm assumes the data stays still while it probes indices. If you modify an element, swap values, or change the slice length during comparison, the internal state desynchronizes. The sorter will either panic with sort: Slice comparison is inconsistent or return garbage. Keep the closure pure. Read only, compare only, return a boolean.

Closure allocation is a real performance cost. Every call to sort.Slice allocates a closure object on the heap. In tight loops or high-throughput services, those allocations add up. The garbage collector handles them, but they still consume CPU cycles. If profiling shows sorting as a bottleneck, switch to sort.Interface. It requires you to implement three methods: Len(), Less(i, j int) bool, and Swap(i, j int). The interface avoids closure allocation entirely and lets the compiler optimize the comparison path.

The compiler enforces the signature strictly. If you accidentally return an integer or a string from the closure, you get cannot use func literal as type func(i, j int) bool in argument to sort.Slice. The error message points directly to the type mismatch. Fix the return type and the program compiles.

Loop variable capture used to be a silent bug in older Go versions. Closures inside a for loop would all reference the same loop variable, causing every comparison to use the final index. Go 1.22 changed loop variable scoping to fix this automatically. The compiler now creates a new variable for each iteration. You no longer need to manually shadow the loop variable with i := i. The language handles it for you.

Choosing the right sorter

Picking the right sorting tool depends on your data shape, performance requirements, and whether tie-breaking matters. Match the function to the scenario.

Use sort.Ints, sort.Strings, or sort.Float64s when you are sorting a slice of a single primitive type.

Use sort.Slice when you need a quick, readable sort for a custom struct or slice of pointers.

Use sort.SliceStable when equal elements must keep their original relative order, such as sorting by multiple fields in sequence.

Use sort.Interface when performance matters in a hot loop and you want to avoid closure allocation overhead.

Use manual sorting or a priority queue when you only need the top or bottom N elements and full sorting wastes cycles.

Readability wins until profiling says otherwise. The standard library gives you safe defaults. Optimize only when the numbers demand it.

Where to go next