How to Implement a Priority Queue (Heap) in Go

Implement a Go priority queue by defining a custom type that satisfies the container/heap.Interface methods.

The problem with plain slices

You are building a task scheduler. New jobs arrive constantly. Each job carries a deadline or a priority score. You need to grab the most urgent job instantly, without scanning the entire list. A plain slice forces you to sort on every insertion or scan linearly on every extraction. Both approaches waste CPU cycles as the queue grows. Go gives you a tool to keep the data ordered with minimal work.

A heap trades global sorting for local rearrangement.

What a heap actually is

A priority queue is a heap. Think of it like a pyramid of stones. The heaviest stone sits at the top. When you remove the top stone, the pyramid shifts to bring the next heaviest stone to the peak. The structure maintains a simple rule: every parent stone is heavier than its children. You never sort the whole pile. You only rearrange the stones near the top or bottom when something changes. This keeps insertion and removal fast, regardless of how many stones you stack.

The algorithm works on an array. Index zero holds the root. For any element at index i, its left child lives at 2*i + 1 and its right child at 2*i + 2. The parent of index i sits at (i-1)/2. The heap property only requires that parents compare correctly against children. Siblings can be in any order. This relaxed ordering is what makes the structure efficient. Insertion appends to the end and bubbles the new element up until it finds its parent. Removal swaps the root with the last element, drops the last element, and sinks the new root down until it finds its children. Both operations run in logarithmic time.

The interface contract

Go does not include a built-in priority queue type. Instead, it provides container/heap as a generic algorithm that works on any collection satisfying a specific contract. The contract is the heap.Interface. It requires five methods. Len returns the size. Less defines the ordering. Swap exchanges two elements. Push adds an element. Pop removes the last element. The package treats your type as a black box. It calls these methods to maintain the heap property.

Here is the simplest implementation using a slice of integers.

package main

import (
	"container/heap"
	"fmt"
)

// IntHeap implements heap.Interface for a slice of integers.
type IntHeap []int

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

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

// Swap exchanges two elements and updates their positions.
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

// Push appends a new element to the underlying slice.
func (h *IntHeap) Push(x any) {
	*h = append(*h, x.(int))
}

// Pop removes the last element, which the heap algorithm places there.
func (h *IntHeap) Pop() any {
	old := *h
	n := len(old)
	item := old[n-1]
	*h = old[0 : n-1]
	return item
}

func main() {
	h := &IntHeap{3, 1, 4, 1, 5}
	heap.Init(h)
	heap.Push(h, 9)
	fmt.Println(heap.Pop(h)) // prints: 1
}

Notice the receiver types. Len, Less, and Swap use value receivers because they only read or rearrange data. Push and Pop use pointer receivers because they modify the underlying slice length. The any type in Push and Pop exists because container/heap predates Go generics. The standard library designed the interface before type parameters existed, so it uses any to accept any type and leaves type assertions to the implementer. This is a historical artifact you will see throughout older standard library packages.

The package doesn't care about your data. It only cares about your contract.

Tracking items by index

Real queues rarely hold bare integers. They hold structs with payloads, deadlines, and metadata. You often need to update an item's priority or remove it by reference. The heap algorithm relies on array indices. If you change a priority directly, the heap property breaks. You need to tell the algorithm where the item lives so it can rebalance correctly. That is why the index field exists in production heap implementations.

Here is a realistic priority queue that tracks positions.

package main

import (
	"container/heap"
	"fmt"
)

// Item holds a task payload and its current position in the heap.
type Item struct {
	value    string
	priority int
	index    int // tracks the item's array position
}

// PriorityQueue implements heap.Interface for a slice of Items.
type PriorityQueue []*Item

// Len returns the number of items in the queue.
func (pq PriorityQueue) Len() int { return len(pq) }

// Less defines a min-heap: lower priority numbers bubble to the top.
func (pq PriorityQueue) Less(i, j int) bool {
	return pq[i].priority < pq[j].priority
}

// Swap exchanges two items and updates their tracked indices.
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 a new item and records its index.
func (pq *PriorityQueue) Push(x any) {
	n := len(*pq)
	item := x.(*Item)
	item.index = n
	*pq = append(*pq, item)
}

// Pop removes the last item and marks it as detached.
func (pq *PriorityQueue) Pop() any {
	old := *pq
	n := len(old)
	item := old[n-1]
	old[n-1] = nil // avoid memory leak
	item.index = -1
	*pq = old[0 : n-1]
	return item
}

The index field is the bridge between your application logic and the heap algorithm. When you call heap.Fix(pq, item.index), the package checks the item against its parent and children. It swaps elements as needed and calls your Swap method. Your Swap method must update the index field on both items. If you forget to update index, the heap rearranges the array but your struct still points to the old position. The next Fix call will panic with an index out of range error or silently corrupt the ordering.

Track the index or lose the map.

Common mistakes and compiler feedback

The compiler catches type mismatches quickly. If you pass a value instead of a pointer to heap.Init, heap.Push, or heap.Pop, you get cannot use pq (type PriorityQueue) as type *PriorityQueue in argument to heap.Push. The algorithm needs a pointer to modify the slice length. Always pass &pq to the heap functions.

The compiler will not catch logical inversions in Less. If you write pq[i].priority > pq[j].priority when you want a min-heap, the code compiles and runs. You will simply get a max-heap. The package trusts your comparison logic. Test the ordering explicitly.

Another silent failure happens when you forget to nil out the popped element. Go's garbage collector will eventually clean it up, but holding a reference to a detached item prevents immediate reclamation. Setting old[n-1] = nil in Pop is a convention that speeds up garbage collection for long-running queues.

Receiver naming follows Go conventions. Use a short name matching the type, like (pq *PriorityQueue) or (h *IntHeap). Avoid (this *PriorityQueue) or (self *PriorityQueue). The language does not have a this keyword, and the community treats receiver names as local variables.

The compiler catches type mismatches. It won't catch logical inversions in Less.

When to reach for a heap

Use a plain slice with sort.Slice when the dataset is small and you only need ordering once. Sorting runs in O(n log n) time, which is perfectly fine for a few hundred items that you process in batches.

Use container/heap when you continuously add and remove items and need the extreme value instantly. The logarithmic insertion and removal cost shines when you process streams of events, schedule background jobs, or implement Dijkstra's algorithm.

Use a sorted slice with binary search insertion when you need to iterate the entire queue in order frequently. Maintaining a fully sorted slice costs O(n) per insertion due to shifting, but iteration and range queries become trivial.

Use a third-party library or a channel-based worker pool when you need thread-safe concurrent access or complex update semantics. The standard library heap is not concurrent. Wrap it in a mutex or switch to a design that routes work through channels if multiple goroutines will modify the queue simultaneously.

Pick the structure that matches your access pattern, not the one that sounds clever.

Where to go next