Reduce memory allocations

Reduce memory allocations by using the arena package to allocate and free memory in bulk, bypassing the garbage collector.

When allocations become the bottleneck

You are running a high-throughput service. It processes thousands of records per second. The CPU usage is creeping up, not because your business logic is slow, but because the garbage collector is waking up every few milliseconds to clean up millions of tiny objects. You see latency spikes in your p99 metrics. The profiler points to runtime.mallocgc. You need to stop allocating.

Go manages memory automatically. You do not call malloc or free. This keeps your code safe and easy to write. It comes with a cost. Every time you create a new object, the runtime has to find space, update bookkeeping, and eventually scan that memory during a GC cycle. An arena changes the math. Instead of allocating and freeing thousands of small items individually, you allocate one big chunk of memory. You hand out pieces of that chunk to your objects. When you are done, you throw away the whole chunk in one operation. The GC never sees the individual objects. It only sees the arena, which you manage.

How arena allocation works

Go's garbage collector is a tracing collector. It walks the heap, marking objects that are still reachable. The more objects you have, the more work the collector does. Each object also carries metadata for the runtime. That metadata adds up.

An arena removes the per-object overhead. The arena holds a large block of memory. When you request an allocation, the arena just moves a pointer forward. This is called a bump pointer allocator. It is incredibly fast. There is no searching for free space. There is no updating complex data structures. You get a pointer and you go.

When you are finished with the batch of work, you free the arena. The runtime gets one big block back. The GC does not need to scan the contents. The memory is reclaimed instantly. This reduces GC pauses and lowers total memory churn.

Minimal example

Here is the simplest arena pattern. You create an arena, allocate a slice inside it, use the data, and free the arena. The entire lifetime of the data is bound to the arena.

import "arena"

func processBatch() {
    // Create a fresh arena for this batch.
    // All subsequent allocations will come from this block of memory.
    a := arena.NewArena()
    
    // Ensure the arena is freed when the function returns.
    // This releases the entire block back to the runtime at once.
    defer a.Free()

    // Allocate a slice of 10,000 ints directly in the arena.
    // No per-element allocation overhead. Just a bump pointer.
    items := arena.MakeSlice[int](a, 0, 10000)

    // Use items...
    for i := range items {
        items[i] = i * 2
    }
    
    // Memory is freed instantly when a.Free() runs.
}

Go does not ship with an arena package in the standard library. You will find third-party implementations, or you can build a simple one using a byte slice and an offset. The pattern is what matters. The code above assumes a library that exposes NewArena and MakeSlice.

Walk through the mechanics

When you call arena.MakeSlice, the arena checks if it has enough space in its current block. If it does, it returns a slice header pointing into that block and increments its internal offset. The slice header is a small struct containing a pointer to the data, the length, and the capacity. The data lives in the arena. The header lives on the stack or in the caller.

If the arena runs out of space, it allocates a new block from the heap and repeats the process. This is transparent to your code. You just keep allocating.

When a.Free() runs, the arena returns all its blocks to the runtime. The runtime sees a few large allocations being freed. It does not see the thousands of small objects inside. The GC workload drops. Allocation latency drops. Cache locality improves because related objects sit next to each other in memory.

Arenas are a trade-off. You trade allocation flexibility for bulk speed. You cannot free individual objects inside the arena. You cannot shrink the arena. You free everything or nothing.

Realistic example

Arenas shine when you parse or process a batch of data where all intermediate objects share the same lifetime. Consider a log parser that reads a file, creates structs for each line, analyzes them, and discards them.

func parseAndAnalyze(lines []string) {
    // One arena per batch.
    // If the batch fails, the arena cleans up everything.
    a := arena.NewArena()
    defer a.Free()

    // Pre-allocate a slice of log entries in the arena.
    // Capacity matches the input size to avoid resizing.
    entries := arena.MakeSlice[LogEntry](a, 0, len(lines))

    for i, line := range lines {
        // Parse line into the pre-allocated entry.
        // No new allocations per line.
        entries[i] = parseLine(line)
        analyze(entries[i])
    }
}

The parseLine function writes into the LogEntry struct. It does not allocate new memory for the struct itself. The struct lives in the arena. If parseLine needs to allocate strings, it can also use the arena to store those strings. The entire parse tree lives in one contiguous region.

When the function returns, defer a.Free() runs. The memory is gone. The GC never touches the log entries. This pattern is common in compilers, parsers, and high-performance batch processors.

Pitfalls and errors

Arenas remove safety nets. Go's compiler cannot track arena lifetimes. If you hold a pointer to arena memory after calling Free, your program will access freed memory. This leads to undefined behavior. You might see a runtime error: invalid memory address or nil pointer dereference panic. Or you might see corrupted data that is hard to debug.

Never return a pointer from an arena out of the function that owns the arena. The arena will be freed, and the pointer will dangle. If you need to keep data alive, copy it out of the arena into standard heap allocations before freeing the arena.

Arenas also hold onto memory. If you allocate a large arena and only use a small part of it, the memory is not returned until you free the arena. Do not use arenas for long-lived data. Use them for short-lived batches where you can predict the lifetime.

Error handling matters. If arena.NewArena fails, handle the error. If your processing logic panics, defer a.Free() still runs. This is good. The cleanup happens. Make sure your Free implementation is panic-safe.

Convention aside: Go functions usually return errors. If your arena library returns an error on allocation, check it. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not ignore errors just because you are optimizing.

Decision matrix

Use an arena when you have a batch of short-lived allocations that all share the same lifetime. Use sync.Pool when you need to reuse objects across multiple requests or goroutines to reduce allocation pressure. Use standard allocation when objects have different lifetimes or you do not see GC pressure in your profiler. Use strings.Builder or bytes.Buffer when you are concatenating strings or bytes and want to avoid intermediate allocations.

The simplest thing that works is usually the right thing. Measure first. If allocations are not slowing you down, do not add complexity. If they are, an arena can give you a massive win with minimal code changes.

Arenas are cheap. The GC is not magic.

Where to go next