How to Use container/heap in Go

Use container/heap by defining a type that implements the heap.Interface methods and calling heap.Init, Push, and Pop.

When order matters more than sequence

You are building a task scheduler. Some jobs are "deploy hotfix," others are "generate weekly report." You need to process the hotfix first, no matter when it arrived. A regular slice processes items in the order they were added. You need a structure that always surfaces the highest priority item instantly, even as new items arrive.

That structure is a priority queue. In Go, you build one with container/heap. The package doesn't give you a ready-made queue. It gives you the machinery to turn any slice into a heap, provided you tell it how to compare elements. You define the type and the rules. The package handles the tree rearrangement.

The heap interface pattern

A heap is a binary tree stored inside a slice. The root holds the extreme value (minimum or maximum). Children are always worse than their parent. This invariant lets you find the best item in constant time and insert or remove items in logarithmic time.

container/heap uses an interface, heap.Interface, to stay generic without generics. You create a type that wraps your data and implements five methods: Len, Less, Swap, Push, and Pop. Once your type satisfies the interface, you pass it to heap.Init, heap.Push, and heap.Pop. The package calls your methods to maintain the heap property.

This design follows the Go convention of accepting interfaces. The heap package accepts your type as an interface. You return your concrete struct or slice. The interface decouples the algorithm from your data. You can use the same heap logic for integers, structs, or pointers without copying code.

Here is the skeleton of a min-heap for integers. You define the type and five methods, then hand it to the package.

// IntHeap wraps a slice of ints and implements heap.Interface.
type IntHeap []int

// Len returns the number of items in the heap.
func (h IntHeap) Len() int { return len(h) }

// Less defines the ordering. Smaller values bubble to the top for a min-heap.
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }

// Swap exchanges elements. The heap package calls this to rearrange nodes.
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

// Push adds an element. The interface requires any, so assert to int.
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }

// Pop removes the last element and returns it. The heap package moves the root to the end before calling this.
func (h *IntHeap) Pop() any {
    n := len(*h)
    item := (*h)[n-1]
    *h = (*h)[:n-1]
    return item
}

The receiver types matter. Len, Less, and Swap can use value receivers because they only read the slice. Push and Pop must use pointer receivers because they modify the underlying slice. If you use a value receiver for Push, the heap package modifies a copy and the original slice never grows. The compiler won't catch this error. The program compiles, but the heap stays empty.

Convention aside: receiver names should be short and match the type. (h IntHeap) is standard. (this IntHeap) or (self IntHeap) breaks Go style. Keep it to one or two letters.

How the operations work

heap.Init builds a heap from an existing slice. It runs in linear time, O(n). It doesn't sort the slice. It rearranges elements just enough to satisfy the heap property. If you start with a sorted slice, Init still scans the whole thing. Don't call Init on a slice you just sorted; you're paying linear work for no gain.

heap.Push appends the new element to the end of the slice, then bubbles it up toward the root until the heap property holds. It calls your Push method to add the element, then swaps nodes using your Swap method. The cost is O(log n).

heap.Pop removes the root. It moves the last element to the root, shrinks the slice, then bubbles the new root down. It calls your Pop method to remove the element from the end. You must return the element from Pop. The package expects the return value to be the item that was at the root. If you return the wrong value, the caller gets garbage. The cost is O(log n).

The heap package uses any in Push and Pop signatures. This is a legacy artifact from before Go 1.18 generics. The interface was defined with interface{}. any is just an alias. You must type-assert in Push and cast the return value after Pop. If you forget the assertion, the compiler rejects the program with cannot use x (variable of type any) as int value in argument.

The heap package is fast. Your interface implementation is the bottleneck. Keep Less cheap. If Less does heavy computation, every heap operation pays that cost multiple times.

Tracking indices for updates

Real priority queues often need to update an item's priority or remove a specific item. The heap package doesn't support these operations directly. You can remove the root, but not an arbitrary node. You can push a new item, but not modify an existing one.

The workaround is to track the index of each item in the underlying slice. When the heap swaps elements, you update the indices. When you need to update priority, you change the value and call heap.Fix to restore the heap property.

Here is a priority queue for tasks with priorities and indices. You define the item struct, the queue type, and methods that keep indices in sync.

// Item holds a value, priority, and its current index in the heap slice.
type Item struct {
    value    string
    priority int
    index    int
}

// PriorityQueue stores items with priorities and tracks indices for updates.
type PriorityQueue []*Item

// Less defines ordering. Lower priority numbers mean higher urgency.
func (pq PriorityQueue) Less(i, j int) bool { return pq[i].priority < pq[j].priority }

// Swap updates indices to keep the index map consistent with the slice layout.
func (pq PriorityQueue) Swap(i, j int) {
    pq[i], pq[j] = pq[j], pq[i]
    pq[i].index = i
    pq[j].index = j
}

// Push appends the item and sets its index to the new end of the slice.
func (pq *PriorityQueue) Push(x any) {
    item := x.(*Item)
    item.index = len(*pq)
    *pq = append(*pq, item)
}

// Pop removes the last element. Setting the slot to nil helps the garbage collector.
func (pq *PriorityQueue) Pop() any {
    old := *pq
    n := len(old)
    item := old[n-1]
    old[n-1] = nil
    item.index = -1
    *pq = old[0 : n-1]
    return item
}

The Swap method is critical. It updates item.index for both swapped items. If you forget this, the indices drift. The heap property holds, but your index map lies. You call heap.Fix on the wrong index, and the heap corrupts silently.

Convention aside: setting old[n-1] = nil in Pop helps the garbage collector. The slice capacity stays large, but the removed slot holds a pointer to the item. If you don't nil it, the item stays alive even though it's logically removed. This is a common source of memory leaks in Go heaps.

Here is how you update an item's priority. You change the value and call heap.Fix.

// Update changes an item's value and priority, then restores the heap property.
func (pq *PriorityQueue) Update(item *Item, value string, priority int) {
    item.value = value
    item.priority = priority
    heap.Fix(pq, item.index) // Re-heapify starting from the changed node
}

heap.Fix assumes the element at index has changed. It bubbles the element up or down as needed. It's cheaper than removing and re-adding the item. The cost is O(log n).

Indices are the glue. If Swap doesn't update them, the heap lies to you.

Pitfalls and runtime traps

Calling heap.Pop on an empty heap panics. The package doesn't check length. It assumes you know the heap has elements. Check Len() before popping. If you pop from an empty heap, the runtime panics with runtime error: index out of range [0] with length 0.

The Pop method must return the element that was at the root. The package moves the root to the end of the slice before calling Pop. Your Pop implementation removes the last element and returns it. If you return a different element, the caller gets the wrong value. This is a logic error, not a compiler error. Test thoroughly.

If you modify an item's priority without calling heap.Fix, the heap property breaks. The next Pop might return the wrong item. The heap doesn't validate invariants. It trusts you.

Convention aside: context.Context always goes as the first parameter in functions. If your heap operations are part of a larger system that respects cancellation, pass context through your handlers. The heap package itself doesn't use context. It's a data structure, not a goroutine.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. If you use a heap to feed a worker pool, ensure the channel closes when the heap is done. The worst goroutine bug is the one that never logs.

The heap package works with any type. You can store pointers, structs, or interfaces. If you store pointers, Less compares pointer values unless you dereference. Be explicit. pq[i].priority < pq[j].priority compares priorities. pq[i] < pq[j] compares addresses and gives nonsense results.

When to use a heap

Use container/heap when you need frequent inserts and removals of the extreme element, and the dataset changes dynamically.

Use a sorted slice when the data is static or changes rarely, and you need random access or range queries.

Use a channel-based priority queue when multiple goroutines produce items and a single consumer processes them by priority.

Use a simple slice with sort.Search when you only need to find the min or max occasionally and don't want the overhead of maintaining heap invariants.

The heap package gives you the machinery. You supply the logic. Keep the interface implementation tight.

Where to go next