Skip to content

Sentinel-Terminated Arrays

A sentinel-terminated array is an array with a special value at the end.

A sentinel-terminated array is an array with a special value at the end.

That special value is called the sentinel.

The sentinel marks where the useful data stops.

The most common sentinel is 0. C strings use this idea. A C string is a sequence of bytes ending with a zero byte.

h e l l o 0

The useful text is:

hello

The final 0 is not part of the text. It marks the end.

Zig supports this idea directly in its type system.

Normal Arrays

A normal array type looks like this:

[N]T

Read it as:

array of N T values

For example:

[4]u8

means:

array of 4 u8 values

Example:

const data: [4]u8 = .{ 10, 20, 30, 40 };

This array has exactly four items.

Sentinel-Terminated Array Types

A sentinel-terminated array type looks like this:

[N:S]T

Read it as:

array of N T values, followed by sentinel S

For example:

[5:0]u8

means:

array of 5 u8 values, followed by a 0 sentinel

The array has 5 logical items, but there is also a sentinel value after them.

Example:

const name: [5:0]u8 = .{ 'h', 'e', 'l', 'l', 'o' };

The logical items are:

h e l l o

The sentinel is automatically present after them:

h e l l o 0

The type records that fact.

String Literals Have Sentinels

Zig string literals are sentinel-terminated.

This means a string literal like:

"hello"

has bytes for the text, plus a zero byte after the text.

That is why this is valid:

const message: [*:0]const u8 = "hello";

The type:

[*:0]const u8

means:

many item pointer to const u8, terminated by 0

The string literal can provide that because it has a zero sentinel.

Sentinel Is Not Counted in .len

For a sentinel-terminated array, .len gives the number of logical items.

It does not count the sentinel.

const std = @import("std");

pub fn main() void {
    const name: [5:0]u8 = .{ 'h', 'e', 'l', 'l', 'o' };

    std.debug.print("len = {}\n", .{name.len});
    std.debug.print("sentinel = {}\n", .{name[name.len]});
}

Output:

len = 5
sentinel = 0

The valid logical indexes are:

0, 1, 2, 3, 4

The sentinel is at:

index 5

This is unusual compared with normal arrays. For a normal [5]u8, index 5 would be out of bounds. For [5:0]u8, index 5 is the sentinel.

Sentinel-Terminated Slices

Zig also has sentinel-terminated slices:

[:S]T

For example:

[:0]const u8

means:

slice of const u8 values, terminated by 0

A normal slice carries a pointer and a length.

A sentinel-terminated slice carries a pointer, a length, and a guarantee that the sentinel exists at the end.

Example:

const std = @import("std");

pub fn main() void {
    const message: [:0]const u8 = "hello";

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

Output:

len = 5
hello

The text has length 5. The zero sentinel exists after it.

Sentinel-Terminated Pointers

A sentinel-terminated many item pointer looks like this:

[*:S]T

For example:

[*:0]const u8

This means:

many item pointer to const u8, ending with 0

Unlike a slice, this pointer does not store a length.

It only says that if you keep reading forward, eventually there is a sentinel.

This is exactly the model used by many C APIs.

Why Sentinels Are Useful

Sentinels are useful when a sequence does not carry its length separately.

A normal Zig slice solves this by carrying length:

[]const u8

A C string solves this by ending with zero:

const char *name;

The pointer alone does not say the length. The program reads bytes until it finds 0.

Zig can represent that more precisely than a plain pointer:

[*:0]const u8

This type tells the reader:

The pointer does not carry a length, but the sequence is expected to end with zero.

C Strings

C strings are the main reason beginners meet sentinels.

Many C functions expect strings like this:

int puts(const char *s);

The function receives a pointer to the first character. It keeps reading until it finds a zero byte.

In Zig, a compatible type is often:

[*:0]const u8

Example:

const std = @import("std");

pub fn main() void {
    const s: [*:0]const u8 = "hello";

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

The string literal "hello" can be used because it has a zero sentinel.

Sentinel Values Can Be Other Values

The sentinel does not have to be 0.

For example, you could use 255 as a sentinel for a byte sequence:

const data: [3:255]u8 = .{ 10, 20, 30 };

This represents:

10 20 30 255

The logical length is 3.

The sentinel is 255.

const std = @import("std");

pub fn main() void {
    const data: [3:255]u8 = .{ 10, 20, 30 };

    std.debug.print("len = {}\n", .{data.len});
    std.debug.print("sentinel = {}\n", .{data[data.len]});
}

Output:

len = 3
sentinel = 255

The type records the sentinel value.

Creating a Sentinel Slice from a Range

You can create a sentinel-terminated slice when Zig can prove the sentinel exists.

String literals make this easy:

const s: [:0]const u8 = "hello";

For arrays, the source must actually have the sentinel.

const data: [3:0]u8 = .{ 1, 2, 3 };
const slice: [:0]const u8 = data[0..];

The array type [3:0]u8 guarantees that 0 exists after the three items.

So the slice can preserve that guarantee.

Sentinel-Terminated Slice vs Normal Slice

A normal slice:

[]const u8

says:

pointer plus length

A sentinel-terminated slice:

[:0]const u8

says:

pointer plus length, with 0 after the last item

That extra guarantee matters when passing data to code that expects a sentinel.

For ordinary Zig APIs, []const u8 is usually enough.

For C string APIs, [:0]const u8 or [*:0]const u8 is often needed.

A Function That Accepts a C-Style String

Here is a function that walks through a sentinel-terminated pointer:

const std = @import("std");

fn printCString(s: [*:0]const u8) void {
    var i: usize = 0;

    while (s[i] != 0) : (i += 1) {
        std.debug.print("{c}", .{s[i]});
    }

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

pub fn main() void {
    printCString("zig");
}

Output:

zig

The function does not receive a length.

It stops when it sees the sentinel value 0.

A Function That Accepts a Zig String

Now compare that with a normal Zig function:

const std = @import("std");

fn printString(s: []const u8) void {
    for (s) |byte| {
        std.debug.print("{c}", .{byte});
    }

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

pub fn main() void {
    printString("zig");
}

This function receives a slice.

It does not need a sentinel because it has s.len.

Both styles work, but they express different contracts.

The first says:

I need a zero-terminated sequence.

The second says:

I need a slice with a known length.

For Zig code, prefer the second style.

For C interop, use the first style when required.

Common Mistake: Confusing Length and Sentinel

For this value:

const s: [:0]const u8 = "hello";

The length is:

5

The memory contains:

h e l l o 0

The sentinel is present, but it is not part of the length.

So this loop prints only the text:

for (s) |byte| {
    std.debug.print("{c}", .{byte});
}

It does not print the sentinel.

To inspect the sentinel directly:

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

That prints:

0

Common Mistake: Assuming Every Slice Has a Sentinel

This is wrong:

fn needsCString(s: []const u8) void {
    const p: [*:0]const u8 = s.ptr; // not generally safe
    _ = p;
}

A normal slice does not guarantee there is a zero byte after its last item.

The memory might look like this:

h e l l o X Y Z

There may be no zero byte where C expects one.

So you cannot freely treat every []const u8 as a C string.

If a C function needs a zero-terminated string, you must provide one.

Making a Zero-Terminated Copy

Sometimes you have a normal slice:

[]const u8

but need a zero-terminated string for C.

Then you often allocate a new buffer with space for the sentinel.

Conceptually:

const result = try allocator.alloc(u8, s.len + 1);

Copy the bytes, then add zero at the end:

@memcpy(result[0..s.len], s);
result[s.len] = 0;

Then pass the pointer as a zero-terminated sequence.

In real programs, prefer standard library helpers when available. The main idea is that the sentinel must really exist in memory.

Sentinel-Terminated Arrays Are About Guarantees

The value is not just the bytes.

The type carries a guarantee.

[5:0]u8

guarantees a 0 after 5 logical items.

[:0]u8

guarantees a 0 after the slice length.

[*:0]u8

guarantees the sequence eventually ends with 0.

These guarantees help Zig express low-level contracts that are common in C and systems programming.

When to Use Sentinels

Use sentinel-terminated types when:

You are working with C strings.

You are calling APIs that expect zero-terminated data.

You are representing a format that uses a sentinel value.

You want the type to record the end marker.

Avoid them when:

A normal slice is enough.

You already have a length.

The sentinel has no real meaning.

You are writing ordinary Zig application code.

Most Zig functions should take slices, not sentinel pointers.

The Main Idea

A sentinel-terminated array stores a special end value after its logical items.

The type:

[N:S]T

means an array of N items of type T, followed by sentinel S.

The type:

[:S]T

means a slice with sentinel S after its last item.

The type:

[*:S]T

means a many item pointer to a sequence that ends with sentinel S.

Sentinels are especially important for C strings. In normal Zig code, slices are usually simpler because they carry their length directly.