When the data arrives out of order
You pull a list of transactions from a database. The API returns them in insertion order, but your dashboard needs them sorted by amount. You reach for a sort function, but Go gives you two different packages: slices and sort. Both work. Both modify the slice in place. The difference comes down to what you are sorting and how much control you need over the comparison logic.
How Go approaches sorting
Go does not attach a .Sort() method to slices. Slices are just three-word headers: a pointer to an array, a length, and a capacity. Attaching methods to them would require boxing or hidden allocations. Instead, Go uses standalone functions that take the slice as an argument. The sorting algorithm runs directly on the backing array, swapping elements until they are in order.
Think of it like rearranging files on a physical desk. You do not buy a new desk. You slide the papers around until they match the filing system. The slices package handles built-in types like integers, floats, and strings. It knows exactly how to compare them without extra instructions. The sort package handles everything else. You hand it a comparison function, and it uses that function to decide which element comes first.
Go favors explicit control flow. Sorting functions do not return errors. They assume the comparison function is valid and the slice is accessible. If your comparison function panics, the sort panics. Keep the comparison logic simple and side-effect free.
The simplest case: built-in types
Here is the modern approach for primitive slices. The slices package arrived in Go 1.21 to remove boilerplate. It uses generics under the hood, so you get type safety without writing custom comparators.
package main
import (
"fmt"
"slices"
)
// main runs the program entry point
func main() {
// Start with an unsorted slice of integers
scores := []int{42, 17, 89, 17, 5}
// Sorts in place using introsort. Modifies the backing array directly.
// No new slice is allocated. Memory usage stays constant.
slices.Sort(scores)
// Print the result to verify the order
fmt.Println(scores)
}
The function returns nothing. It mutates the slice you pass in. If you need the original order preserved, make a copy before calling slices.Sort. The algorithm uses introsort, which starts as quicksort, switches to heapsort if recursion gets too deep, and falls back to insertion sort for tiny partitions. This guarantees O(n log n) performance even on pathological inputs.
Trust the standard library. The generic implementation is heavily optimized for CPU cache locality.
What happens under the hood
When you call slices.Sort, the compiler generates a specialized version of the sort routine for your exact type. For []int, it uses direct integer comparisons. For []string, it uses lexicographical byte comparison. The algorithm partitions the slice around a pivot, recursively sorts the left and right halves, and merges them. Because it works in place, it only needs a few stack frames for recursion.
The sort package predates generics. It relies on a comparison function that takes two indices and returns a boolean. Every comparison requires a function call, a closure environment lookup, and an index bounds check. This adds measurable overhead for large slices. The tradeoff is flexibility. You can sort by any field, combine multiple fields, or apply business logic without implementing an entire interface.
Convention aside: Go community style prefers slices for primitives and sort for custom types. The slices package is not a replacement for sort. It is a convenience layer for the most common case. Keep both in your mental toolkit.
Sorting custom types with a closure
Real code rarely deals with plain integers. You usually have structs. When the type is not a built-in, Go needs a rule to compare two elements. You provide that rule as a function literal.
package main
import (
"fmt"
"sort"
)
// Task represents a unit of work with metadata
type Task struct {
Title string
Priority int
}
// main demonstrates sorting a slice of structs
func main() {
// Initialize a slice of task structs
tasks := []Task{
{"Write docs", 2},
{"Fix crash", 1},
{"Refactor", 3},
}
// Pass a closure that compares two elements by index
// The closure captures the tasks slice by reference
sort.Slice(tasks, func(i, j int) bool {
// Return true if element i should come before element j
// The algorithm uses this boolean to decide swap direction
return tasks[i].Priority < tasks[j].Priority
})
fmt.Println(tasks)
}
The closure captures the tasks slice by reference. The sorting algorithm calls your function repeatedly, passing index pairs. Your function returns true if the element at i belongs before the element at j. The algorithm handles the swapping. You only define the ordering rule.
If two elements compare as equal, sort.Slice does not guarantee their relative order. The algorithm may swap them during partitioning. This is called unstable sorting. If preserving the original order of equal elements matters, use sort.SliceStable. It uses a merge-sort variant that guarantees stability at a small memory and speed cost.
Stability matters when you chain sorts. Sort by secondary key first, then by primary key. The final order respects both.
Realistic example: ordering API responses
Production code often needs to sort query results before sending them to a client. You might need to order by timestamp, break ties by ID, and handle missing data gracefully.
package main
import (
"fmt"
"sort"
"time"
)
// Event represents a system log entry
type Event struct {
ID string
Timestamp time.Time
Severity int
}
// sortEvents orders events by time, then by ID for stability
func sortEvents(events []Event) {
// Use stable sort to preserve insertion order for identical timestamps
sort.SliceStable(events, func(i, j int) bool {
// Compare timestamps first. Earlier times come first.
if !events[i].Timestamp.Equal(events[j].Timestamp) {
return events[i].Timestamp.Before(events[j].Timestamp)
}
// Fall back to lexicographical ID comparison for ties
// Guarantees deterministic output across runs
return events[i].ID < events[j].ID
})
}
// main demonstrates the realistic sorting scenario
func main() {
events := []Event{
{"evt-3", time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), 2},
{"evt-1", time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), 1},
{"evt-2", time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC), 3},
}
sortEvents(events)
fmt.Println(events)
}
The function modifies the slice in place. The caller receives the reordered data without extra allocations. The stable sort ensures that events with identical timestamps keep their original relative order, which prevents flaky test failures and unpredictable UI rendering.
Convention aside: Receiver names in Go are usually one or two letters matching the type. If you were to attach this logic to a type, you would write (e *EventList) Sort() instead of (this *EventList). Keep it idiomatic.
Common pitfalls and compiler feedback
The most frequent mistake is sorting a sub-slice while the closure captures the original slice. The indices passed to your function refer to the slice you passed to sort.Slice, not the underlying array. If you pass tasks[1:], the indices start at zero relative to that sub-slice. The closure must reference the exact slice you are sorting.
If you accidentally pass the wrong type to a sorting function, the compiler rejects it immediately. You will see something like cannot use tasks (variable of type []Task) as []int value in argument. Go does not allow implicit type conversions between slices, even if the element types are compatible. You must convert explicitly or use the correct package.
Another trap is modifying the slice inside the comparison function. The sorting algorithm assumes your function is a pure comparison. Changing elements while the algorithm is mid-swap corrupts the partition state and produces garbage output. The compiler cannot catch this at build time. It only manifests as incorrect ordering at runtime.
Performance degrades when the comparison function does heavy work. Database lookups, network calls, or complex string parsing inside the closure turn an O(n log n) sort into an O(n log n * k) operation. Precompute the sort keys before calling the sort function. Extract the values you need, sort the indices, then reorder the original slice.
The worst sort bug is the one that silently produces wrong order. Validate your comparison function with edge cases: empty slices, single elements, all-equal elements, and reverse-sorted input.
When to pick which tool
Use slices.Sort when you are ordering a slice of built-in types like integers, floats, or strings. Use slices.SortFunc when you need a custom comparison for built-in types without defining a full struct. Use sort.Slice when you are ordering a slice of structs or custom types and want a quick inline comparison. Use sort.SliceStable when equal elements must retain their original relative order. Use a custom sort.Interface implementation when you are sorting the same custom type repeatedly in performance-critical paths. Use sequential iteration with manual partitioning when the dataset is tiny and the overhead of any sort function outweighs the benefit.
Pick the tool that matches your data shape. Do not overengineer the comparison.