A Practical Guide to Implementing a Generic Ring Buffer in Go

Learn how to implement a generic, thread-safe ring buffer in Go using generics, with practical examples for streaming, telemetry, networking, logging, and more.
Written by
Nathan Crocker
Published on
August 18, 2026

In this article, we’ll explore the implementation of a generic ring buffer (also known as a circular buffer) in Go. We’ll walk through the key elements of the design, explain the code step by step, and discuss the ideal use cases for a ring buffer in software development.

What is a Ring Buffer?

A ring buffer is a fixed-size, circular data structure that overwrites the oldest data when the buffer is full. It’s particularly useful for scenarios where you want to store and retrieve data in a FIFO (First-In-First-Out) manner but with limited memory. When the buffer reaches its size limit, new data will overwrite the oldest data.

Ring buffers are often used in systems where memory efficiency and time complexity are crucial, such as in real-time data streaming or telemetry systems. They shine in scenarios where you don’t need all historical data, just the most recent items.

Go’s Generics in Action

The beauty of Go’s new generics feature (introduced in Go 1.18) is its ability to create reusable, type-safe data structures. With generics, we can design a ring buffer that works for any type of data without compromising on safety or performance.

Below is a detailed implementation of a thread-safe, generic ring buffer in Go.

The Code

package main
import (
    "sync"
)
type RingBuffer[T any] struct {
    buffer []T
    size   int
    mu     sync.Mutex
    write  int
    count  int
}
// NewRingBuffer creates a new ring buffer with a fixed size.
func NewRingBuffer[T any](size int) *RingBuffer[T] {
    return &RingBuffer[T]{
        buffer: make([]T, size),
        size:   size,
    }
}
// Add inserts a new element into the buffer, overwriting the oldest if full.
func (rb *RingBuffer[T]) Add(value T) {
    rb.mu.Lock()
    defer rb.mu.Unlock()
    rb.buffer[rb.write] = value
    rb.write = (rb.write + 1) % rb.size
    if rb.count < rb.size {
        rb.count++
    }
}
// Get returns the contents of the buffer in FIFO order.
func (rb *RingBuffer[T]) Get() []T {
    rb.mu.Lock()
    defer rb.mu.Unlock()
    result := make([]T, 0, rb.count)
    for i := 0; i < rb.count; i++ {
        index := (rb.write + rb.size - rb.count + i) % rb.size
        result = append(result, rb.buffer[index])
    }
    return result
}
// Len returns the current number of elements in the buffer.
func (rb *RingBuffer[T]) Len() int {
    rb.mu.Lock()
    defer rb.mu.Unlock()
    return rb.count
}

Key Concepts Explained

Generic Type Parameter

This implementation leverages Go’s any keyword, which allows the ring buffer to accept any type (T). This means that the ring buffer can hold integers, strings, or any custom struct without needing to write type-specific implementations.

Thread Safety with Mutex

To make the ring buffer safe to use in concurrent environments, we utilize a sync.Mutex to lock the buffer during writes and reads. This ensures that no data is corrupted when multiple goroutines attempt to access the buffer simultaneously.

Circular Buffer Logic

The most important logic in a ring buffer is how it handles wrapping around. The write pointer always moves forward when new data is added, and when it reaches the buffer size, it wraps around to the beginning using the modulo operation: (rb.write + 1) % rb.size.

If the buffer isn’t full, the count is incremented; otherwise, the buffer size stays constant, ensuring old data is overwritten correctly.

Testing the Ring Buffer

No code should be considered complete until it is accompanied by a comprehensive suite of tests.

package main
import (
    "reflect"
    "sync"
    "testing"
    "time"
)
func TestRingBuffer_AddAndGet(t *testing.T) {
    ringBuffer := NewRingBuffer[int](5)
    ringBuffer.Add(1)
    ringBuffer.Add(2)
    ringBuffer.Add(3)
    expected := []int{1, 2, 3}
    actual := ringBuffer.Get()
    if !reflect.DeepEqual(actual, expected) {
        t.Errorf("Expected %v, but got %v", expected, actual)
    }
    ringBuffer.Add(4)
    ringBuffer.Add(5)
    ringBuffer.Add(6)
    expected = []int{2, 3, 4, 5, 6}
    actual = ringBuffer.Get()
    if !reflect.DeepEqual(actual, expected) {
        t.Errorf("Expected %v, but got %v", expected, actual)
    }
    ringBuffer.Add(7)
    ringBuffer.Add(8)
    expected = []int{4, 5, 6, 7, 8}
    actual = ringBuffer.Get()
    if !reflect.DeepEqual(actual, expected) {
        t.Errorf("Expected %v, but got %v", expected, actual)
    }
}
func TestRingBufferConcurrent(t *testing.T) {
    ringBuffer := NewRingBuffer[int](3)
    var wg sync.WaitGroup
    addValues := func(values []int) {
        for _, value := range values {
            ringBuffer.Add(value)
            // Simulate delay
            time.Sleep(10 * time.Millisecond)
        }
        wg.Done()
    }
    readValues := func() {
        prices := ringBuffer.Get()
        if len(prices) > 0 && len(prices) != ringBuffer.size {
            t.Errorf("Buffer length inconsistency: expected size %d but got %d", ringBuffer.size, len(prices))
        }
        wg.Done()
    }
    wg.Add(3)
    go addValues([]int{1, 2, 3})
    go addValues([]int{4, 5})
    go addValues([]int{6, 7, 8})
    wg.Add(2)
    go readValues()
    go readValues()
    wg.Wait()
    finalValues := ringBuffer.Get()
    for _, value := range finalValues {
        if value < 1 || value > 8 {
            t.Errorf("Unexpected value in buffer: %d", value)
        }
    }
    if len(finalValues) != ringBuffer.size {
        t.Errorf("Expected buffer size %d, but got %d", ringBuffer.size, len(finalValues))
    }
}

Use Cases for a Ring Buffer

Now that we’ve explored the implementation, let’s talk about when and why you would want to use a ring buffer in your applications.

1. Real-Time Streaming Data

When dealing with real-time data streams — such as financial tick data, sensor readings, or log messages — storing every single data point can be impractical due to memory constraints. A ring buffer allows you to focus on the latest data while gracefully discarding older entries.

Example: In a financial application, you might store the last 100 price changes of Bitcoin for real-time charting or analysis.

2. Telemetry and Monitoring

Telemetry systems often capture high-frequency data from various devices, but keeping all of it isn’t necessary. A ring buffer enables these systems to retain only the most recent events or metrics, ensuring efficient memory usage.

Example: A microservice monitoring system could use a ring buffer to store the latest 500 HTTP requests for quick access to the most recent traffic data.

3. Networking Buffers

In network programming, especially when working with streaming data over TCP or UDP, you can use a ring buffer to buffer incoming or outgoing packets. Since network traffic can spike or dip unpredictably, having a fixed-size buffer that overwrites old data ensures that memory usage remains controlled.

Example: A VoIP application could use a ring buffer to temporarily store voice packets, ensuring a smooth audio experience even if there are small delays in receiving packets.

4. Logging Systems

Logging systems that handle high-throughput data often can’t afford to store all logs indefinitely. A ring buffer allows you to store the latest logs, ensuring that you have immediate access to the most recent events while preventing memory exhaustion.

Example: An IoT gateway might use a ring buffer to store the latest 1,000 log messages locally before sending them to a centralized server for further processing.

5. Undo Functionality

If you’ve ever used a software program that allows you to undo your last few actions, you’ve likely benefited from a ring buffer. The buffer stores the last N actions and discards the oldest ones when the buffer is full.

Example: A text editor could use a ring buffer to store the last 50 changes, enabling users to undo multiple actions without consuming an unbounded amount of memory.

Conclusion

The ring buffer is a highly efficient data structure for use cases that require a fixed amount of memory while still needing to process data in a FIFO manner. Go’s generics make it easy to create a reusable, type-safe ring buffer that can handle any data type. Whether you’re building real-time systems, network buffers, or telemetry pipelines, a ring buffer can help you maintain efficient and predictable memory usage.

If you want to experiment with this implementation in your own Go projects, feel free to fork the code and adapt it to your needs. Happy coding!

What’s Next?

If you enjoyed this deep dive into ring buffers, consider exploring other data structures in Go using generics, such as stacks, queues, or priority queues. Each has its unique strengths and use cases in the world of efficient data handling.

Newsletter
No spam. Just the latest releases and tips, interesting articles, and exclusive interviews in your inbox every week.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.