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
}

Leave a Comment

Your email address will not be published. Required fields are marked *