TL;DR
Learn Zig string handling — work with []const u8 byte slices, compare and search strings, iterate bytes, and build strings with std.fmt.
Key concepts
- zig strings
- zig string handling
- zig byte slices
- zig string comparison
- zig []const u8
String Handling
In many languages, strings are a built-in type with magic attached — reference counting, garbage collection, hidden allocation. In Zig, a string is just a slice of bytes: []const u8. That simplicity is a strength. Once you understand slices and pointers, you already understand strings. There is no hidden runtime cost, no implicit conversion, no mystery. What you see is what you get.
This lesson covers how Zig represents strings, how to compare and search them, how to iterate over their bytes, and how to build new strings at runtime using fixed buffers and formatters.
Strings Are Byte Slices
A string literal in Zig has the type *const [N:0]u8 — a pointer to a null-terminated array of N bytes. The :0 signals a sentinel-terminated array, and the null terminator exists for C interoperability. In almost every context this coerces to []const u8, the idiomatic Zig string type. The null terminator is not included in the slice's len.
const std = @import("std");
pub fn main() void {
const greeting: []const u8 = "Hello, Zig!";
std.debug.print("String: {s}\n", .{greeting});
std.debug.print("Length: {}\n", .{greeting.len});
std.debug.print("First byte: {c}\n", .{greeting[0]});
std.debug.print("Slice: {s}\n", .{greeting[0..5]});
std.debug.print("Last char: {c}\n", .{greeting[greeting.len - 1]});
}
The {s} format specifier prints a byte slice as a human-readable string. The {c} specifier prints a single byte as its ASCII character. Indexing and slicing work exactly the same as with any other Zig slice — you get a raw view into the underlying bytes with no copying involved.
Comparing and Searching Strings
Because strings are slices, you cannot compare them with ==. That would compare the slice headers — the pointer address and length — not the actual content. Use std.mem.eql to compare two strings byte by byte.
const std = @import("std");
pub fn main() void {
const language = "Zig";
if (std.mem.eql(u8, language, "Zig")) {
std.debug.print("Matched!\n", .{});
}
const sentence = "the quick brown fox jumps";
// Find the position of a substring
if (std.mem.indexOf(u8, sentence, "brown")) |pos| {
std.debug.print("'brown' found at index {}\n", .{pos});
}
const starts = std.mem.startsWith(u8, sentence, "the");
const ends = std.mem.endsWith(u8, sentence, "jumps");
std.debug.print("Starts with 'the': {}\n", .{starts});
std.debug.print("Ends with 'jumps': {}\n", .{ends});
const count = std.mem.count(u8, sentence, "o");
std.debug.print("Occurrences of 'o': {}\n", .{count});
}
std.mem.indexOf returns an optional ?usize — the byte index of the first match, or null if not found. Capturing it with |pos| in an if expression gives you the value only when it exists. std.mem.startsWith and std.mem.endsWith return plain booleans and are often useful for parsing input or routing based on prefixes and suffixes.
Iterating Over a String
Iterating byte by byte is a plain for loop over the slice. This works perfectly for ASCII text, where each character is exactly one byte.
const std = @import("std");
pub fn main() void {
const word = "Hello";
// Print each character with its index
for (word, 0..) |byte, i| {
std.debug.print("[{}] {c}\n", .{ i, byte });
}
// Count vowels using a switch
var vowel_count: usize = 0;
for (word) |byte| {
switch (byte) {
'a', 'e', 'i', 'o', 'u',
'A', 'E', 'I', 'O', 'U' => vowel_count += 1,
else => {},
}
}
std.debug.print("Vowels in '{s}': {}\n", .{ word, vowel_count });
}
The for (word, 0..) syntax zips the slice with an auto-incrementing index, giving you both the byte and its position. This is a common Zig idiom that avoids a manual counter variable.
For Unicode text, byte iteration is not always correct. A single emoji or accented character may span two to four bytes. Iterating bytes would break such a character apart. In those cases, use std.unicode.Utf8View to iterate over codepoints instead of raw bytes.
Building Strings at Runtime
Zig has no runtime string concatenation operator. The ++ operator joins string literals at comptime only. For runtime string building, you write into a buffer using std.fmt.bufPrint, which formats values into a fixed-size byte array and returns a slice of the written bytes.
const std = @import("std");
pub fn main() !void {
var buf: [128]u8 = undefined;
const name = "world";
const greeting = try std.fmt.bufPrint(&buf, "Hello, {s}!", .{name});
std.debug.print("{s}\n", .{greeting});
const items: u32 = 7;
const price: f32 = 3.99;
const receipt = try std.fmt.bufPrint(&buf, "{} items at ${d:.2} each", .{ items, price });
std.debug.print("{s}\n", .{receipt});
// Slice the result to check its length
std.debug.print("Receipt is {} bytes long\n", .{receipt.len});
}
std.fmt.bufPrint returns error.NoSpaceLeft if the buffer is too small, so you must handle the error with try or a catch branch. The returned slice points into your local buffer — it is valid as long as buf is in scope, and no allocation happens. For strings that grow dynamically, use std.ArrayList(u8) with an allocator and write through its writer() interface.
Try It Yourself
Write a function that checks whether a string is a palindrome — the same forwards and backwards. Use index arithmetic to compare bytes from both ends toward the middle.
const std = @import("std");
fn isPalindrome(s: []const u8) bool {
if (s.len == 0) return true;
var left: usize = 0;
var right: usize = s.len - 1;
while (left < right) {
if (s[left] != s[right]) return false;
left += 1;
right -= 1;
}
return true;
}
pub fn main() void {
const words = [_][]const u8{
"racecar",
"hello",
"level",
"world",
"noon",
"Zig",
"civic",
};
for (words) |word| {
std.debug.print("{s:<10} palindrome: {}\n", .{ word, isPalindrome(word) });
}
}
Try extending this to ignore case: compare std.ascii.toLower(s[left]) against std.ascii.toLower(s[right]) so that "Racecar" is also recognized as a palindrome. You can find std.ascii.toLower in the standard library's ascii namespace.
Key Takeaways
- Strings in Zig are
[]const u8— a slice of bytes with no hidden behavior or runtime cost - String literals coerce from
*const [N:0]u8to[]const u8; the null terminator exists for C interop only - Use
{s}to print strings and{c}to print individual bytes as characters - Never compare strings with
==; usestd.mem.eql(u8, a, b)to compare contents std.mem.indexOf,std.mem.startsWith,std.mem.endsWith, andstd.mem.countcover most search needs- Iterate over bytes with a plain
forloop; usestd.unicode.Utf8Viewfor multi-byte Unicode codepoints - Build runtime strings with
std.fmt.bufPrintinto a stack buffer, orstd.ArrayList(u8)when the length is unknown upfront - The
++operator concatenates string literals at comptime only — it does not work on runtime values
Pro Tip: Reach for
std.fmt.bufPrintand a stack-allocated buffer whenever you need to build a short string in a hot path. It formats into memory you already own, returns a slice into that buffer, and performs zero heap allocations. Avar buf: [256]u8 = undefined;is often all you need — and it communicates the maximum size constraint clearly at the call site.
Next Steps
You've seen how strings work as concrete byte slices. Next, you'll learn how to write functions that work with any type at all -- using anytype and comptime type parameters to build generic, reusable code without runtime cost.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.