Skip to content

String Literals

A string literal is text written directly in your source code.

A string literal is text written directly in your source code.

const name = "Zig";

The text between the double quotes is the string literal:

"Zig"

In Zig, strings are bytes. More precisely, a string literal is a constant sequence of UTF-8 bytes.

That means "Zig" contains these three visible bytes:

Z i g

Their byte values are:

90 105 103

Zig does not have a special built-in String type like many high-level languages. Most Zig code represents text as:

[]const u8

Read this as:

read-only slice of bytes

That is the most common type for text passed to functions.

A Basic String Literal

Here is a complete program:

const std = @import("std");

pub fn main() void {
    const message = "Hello, Zig!";

    std.debug.print("{s}\n", .{message});
}

Output:

Hello, Zig!

The {s} formatter tells Zig to print the bytes as a string.

This line:

const message = "Hello, Zig!";

creates a string literal and gives it the name message.

You can pass it to a function that expects []const u8:

fn printMessage(message: []const u8) void {
    std.debug.print("{s}\n", .{message});
}

Usage:

printMessage("Hello");

This works because a string literal can be used as a read-only byte slice.

Strings Are Read-Only

String literals are constant data.

This is valid:

const name = "Zig";

This is not valid:

name[0] = 'B';

You cannot modify a string literal.

The reason is simple: string literals are stored as read-only program data. Many parts of the program may refer to the same literal. Allowing mutation would be unsafe and confusing.

Use a mutable array if you want text that can change:

var name = [_]u8{ 'Z', 'i', 'g' };

name[0] = 'B';

Now the bytes contain:

B i g

This is not a string literal anymore. It is a mutable array of bytes.

String Literals and []const u8

Most functions that read text should accept this type:

[]const u8

Example:

const std = @import("std");

fn greet(name: []const u8) void {
    std.debug.print("Hello, {s}!\n", .{name});
}

pub fn main() void {
    greet("Zig");
    greet("Ada");
    greet("C");
}

Output:

Hello, Zig!
Hello, Ada!
Hello, C!

The function does not care where the bytes come from. They may come from a string literal, an array, a slice, or allocated memory.

That is why []const u8 is the standard input type for text.

A String Literal Has a Sentinel

A string literal in Zig has a sentinel value at the end.

For example:

const name = "Zig";

The visible text has 3 bytes:

Z i g

But Zig also stores a zero byte after them:

Z i g 0

This final zero is called a sentinel.

It is useful for C interop because C strings usually end with a zero byte.

The visible length is still 3:

const name = "Zig";
std.debug.print("{}\n", .{name.len});

Output:

3

The sentinel is not counted in .len.

For beginners, remember this:

A string literal behaves like a read-only byte slice, but it also has a hidden zero byte after the visible text.

Escape Sequences

Some characters are written with escape sequences.

A newline is written as:

"\n"

Example:

std.debug.print("line one\nline two\n", .{});

Output:

line one
line two

A tab is written as:

"\t"

Example:

std.debug.print("name\tage\n", .{});

A double quote inside a string is written as:

"She said \"hello\""

A backslash is written as:

"C:\\Users\\zig"

Common escapes:

EscapeMeaning
\nnewline
\ttab
\"double quote
\\backslash
\rcarriage return
\0zero byte

Multiline String Literals

Zig has multiline string literals.

They begin each line with two backslashes:

const text =
    \\line one
    \\line two
    \\line three
;

This is useful for long text, generated code, help messages, SQL, JSON examples, and test data.

Example:

const std = @import("std");

pub fn main() void {
    const help =
        \\usage: demo [options]
        \\
        \\options:
        \\  --help      show help
        \\  --version   show version
    ;

    std.debug.print("{s}\n", .{help});
}

Output:

usage: demo [options]

options:
  --help      show help
  --version   show version

The multiline form avoids escaping every quote and newline manually.

Strings Are UTF-8 Bytes

Zig string literals are UTF-8.

That means non-ASCII text is stored as one or more bytes per character.

Example:

const text = "é";

The character é is one visible character, but in UTF-8 it uses 2 bytes.

So this:

std.debug.print("{}\n", .{text.len});

prints:

2

Another example:

const text = "你好";

Each Chinese character uses 3 bytes in UTF-8, so the length is 6 bytes.

std.debug.print("{}\n", .{text.len});

Output:

6

This is important.

In Zig, .len on text usually means byte length, not character count.

Iterating Over Bytes

When you loop over a string, you get bytes.

const std = @import("std");

pub fn main() void {
    const text = "Zig";

    for (text) |byte| {
        std.debug.print("{}\n", .{byte});
    }
}

Output:

90
105
103

Those are byte values.

You can print them as characters:

for (text) |byte| {
    std.debug.print("{c}\n", .{byte});
}

Output:

Z
i
g

This works well for ASCII text.

For full Unicode text, one visible character may use multiple bytes. You should not assume one byte equals one character.

Comparing Strings

Use the standard library to compare strings.

const std = @import("std");

pub fn main() void {
    const a = "zig";
    const b = "zig";
    const c = "Zig";

    std.debug.print("{}\n", .{std.mem.eql(u8, a, b)});
    std.debug.print("{}\n", .{std.mem.eql(u8, a, c)});
}

Output:

true
false

This function compares byte sequences.

std.mem.eql(u8, a, b)

means:

compare two slices of u8 values for equality

Do not compare strings by comparing pointers. You usually want to compare contents, not addresses.

Finding Text

The standard library has functions for searching byte slices.

Example:

const std = @import("std");

pub fn main() void {
    const text = "hello zig";

    if (std.mem.indexOf(u8, text, "zig")) |index| {
        std.debug.print("found at {}\n", .{index});
    } else {
        std.debug.print("not found\n", .{});
    }
}

Output:

found at 6

The result is optional. If the search succeeds, you get an index. If it fails, you get null.

Slicing Strings

Since strings behave like byte slices, you can slice them.

const text = "hello zig";

const first = text[0..5];
const second = text[6..9];

Now:

first  = hello
second = zig

Example:

const std = @import("std");

pub fn main() void {
    const text = "hello zig";

    std.debug.print("{s}\n", .{text[0..5]});
    std.debug.print("{s}\n", .{text[6..9]});
}

Output:

hello
zig

Be careful with UTF-8. Slicing at arbitrary byte positions can split a character.

For ASCII text, byte indexes and character positions match. For non-ASCII text, they may not.

Mutable Text

A string literal cannot be changed, but a byte array can.

var text = [_]u8{ 'z', 'i', 'g' };

text[0] = 'b';

Now it contains:

b i g

You can print it as a string by slicing the array:

std.debug.print("{s}\n", .{text[0..]});

Complete example:

const std = @import("std");

pub fn main() void {
    var text = [_]u8{ 'z', 'i', 'g' };

    text[0] = 'b';

    std.debug.print("{s}\n", .{text[0..]});
}

Output:

big

Use mutable byte arrays or allocated buffers when text must change.

Building Text with a Buffer

A common Zig pattern is: give a function a buffer, and let it write text into that buffer.

const std = @import("std");

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

    const name = "Zig";
    const message = try std.fmt.bufPrint(buffer[0..], "Hello, {s}!", .{name});

    std.debug.print("{s}\n", .{message});
}

Output:

Hello, Zig!

Here:

var buffer: [64]u8 = undefined;

creates a fixed array of 64 bytes.

This line:

const message = try std.fmt.bufPrint(buffer[0..], "Hello, {s}!", .{name});

writes formatted text into the buffer and returns a slice containing the part that was used.

The returned message points into buffer. It does not allocate.

Building Text with an Allocator

When the output size is not known in advance, you can allocate.

const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const name = "Zig";
    const message = try std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
    defer allocator.free(message);

    std.debug.print("{s}\n", .{message});
}

This creates a new allocated byte slice.

Because it allocates, you must free it:

defer allocator.free(message);

This pattern is common:

bufPrint: writes into caller-provided memory
allocPrint: allocates new memory

Use bufPrint when you already have a buffer. Use allocPrint when you need the function to create the result.

Common Mistake: Expecting .len to Count Characters

This surprises many beginners:

const text = "é";
std.debug.print("{}\n", .{text.len});

Output:

2

The visible character count is 1. The byte count is 2.

Zig reports the byte count.

For ASCII text, byte count and visible character count are usually the same. For Unicode text, they often differ.

Common Mistake: Modifying a String Literal

This is wrong:

const text = "hello";
text[0] = 'H';

String literals are read-only.

Use a mutable array:

var text = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
text[0] = 'H';

Or allocate mutable memory if the size is dynamic.

Common Mistake: Using {} Instead of {s}

This is usually wrong for printing strings:

std.debug.print("{}\n", .{"hello"});

Use {s}:

std.debug.print("{s}\n", .{"hello"});

The {s} formatter means “print this byte slice as a string.”

Use {} for many ordinary values, such as integers and booleans. Use {s} for strings.

Complete Example

const std = @import("std");

fn startsWithHello(text: []const u8) bool {
    return std.mem.startsWith(u8, text, "hello");
}

pub fn main() !void {
    const a = "hello zig";
    const b = "goodbye zig";

    std.debug.print("{s}: {}\n", .{ a, startsWithHello(a) });
    std.debug.print("{s}: {}\n", .{ b, startsWithHello(b) });

    var buffer: [64]u8 = undefined;
    const message = try std.fmt.bufPrint(buffer[0..], "language = {s}", .{"Zig"});

    std.debug.print("{s}\n", .{message});
}

Output:

hello zig: true
goodbye zig: false
language = Zig

This example shows the main string literal habits:

const a = "hello zig";

A string literal is read-only text.

fn startsWithHello(text: []const u8) bool

A function accepts text as []const u8.

std.mem.startsWith(u8, text, "hello")

String operations usually work on byte slices.

std.fmt.bufPrint(buffer[0..], "language = {s}", .{"Zig"})

Formatted text can be written into a caller-provided buffer.

Summary

A string literal is read-only UTF-8 text stored in the program.

Most Zig APIs represent text as:

[]const u8

That means a read-only slice of bytes.

String length is byte length, not character count.

String literals cannot be modified. Use a mutable byte array or allocated buffer when you need editable text.

Use {s} to print strings. Use std.mem functions to compare, search, and inspect text.

Zig treats text honestly: it is bytes. That may feel low-level at first, but it gives you clear control over memory, encoding, and allocation.