Ring Buffers
A ring buffer is a fixed-size queue that reuses its storage.
A normal queue has a front and a back:
front back
[10][20][30][40]When you remove from the front, the front moves forward:
[old][20][30][40]
^
frontAfter many removals, unused space builds up at the beginning.
A ring buffer solves this by wrapping around to the start of the array.
[50][60][old][40]
^ ^
back frontThe storage is treated like a circle.
The Core Idea
A ring buffer keeps three pieces of state:
buffer: []T
head: usize
len: usizehead is the index of the first item.
len is the number of stored items.
The next item to remove is at head.
The next place to insert is:
(head + len) % buffer.lenThe % operator wraps the index back to zero when it reaches the end.
Example with capacity 4:
buffer indexes:
0 1 2 3If head = 3 and len = 2, the items are at:
3, 0The buffer wraps around.
Why Ring Buffers Are Useful
Ring buffers are useful when you want queue behavior without repeated allocation.
They are common in:
task queues
audio buffers
network buffers
logging buffers
producer-consumer systems
embedded systems
event loopsThey are especially useful when the maximum capacity is known.
A Simple Fixed Ring Buffer
Here is a small ring buffer for i32 values:
const std = @import("std");
const RingBuffer = struct {
buffer: []i32,
head: usize,
len: usize,
pub fn init(buffer: []i32) RingBuffer {
return RingBuffer{
.buffer = buffer,
.head = 0,
.len = 0,
};
}
pub fn capacity(self: *const RingBuffer) usize {
return self.buffer.len;
}
pub fn isEmpty(self: *const RingBuffer) bool {
return self.len == 0;
}
pub fn isFull(self: *const RingBuffer) bool {
return self.len == self.buffer.len;
}
pub fn push(self: *RingBuffer, value: i32) !void {
if (self.isFull()) return error.Full;
const index = (self.head + self.len) % self.buffer.len;
self.buffer[index] = value;
self.len += 1;
}
pub fn pop(self: *RingBuffer) ?i32 {
if (self.isEmpty()) return null;
const value = self.buffer[self.head];
self.head = (self.head + 1) % self.buffer.len;
self.len -= 1;
return value;
}
};Using it:
pub fn main() !void {
var storage: [4]i32 = undefined;
var queue = RingBuffer.init(&storage);
try queue.push(10);
try queue.push(20);
try queue.push(30);
std.debug.print("{}\n", .{queue.pop().?});
std.debug.print("{}\n", .{queue.pop().?});
try queue.push(40);
try queue.push(50);
while (queue.pop()) |value| {
std.debug.print("{}\n", .{value});
}
}Output:
10
20
30
40
50The storage array never moves and never reallocates.
Push
The push operation inserts at the back.
const index = (self.head + self.len) % self.buffer.len;
self.buffer[index] = value;
self.len += 1;Suppose:
capacity = 4
head = 2
len = 2The occupied indexes are:
2, 3The next insert index is:
(2 + 2) % 4 = 0So the new item goes at index 0.
[new][ ][old][old]That is the wraparound.
Pop
The pop operation removes from the front.
const value = self.buffer[self.head];
self.head = (self.head + 1) % self.buffer.len;
self.len -= 1;
return value;If head reaches the end, it wraps back to zero.
(head + 1) % capacityFor capacity 4:
0 -> 1 -> 2 -> 3 -> 0Full and Empty
A ring buffer must know whether it is full or empty.
In this design:
len == 0means empty.
len == buffer.lenmeans full.
This is clear because we store len.
Some ring buffers store only head and tail. Those designs often need one unused slot or an extra flag to distinguish full from empty.
For beginners, storing len is simpler.
Why Capacity Cannot Be Zero
The code uses % self.buffer.len.
If buffer.len is zero, that would divide by zero.
A production version should reject empty storage:
pub fn init(buffer: []i32) !RingBuffer {
if (buffer.len == 0) return error.EmptyBuffer;
return RingBuffer{
.buffer = buffer,
.head = 0,
.len = 0,
};
}Then callers must use try:
var queue = try RingBuffer.init(&storage);Generic Ring Buffer
The previous version works only for i32.
We can make it generic with comptime.
const std = @import("std");
fn RingBuffer(comptime T: type) type {
return struct {
const Self = @This();
buffer: []T,
head: usize,
len: usize,
pub fn init(buffer: []T) !Self {
if (buffer.len == 0) return error.EmptyBuffer;
return Self{
.buffer = buffer,
.head = 0,
.len = 0,
};
}
pub fn capacity(self: *const Self) usize {
return self.buffer.len;
}
pub fn isEmpty(self: *const Self) bool {
return self.len == 0;
}
pub fn isFull(self: *const Self) bool {
return self.len == self.buffer.len;
}
pub fn push(self: *Self, value: T) !void {
if (self.isFull()) return error.Full;
const index = (self.head + self.len) % self.buffer.len;
self.buffer[index] = value;
self.len += 1;
}
pub fn pop(self: *Self) ?T {
if (self.isEmpty()) return null;
const value = self.buffer[self.head];
self.head = (self.head + 1) % self.buffer.len;
self.len -= 1;
return value;
}
};
}Using it:
pub fn main() !void {
var storage: [8][]const u8 = undefined;
var queue = try RingBuffer([]const u8).init(&storage);
try queue.push("alpha");
try queue.push("beta");
try queue.push("gamma");
while (queue.pop()) |name| {
std.debug.print("{s}\n", .{name});
}
}Output:
alpha
beta
gammaNow the same ring buffer works for many types.
Overwrite Mode
Some ring buffers reject new items when full.
Others overwrite the oldest item.
Overwrite mode is useful for logs, telemetry, and recent-history buffers.
Example rule:
capacity = 3
push A -> [A]
push B -> [A, B]
push C -> [A, B, C]
push D -> [B, C, D]The oldest item is dropped.
A push function for overwrite mode can look like this:
pub fn pushOverwrite(self: *Self, value: T) void {
if (self.isFull()) {
self.buffer[self.head] = value;
self.head = (self.head + 1) % self.buffer.len;
} else {
const index = (self.head + self.len) % self.buffer.len;
self.buffer[index] = value;
self.len += 1;
}
}When full, the new value replaces the oldest value at head, then head moves forward.
Looking Without Removing
A queue often needs peek.
pub fn peek(self: *const Self) ?T {
if (self.isEmpty()) return null;
return self.buffer[self.head];
}This returns the front value without changing the buffer.
For large values, you might prefer returning a pointer:
pub fn peekPtr(self: *Self) ?*T {
if (self.isEmpty()) return null;
return &self.buffer[self.head];
}This avoids copying the value.
But be careful: the pointer is valid only while the buffer item remains in place.
Iterating Over a Ring Buffer
A ring buffer may wrap, so iteration must use modular indexing.
pub fn at(self: *const Self, offset: usize) ?T {
if (offset >= self.len) return null;
const index = (self.head + offset) % self.buffer.len;
return self.buffer[index];
}Using it:
var i: usize = 0;
while (i < queue.len) : (i += 1) {
const value = queue.at(i).?;
std.debug.print("{}\n", .{value});
}This visits items in queue order.
Ring Buffer vs ArrayList Queue
An ArrayList queue with a head index can grow dynamically, but old space may accumulate until you compact it.
A ring buffer has fixed capacity and reuses the same storage.
| Structure | Capacity | Allocation | Good for |
|---|---|---|---|
ArrayList queue | Can grow | May allocate | Unknown size |
| Ring buffer | Fixed | Usually none after setup | Known maximum size |
| Linked list queue | Can grow | Per node | Stable node pointers |
A ring buffer is often the best choice when the maximum size is bounded.
Stack Allocation
One nice feature of a fixed ring buffer is that the storage can live on the stack.
var storage: [128]u8 = undefined;
var rb = try RingBuffer(u8).init(&storage);No heap allocator is needed.
This is useful in embedded systems, parsers, temporary buffers, and performance-sensitive code.
Heap Allocation
If the capacity is known only at runtime, allocate the storage:
const storage = try allocator.alloc(u8, capacity);
defer allocator.free(storage);
var rb = try RingBuffer(u8).init(storage);The ring buffer itself does not own the memory in this design. It only borrows the slice.
The caller owns and frees the storage.
This makes ownership clear.
Common Beginner Mistakes
The first mistake is forgetting the wraparound. The insert index is not always head + len; it must be (head + len) % capacity.
The second mistake is allowing zero capacity. Modulo by zero is invalid.
The third mistake is confusing full and empty when using only head and tail. Storing len avoids that problem.
The fourth mistake is assuming a ring buffer grows. A ring buffer usually has fixed capacity.
The fifth mistake is returning pointers and then modifying the buffer in ways that overwrite the pointed item.
A Useful Mental Model
Think of a ring buffer as an array bent into a circle.
0 -> 1 -> 2 -> 3
^ |
| v
7 <- 6 <- 5 <- 4When the index reaches the end, it returns to the beginning.
The queue order is logical.
The storage order may wrap.
Summary
A ring buffer is a queue backed by fixed-size circular storage.
It keeps a head, a len, and a buffer.
Push inserts at:
(head + len) % capacityPop removes from:
headThen head moves forward with wraparound.
Ring buffers are useful for bounded queues, logs, audio buffers, network buffers, event loops, and embedded systems.
They are simple, fast, allocation-free after setup, and predictable.