Skip to content

Mutable Strings

Zig does not have a separate built-in mutable String type.

Zig does not have a separate built-in mutable String type.

Text is usually stored as bytes. If the bytes are read-only, you use []const u8. If the bytes can change, you use []u8.

[]const u8  // read-only bytes
[]u8        // mutable bytes

A string literal is read-only:

const name = "zig";

You cannot change it:

name[0] = 'Z'; // error

To change text, you need mutable storage.

Mutable Text with an Array

The simplest mutable text is a byte array:

const std = @import("std");

pub fn main() void {
    var name = [_]u8{ 'z', 'i', 'g' };

    name[0] = 'Z';

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

Output:

Zig

The array owns the bytes. Since it is declared with var, its bytes can change.

This line:

name[0] = 'Z';

changes the first byte.

This line:

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

prints the whole array as a slice.

Mutable Text with a Buffer

A buffer is a block of memory used to store data temporarily.

var buffer: [64]u8 = undefined;

This creates 64 bytes on the stack. The bytes are uninitialized because of undefined.

You should write valid data before reading from it.

Example:

const std = @import("std");

pub fn main() !void {
    var buffer: [64]u8 = undefined;

    const message = try std.fmt.bufPrint(buffer[0..], "Hello, {s}", .{"Zig"});

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

Output:

Hello, Zig

bufPrint writes into the buffer and returns a slice of the part it used.

The returned message does not own memory. It points into buffer.

The Buffer May Be Larger Than the Text

This is important.

var buffer: [64]u8 = undefined;
const message = try std.fmt.bufPrint(buffer[0..], "Hi", .{});

The buffer has 64 bytes. The message has 2 bytes.

buffer capacity: 64 bytes
message length: 2 bytes

You should print message, not the whole buffer.

Correct:

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

Wrong:

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

The rest of the buffer may contain uninitialized garbage.

Mutable Slice Parameters

A function can accept mutable text as []u8.

fn uppercaseAscii(text: []u8) void {
    for (text) |*byte| {
        if (byte.* >= 'a' and byte.* <= 'z') {
            byte.* = byte.* - 32;
        }
    }
}

Usage:

const std = @import("std");

fn uppercaseAscii(text: []u8) void {
    for (text) |*byte| {
        if (byte.* >= 'a' and byte.* <= 'z') {
            byte.* = byte.* - 32;
        }
    }
}

pub fn main() void {
    var name = [_]u8{ 'z', 'i', 'g' };

    uppercaseAscii(name[0..]);

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

Output:

ZIG

The function takes a mutable slice:

[]u8

That means it can modify the bytes.

The loop uses:

|*byte|

This captures each byte by pointer. Without the *, the loop variable would be a copy, and changing it would not modify the original slice.

ASCII vs UTF-8

The uppercaseAscii function only works correctly for ASCII letters.

ASCII letters use one byte each:

a b c ... z
A B C ... Z

But UTF-8 text may use multiple bytes for one visible character.

For example:

const text = "é";

The visible character is one character, but the UTF-8 encoding uses 2 bytes.

Changing arbitrary bytes in UTF-8 text can corrupt the text.

For beginner code, it is fine to write simple ASCII examples. Just remember the boundary:

byte operations are simple
Unicode text rules are more complex

Replacing Bytes

You can write a function that replaces one byte with another.

fn replaceByte(text: []u8, old: u8, new: u8) void {
    for (text) |*byte| {
        if (byte.* == old) {
            byte.* = new;
        }
    }
}

Usage:

const std = @import("std");

fn replaceByte(text: []u8, old: u8, new: u8) void {
    for (text) |*byte| {
        if (byte.* == old) {
            byte.* = new;
        }
    }
}

pub fn main() void {
    var text = [_]u8{ 'h', 'e', 'l', 'l', 'o' };

    replaceByte(text[0..], 'l', 'x');

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

Output:

hexxo

This is safe because the replacement keeps the same length. Each l byte becomes one x byte.

Changing Length Is Different

Changing bytes is easy when the length stays the same.

Changing the length is harder.

For example, replacing:

cat

with:

tiger

needs more space.

A fixed array cannot grow:

var text = [_]u8{ 'c', 'a', 't' };

This array has exactly 3 bytes. It cannot hold 5 bytes.

For variable-length text, you need one of these:

a larger caller-provided buffer
an ArrayList
an allocated slice

You will usually use std.ArrayList(u8) for text that grows over time.

Building Mutable Text with ArrayList

An ArrayList is a growable array from the standard library.

For mutable text, use:

std.ArrayList(u8)

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(u8).init(allocator);
    defer list.deinit();

    try list.appendSlice("Hello");
    try list.append(' ');
    try list.appendSlice("Zig");

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

Output:

Hello Zig

The important field is:

list.items

It is a slice of the currently used bytes.

The ArrayList owns its memory. When it needs more space, it can allocate a larger buffer and copy the old bytes into it.

Because it owns memory, you must call:

defer list.deinit();

This frees the memory when the list is no longer needed.

Appending Text

Use appendSlice to append a string or byte slice:

try list.appendSlice("hello");

Use append to append one byte:

try list.append('!');

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.appendSlice("file");
    try text.appendSlice(".");
    try text.appendSlice("zig");

    std.debug.print("{s}\n", .{text.items});
}

Output:

file.zig

This is a common Zig pattern for building strings dynamically.

Formatting into an ArrayList

You can format text into an ArrayList.

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("x = {}, y = {}", .{ 10, 20 });

    std.debug.print("{s}\n", .{text.items});
}

Output:

x = 10, y = 20

The writer() method gives a writer interface that appends formatted output to the list.

This is useful for logs, generated code, file contents, HTTP responses, and command output.

Taking Ownership of the Built Text

Sometimes you want to return the built text from a function.

You can 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();
}

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

toOwnedSlice transfers ownership of the list’s memory to the caller.

After that, the caller must free the returned slice:

defer allocator.free(greeting);

Notice the use of:

errdefer text.deinit();

If an error happens before ownership is transferred, the list is cleaned up.

Mutable Text Does Not Mean Owned Text

This distinction matters:

[]u8

means mutable bytes.

It does not automatically mean owned bytes.

A mutable slice may point into:

a stack array
a heap allocation
an ArrayList's internal buffer
a memory-mapped file
caller-owned memory

The type tells you that you can write through the slice. It does not tell you who must free the memory.

Ownership is part of the API design.

Common Mistake: Mutating a String Literal

This is wrong:

var text = "hello";
text[0] = 'H';

The variable text is mutable, but the bytes of the string literal are not.

var lets you assign a new value to the variable. It does not make read-only memory writable.

Use a byte array:

var text = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
text[0] = 'H';

Or copy the string into mutable memory.

Common Mistake: Printing the Whole Buffer

This is wrong when the buffer is larger than the text:

var buffer: [64]u8 = undefined;
const message = try std.fmt.bufPrint(buffer[0..], "Hi", .{});

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

Print the returned slice:

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

The returned slice says which part of the buffer contains valid text.

Common Mistake: Keeping list.items After the List Changes

ArrayList can reallocate when it grows.

This means a slice pointing to list.items may become invalid after more appends.

Risky:

const before = list.items;

try list.appendSlice("more text");

// before may no longer point to valid memory

After an append, use list.items again instead of keeping an old slice for too long.

This is an important lifetime rule. A growable list may move its storage.

Complete Example

const std = @import("std");

fn replaceSpaces(text: []u8) void {
    for (text) |*byte| {
        if (byte.* == ' ') {
            byte.* = '_';
        }
    }
}

fn makePath(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    var path = std.ArrayList(u8).init(allocator);
    errdefer path.deinit();

    try path.appendSlice("/tmp/");
    try path.appendSlice(name);
    try path.appendSlice(".txt");

    replaceSpaces(path.items);

    return try path.toOwnedSlice();
}

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

    const allocator = gpa.allocator();

    const path = try makePath(allocator, "hello zig");
    defer allocator.free(path);

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

Output:

/tmp/hello_zig.txt

This example shows three ideas:

ArrayList builds text that can grow
[]u8 lets a function mutate text in place
toOwnedSlice transfers ownership to the caller

Summary

Mutable strings in Zig are mutable byte sequences.

Use a byte array when the size is fixed:

var text = [_]u8{ 'z', 'i', 'g' };

Use a mutable slice when a function should modify caller-provided text:

fn edit(text: []u8) void

Use std.ArrayList(u8) when text needs to grow.

Use std.fmt.bufPrint to write into an existing buffer. Use std.fmt.allocPrint or ArrayList when you need allocated text.

The main rule is simple: Zig treats text as bytes, and memory ownership stays visible.