Odin · dynamic arrays

A growable array, four levels deep

A [dynamic]T is a small fixed header that owns a separate, growable buffer. The header is four fields — data (where the elements live), len (how many are in use), cap (how many fit before the buffer must be replaced), and an allocator (where new memory comes from). Append items and watch len fill while the buffer stays put — until it can't.

Real sizes & the cap schedule, measured from a compiled Odin program. The data address is nondeterministic, shown as one representative run.
data
0x…
8 B
len
0
8 B
cap
0
8 B
allocator
proc | data
16 B
data ↓ points at the backing buffer (lives elsewhere)
live element (counted by len) spare capacity (allocated, not yet used)

Widths (size_of)

data (rawptr) = 8
len  (int)    = 8
cap  (int)    = 8
allocator   = 16

[dynamic]int = 40
 = 8 + 8 + 8 + 16

a plain []int view = 16 (just data + len — no cap, no owner)

What you're seeing

Level 1 was what it is. Now: the case where a fixed array is the wrong call — a count you only learn at runtime, that keeps changing.

Think of the live entities a frame has to update: monsters spawn, projectiles fly, particles are born and die. How many there are, and which kinds, change every frame — you cannot write the count into the source. The example below is one frame; hit "next frame" and watch the population swing. A fixed [N]T forces you to guess N at compile time and either overflow it or waste it; a [dynamic]T simply grows to whatever this frame needs and shrinks back when you clear it.

The mechanism: one call does it — append(&entities, e). Because the live elements sit contiguously in the backing buffer, getting entities[i] is a direct indexed access — step i elements from the start of the buffer, you are there. The buffer is one packed run, so iterating the frame's entities walks straight through memory. The header carries the bookkeeping; you carry none of it.

The counterfactual: if the count were genuinely fixed — exactly four party members, always — you would not reach for this. A [4]Entity fixed array is smaller, needs no allocator, and never reallocates. The dynamic array earns its 40-byte header and its allocation because the count is unknown and moves.

Why the alternatives are worse

guess a fixed size, then patch around being wrong
[0]
[1]
[2]
[3]
[4] ✕ overflow
a [4]Entity that a fifth spawn cannot fit into — and you find out at the worst time.

A fixed [N]T sized to the worst case — say [1024]Entity "to be safe": every frame pays for 1024 slots even when 12 are alive, and a 1025th spawn still overflows. Wasteful and still not safe.

A fixed array plus a hand-tracked count (buf: [1024]Entity alongside count: int you bump yourself): now count and the buffer can drift out of step — bump it but forget to write the slot, or write the slot but forget to bump, and you read a stale or uninitialized element. The same two numbers the dynamic-array header keeps in lockstep for you, now yours to desync.

A buffer you resize by hand — allocate, and when it fills, allocate a bigger one, copy across, free the old: that is a dynamic array, rebuilt by hand, with the data pointer, len, cap, and the free all yours to get wrong. [dynamic]T folds those four into one type and hands you append. More code, same idea, more bugs.

Level 2 sold you on it. Level 3 is the bill: growth is not free — when the buffer fills, the array allocates a bigger one, copies every element across, and frees the old. Append one at a time and watch exactly when that happens.

Each press of append adds one element. While len < cap it just writes the next slot — cheap. When len would exceed cap, the buffer is replaced: this toolchain goes cap 0 → 8 on the first append, holds at 8 through the eighth element, then jumps 8 → 24 on the ninth. Each jump is a full copy of everything already in the array.

The hidden hazard: because a grow moves the buffer, any pointer you took into the old one — p := &entities[0] — now points at freed memory. A measured grow on this toolchain: cap before = 8, cap after = 24, buffer moved = true. Hold a pointer across an append that grows, and you are reading reclaimed storage. A slice entities[:] goes stale the same way. Treat any pointer or slice into a dynamic array as valid only until the next append that might grow.

The fix when you know the size

Each grow is alloc + copy + free. The growth is geometric, so it is amortized cheap — but if you already know roughly how many elements are coming, you can skip the intermediate grows entirely with make([dynamic]int, 0, 1024) (length 0, capacity 1024) or reserve(&xs, 1024). Toggle the two paths to 1024 elements:

reallocations & copies
cap is not len, and reserve is not resize cap is how much memory is allocated; len is how much you have actually filled. Growing cap changes neither len nor the visible contents — it just buys headroom. reserve(&xs, 1024) grows cap only and leaves len alone; resize(&xs, 6) sets len directly — extending it zero-initializes the new slots, shrinking it drops the tail. Reach for reserve to avoid grows; reach for resize when you actually want that many live elements.

The last level isn't about bytes — it's a discipline that emerges from one fact: a [dynamic]T owns its buffer, and nothing frees it for you. Ownership you can see is ownership you can audit.

There is no garbage collector. Every dynamic array you allocate is a buffer that stays alive until you hand it back with delete. Forget, and it leaks — silently at small scale, fatally over hours. The idiom makes the obligation visible: write defer delete(xs) on the line right after the declaration, so the cleanup sits next to the thing it cleans up and a reader can confirm the pair at a glance.

xs: [dynamic]int
defer delete(xs)   // paired with the declaration — runs at scope exit

append(&xs, 10)
append(&xs, 20, 30, 40)   // the array owns one buffer; one delete frees it

Two ways to empty, and they are not the same. Within a frame you usually want to reuse the buffer, not give it back: clear(&xs) sets len to 0 but keeps the capacity, so the slots you already paid for are ready for next frame's appends — no allocation. delete(xs) is the opposite: it returns the buffer to the allocator entirely. The measured difference on a 5-element array:

clear(&xs) — empty, keep the buffer

filled     len=5 cap=8
after clear  len=0 cap=8
re-append   len=1 cap=8
len resets, cap survives → next appends reuse the storage

delete(xs) — give the buffer back

the backing buffer returns to the allocator;
the array's storage is gone. Use defer delete so this fires exactly once, at scope exit.
delete = end of life · clear = reuse · don't confuse them

The emergent payoff: because ownership is explicit, "who frees this?" is never a mystery you chase at runtime — it is a line of code you can point at. Every [dynamic]T declaration carries a visible defer delete beside it; every per-frame buffer is a clear at the top of the frame instead of a fresh allocation. At the scale of a real game loop, that discipline is the difference between a process that runs for ten frames and one that runs for ten hours — and it is auditable by reading, because the language never hid the allocation from you in the first place.

That's the arc: L1 a 40-byte header — data, len, cap, allocator — owning a separate buffer → L2 that's why you reach for it: a runtime count that keeps changing, grown by one appendL3 the bill is grow = alloc + copy + free (cap 0→8→24 here), so a held pointer dangles and reserve skips the grows → L4 you own the buffer, so defer delete frees it and clear reuses it.

probes reproduce with odin run · sizes, the cap schedule, the grow/reserve counts & the clear/delete numbers are real compiler output (claims/lessons/07-dynamic-arrays)