How to Implement a Ring Buffer in Go
You're building a dashboard that displays the last 50 sensor readings. New data arrives every second. You don't want to store every reading ever taken; that would eat memory and slow down queries. You need a container that holds exactly 50 items, where the oldest item gets kicked out the moment a new one arrives. This is a ring buffer. Go doesn't ship with one in the standard library, but building one is a straightforward exercise in slices, modulo arithmetic, and pointer semantics.
A ring buffer, also called a circular buffer, is a fixed-size data structure that treats the end of the storage as connected to the beginning. Imagine a round table with five seats. Guests sit down one by one. When the sixth guest arrives, the first guest leaves to make room. The table never grows; it just rotates. In code, you use a slice as the table and two indices to track where the oldest item sits and where the next item goes. The magic happens when those indices hit the end of the slice and wrap back to zero.
The minimal implementation
Here's the skeleton of a ring buffer for integers. It uses a slice for storage, tracks the head and tail positions, and maintains a count to distinguish between full and empty states.
package main
// RingBuffer holds a fixed number of integers in a circular layout.
type RingBuffer struct {
buf []int // backing storage; length equals max capacity
head int // index of the oldest item
tail int // index where the next item will be written
size int // maximum number of items the buffer can hold
count int // current number of items; distinguishes full from empty
}
// NewRingBuffer creates a buffer that can hold capacity items.
func NewRingBuffer(capacity int) *RingBuffer {
// pre-allocate the slice to avoid resizing during operation
return &RingBuffer{
buf: make([]int, capacity),
size: capacity,
}
}
// Push adds a value to the tail. Returns false if the buffer is full.
func (r *RingBuffer) Push(v int) bool {
// reject the push if we've reached the limit
if r.count == r.size {
return false
}
r.buf[r.tail] = v
// advance tail, wrapping to zero if we hit the end
r.tail = (r.tail + 1) % r.size
r.count++
return true
}
// Pop removes and returns the oldest value from the head.
func (r *RingBuffer) Pop() (int, bool) {
// return false if there's nothing to read
if r.count == 0 {
return 0, false
}
v := r.buf[r.head]
// advance head, wrapping to zero if needed
r.head = (r.head + 1) % r.size
r.count--
return v, true
}
How the indices dance
The compiler checks that RingBuffer is exported because the name starts with a capital letter. Other packages can create and use this type. The fields are lowercase, so they're private to this package. This is standard Go encapsulation: export the type, hide the internals. The receiver name r matches the type RingBuffer. Go convention favors short receiver names, usually one or two letters that echo the type name.
At runtime, make([]int, capacity) allocates a contiguous block of memory. The slice header points to this block. The head and tail indices are just integers. When you push a value, you write to buf[tail] and increment tail. The modulo operator % handles the wrap. When tail equals size, the expression (size) % size evaluates to zero, so the index jumps back to the start of the slice. The same logic applies to head during pop operations.
The count field is the safety net. Without it, you can't tell if the buffer is full or empty when head and tail point to the same index. Some implementations waste one slot to avoid tracking count, but that reduces effective capacity by one. Tracking count is cleaner and uses every byte of the slice.
Track the count. Distinguishing full from empty saves headaches.
Real-world usage with generics
Real code rarely deals with just integers. You want a ring buffer of structs, strings, or log entries. Go generics let you write the logic once and reuse it for any type.
Here's a generic version that works with any type. This is how you'd actually reuse the code in a project.
package main
// GenericRingBuffer holds a fixed number of items of type T.
type GenericRingBuffer[T any] struct {
buf []T
head int
tail int
size int
count int
}
// NewGenericRingBuffer creates a buffer for type T.
func NewGenericRingBuffer[T any](capacity int) *GenericRingBuffer[T] {
return &GenericRingBuffer[T]{
buf: make([]T, capacity),
size: capacity,
}
}
// Push adds an item. Returns false if full.
func (r *GenericRingBuffer[T]) Push(v T) bool {
if r.count == r.size {
return false
}
r.buf[r.tail] = v
r.tail = (r.tail + 1) % r.size
r.count++
return true
}
// Pop returns the oldest item. Returns zero value and false if empty.
func (r *GenericRingBuffer[T]) Pop() (T, bool) {
if r.count == 0 {
var zero T
return zero, false
}
v := r.buf[r.head]
r.head = (r.head + 1) % r.size
r.count--
return v, true
}
The generic syntax [T any] declares a type parameter. The function Pop returns (T, bool). When the buffer is empty, it returns the zero value of T along with false. The caller must check the boolean to know if the value is valid. For strings, the zero value is an empty string. For structs, it's an empty struct. The boolean is the guard.
Generics add flexibility. Zero values demand checks.
Pitfalls and runtime traps
Ring buffers are not thread-safe. If two goroutines call Push simultaneously, they might read the same tail index, write to the same slot, and corrupt the count. The compiler won't catch this. You need to run the program with the race detector enabled using go run -race. The detector reports WARNING: DATA RACE when it spots concurrent access to shared memory. If you need concurrency, wrap the buffer in a mutex or use a channel instead.
Passing a capacity of zero causes a panic. The modulo operation (r.tail + 1) % r.size divides by zero when size is zero. The runtime aborts with runtime error: integer divide by zero. Always validate capacity in the constructor. A simple check like if capacity <= 0 { return nil } prevents this.
The standard library includes container/ring, which implements a circular doubly-linked list. It supports traversal methods like Next and Prev. However, it allocates a new node for every item. A slice-based ring buffer reuses the same memory block and benefits from cache locality. For tight loops and high-throughput scenarios, the slice wins. The linked list is useful when you need to insert or remove items in the middle, but a ring buffer is strictly append-only and remove-from-head.
Cache locality wins. Keep data contiguous.
When to use a ring buffer
Use a ring buffer when you need a fixed-size history where the oldest item is discarded automatically. Use a standard slice when the size can grow and you need to append arbitrarily. Use a buffered channel when you need concurrency-safe communication between goroutines. Use container/ring when you need a doubly-linked circular list with traversal methods, though it's slower than a slice-based buffer. Use a ring buffer when performance matters and you want to avoid allocation churn by reusing the same memory block.
Slices are fast. Rings are faster when you stop allocating.