6 Collections & Containers
- 0.15 default:
ArrayList(T)is unmanaged (pass allocator to methods) - Managed variant:
ArrayListManaged(T)stores allocator (simpler API, +8 bytes overhead) - Common types: ArrayList, HashMap, AutoHashMap, StringHashMap
- Always: Call
.deinit(allocator)to free memory - See comparison table below
- Jump to: ArrayList §3.3 | HashMap §3.4 | Iteration §3.5
6.1 Overview
Zig’s standard library provides dynamic collection types that integrate with the explicit allocator model (see Ch2). This chapter examines container types including ArrayList, HashMap, and their variants, focusing on the distinction between managed and unmanaged containers, ownership semantics, and cleanup responsibilities.
Understanding container ownership is critical for correct memory management. Unlike languages with garbage collection or implicit resource management, Zig requires developers to explicitly handle container lifecycles. The choice between managed and unmanaged containers affects memory overhead, API clarity, and program correctness.
As of Zig 0.15, the standard library has shifted toward unmanaged containers as the default pattern.1 This change reflects a broader philosophy: explicit allocator parameters make allocation sites visible, reduce per-container memory overhead, and enable better composition of container-heavy data structures.
6.2 Core Concepts
Managed vs Unmanaged Containers
| Aspect | Managed | Unmanaged (Default) |
|---|---|---|
| Allocator storage | Stored in struct field (+8 bytes/container) | Not stored (passed as parameter) |
| API example | list.append(item) |
list.append(allocator, item) |
| Allocation visibility | Hidden in method | Explicit in call site |
| Memory overhead | 8 bytes per container (64-bit) | Zero overhead |
| Use case | Single containers, simpler API | Structs with many containers (recommended) |
| Type name | std.ArrayListManaged(T) |
std.ArrayList(T) (default) |
| 10 containers cost | +80 bytes | +0 bytes |
// Unmanaged (default, recommended)
var list = std.ArrayList(u8){}; // No stored allocator
try list.append(allocator, 'x'); // Pass allocator explicitly
defer list.deinit(allocator);
// Managed (alternative for simple cases)
var list = std.ArrayListManaged(u8).init(allocator); // Stores allocator
try list.append('x'); // Uses stored allocator
defer list.deinit();Why unmanaged is default: Explicit allocator parameters make allocation sites visible and reduce memory overhead. For data structures with many containers, the savings are significant.23
Container Type Taxonomy
Zig’s standard library provides several core container types, each available in both managed and unmanaged variants.
ArrayList provides a dynamic array with automatic growth. The unmanaged variant exposes this structure:
The absence of an allocator field characterizes the unmanaged pattern.4 Methods that allocate memory accept an allocator parameter:
HashMap provides key-value storage with O(1) average-case lookup. The standard library offers six primary hash map variants:
HashMapandHashMapUnmanaged- Custom hash contextAutoHashMapandAutoHashMapUnmanaged- Automatic hashing for supported typesStringHashMapandStringHashMapUnmanaged- Optimized for string keys
The Auto prefix indicates automatic hash function selection. StringHashMap treats string keys by content rather than pointer equality.5
ArrayHashMap maintains insertion order and provides O(1) indexing through contiguous storage. This variant trades slightly slower insertion for dramatically faster iteration compared to standard HashMap.6
var ordered = std.AutoArrayHashMapUnmanaged(u32, []const u8).init();
try ordered.put(allocator, 1, "first");
try ordered.put(allocator, 2, "second");
// Iteration over contiguous memory is cache-friendly
var it = ordered.iterator();
while (it.next()) |entry| {
std.debug.print("{}: {s}\n", .{ entry.key_ptr.*, entry.value_ptr.* });
}Less common but useful container types include PriorityQueue for heap operations, MultiArrayList for structure-of-arrays layouts, and SegmentedList for stable pointer semantics across resizing.
Ownership Transfer and Borrowing
Container ownership follows the same principles as other Zig resources: explicit ownership transfer and clear borrowing boundaries.
Direct value storage means the container owns the values it stores. When storing non-pointer types, deinit() frees the container’s internal arrays but not the values themselves, as they are embedded directly:
However, if User contains allocated fields, cleanup becomes the developer’s responsibility:
const User = struct {
id: u32,
name: []u8, // Allocated separately
fn deinit(self: *User, alloc: std.mem.Allocator) void {
alloc.free(self.name);
}
};
var users = std.AutoHashMapUnmanaged(u32, User).init();
defer {
var it = users.iterator();
while (it.next()) |entry| {
entry.value_ptr.deinit(allocator); // Clean user's name
}
users.deinit(allocator); // Clean hash map structure
}Pointer storage detaches value lifetime from container lifetime. The container stores only pointers; pointed-to values require separate cleanup:
This pattern is described in the community resources as “the lifetime of the values is detached from the lifetime of the hash map.”7
Ownership transfer through toOwnedSlice() transfers an ArrayList’s internal buffer to the caller:
The list becomes empty after the transfer. This pattern enables functions to return dynamically-sized data without copying.8
Deinit Responsibilities
Every container that allocates memory must call deinit() with the same allocator used for initialization. Failure to do so causes memory leaks.
Basic cleanup requires matching init() with deinit():
The defer statement ensures cleanup occurs even on early return or error paths.
Nested containers require cleanup in reverse order of initialization:
Error-path cleanup uses errdefer to handle partial initialization failures. The TigerBeetle codebase demonstrates this pattern extensively:9
pub fn init(allocator: std.mem.Allocator, options: Options) !CacheMap {
var cache: ?Cache = if (options.cache_value_count_max == 0)
null
else
try Cache.init(allocator, options.cache_value_count_max, .{ .name = options.name });
errdefer if (cache) |*c| c.deinit(allocator);
var stash: Map = .{};
try stash.ensureTotalCapacity(allocator, options.stash_value_count_max);
errdefer stash.deinit(allocator);
var scope_rollback_log = try std.ArrayListUnmanaged(Value).initCapacity(
allocator,
options.scope_value_count_max,
);
errdefer scope_rollback_log.deinit(allocator);
return CacheMap{
.cache = cache,
.stash = stash,
.scope_rollback_log = scope_rollback_log,
.options = options,
};
}Each allocation is immediately followed by errdefer cleanup. If any subsequent allocation fails, previously initialized resources are automatically freed in reverse order (LIFO).
Arena allocators provide bulk cleanup for containers with similar lifetimes:
var arena = std.heap.ArenaAllocator.init(page_allocator);
defer arena.deinit(); // Frees everything at once
const arena_alloc = arena.allocator();
var list1 = std.ArrayList(u8).init(arena_alloc);
var list2 = std.ArrayList(u32).init(arena_alloc);
// No individual deinit needed - arena cleanup handles all
try list1.appendSlice(arena_alloc, "data");
try list2.append(arena_alloc, 42);The arena pattern is common in request-scoped or phase-based processing where many containers share the same lifetime.10
Container Selection Guidance
Choosing the appropriate container requires understanding performance characteristics and usage patterns.
ArrayList vs fixed arrays vs slices:
- ArrayList: Unknown size at compile time, needs growth
- Fixed array
[N]T: Known size at compile time, stack allocation - Slice
[]T: Borrows existing data, no ownership
HashMap vs ArrayHashMap:
HashMap provides O(1) average-case lookup with unordered storage. ArrayHashMap maintains insertion order and offers O(1) indexing with faster iteration due to contiguous memory layout.11
Choose HashMap when: - Insertion order does not matter - Lookup performance is critical - Memory layout is less important
Choose ArrayHashMap when: - Iteration is frequent - Insertion order matters - Array-like indexing is needed - Cache-friendly traversal is beneficial
Pre-allocation strategies avoid repeated reallocation during container growth:
The Ghostty terminal emulator demonstrates this pattern with a documented rationale:12
The comment explains why 9 is chosen: it covers the common case (shell execution) while allowing growth for uncommon cases.
Container reuse with clearRetainingCapacity() avoids allocation churn in loops:
This pattern is common in performance-critical code, particularly when processing repeated requests or iterations.13
6.3 Code Examples
Example 1: Managed vs Unmanaged ArrayList
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
// Unmanaged ArrayList (default)
std.debug.print("=== Unmanaged ArrayList ===\n", .{});
var unmanaged_list = std.ArrayList(u32).init(allocator);
defer unmanaged_list.deinit(allocator); // Allocator required
try unmanaged_list.append(allocator, 10); // Allocator required
try unmanaged_list.append(allocator, 20);
try unmanaged_list.append(allocator, 30);
std.debug.print("Items: ", .{});
for (unmanaged_list.items) |item| {
std.debug.print("{} ", .{item});
}
std.debug.print("\n", .{});
std.debug.print("Capacity: {}, Length: {}\n", .{ unmanaged_list.capacity, unmanaged_list.items.len });
// Show struct size difference
std.debug.print("Unmanaged struct size: {} bytes\n\n", .{@sizeOf(@TypeOf(unmanaged_list))});
// Pre-allocation pattern
std.debug.print("=== Pre-allocation Pattern ===\n", .{});
var preallocated = std.ArrayList(u32).init(allocator);
defer preallocated.deinit(allocator);
// Allocate exact capacity upfront (no reallocation needed)
try preallocated.ensureTotalCapacity(allocator, 100);
std.debug.print("Pre-allocated capacity: {}\n", .{preallocated.capacity});
// Fast append without allocation
for (0..100) |i| {
preallocated.appendAssumeCapacity(@intCast(i));
}
std.debug.print("After 100 appends, capacity: {}\n", .{preallocated.capacity});
}This example demonstrates the unmanaged ArrayList API where allocators must be passed to every method. Pre-allocation with ensureTotalCapacity() enables zero-allocation appends using appendAssumeCapacity().
Example 2: HashMap Ownership Patterns
const std = @import("std");
const User = struct {
id: u32,
name: []u8,
score: i32,
pub fn init(allocator: std.mem.Allocator, id: u32, name: []const u8, score: i32) !User {
return .{
.id = id,
.name = try allocator.dupe(u8, name),
.score = score,
};
}
pub fn deinit(self: *User, allocator: std.mem.Allocator) void {
allocator.free(self.name);
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
// Pattern 1: Direct value storage
std.debug.print("=== Pattern 1: Direct Value Storage ===\n", .{});
var users_direct = std.AutoHashMapUnmanaged(u32, User).init();
defer {
// Must clean up allocated fields within values
var it = users_direct.iterator();
while (it.next()) |entry| {
entry.value_ptr.deinit(allocator);
}
users_direct.deinit(allocator);
}
var user1 = try User.init(allocator, 1, "Alice", 100);
try users_direct.put(allocator, user1.id, user1);
if (users_direct.get(1)) |user| {
std.debug.print("Found user: {s}, score: {}\n\n", .{ user.name, user.score });
}
// Pattern 2: Pointer storage (detached lifetime)
std.debug.print("=== Pattern 2: Pointer Storage ===\n", .{});
var users_ptr = std.AutoHashMapUnmanaged(u32, *User).init();
defer {
// Must free both the pointed-to objects AND the pointers
var it = users_ptr.iterator();
while (it.next()) |entry| {
entry.value_ptr.*.deinit(allocator);
allocator.destroy(entry.value_ptr.*);
}
users_ptr.deinit(allocator);
}
var user2 = try allocator.create(User);
user2.* = try User.init(allocator, 2, "Bob", 200);
try users_ptr.put(allocator, user2.id, user2);
if (users_ptr.get(2)) |user_ptr| {
std.debug.print("Found user: {s}, score: {}\n\n", .{ user_ptr.name, user_ptr.score });
}
// Pattern 3: HashMap as Set (void value)
std.debug.print("=== Pattern 3: HashMap as Set ===\n", .{});
var seen_ids = std.AutoHashMapUnmanaged(u32, void).init();
defer seen_ids.deinit(allocator);
try seen_ids.put(allocator, 42, {});
try seen_ids.put(allocator, 100, {});
std.debug.print("Contains 42? {}\n", .{seen_ids.contains(42)});
std.debug.print("Contains 99? {}\n", .{seen_ids.contains(99)});
}This example illustrates three HashMap ownership patterns. Pattern 1 stores values directly, requiring cleanup of allocated fields. Pattern 2 stores pointers with detached lifetimes, requiring both object and pointer cleanup. Pattern 3 demonstrates the set idiom using void values.
Example 3: Nested Container Cleanup with errdefer
const std = @import("std");
const Database = struct {
tables: std.ArrayList(Table),
allocator: std.mem.Allocator,
const Table = struct {
name: []u8,
rows: std.ArrayList([]u8),
};
pub fn init(allocator: std.mem.Allocator, table_names: []const []const u8) !Database {
var tables = std.ArrayList(Table).init(allocator);
errdefer {
// Clean up any successfully initialized tables on error
for (tables.items) |*table| {
for (table.rows.items) |row| {
allocator.free(row);
}
table.rows.deinit(allocator);
allocator.free(table.name);
}
tables.deinit(allocator);
}
for (table_names) |name| {
const table_name = try allocator.dupe(u8, name);
errdefer allocator.free(table_name); // If rows allocation fails
var rows = std.ArrayList([]u8).init(allocator);
errdefer rows.deinit(allocator); // If append to tables fails
try tables.append(allocator, .{
.name = table_name,
.rows = rows,
});
}
return .{
.tables = tables,
.allocator = allocator,
};
}
pub fn deinit(self: *Database) void {
for (self.tables.items) |*table| {
for (table.rows.items) |row| {
self.allocator.free(row);
}
table.rows.deinit(self.allocator);
self.allocator.free(table.name);
}
self.tables.deinit(self.allocator);
}
pub fn addRow(self: *Database, table_idx: usize, data: []const u8) !void {
if (table_idx >= self.tables.items.len) return error.InvalidTable;
const row = try self.allocator.dupe(u8, data);
errdefer self.allocator.free(row);
try self.tables.items[table_idx].rows.append(self.allocator, row);
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
std.debug.print("=== Nested Container with errdefer ===\n", .{});
// Success case
const table_names = [_][]const u8{ "users", "products", "orders" };
var db = try Database.init(allocator, &table_names);
defer db.deinit();
try db.addRow(0, "Alice");
try db.addRow(0, "Bob");
try db.addRow(1, "Widget");
for (db.tables.items, 0..) |table, i| {
std.debug.print("Table {s} has {} rows\n", .{ table.name, table.rows.items.len });
}
std.debug.print("\nDatabase cleaned up successfully\n", .{});
}This example demonstrates cascading errdefer for multi-level nested containers. Each allocation is followed by cleanup code that runs only on error paths, preventing leaks during partial initialization.
Example 4: Ownership Transfer with toOwnedSlice
const std = @import("std");
fn buildMessage(allocator: std.mem.Allocator, parts: []const []const u8) ![]const u8 {
var list = std.ArrayList(u8).init(allocator);
// Note: No defer here - ownership transferred via toOwnedSlice
for (parts, 0..) |part, i| {
try list.appendSlice(allocator, part);
if (i < parts.len - 1) {
try list.append(allocator, ' ');
}
}
// Transfer ownership to caller
return list.toOwnedSlice(allocator);
}
fn processData(allocator: std.mem.Allocator, input: []const u8) !std.ArrayList(u32) {
var numbers = std.ArrayList(u32).init(allocator);
errdefer numbers.deinit(allocator); // Clean up on error
for (input) |byte| {
if (byte >= '0' and byte <= '9') {
try numbers.append(allocator, byte - '0');
}
}
// Transfer ownership by returning the ArrayList directly
return numbers;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
std.debug.print("=== Ownership Transfer Patterns ===\n\n", .{});
// Pattern 1: toOwnedSlice (ArrayList → Slice)
std.debug.print("Pattern 1: toOwnedSlice\n", .{});
const parts = [_][]const u8{ "Hello", "from", "Zig" };
const message = try buildMessage(allocator, &parts);
defer allocator.free(message); // Caller owns and must free
std.debug.print("Message: {s}\n\n", .{message});
// Pattern 2: Return ArrayList directly
std.debug.print("Pattern 2: Return ArrayList\n", .{});
var numbers = try processData(allocator, "a1b2c3d4e5");
defer numbers.deinit(allocator); // Caller owns and must deinit
std.debug.print("Numbers: ", .{});
for (numbers.items) |num| {
std.debug.print("{} ", .{num});
}
std.debug.print("\n\n", .{});
// Pattern 3: fromOwnedSlice (Slice → ArrayList)
std.debug.print("Pattern 3: fromOwnedSlice\n", .{});
const raw_data = try allocator.alloc(u8, 5);
for (raw_data, 0..) |*byte, i| {
byte.* = @intCast('A' + i);
}
var list_from_slice = std.ArrayList(u8).fromOwnedSlice(allocator, raw_data);
defer list_from_slice.deinit(allocator); // Now list owns the data
try list_from_slice.append(allocator, 'F'); // Can grow
std.debug.print("From slice: {s}\n", .{list_from_slice.items});
}This example shows three ownership transfer patterns. toOwnedSlice() transfers buffer ownership to the caller. Returning an ArrayList directly transfers the entire container. fromOwnedSlice() allows an ArrayList to take ownership of an existing slice.
Example 5: Container Reuse with clearRetainingCapacity
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
std.debug.print("=== Reusing Containers Across Iterations ===\n\n", .{});
var buffer = std.ArrayList(u8).init(allocator);
defer buffer.deinit(allocator);
// Pre-allocate reasonable capacity
try buffer.ensureTotalCapacity(allocator, 1024);
const requests = [_][]const u8{ "request1", "request2", "request3" };
for (requests, 0..) |request, i| {
// Clear contents but keep capacity
buffer.clearRetainingCapacity();
std.debug.print("Iteration {}: ", .{i});
std.debug.print("Length: {}, Capacity: {}\n", .{ buffer.items.len, buffer.capacity });
// Build response using existing capacity
try buffer.appendSlice(allocator, "Response to ");
try buffer.appendSlice(allocator, request);
std.debug.print(" Built: {s}\n", .{buffer.items});
std.debug.print(" Final length: {}, Capacity: {}\n\n", .{ buffer.items.len, buffer.capacity });
}
std.debug.print("No reallocations occurred - capacity stayed constant\n", .{});
// HashMap example
std.debug.print("\n=== HashMap Reset Pattern ===\n\n", .{});
var cache = std.AutoHashMapUnmanaged(u32, []const u8).init();
defer cache.deinit(allocator);
try cache.ensureTotalCapacity(allocator, 100);
for (0..3) |batch| {
std.debug.print("Batch {}: ", .{batch});
// Populate cache
for (0..10) |i| {
try cache.put(allocator, @intCast(i), "data");
}
std.debug.print("Count: {}, Capacity: {}\n", .{ cache.count(), cache.capacity() });
// Reset for next batch
cache.clearRetainingCapacity();
}
std.debug.print("\nCache reused across batches without reallocation\n", .{});
}This example demonstrates clearRetainingCapacity() for efficient container reuse. Pre-allocation followed by clearing avoids repeated allocation/deallocation cycles in iterative processing.
Example 6: HashMap vs ArrayHashMap Performance
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();
const iterations = 1000;
// HashMap vs ArrayHashMap iteration performance
std.debug.print("=== HashMap vs ArrayHashMap Iteration ===\n", .{});
var hash_map = std.AutoHashMapUnmanaged(u32, u32).init();
defer hash_map.deinit(allocator);
var array_hash_map = std.AutoArrayHashMapUnmanaged(u32, u32).init();
defer array_hash_map.deinit(allocator);
// Populate both
for (0..100) |i| {
try hash_map.put(allocator, @intCast(i), @intCast(i * 2));
try array_hash_map.put(allocator, @intCast(i), @intCast(i * 2));
}
// Iterate HashMap
var timer = try std.time.Timer.start();
var sum1: u64 = 0;
for (0..iterations) |_| {
var it1 = hash_map.iterator();
while (it1.next()) |entry| {
sum1 += entry.value_ptr.*;
}
}
const hash_map_time = timer.read();
// Iterate ArrayHashMap
timer.reset();
var sum2: u64 = 0;
for (0..iterations) |_| {
var it2 = array_hash_map.iterator();
while (it2.next()) |entry| {
sum2 += entry.value_ptr.*;
}
}
const array_hash_map_time = timer.read();
std.debug.print("HashMap iteration: {} ns (sum: {})\n", .{ hash_map_time, sum1 });
std.debug.print("ArrayHashMap iteration: {} ns (sum: {})\n", .{ array_hash_map_time, sum2 });
if (array_hash_map_time > 0) {
const speedup = @as(f64, @floatFromInt(hash_map_time)) / @as(f64, @floatFromInt(array_hash_map_time));
std.debug.print("ArrayHashMap is {d:.2}x faster for iteration\n", .{speedup});
}
}This example compares HashMap and ArrayHashMap iteration performance. ArrayHashMap’s contiguous memory layout provides better cache locality, resulting in faster iteration over the same data.
6.4 Common Pitfalls
Pitfall 1: Forgetting Container deinit()
Containers that allocate memory must call deinit() before going out of scope. Without this cleanup, memory leaks occur.
Problem:
Detection: Use std.testing.allocator in tests. This allocator detects memory leaks automatically:
If the defer is omitted, the test fails with a leak detection error.
Solution: Place defer immediately after initialization:
Pitfall 2: Incomplete Nested Container Cleanup
Containers containing other containers require multi-level cleanup. Calling deinit() on the outer container does not automatically clean inner containers.
Problem:
Solution: Iterate and clean inner containers before cleaning the outer:
Pitfall 3: HashMap with Allocated Keys or Values
HashMap deinit() frees the hash table structure but not allocated keys or values stored as pointers.
Problem:
Solution: Iterate and free values before calling deinit():
This pattern appears in community documentation on HashMap ownership.14
Pitfall 4: Pointer Invalidation After Growth
Pointers into container storage become invalid when the container reallocates during growth.
Problem:
If the append() causes reallocation, ptr points to freed memory. Dereferencing it invokes undefined behavior.
Solution: Use indices instead of pointers:
Alternatively, pre-allocate capacity to prevent reallocation:
Pitfall 5: Version Migration API Confusion
Code written for Zig 0.14.x fails to compile under Zig 0.15+ due to the unmanaged default change.
Problem (0.14.x → 0.15+):
Migration Strategy: Search the codebase for container method calls and add allocator parameters:
- Search for
\.deinit\(\)without allocator - Search for
\.append\(without allocator as first parameter - Search for
\.put\(in HashMap code without allocator
Solution: Add allocator parameters to all methods:
Test with std.testing.allocator to catch remaining leaks from missed cleanup calls.
6.5 In Practice
Production codebases demonstrate these container patterns at scale.
TigerBeetle: Static Allocation and Unmanaged Containers
TigerBeetle’s architecture mandates static allocation: all memory is allocated at startup, with no dynamic allocation during operation.15 This constraint shapes their container usage.
The LSM tree implementation demonstrates extensive use of unmanaged containers with pre-allocated capacity:16
Capacity is determined at initialization and never exceeded. The ArrayListUnmanaged pattern saves memory overhead while maintaining deterministic allocation behavior.
HashMap usage follows similar patterns, with HashMapUnmanaged for sets:17
pub const Map = std.HashMapUnmanaged(
Value,
void, // Set pattern: no associated data
struct {
pub inline fn eql(_: @This(), a: Value, b: Value) bool {
return key_from_value(&a) == key_from_value(&b);
}
pub inline fn hash(_: @This(), value: Value) u64 {
return stdx.hash_inline(key_from_value(&value));
}
},
50, // 50% max load factor
);The void value type creates a set (membership testing without associated data). Custom hash and equality functions enable value-based rather than pointer-based comparison.
Ghostty: Capacity Optimization
The Ghostty terminal emulator demonstrates capacity pre-allocation with documented rationale:18
The comment explains the design choice: optimize for the common case (shell execution requiring 9 arguments) while allowing growth for uncommon cases. This balances memory efficiency with performance.
Bun: Arena Allocators with Containers
Bun’s snapshot testing implementation uses arena allocators for temporary containers:19
The arena provides bulk cleanup. When processing completes, a single arena.deinit() frees all containers and their contents at once. This pattern is common in request-scoped or phase-based processing.
ZLS: MultiArrayList and SegmentedList
The Zig Language Server uses specialized container types for compiler data structures:20
MultiArrayList uses structure-of-arrays layout for cache efficiency. SegmentedList provides stable pointers across resizing, critical for compiler IR where nodes reference each other.
Mach: Game Engine Collection Patterns
Mach demonstrates sophisticated collection patterns across its game engine architecture, from memory-efficient string interning to entity component systems.
Pattern 1: String Interning with Custom HashMap Context
Mach’s StringTable implements bidirectional string-to-index mapping using custom hash map contexts:21
21 Mach Source: StringTable with Custom HashMap Context - Bidirectional string interning using indices as keys
The key insight: instead of storing strings as keys, store byte array indices as keys. The custom context translates between string slices and indices:
// mach/src/StringTable.zig:68-80
const SliceAdapter = struct {
string_bytes: *std.ArrayListUnmanaged(u8),
pub fn eql(adapter: SliceAdapter, a_slice: []const u8, b: u32) bool {
const b_slice = std.mem.span(@as([*:0]const u8, @ptrCast(adapter.string_bytes.items.ptr)) + b);
return std.mem.eql(u8, a_slice, b_slice);
}
pub fn hash(adapter: SliceAdapter, adapted_key: []const u8) u64 {
_ = adapter;
return std.hash_map.hashString(adapted_key);
}
};Benefits: - Memory: One copy of each string, no duplication - Lookups: O(1) for both string→index and index→string - Cache-friendly: Contiguous string storage in string_bytes
Pattern 2: Entity Component System with Multiple Unmanaged Collections
Mach’s Objects type implements an ECS (Entity Component System) using five unmanaged collections:22
22 Mach Source: Objects ECS Implementation - Entity component system with MultiArrayList, BitSet, and generation counters
// mach/src/module.zig:38-76
pub fn Objects(options: ObjectsOptions, comptime T: type) type {
return struct {
internal: struct {
allocator: std.mem.Allocator,
mu: std.Thread.Mutex = .{},
type_id: ObjectTypeID,
// Five unmanaged collections working together:
data: std.MultiArrayList(T) = .{},
dead: std.bit_set.DynamicBitSetUnmanaged = .{},
generation: std.ArrayListUnmanaged(Generation) = .{},
recycling_bin: std.ArrayListUnmanaged(Index) = .{},
tags: std.AutoHashMapUnmanaged(TaggedObject, ?ObjectID) = .{},
thrown_on_the_floor: u32 = 0,
graph: *Graph,
updated: ?std.bit_set.DynamicBitSetUnmanaged = if (options.track_fields) .{} else null,
},
};
}Why MultiArrayList: Structure-of-arrays layout for cache efficiency when iterating:
Iterating over just X coordinates accesses contiguous memory, maximizing cache hits.
Why DynamicBitSetUnmanaged: Track alive/dead entities with 1 bit per entity instead of 1 byte:
Pattern 3: Object Recycling with Generation Counters
When entities are deleted, Mach recycles their indices using a generation counter to detect use-after-free:23
23 Mach Source: Objects ECS Implementation - Entity component system with MultiArrayList, BitSet, and generation counters
// mach/src/module.zig:139-164
pub fn new(objs: *@This(), value: T) std.mem.Allocator.Error!ObjectID {
const data = &objs.internal.data;
const dead = &objs.internal.dead;
const generation = &objs.internal.generation;
const recycling_bin = &objs.internal.recycling_bin;
// Periodically clean up if 10% of objects are on the floor
if (objs.internal.thrown_on_the_floor >= (data.len / 10)) {
var iter = dead.iterator(.{ .kind = .set });
while (iter.next()) |index| {
try recycling_bin.append(allocator, @intCast(index));
}
objs.internal.thrown_on_the_floor = 0;
}
// Reuse dead object slot if available
const index = if (recycling_bin.items.len > 0)
recycling_bin.pop()
else
@as(Index, @intCast(data.len));
// Increment generation to invalidate old IDs
if (index < generation.items.len) {
generation.items[index] += 1;
}
}ObjectID encoding: Packs type, generation, and index into u64:
Old references fail gracefully when generation mismatches, catching use-after-free bugs.
Pattern 4: Unmanaged Container Aggregation
Mach’s shader compiler demonstrates a struct with many unmanaged containers:24
// mach sysgpu/shader/AstGen.zig
allocator: std.mem.Allocator,
instructions: std.AutoArrayHashMapUnmanaged(Inst, void) = .{},
refs: std.ArrayListUnmanaged(InstIndex) = .{},
strings: std.ArrayListUnmanaged(u8) = .{},
values: std.ArrayListUnmanaged(u8) = .{},
scratch: std.ArrayListUnmanaged(InstIndex) = .{},
global_var_refs: std.AutoArrayHashMapUnmanaged(InstIndex, void) = .{},
globals: std.ArrayListUnmanaged(InstIndex) = .{},Memory savings: 7 containers × 8 bytes = 56 bytes saved vs managed variants. For graphics code with hundreds of these structs, savings are substantial.
Pattern 5: Event Queue with Pre-Allocation
Mach’s Core module pre-allocates event queue capacity during initialization:25
25 Mach Source: Core Event Queue Pre-Allocation - Zero-allocation event handling with pre-allocated capacity
Where EventQueue is defined:
Why 8192: Prevents reallocation during gameplay. Input events (keyboard, mouse) occur frequently; pre-allocation ensures zero-allocation event handling in the main loop.
Key Takeaways from Mach: - Custom hash contexts enable memory-efficient string interning and specialized lookups - MultiArrayList (structure-of-arrays) maximizes cache efficiency for component iteration - BitSetUnmanaged provides 8x memory savings over bool arrays for entity state - Generation counters catch use-after-free bugs by encoding version in object IDs - Pre-allocation eliminates allocation overhead in performance-critical loops - Unmanaged containers reduce memory overhead when aggregating many collections
6.6 Summary
Zig containers integrate with the explicit allocator model, requiring developers to manage ownership and cleanup. The shift from managed to unmanaged containers as of Zig 0.15 reflects a broader philosophy: explicit allocation sites improve code clarity and reduce memory overhead.
Key takeaways:
Unmanaged is default (0.15+): ArrayList, HashMap, and related containers no longer store allocators. Methods require explicit allocator parameters.
Ownership determines cleanup: Direct value storage requires cleaning allocated fields. Pointer storage requires both object and pointer cleanup. The container’s
deinit()only frees its internal structure.Pre-allocate when possible:
ensureTotalCapacity()avoids reallocation overhead. Combine withappendAssumeCapacity()orputAssumeCapacity()for zero-allocation operations.Reuse containers:
clearRetainingCapacity()resets contents while preserving allocated memory, avoiding allocation churn in loops.Use appropriate variants: ArrayHashMap for iteration-heavy workloads, HashMap for lookup-heavy. SegmentedList when pointer stability matters. MultiArrayList for cache efficiency.
Arena for bulk cleanup: When containers share lifetimes, an arena allocator simplifies cleanup by freeing everything at once.
Production codebases demonstrate these patterns at scale. TigerBeetle uses static pre-allocation with unmanaged containers. Ghostty optimizes capacity for common cases. Bun employs arenas for request-scoped processing. ZLS uses specialized containers for compiler data structures. Mach aggregates many unmanaged containers to reduce memory overhead.
The transition from managed to unmanaged containers represents a maturation of Zig’s approach to explicit resource management. By making allocation sites visible and eliminating per-container overhead, unmanaged containers provide better composability and clearer code.