When allocation becomes the bottleneck
You are processing a burst of high-throughput data. Thousands of small objects get created, used for a millisecond, and then discarded. The garbage collector wakes up, scans the heap, and pauses your program to reclaim memory. The pause is small, but it ruins your tail latency. You want to allocate memory instantly and wipe it all away in one shot, bypassing the GC entirely for that batch of work.
Go's garbage collector is excellent for general-purpose programming. It handles complex object graphs and concurrent allocations with minimal overhead. It struggles when you allocate massive numbers of tiny objects that all die at the same time. The GC has to track every single object. It has to scan every single pointer. An arena changes the rules. You allocate a block of memory once. You carve objects out of that block. When you are done, you free the block. The GC never sees the individual objects.
The scratchpad analogy
Think of a scratchpad. Instead of writing on individual sticky notes and throwing them away one by one, you write on a large sheet of paper. When you are done with the sheet, you crumple it up and toss it. The cleanup crew never sees the individual notes. They only see the sheet.
An arena works the same way. You request a large block of memory. The arena keeps a cursor at the start. When you need an object, the arena returns the current cursor position and moves the cursor forward. This is called a bump allocator. It is incredibly fast. There is no searching for free slots. There is no bookkeeping. There is just a pointer bump.
You trade flexibility for speed. You cannot free individual objects early. You free everything or nothing. If you need to keep one object after the batch, you must copy it out before freeing the arena.
Minimal example
Here is the simplest arena usage: create the arena, allocate a struct and a slice, and let the defer handle cleanup.
//go:build goexperiment.arenas
package main
import "arena"
type Item struct {
ID int
}
func main() {
// Create the arena. This allocates a large backing block from the OS.
a := arena.NewArena()
// Free the arena when main exits. All objects inside vanish instantly.
defer a.Free()
// Allocate a struct. The arena bumps a pointer and returns a pointer to Item.
// No GC pressure. No metadata overhead for the object itself.
item := arena.New[Item](a)
item.ID = 1
// Allocate a slice backed by the arena.
// The slice header is on the stack, but the underlying array lives in the arena.
data := arena.MakeSlice[int](a, 0, 10)
data[0] = 99
}
The build tag //go:build goexperiment.arenas tells the compiler this file only exists if you enable the experiment. Without the tag, the file is invisible. The arena package is experimental. The API may change. The implementation may change. The experiment tag protects you from using unstable APIs in production by accident.
How the bump allocator works
When you call arena.NewArena, the package requests a chunk of memory from the operating system. It keeps track of a cursor that points to the next free byte. When you call arena.New[Item], the arena checks if there is enough room for an Item. If yes, it returns the current cursor position and advances the cursor by the size of Item. The object lives at that address.
This allocation is a constant-time operation. It is just an addition and a return. There is no locking. There is no search. There is no interaction with the GC. The GC does not know the object exists. The GC only sees the arena block. To the GC, the arena is an opaque blob of bytes. The GC does not scan inside the arena. It treats the arena as a single unit. If the arena pointer is live, the whole block is live. The internal objects are invisible to the GC.
When you call a.Free, the arena returns the whole block to the OS. The memory is reclaimed. The individual objects are never scanned. The GC pause time drops because the GC has fewer objects to track. The number of objects the GC sees can drop by orders of magnitude.
Realistic example
Here is a realistic pattern: a function processes a batch of input, allocates temporary objects in an arena, and frees the arena before returning.
//go:build goexperiment.arenas
package main
import (
"arena"
"fmt"
)
type Record struct {
Name string
Age int
}
// ProcessBatch allocates records in an arena and returns the count.
// The arena is freed immediately after processing.
func ProcessBatch(rawData []string) int {
// Create a fresh arena for this batch.
// Each call gets its own memory block.
a := arena.NewArena()
// Free the arena as soon as we return.
// This ensures no memory leaks and no dangling pointers.
defer a.Free()
count := 0
for _, raw := range rawData {
// Allocate a record in the arena.
// This is a bump allocation: extremely fast, no GC pressure.
rec := arena.New[Record](a)
rec.Name = raw
rec.Age = 25
// Use the record.
fmt.Println(rec.Name)
count++
}
return count
}
func main() {
data := []string{"Alice", "Bob", "Charlie"}
n := ProcessBatch(data)
fmt.Println("Processed", n)
}
The ProcessBatch function creates a local arena. It allocates records inside the arena. It uses the records. It returns a count. The defer a.Free ensures the arena is cleaned up when the function returns. The records are temporary. They do not need to survive the function call. The arena is the perfect tool.
Convention aside: Go functions that take a context should respect cancellation. If ProcessBatch took a context.Context, you would check ctx.Err inside the loop. The arena cleanup remains the same. The arena does not care about context. You manage the lifetime.
Why the GC loves arenas
The garbage collector works by tracing pointers. It starts at the roots (stacks, globals) and follows pointers to find live objects. Every object on the heap has metadata. The GC has to check every object to see if it is live. If you allocate ten thousand tiny structs, the GC has to visit ten thousand locations. It has to read metadata, check pointers, and update write barriers. This takes time.
An arena changes the game. The arena allocates a large block. To the GC, that block is a single unit. The GC does not look inside the arena. It treats the arena as an opaque blob. The GC only sees the arena pointer. If the arena pointer is live, the whole block is live. The internal objects are invisible to the GC. This reduces the number of objects the GC tracks by orders of magnitude. The pause time drops.
The GC also has to handle write barriers. When you write a pointer to a heap object, the runtime has to record that write so the GC can trace it later. Arena allocations bypass the write barrier for the objects inside the arena. The GC does not need to trace pointers inside the arena because it does not scan the arena. This reduces the overhead of every pointer write.
The GC doesn't scan what it cannot see.
The lifetime trap
The biggest risk is lifetime. If you return a pointer allocated by the arena to code outside the arena's scope, you get a dangling pointer. The arena frees everything. The pointer becomes invalid. The compiler cannot catch this. You must ensure the arena lives as long as any pointer derived from it.
If you need to keep one object after the batch, copy it out before freeing the arena. Do not return the arena pointer. Copy the data to a new allocation that lives independently.
// BAD: Returns a pointer into the arena.
// The arena is freed when the function returns.
// The pointer is dangling.
func GetRecord() *Record {
a := arena.NewArena()
defer a.Free()
rec := arena.New[Record](a)
rec.Name = "Alice"
return rec // Dangling pointer!
}
// GOOD: Copies the data out.
func GetRecordCopy() Record {
a := arena.NewArena()
defer a.Free()
rec := arena.New[Record](a)
rec.Name = "Alice"
// Copy the struct by value.
// The copy lives on the stack or heap independently.
return *rec
}
Arenas hide memory from the GC. They also hide lifetime bugs from the compiler. You are the safety net.
Enabling the experiment
The arena package is behind a build tag. You must enable it explicitly. Run your code with GOEXPERIMENT=arenas go run main.go. If you forget the tag, the compiler ignores the file. You get undefined: arena because the package is not available.
The GOEXPERIMENT environment variable is how Go manages experimental features. It allows the team to test APIs without committing to stability. You can enable multiple experiments with a comma-separated list: GOEXPERIMENT=arenas,loopvar. The experiment tag signals that this is a work in progress. The API may change. The implementation may change. Use it for performance-critical code where you are willing to track changes.
Use the experiment tag. It keeps your build stable while you test the bleeding edge.
Decision matrix
Use an arena when you allocate many short-lived objects in a batch and can free them all at once. Use standard heap allocation when objects have independent lifetimes and must be freed individually. Use a sync.Pool when you need to reuse objects across goroutines and want to amortize allocation cost over time. Use stack allocation when the data is small and the lifetime is strictly within the function call. Use an arena when you are building a parser or compiler intermediate representation where nodes are created in a burst and discarded after the pass.
Arenas are a hammer. If your objects don't die together, you need a scalpel.