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 bytesA string literal is read-only:
const name = "zig";You cannot change it:
name[0] = 'Z'; // errorTo 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:
ZigThe 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, ZigbufPrint 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 bytesYou 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:
ZIGThe function takes a mutable slice:
[]u8That 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 ... ZBut 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 complexReplacing 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:
hexxoThis 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:
catwith:
tigerneeds 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 sliceYou 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 ZigThe important field is:
list.itemsIt 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.zigThis 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 = 20The 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, ZigtoOwnedSlice 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:
[]u8means 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 memoryThe 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 memoryAfter 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.txtThis example shows three ideas:
ArrayList builds text that can grow
[]u8 lets a function mutate text in place
toOwnedSlice transfers ownership to the callerSummary
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) voidUse 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.