How to Implement a Queue in Go

Implement a circular queue in Go using a slice, a head index, and modulo arithmetic to manage fixed capacity efficiently.

How to Implement a Queue in Go

You are building a background worker that processes images. Requests arrive faster than the processor can handle them. You need a buffer that holds the pending images and hands them out one by one in the order they arrived. A slice works for a moment, but removing the first element shifts everything else, costing time proportional to the queue size. As the queue grows, the lag becomes unbearable. You need a data structure that adds to the back and removes from the front without moving the rest of the data.

A queue follows first-in, first-out order. The first item added is the first one removed. Think of a line at a coffee shop. People join at the back. The barista serves the person at the front. When the barista calls the next customer, the line doesn't collapse. Everyone just steps forward, or the barista moves to the next person. In code, you need Enqueue to add to the tail and Dequeue to remove from the head. The challenge is efficiency. A naive slice implementation copies all remaining elements on every dequeue. A circular buffer solves this by wrapping around the underlying array. The data stays put. Only the indices move.

The circular buffer structure

The queue uses a fixed-size slice as a ring. You track the head index, which points to the oldest element. You track the size, which counts how many items are stored. The tail index is never stored. It is computed from the head and size. This keeps the state minimal and avoids ambiguity between empty and full states.

// Queue holds a generic circular buffer with O(1) operations.
type Queue[T any] struct {
	data []T // Fixed-capacity backing array.
	head int // Index of the front element.
	size int // Number of valid elements.
}

// NewQueue creates a queue with the given capacity.
func NewQueue[T any](cap int) *Queue[T] {
	return &Queue[T]{
		data: make([]T, cap), // Pre-allocate memory to avoid resizing overhead.
		head: 0,
		size: 0,
	}
}

Pre-allocating the slice ensures the queue never resizes. Resizing a slice copies the entire array, which breaks the O(1) guarantee. The capacity is fixed at creation. If you need a dynamic queue, you must implement a resize strategy that doubles the capacity and copies elements, but that adds complexity. For most bounded buffers, a fixed capacity is the right choice. It prevents memory leaks and forces you to handle overflow explicitly.

Adding and removing elements

Enqueueing calculates the tail index using modulo arithmetic. The tail is (head + size) % capacity. If the queue is full, the operation fails. Dequeueing reads from the head, advances the head with wrap-around, and decrements the size. Both operations run in constant time. No elements shift. The indices rotate around the buffer.

// Enqueue adds an element to the back of the queue.
func (q *Queue[T]) Enqueue(v T) {
	if q.size == len(q.data) {
		panic("queue full") // Capacity exceeded.
	}
	tail := (q.head + q.size) % len(q.data) // Wrap around using modulo.
	q.data[tail] = v
	q.size++
}

// Dequeue removes and returns the front element.
func (q *Queue[T]) Dequeue() (T, bool) {
	if q.size == 0 {
		var zero T
		return zero, false // Empty queue returns zero value.
	}
	v := q.data[q.head]
	q.head = (q.head + 1) % len(q.data) // Advance head with wrap-around.
	q.size--
	return v, true
}

The modulo operator handles the wrap-around. When head reaches the end of the slice, (head + 1) % cap wraps it back to zero. This creates the circular behavior. The compiler rejects the program with loop variable captured by func literal if you try to use this queue inside a goroutine loop without capturing variables correctly, but the queue logic itself is safe from that specific error. The receiver name q matches the type Queue. Go convention favors short receiver names that hint at the type.

Realistic usage with error handling

Panic is not an option in production code. A full queue should return an error so the caller can decide whether to drop the item, wait, or expand the buffer. The if err != nil pattern makes the unhappy path visible. This is verbose by design. The community accepts the boilerplate because it forces you to handle errors explicitly.

import "fmt"

type Task struct {
	ID   int
	Data string
}

// Enqueue returns an error if the queue is full.
func (q *Queue[T]) Enqueue(v T) error {
	if q.size == len(q.data) {
		return fmt.Errorf("queue capacity reached")
	}
	tail := (q.head + q.size) % len(q.data)
	q.data[tail] = v
	q.size++
	return nil
}

func processTasks() {
	q := NewQueue[Task](10) // Bounded buffer.

	// Simulate incoming work that exceeds capacity.
	for i := 0; i < 12; i++ {
		err := q.Enqueue(Task{ID: i, Data: fmt.Sprintf("job-%d", i)})
		if err != nil {
			fmt.Printf("dropped task %d: %v\n", i, err) // Handle overflow.
			continue
		}
	}

	// Drain the queue.
	for {
		task, ok := q.Dequeue()
		if !ok {
			break
		}
		fmt.Printf("processing %s\n", task.Data)
	}
}

The loop checks the error and logs the dropped task. This is a common pattern for bounded buffers. You accept that some items might be lost under load. The queue protects the downstream processor from being overwhelmed. The Dequeue loop runs until the queue is empty. The boolean return value signals whether an item was available. This avoids panics on empty queues.

Pitfalls and runtime behavior

The queue is not thread-safe. Accessing it from multiple goroutines causes data races. The race detector will flag read and write of shared variable. If you need concurrency, wrap the queue in a mutex or use a channel. A channel is often the better choice for concurrent code. It handles synchronization and blocking automatically.

import "sync"

type SafeQueue[T any] struct {
	mu sync.Mutex
	q  *Queue[T]
}

func (sq *SafeQueue[T]) Enqueue(v T) error {
	sq.mu.Lock()
	defer sq.mu.Unlock() // Unlock on return.
	return sq.q.Enqueue(v)
}

The mutex protects the internal state. Every public method must lock the mutex. This adds overhead. If you find yourself writing a mutex wrapper, consider whether a channel would be simpler. Channels are the idiomatic way to communicate between goroutines. A slice-based queue shines in single-threaded contexts or when you need to inspect the buffer contents.

Modulo arithmetic can be expensive on some architectures. If the capacity is a power of two, the compiler may optimize the modulo to a bitwise AND. You can hint this by choosing capacities like 1024 or 4096. The compiler is smart, but explicit power-of-two sizes make the intent clear. The worst goroutine bug is the one that never logs. If your queue blocks or drops items silently, you will spend hours debugging. Always log overflow or empty conditions.

When to use a slice queue

Use a slice-based queue when you need a bounded buffer with O(1) operations and no allocation overhead per element. Use a channel when you need safe concurrent communication between goroutines. Use a linked list when the queue size is unbounded and you cannot afford a fixed capacity. Use a simple slice with append and copy when the queue is small and performance is not critical.

The queue is a ring. The indices dance around the data. Bounded buffers prevent memory explosions. Set the capacity. Trust the modulo. Wrap the queue or change the design.

Where to go next