Skip to lesson

learningzig.org / intermediate / 18-string-handling · lesson 12 of 25

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.

What You'll Learn

By the end of this lesson you will build a small string toolkit: a palindrome check that walks a slice from both ends, a counter that branches on individual byte values, and a formatter that writes a labelled row into a buffer its caller owns. That last signature is the point — where the buffer lives decides whether the slice you hand back is still readable a line later.

This is the milestone the capstone's Record Analyzer calls report formatting. The analyzer prints one aligned row per category and splits each input line into fields it compares by content, so both halves of what you build here show up there directly. You arrive here able to allocate through an explicit allocator and pair it with a release (Memory and Allocators), and to read a slice as a pointer plus a length (Slices and Iteration); what is new is that the elements are now bytes that people expect to mean text, and Zig does not share that expectation.

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.

zig
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.

len is a slice's length, and this slice's elements are bytes. Work out both printed numbers below before you run it.

Predict

Two strings, both five visible characters long. What does each len print?

zig
const ascii: []const u8 = "cafes";
const accented: []const u8 = "cafés";

std.debug.print("ascii len = {}\n", .{ascii.len});
std.debug.print("accented len = {}\n", .{accented.len});

Continue learning

The lines print 5 and 6. len is the slice's length in elements, and the elements are bytes: "cafes" is five ASCII bytes, while in "cafés" the é is a single character that UTF-8 encodes as two bytes. A []const u8 carries no encoding information at all, so byte count and character count only coincide for pure ASCII. (The literal's null terminator sits at index len and is not counted in it.)

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.

zig
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.

A found position is a number, and one particular number causes trouble. The next program builds and runs, and gets one of its three answers wrong. Commit a hypothesis before you fix it.

Debug

This program should report which of the three lines contain a colon. It compiles and runs, but one of the three answers is wrong. Say which one and why before you change anything, then fix it.

zig
const std = @import("std");

fn hasColon(line: []const u8) bool {
  const pos = std.mem.indexOf(u8, line, ":") orelse 0;
  return pos > 0;
}

pub fn main() void {
  const lines = [_][]const u8{
      "name: Aria",
      ": leading colon",
      "no colon here",
  };

  for (lines) |line| {
      std.debug.print("{s} -> {}\n", .{ line, hasColon(line) });
  }
}

Expected output:

name: Aria -> true
: leading colon -> true
no colon here -> false
Continue learning

The orelse 0 is the bug. It flattens "not found" into the number 0, which is also the perfectly legal index of a match at the very first byte — so pos > 0 reports false for ": leading colon". That is the sentinel-value bug optionals are designed to eliminate, reintroduced by hand. Ask the question directly instead: return std.mem.indexOf(u8, line, ":") != null;, or capture the position with if (…) |pos| when you need it. Never map null onto a value that is also a valid result.

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.

zig
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.

Three things about bufPrint decide how you use it, and each one is easier to see on its own. Start with what it hands back.

Stage 1 — the return value is not the buffer. You supply storage; bufPrint reports how much of it it used.

zig
const std = @import("std");

pub fn main() !void {
    // 64 bytes of storage, uninitialised — you are supplying the room, not the text.
    var buf: [64]u8 = undefined;

    const written = try std.fmt.bufPrint(&buf, "Hello, {s}!", .{"world"});

    // Two different lengths, and the difference is the whole idea: buf is how
    // much room exists, written is how much was actually filled. Printing buf
    // itself would print 64 bytes, most of them garbage.
    std.debug.print("buf.len     = {}\n", .{buf.len});
    std.debug.print("written.len = {}\n", .{written.len});
    std.debug.print("written     = {s}\n", .{written});
}

That prints 64, 13, and Hello, world!. The returned slice is a view of the filled prefix — same bytes, narrower length — which is why you print written and never buf.

Stage 2 — the room can run out, and that is an error, not a truncation. This is why every bufPrint call needs try or a catch. The buffer below is deliberately too small for the second call, and the failure is caught rather than propagated so you can see the error's name.

zig
const std = @import("std");

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

    const ok = try std.fmt.bufPrint(&buf, "{s}: {d}", .{ "cpu", 93 });
    std.debug.print("ok = {s}\n", .{ok});

    // "a-very-long-label: 100" needs 22 bytes and there are 16. Note what
    // bufPrint does NOT do: it does not write as much as fits and stop.
    const result = std.fmt.bufPrint(&buf, "{s}: {d}", .{ "a-very-long-label", 100 });
    if (result) |slice| {
        std.debug.print("unexpected success: {s}\n", .{slice});
    } else |err| {
        std.debug.print("failed with: {s}\n", .{@errorName(err)});
    }
}

The second call fails with NoSpaceLeft. It refuses the whole write rather than filling the buffer partway, so a caught error means the buffer is untouched — there is no half-written string to reason about.

Stage 3 — now the part you will actually write. Push the formatting into a function and the buffer has to come from somewhere. Take it as a parameter, and predict what the last line prints before you run it.

zig
const std = @import("std");

// The buffer is a parameter, not a local: the caller owns the storage, so the
// slice this returns is still valid after the function has returned.
fn row(buf: []u8, label: []const u8, value: i32) ![]const u8 {
    return try std.fmt.bufPrint(buf, "{s}: {d}", .{ label, value });
}

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

    const a = try row(&buf, "cpu", 93);
    std.debug.print("{s}\n", .{a});

    const b = try row(&buf, "mem", 41);
    std.debug.print("{s}\n", .{b});

    // Both calls wrote into the SAME buffer. What does the first slice say now?
    std.debug.print("first slice now reads: {s}\n", .{a});
}

It prints cpu: 93, mem: 41, and then first slice now reads: mem: 41. Nothing was copied: a and b are two views into one buffer, and the second write overwrote what the first one said. That is the cost of not allocating — one buffer holds one result at a time, so you either finish with a result before formatting the next, or you need a buffer per result.

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) — start it from .empty and format into it with list.print(allocator, fmt, args), passing the allocator to every call that grows it.

Assemble that shape yourself. The lines below format a label and then cut a piece back out of it — the pieces are shuffled, and only one arrangement runs.

Arrange the code

These lines build the text count=7 in a buffer, find the equals sign in it, and slice the part before it back out. The pieces are shuffled. Put them in the only order that runs — then answer what the order is really testing: each line consumes something the line before it produced, so name what would be missing if you moved each one up.

  1. const name = label[0..eq];
  2. std.debug.print("name={s} whole={s}\n", .{ name, label });
  3. var buf: [64]u8 = undefined;
  4. const eq = std.mem.indexOfScalar(u8, label, '=').?;
  5. const label = try std.fmt.bufPrint(&buf, "{s}={d}", .{ "count", 7 });
Continue learning

bufPrint hands back a slice into memory you declared yourself, which raises a lifetime question you met in an earlier lesson. Close the code and answer it.

Recall

Without scrolling up: a function declares var buf: [128]u8 = undefined, formats into it with std.fmt.bufPrint, and returns the slice bufPrint gave back. Drawing on what Memory and Allocators taught about lifetimes, what happens when the caller prints that returned slice?

Continue learning

The returned slice points into buf, and buf is a stack local that ceases to exist when the function returns — so the caller is reading memory that may already have been reused. A slice borrows, it never owns. Either declare the buffer in the scope that will read the result and pass it down, or allocate with an allocator and hand the caller something it is responsible for freeing. Zig will not flag this for you; there is no lifetime tracking across function boundaries.

Rust draws the borrowed-versus-owned line in the same place Zig does, but it adds one guarantee Zig leaves out. Work out which half you can carry across.

Transfer

Rust splits strings in two: &str borrows text it does not own, and String owns a heap buffer it frees. Zig's []const u8 borrows in exactly the same way — a pointer and a length into bytes belonging to someone else — and the owning case is a buffer you allocated and must free yourself. Which statement names something the two languages genuinely share, rather than a resemblance that breaks under a non-ASCII byte?

Continue learning

Try It Yourself

Reading about byte slices is not the same as manipulating them by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one walks a string from both ends, one counts with a std.mem helper, one formats into a caller-supplied buffer. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

There is no assert to import: the starter defines its own check helper, which prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything.

Build

Finish the build. Four pieces are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function needs, then implement them all until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Write TODO 4b out in full even though it will feel like you have already written it; noticing that is part of the task.

zig
const std = @import("std");

// TODO 1: return true when s reads the same forwards and backwards, ignoring
//   ASCII case. Walk two indices toward each other and compare bytes with
//   std.ascii.toLower on both sides.
//   isPalindrome("Racecar") -> true,  isPalindrome("hello") -> false
//   An empty string counts as a palindrome.
fn isPalindrome(s: []const u8) bool {
  _ = s;
  return false;
}

// TODO 2: return how many vowels (a e i o u, either case) are in s.
//   A for loop over the bytes with a switch is the direct route.
//   countVowels("Zig lang") -> 2
fn countVowels(s: []const u8) usize {
  _ = s;
  return 0;
}

// TODO 3: format "<name>: <score>" into the caller's buffer and return the
//   slice bufPrint gives back. The buffer belongs to the caller, so the
//   returned slice stays valid after this function returns.
//   label(&buf, "Aria", 42) -> "Aria: 42"
fn label(buf: []u8, name: []const u8, score: u32) ![]const u8 {
  _ = buf;
  _ = name;
  _ = score;
  return "";
}

// TODO 4a: return how many bytes of s equal wanted.
//   countMatchingBytes("banana", 'a') -> 3
fn countMatchingBytes(items: []const u8, wanted: u8) usize {
  _ = items;
  _ = wanted;
  return 0;
}

// TODO 4b: the same question, asked of scores instead of bytes.
//   countMatchingScores(&.{ 7, 3, 7, 7 }, 7) -> 3
//   Write this one out too, even though you are about to notice something.
fn countMatchingScores(items: []const i32, wanted: i32) usize {
  _ = items;
  _ = wanted;
  return 0;
}

// A tiny stand-in for an assert: print what went wrong and return an error.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  try check(isPalindrome("racecar"), "isPalindrome(\"racecar\") should be true");
  try check(isPalindrome("Racecar"), "isPalindrome(\"Racecar\") should be true — case is ignored");
  try check(!isPalindrome("hello"), "isPalindrome(\"hello\") should be false");
  try check(isPalindrome(""), "an empty string should count as a palindrome");

  try check(countVowels("Zig lang") == 2, "countVowels(\"Zig lang\") should be 2 (i and a)");
  try check(countVowels("AEIOU") == 5, "countVowels should count uppercase vowels too");
  try check(countVowels("rhythm") == 0, "countVowels(\"rhythm\") should be 0");

  var buf: [64]u8 = undefined;
  const first = try label(&buf, "Aria", 42);
  try check(std.mem.eql(u8, first, "Aria: 42"), "label should format to \"Aria: 42\"");
  const second = try label(&buf, "Bo", 7);
  try check(std.mem.eql(u8, second, "Bo: 7"), "label should format to \"Bo: 7\"");

  try check(countMatchingBytes("banana", 'a') == 3, "countMatchingBytes(\"banana\", 'a') should be 3");
  try check(countMatchingBytes("banana", 'z') == 0, "countMatchingBytes should be 0 when nothing matches");
  try check(countMatchingScores(&.{ 7, 3, 7, 7 }, 7) == 3, "countMatchingScores(&.{ 7, 3, 7, 7 }, 7) should be 3");
  try check(countMatchingScores(&.{ 7, 3 }, 9) == 0, "countMatchingScores should be 0 when nothing matches");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("{s}\n", .{try label(&buf, "Cy", 91)});
  std.debug.print("Vowels in 'Zig lang': {}\n", .{countVowels("Zig lang")});
  std.debug.print("Matches: {} {}\n", .{ countMatchingBytes("banana", 'a'), countMatchingScores(&.{ 7, 3, 7, 7 }, 7) });
}

Expected output:

All checks passed.
Cy: 91
Vowels in 'Zig lang': 2
Matches: 3 3
Continue learning

Once it passes, try two variations and predict each before running:

  1. Remove the empty-string guard. In isPalindrome, delete the if (s.len == 0) return true; line. Predict whether the failure is a wrong answer or a crash before running. It crashes: s.len - 1 on an empty slice underflows an unsigned integer, and the program panics with an integer-overflow trap before any comparison happens. The guard is not politeness, it is the only thing standing between you and a usize wrapping around.
  2. Format with the wrong specifier. In label, change the format string from "{s}: {d}" to "{s}: {s}". Predict whether this fails at build time or at run time. It never runs: {s} demands something printable as a string and score is a u32, so std.fmt rejects it at compile time. Format strings in Zig are checked against their arguments at comptime, not discovered to be wrong in production.

The duplicate you just wrote

countMatchingBytes and countMatchingScores are the same function twice. Line for line the bodies are identical — declare a counter, walk the slice, compare each item against wanted, return the count — and the only difference is the element type appearing in the signature. Try to delete either one and the build breaks, because []const u8 and []const i32 are separate types and neither function will accept the other's argument.

Nothing you know so far can fix that. You cannot write one function serving both, because a parameter's type has to be written down and there is only one place to write it. So the duplication is not carelessness — it is the honest consequence of the tools available at this point in the course, and every additional element type would cost another copy of the same five lines.

Hold on to it. Comptime and Generics And Anytype are the two lessons that make this pair collapse into one definition, and they open by picking up exactly these two functions.

Capstone milestone

Milestone — report formatting. The capstone's Record Analyzer builds each output row by formatting values into a writer, and its parser compares and searches field slices to decide what they mean. The string toolkit you just built is both halves in miniature. Confirm you can inspect and format byte slices without reaching for a string type that does not exist.

Hint: You don't need the full report yet — this confirms the byte-slice fluency the formatter is built on. In the capstone, render writes each row through a *std.Io.Writer with format specifiers like {s:<10} for column alignment, and the parser uses std.mem to split and compare fields.

  • Compared string contents with std.mem.eql rather than ==
  • Iterated a string's bytes with a for loop and branched on individual byte values
  • Formatted a runtime value into a byte buffer with std.fmt.bufPrint and used the returned slice
  • Kept the buffer owned by the caller so the returned slice stayed valid after the function returned
Continue learning

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]u8 to []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 ==; use std.mem.eql(u8, a, b) to compare contents
  • std.mem.indexOf, std.mem.startsWith, std.mem.endsWith, and std.mem.count cover most search needs
  • Iterate over bytes with a plain for loop; use std.unicode.Utf8View for multi-byte Unicode codepoints
  • Build runtime strings with std.fmt.bufPrint into a stack buffer, or std.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.bufPrint and 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. A var 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 prove your code actually behaves -- Zig's built-in testing framework lets you write test blocks alongside your source and catch memory leaks while you're at it.

Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.