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 0The useful text is:
helloThe 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]TRead it as:
array of N T valuesFor example:
[4]u8means:
array of 4 u8 valuesExample:
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]TRead it as:
array of N T values, followed by sentinel SFor example:
[5:0]u8means:
array of 5 u8 values, followed by a 0 sentinelThe 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 oThe sentinel is automatically present after them:
h e l l o 0The 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 u8means:
many item pointer to const u8, terminated by 0The 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 = 0The valid logical indexes are:
0, 1, 2, 3, 4The sentinel is at:
index 5This 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]TFor example:
[:0]const u8means:
slice of const u8 values, terminated by 0A 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
helloThe text has length 5. The zero sentinel exists after it.
Sentinel-Terminated Pointers
A sentinel-terminated many item pointer looks like this:
[*:S]TFor example:
[*:0]const u8This means:
many item pointer to const u8, ending with 0Unlike 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 u8A 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 u8This 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 u8Example:
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 255The 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 = 255The 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 u8says:
pointer plus lengthA sentinel-terminated slice:
[:0]const u8says:
pointer plus length, with 0 after the last itemThat 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:
zigThe 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:
5The memory contains:
h e l l o 0The 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:
0Common 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 ZThere 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 u8but 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]u8guarantees a 0 after 5 logical items.
[:0]u8guarantees a 0 after the slice length.
[*:0]u8guarantees 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]Tmeans an array of N items of type T, followed by sentinel S.
The type:
[:S]Tmeans a slice with sentinel S after its last item.
The type:
[*:S]Tmeans 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.