Skip to content

Importing ArrayList

An ArrayList is one of the most important data structures in Zig.

ArrayList

An ArrayList is one of the most important data structures in Zig.

It is a growable array.

A normal Zig array has a fixed size:

const numbers = [_]i32{ 1, 2, 3 };

This array always contains exactly 3 items. You cannot add a fourth item later.

But many real programs need collections that grow and shrink dynamically:

  • reading lines from a file
  • storing network packets
  • collecting search results
  • building strings
  • parsing JSON
  • tracking game objects

That is where ArrayList becomes useful.

An ArrayList automatically manages a dynamic buffer in memory. When the list becomes full, it allocates a larger buffer and moves the data.

You can think of it like this:

Fixed Array:
[1][2][3]

ArrayList:
capacity = 8
length   = 3

[1][2][3][ ][ ][ ][ ][ ]

The list currently stores 3 items, but it already reserved space for 8.

This allows fast appends.

Importing ArrayList

ArrayList lives inside Zig’s standard library.

const std = @import("std");

The actual type is:

std.ArrayList(T)

T is the element type.

Example:

std.ArrayList(i32)

This means:

“An ArrayList storing i32 values.”

Creating an ArrayList

Here is the smallest working example:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var list = std.ArrayList(i32).init(allocator);
    defer list.deinit();
}

Read this slowly.

Step 1: Create an Allocator

var gpa = std.heap.GeneralPurposeAllocator(.{}){};

An ArrayList uses heap memory internally.

That means it needs an allocator.

The general purpose allocator is Zig’s standard heap allocator for normal programs.

Step 2: Get the Allocator Interface

const allocator = gpa.allocator();

This gives us a std.mem.Allocator value.

The allocator is passed into containers like ArrayList.

Step 3: Create the List

var list = std.ArrayList(i32).init(allocator);

This creates an empty list of i32.

At this point:

length   = 0
capacity = 0

No elements exist yet.

Step 4: Free the Memory

defer list.deinit();

ArrayList may allocate heap memory.

You must release that memory later.

deinit() frees the internal buffer.

This is extremely important in Zig. Memory cleanup is explicit.

Appending Elements

Now let’s add items.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var list = std.ArrayList(i32).init(allocator);
    defer list.deinit();

    try list.append(10);
    try list.append(20);
    try list.append(30);

    std.debug.print("{any}\n", .{list.items});
}

Output:

{ 10, 20, 30 }

Why append Uses try

Notice this:

try list.append(10);

Appending may require allocating more memory.

Memory allocation can fail.

So append returns an error union.

That is why we use try.

This is one of Zig’s major ideas:

operations that can fail must say so explicitly

Understanding items

The actual elements live here:

list.items

items is a slice.

Type:

[]i32

You can use it like a normal slice.

Example:

std.debug.print("{}\n", .{list.items[0]});

Output:

10

You can loop over it:

for (list.items) |value| {
    std.debug.print("{}\n", .{value});
}

Length vs Capacity

An ArrayList tracks two important numbers.

Length

How many elements currently exist.

[1][2][3]
 ^
 length = 3

Capacity

How many elements fit before reallocating.

[1][2][3][ ][ ][ ][ ][ ]
 ^
 capacity = 8

You can inspect them:

std.debug.print("len = {}\n", .{list.items.len});
std.debug.print("capacity = {}\n", .{list.capacity});

Automatic Growth

When the list becomes full, Zig allocates a larger buffer.

Example:

capacity = 4

[1][2][3][4]

Appending one more item might create:

capacity = 8

[1][2][3][4][5][ ][ ][ ]

The old data gets copied into the new buffer.

This is why appending is usually fast, but occasionally more expensive.

Removing Elements

pop()

Removes the last item.

const value = list.pop();

Example:

try list.append(10);
try list.append(20);

const x = list.pop();

std.debug.print("{}\n", .{x});

Output:

20

The list now contains only:

10

Inserting Elements

You can insert into the middle.

try list.insert(1, 99);

Example:

Before:
[10][20][30]

After:
[10][99][20][30]

Elements shift to the right.

Removing by Index

_ = list.orderedRemove(1);

Example:

Before:
[10][20][30]

After:
[10][30]

This preserves order.

But shifting elements costs time.

Fast Unordered Removal

If order does not matter:

_ = list.swapRemove(1);

Example:

Before:
[10][20][30][40]

Remove index 1:

After:
[10][40][30]

The last element moves into the removed slot.

This is much faster.

Reserving Capacity

Sometimes you already know the approximate size.

Example:

try list.ensureTotalCapacity(1000);

This preallocates space.

Advantages:

  • fewer reallocations
  • fewer memory copies
  • better performance

Very important for performance-sensitive code.

Clearing the List

list.clearRetainingCapacity();

This removes all items but keeps the allocated memory.

Useful when reusing buffers repeatedly.

Example:

Before:
len = 1000
capacity = 2048

After clear:
len = 0
capacity = 2048

No new allocation needed later.

Converting to Owned Slice

Sometimes you want the final buffer itself.

const slice = try list.toOwnedSlice();

After this:

  • the caller owns the memory
  • the list becomes empty
  • the caller must free the slice later

This is common in parsers and builders.

ArrayList of Strings

You can store more than integers.

Example:

var list = std.ArrayList([]const u8).init(allocator);

Now each item is a string slice.

Example:

try list.append("apple");
try list.append("banana");

Loop:

for (list.items) |item| {
    std.debug.print("{s}\n", .{item});
}

Output:

apple
banana

Common Beginner Mistake: Forgetting deinit

Wrong:

var list = std.ArrayList(i32).init(allocator);

If you never call:

list.deinit();

you leak memory.

Always pair:

init()

with:

deinit()

Usually with defer.

Common Beginner Mistake: Keeping Old Pointers

This is dangerous:

const ptr = &list.items[0];
try list.append(999);

Appending may reallocate memory.

If reallocation happens, ptr may point to invalid memory.

This is a very important concept.

Pointers into an ArrayList can become invalid after resizing.

Internal Mental Model

A simplified internal structure looks like this:

const ArrayList = struct {
    ptr: [*]T,
    len: usize,
    capacity: usize,
};

Not exact implementation, but close enough conceptually.

The list owns a heap buffer:

ptr ---> [10][20][30][ ]

len tracks used items.

capacity tracks allocated space.

Why ArrayList Matters

ArrayList appears everywhere in Zig programs.

It is used for:

  • dynamic strings
  • parsers
  • token lists
  • file buffers
  • HTTP requests
  • JSON processing
  • compiler internals
  • game entities
  • network packets

If you understand ArrayList, you understand one of the core patterns of Zig programming:

  • explicit memory ownership
  • allocator-driven design
  • slices as views into memory
  • manual lifetime management
  • predictable performance

This is not just a container.

It teaches the philosophy of Zig itself.