⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

ArrayList

const std = @import("std");
const expect = std.testing.expect;

test "build up a list" {
    const gpa = std.testing.allocator;

    // `.empty` replaces `init(allocator)`. The list does not store the
    // allocator, so every method that may allocate takes one.
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(gpa);

    try list.append(gpa, 'h');
    try list.appendSlice(gpa, "ello");

    try expect(std.mem.eql(u8, list.items, "hello"));
    try expect(list.items.len == 5);
}

test "items is a plain slice" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u32) = .empty;
    defer list.deinit(gpa);

    for (0..5) |i| try list.append(gpa, @intCast(i * i));

    // `.items` is a `[]T` into the list's buffer, valid until the next
    // reallocation, so do not hold it across an append.
    try expect(list.items[4] == 16);
}

test "pop and remove" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(gpa);

    try list.appendSlice(gpa, "abcd");
    try expect(list.pop().? == 'd');
    _ = list.orderedRemove(0); // shifts the rest down
    try expect(std.mem.eql(u8, list.items, "bc"));
}

test "preallocate when the size is known" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(gpa);

    // One allocation instead of a growth sequence.
    try list.ensureTotalCapacity(gpa, 100);
    for (0..100) |_| list.appendAssumeCapacity('x');
    try expect(list.items.len == 100);
}

test "take ownership of the buffer" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    try list.appendSlice(gpa, "owned");

    // `toOwnedSlice` hands you the memory and empties the list, so the
    // caller frees the slice rather than deinit-ing the list.
    const slice = try list.toOwnedSlice(gpa);
    defer gpa.free(slice);
    try expect(std.mem.eql(u8, slice, "owned"));
}

test "lock the buffer in place while holding a pointer into it" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u32) = .empty;
    defer list.deinit(gpa);

    try list.appendSlice(gpa, &.{ 1, 2, 3 });

    // Between these two calls the buffer may not move, so a pointer into
    // it stays good. Anything that would reallocate panics in a safety
    // build instead of quietly invalidating `first`.
    list.lockPointers();
    const first = &list.items[0];
    first.* += 10;
    list.unlockPointers();

    try expect(list.items[0] == 11);
}

It is unmanaged now

This is the change most likely to break code you find online. std.ArrayList used to store its allocator:

var list = std.ArrayList(u8).init(allocator);   // old
defer list.deinit();
try list.append('a');

On master, ArrayList is the unmanaged variant. It holds no allocator, so you supply one to every method that might allocate:

var list: std.ArrayList(u8) = .empty;           // current
defer list.deinit(gpa);
try list.append(gpa, 'a');

More typing, but the allocator is visible at every call site that can fail, and the struct is two words smaller.

The second reason for the change matters more at scale: a program holding ten thousand small lists was storing ten thousand copies of the same allocator pointer and vtable. Now it stores none, and a struct with several list fields does not carry the allocator once per field.

.empty is a declaration on the type, so var list: std.ArrayList(u8) = .empty; works by inference. When you already know roughly how many items are coming, try .initCapacity(gpa, n) allocates once up front.

What it actually is

An items slice, a capacity, and a safety field covered further down that costs nothing outside a safety build. Three words, then: a pointer, a length, and how many elements the buffer could hold before it has to grow. Appending past the capacity allocates a larger buffer, copies, and frees the old one, which is why append returns an error and why the growth is amortised rather than free.

.items is a borrowed slice

list.items points into the list’s buffer. Appending may reallocate, which invalidates any slice you were holding. Take .items when you need it; do not stash it across mutations.

Left alone, nothing catches this. The old slice is still a valid pointer and a valid length; it just points into memory that has been freed. It reads plausible data or crashes, depending on what the allocator did next. The same applies to a pointer to an element: &list.items[0] is invalid after any append that grows.

Iterating while appending is the common instance. Collect what you want to add into a second list, then append it after the loop.

Locking the buffer in place

lockPointers and unlockPointers mark a region where the buffer is not allowed to move. Inside it, an operation that would reallocate or free panics rather than invalidating a pointer you are still holding:

list.lockPointers();
const first = &list.items[0];
first.* += 10;
list.unlockPointers();

It fires on the invalidation, not on the call. An append that fits in the existing capacity moves nothing and passes; the same append panics on the iteration that has to grow the buffer. Methods that always invalidate assert directly, insert, swapRemove and toOwnedSlice among them.

The field behind it is a std.debug.SafetyLock, which checks nothing outside a safety build. Debug and ReleaseSafe have it. ReleaseFast and ReleaseSmall do not, and the wasm behind the Run button on this page is ReleaseSmall.

Preallocate when you can

ensureTotalCapacity followed by appendAssumeCapacity turns a growth sequence into one allocation, and the AssumeCapacity calls cannot fail, so they need no try.

Which is not only a speed argument. A loop of try list.append(...) has an error path on every iteration. In code that must not fail partway through, reserving the space first turns the whole loop into an operation that either happens or does not. appendAssumeCapacity panics in a safety build if you were wrong about the capacity, so the assumption is checked where it matters.

Handing off ownership

toOwnedSlice(gpa) gives you the buffer and leaves the list empty. The caller now frees the slice, and the list needs no deinit.

This is the idiomatic way to build something of unknown size and return it: use the list while you are growing, hand back a plain []T when you are done. The returned slice is trimmed to the length, so the caller is not carrying the list’s spare capacity around.

The mirror operation is fromOwnedSlice, which adopts a slice you already have so it can keep growing.

When it is the wrong container

When elements are removed from the front, a list is O(n) per removal and a queue is not. When lookup is by key rather than by position, use a hash map. When the element is a large struct and you mostly touch one field at a time, use a MultiArrayList. It stores each field in its own array and reads far fewer cache lines.