Zig String Basics
Strings in Zig are sequences of bytes. Unlike many languages that have a dedicated string type, Zig uses slices of bytes — specifically []const u8 — to represent text. This design makes Zig's string handling explicit, efficient, and compatible with C libraries that work the same way.
String Literals
const greeting = "Hello, World!";
A string literal is a sequence of characters enclosed in double quotes. Zig stores the bytes in read-only memory and gives you a slice pointing to them. The type is *const [13:0]u8 — a pointer to a constant array of 13 bytes followed by a null terminator.
"Hello, World!"
Memory:
[ H e l l o , W o r l d ! \0 ]
72 101 108 108 111 44 32 87 111 114 108 100 33 0
↑ ↑
index 0 null terminator
greeting.len = 13 (null byte not counted in len)
The String Type — []const u8
When you write a function that accepts or returns text, the idiomatic type is []const u8 — a slice of constant bytes:
fn greet(name: []const u8) void {
std.debug.print("Hello, {s}!\n", .{name});
}
greet("Arjun"); // string literal
greet(some_variable); // any []const u8 works
[]const u8 internals: ┌──────────────────┬────────────┐ │ pointer │ length │ │ (points to data)│ (count) │ └──────────────────┴────────────┘ Together these describe a window into a byte sequence.
String Length
const name = "Meera";
std.debug.print("Length: {d}\n", .{name.len}); // 5
The .len property gives the number of bytes, not the number of visible characters. For ASCII text, bytes and characters match one-to-one. For UTF-8 text with accented or non-Latin characters, one visible character may take two, three, or four bytes — so .len counts bytes, not characters.
Accessing Individual Bytes
const word = "Zig";
std.debug.print("First byte: {c}\n", .{word[0]}); // Z
std.debug.print("Second byte: {c}\n", .{word[1]}); // i
std.debug.print("Third byte: {c}\n", .{word[2]}); // g
Index into the string with square brackets. The {c} format specifier prints a byte as its character representation. Accessing an index beyond the end causes a runtime panic in debug mode.
Multi-Line Strings
Zig uses the \\ prefix for multi-line string literals. Each line that starts with \\ is one line of the string:
const poem =
\\Roses are red,
\\Violets are blue,
\\Zig is fast,
\\And memory-safe too.
;
std.debug.print("{s}\n", .{poem});
Output:
Roses are red, Violets are blue, Zig is fast, And memory-safe too.
Each \\ line includes a newline at the end automatically. The final line also gets a newline. Multi-line strings contain no escape sequences — what you see is exactly what you get.
Escape Sequences in Strings
Sequence │ Meaning
─────────┼──────────────────────────
\n │ Newline (line feed)
\r │ Carriage return
\t │ Tab character
\\ │ Literal backslash
\" │ Literal double quote
\0 │ Null byte
\xNN │ Byte with hex value NN
\u{NNNN} │ Unicode code point
const tabbed = "Name:\tValue:"; const quoted = "He said \"hello\" loudly."; const path = "C:\\Users\\Public";
Comparing Strings
The == operator compares pointer addresses, not the content. Two different string literals with the same text may live at different addresses, so == is unreliable for string equality. Always use std.mem.eql:
const a = "hello";
const b = "hello";
// Wrong — compares addresses, not content:
// std.debug.print("{}\n", .{a == b}); // might be false!
// Correct — compares byte by byte:
std.debug.print("{}\n", .{std.mem.eql(u8, a, b)}); // true
String Concatenation at Compile Time
const first = "Hello, "; const last = "World!"; const full = first ++ last; // full = "Hello, World!"
The ++ operator joins two string literals at compile time. Both must be compile-time known. For joining strings at runtime, use std.fmt.allocPrint or an std.ArrayList(u8).
Runtime String Building
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const user = "Priya";
const score: u32 = 98;
const message = try std.fmt.allocPrint(
alloc,
"{s} scored {d} points!",
.{user, score},
);
defer alloc.free(message);
std.debug.print("{s}\n", .{message});
// Priya scored 98 points!
}
String Slicing
const sentence = "The quick brown fox";
const word = sentence[4..9]; // "quick"
std.debug.print("{s}\n", .{word});
const rest = sentence[10..]; // "brown fox"
std.debug.print("{s}\n", .{rest});
"The quick brown fox"
012345678901234567890
↑ ↑
[4] [9) → "quick"
Null-Terminated Strings for C Interop
C functions expect strings that end with a zero byte. Zig's string literals include a null terminator but the type []const u8 does not expose it. Use the sentinel-terminated type when passing strings to C:
const c_str: [*:0]const u8 = "hello"; // or for Zig string literals: const zig_lit = "hello"; // already null-terminated in memory // Pass to C as: _ = c.strlen(zig_lit); // works because literal has \0 at end
Practical Example: Name Formatter
const std = @import("std");
fn formatFullName(
allocator: std.mem.Allocator,
first: []const u8,
last: []const u8,
) ![]u8 {
return std.fmt.allocPrint(allocator, "{s} {s}", .{first, last});
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const full = try formatFullName(alloc, "Ravi", "Shankar");
defer alloc.free(full);
std.debug.print("Full name: {s}\n", .{full}); // Ravi Shankar
std.debug.print("Length: {d} chars\n", .{full.len}); // 11
}
