A dynamic string is text whose length is not fixed ahead of time.
In Zig, dynamic strings are usually built as dynamic byte arrays. The most common tool is:
std.ArrayList(u8)This means a growable list of bytes.
Since Zig represents text as UTF-8 bytes, a growable list of u8 values works well for building strings.
Why Dynamic Strings Need Memory
A fixed array has a fixed size:
var buffer: [16]u8 = undefined;This buffer can hold at most 16 bytes.
That is fine when you know the maximum size. But many strings are not known ahead of time.
Examples:
user input
file paths
generated code
JSON output
error messages
HTML pages
SQL queries
HTTP responsesFor these cases, the string needs room to grow.
Using ArrayList(u8)
Here is a small example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.appendSlice("Hello");
try text.append(' ');
try text.appendSlice("Zig");
std.debug.print("{s}\n", .{text.items});
}Output:
Hello ZigThe list owns heap memory. That is why it needs an allocator.
This line creates the list:
var text = std.ArrayList(u8).init(allocator);This line frees the memory when the function exits:
defer text.deinit();The current string contents are stored in:
text.itemsThat field is a slice:
[]u8You can print it with {s}.
Appending One Byte
Use append for one byte:
try text.append('!');The byte '!' is added to the end.
Full example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.append('A');
try text.append('B');
try text.append('C');
std.debug.print("{s}\n", .{text.items});
}Output:
ABCThis works because ASCII characters are single bytes.
Appending a String
Use appendSlice for a byte slice:
try text.appendSlice("hello");A string literal can be used as []const u8, so it can be appended to an ArrayList(u8).
Example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var path = std.ArrayList(u8).init(allocator);
defer path.deinit();
try path.appendSlice("/usr");
try path.append('/');
try path.appendSlice("local");
try path.append('/');
try path.appendSlice("bin");
std.debug.print("{s}\n", .{path.items});
}Output:
/usr/local/binFormatting into a Dynamic String
ArrayList can act like a writer.
This is useful when you want to append formatted values.
try text.writer().print("x = {}, y = {}", .{ 10, 20 });Complete example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.writer().print("name = {s}, score = {}", .{ "Ada", 95 });
std.debug.print("{s}\n", .{text.items});
}Output:
name = Ada, score = 95This avoids manual number-to-string conversion.
Building Text in Several Steps
Dynamic strings are useful when output is built step by step.
const std = @import("std");
fn writeUser(text: *std.ArrayList(u8), name: []const u8, age: u32) !void {
try text.writer().print("user {{ name = {s}, age = {} }}", .{ name, age });
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.appendSlice("record: ");
try writeUser(&text, "Ada", 36);
std.debug.print("{s}\n", .{text.items});
}Output:
record: user { name = Ada, age = 36 }The function receives a pointer to the ArrayList so it can append to the same list.
fn writeUser(text: *std.ArrayList(u8), name: []const u8, age: u32) !voidThis pattern is common when many helper functions contribute to one output buffer.
Reserving Capacity
An ArrayList grows when needed. Growing may allocate a new buffer and copy the old bytes.
If you have a rough size estimate, you can reserve memory first.
try text.ensureTotalCapacity(1024);This asks the list to prepare enough space for 1024 bytes.
Example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.ensureTotalCapacity(128);
try text.appendSlice("This text can grow without immediate reallocation.");
std.debug.print("{s}\n", .{text.items});
}Reserving capacity is not required for correctness. It is a performance tool.
Use it when you expect many appends and can estimate the final size.
Length and Capacity
An ArrayList has two important ideas:
length: how many bytes are currently used
capacity: how many bytes can fit before growing againThe used bytes are:
text.itemsThe length is:
text.items.lenThe capacity is internal storage size. It may be larger than the length.
For example, a list may have length 11 but capacity 32. Only the first 11 bytes are part of the string.
Always print or use text.items, not the whole internal capacity.
Clearing and Reusing a List
You can clear a list while keeping its allocated capacity.
text.clearRetainingCapacity();This sets the length to zero but keeps the memory for reuse.
Example:
try text.appendSlice("first");
text.clearRetainingCapacity();
try text.appendSlice("second");Now text.items contains:
secondThis is useful in loops where you build many temporary strings.
There is also:
text.clearAndFree();This clears the list and frees its allocated memory.
Use clearRetainingCapacity when you plan to reuse the list. Use clearAndFree when you want to release memory.
Returning a Dynamic String
Sometimes a function should build a string and return it.
Use toOwnedSlice.
const std = @import("std");
fn makeGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
var text = std.ArrayList(u8).init(allocator);
errdefer text.deinit();
try text.writer().print("Hello, {s}!", .{name});
return try text.toOwnedSlice();
}The returned slice is owned by the caller.
Usage:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const greeting = try makeGreeting(allocator, "Zig");
defer allocator.free(greeting);
std.debug.print("{s}\n", .{greeting});
}Output:
Hello, Zig!The important rule:
toOwnedSlice transfers ownership to the callerAfter that, the caller must free the returned slice.
Why errdefer Is Used
Look at this function again:
fn makeGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
var text = std.ArrayList(u8).init(allocator);
errdefer text.deinit();
try text.writer().print("Hello, {s}!", .{name});
return try text.toOwnedSlice();
}The line:
errdefer text.deinit();means: if this function returns an error, clean up the list.
This matters because print or toOwnedSlice may fail if allocation fails.
If everything succeeds, toOwnedSlice transfers ownership, so text.deinit() should not run.
That is why this uses errdefer, not defer.
Borrowing vs Returning
When a function receives an ArrayList pointer, it borrows the list:
fn appendName(text: *std.ArrayList(u8), name: []const u8) !void {
try text.appendSlice(name);
}The caller still owns the list.
When a function returns []u8 from toOwnedSlice, it transfers ownership:
fn makeName(allocator: std.mem.Allocator) ![]u8 {
var text = std.ArrayList(u8).init(allocator);
errdefer text.deinit();
try text.appendSlice("Zig");
return try text.toOwnedSlice();
}The caller owns the returned memory.
These two designs are both common. Choose based on who should own the final string.
Common Mistake: Forgetting deinit
This leaks memory:
var text = std.ArrayList(u8).init(allocator);
try text.appendSlice("hello");The list allocated memory, but nothing freed it.
Correct:
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();
try text.appendSlice("hello");Every owning dynamic structure needs a cleanup path.
Common Mistake: Keeping items Too Long
This can be unsafe:
const old_items = text.items;
try text.appendSlice("more");
// old_items may no longer be validAppending may cause the list to reallocate. If that happens, the old storage is freed or replaced.
After changing the list, use text.items again.
try text.appendSlice("more");
const current_items = text.items;The rule is:
do not keep slices into a growable list across operations that may reallocateCommon Mistake: Using Dynamic Strings When a Buffer Is Enough
Do not allocate when a fixed buffer is enough.
Good for small, bounded output:
var buffer: [128]u8 = undefined;
const message = try std.fmt.bufPrint(buffer[0..], "id={}", .{42});Good for output that grows unpredictably:
var text = std.ArrayList(u8).init(allocator);
defer text.deinit();Use the simpler memory model when possible.
A fixed buffer avoids heap allocation. An ArrayList gives flexibility.
Complete Example
const std = @import("std");
fn appendCsvField(out: *std.ArrayList(u8), field: []const u8) !void {
try out.append('"');
for (field) |byte| {
if (byte == '"') {
try out.appendSlice("\"\"");
} else {
try out.append(byte);
}
}
try out.append('"');
}
fn makeCsvLine(allocator: std.mem.Allocator, name: []const u8, city: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
try appendCsvField(&out, name);
try out.append(',');
try appendCsvField(&out, city);
try out.append('\n');
return try out.toOwnedSlice();
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const line = try makeCsvLine(allocator, "Ada", "London");
defer allocator.free(line);
std.debug.print("{s}", .{line});
}Output:
"Ada","London"This example shows a realistic dynamic string builder:
helper functions append into one ArrayList
the final string is returned with toOwnedSlice
the caller frees the returned sliceSummary
Zig builds dynamic strings with dynamic byte storage.
Use:
std.ArrayList(u8)when text needs to grow.
Use append for one byte, appendSlice for text, and writer().print for formatted output.
Call deinit when the list owns memory. Use toOwnedSlice when returning the final string to the caller.
The main rule is ownership: whoever owns the dynamic string must free it.