⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

Off the End

An array of four numbers occupies a stretch of memory, and the stretch ends. What is at the address just past it? Something. Another variable, part of a frame, the machinery of the program itself. Memory does not run out at the end of your array; your claim on it does.

So what should row[4] do?

There are only three possible answers and every language picks one. Read whatever happens to be there and carry on. Stop the program. Or refuse to be asked, by making the caller check first. The third is the only one that is free, so that is where to start.

The program

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

/// Reading an element without assuming the caller checked first.
fn at(numbers: []const u32, index: usize) !u32 {
    if (index >= numbers.len) return error.OutOfBounds;
    return numbers[index];
}

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout_writer.interface;

    const row = [_]u32{ 10, 20, 30, 40 };

    // A slice is a pointer and a length travelling together, so a function
    // that receives one cannot be ignorant of where the data stops.
    const slice: []const u32 = &row;
    try out.print("the slice has {d} elements\n", .{slice.len});
    try out.print("a slice is {d} bytes: a pointer and a length\n\n", .{@sizeOf([]const u32)});

    // Valid index.
    if (at(slice, 2)) |value| {
        try out.print("element 2 is {d}\n", .{value});
    } else |err| {
        try out.print("element 2: {t}\n", .{err});
    }

    // One past the end. Nothing is read; the caller is told.
    if (at(slice, 4)) |value| {
        try out.print("element 4 is {d}\n", .{value});
    } else |err| {
        try out.print("element 4: {t}\n", .{err});
    }

    // The check the language inserts for `slice[i]` is this comparison, and
    // whether it is present depends on the build.
    try out.print("\nsafety checks in this build: {}\n", .{builtin.mode.runtimeSafety()});

    try out.flush();
}

What just happened

The slice knew where it ended. A slice is a pointer and a length travelling together as one value, so a function receiving one cannot be ignorant of the size. This is the single most useful thing Zig does with memory, and it is why []const u32 rather than a bare pointer is the normal way to pass a sequence around. The pointer alone would have told the function where the data starts and nothing about where to stop.

The slice printed as 8 bytes. Four for the pointer and four for the length, on this 32-bit WebAssembly build. Compile it for your own machine and it prints 16, for the same reason a pointer there is 8 bytes rather than 4.

The checked read returned an error instead of a number. at compares the index against .len and returns error.OutOfBounds when it does not fit. Nothing was read. The caller was told, in the return type, that the answer might not exist, and had to deal with both cases before it could use the result. One comparison bought that.

And then the last line said safety checks in this build: false.

About that last line

This is the honest part, and it is worth more to you than a tidier page would be.

When you write slice[i] directly, Zig inserts that same comparison for you, and on failure the program stops with a message naming the index and the length. That check exists in Debug and ReleaseSafe builds. It does not exist in ReleaseFast or ReleaseSmall, where it is compiled out for speed and size.

The wasm you just ran is a ReleaseSmall build, because that is what gets shipped to a reader’s browser. So on this page, slice[4] would not stop the program. It would read four bytes that are not yours and hand them over as a number.

Here is the program that does it. This one is the exception on this site: it is built ReleaseSafe rather than ReleaseSmall, so the check is present, and it is here to stop. Press Run.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    _ = init;

    const row = [_]u32{ 10, 20, 30, 40 };
    const slice: []const u32 = &row;

    // `index` is a `var` whose value the compiler cannot fold, so this is not
    // rejected at compile time. The check that stops it runs while the program
    // is running, which is the whole point: the compiler cannot know.
    var index: usize = 4;
    _ = &index;

    std.debug.print("value: {d}\n", .{slice[index]});
}
panic: index out of bounds: index 4, len 4

Nothing was read. The program named the index it was given and the length it was allowed, and stopped on the line that asked. Compare that with the silent wrong answer at the top of this page: the mistake is the same, and the difference is entirely in whether the check was compiled in.

The run also says it cannot print a stack trace. That is WebAssembly, not Zig: there is no call stack here to walk. On your own machine the same panic comes with the chain of calls that led to it, file and line for each.

That message is not quoted from memory. Our build runs this program on every change and requires it to stop with exactly that text, so if a future Zig worded the panic differently, this page would fail to publish rather than teach you a message you would never see.

This snippet is about 1.1 MB, against 46 KB for the program at the top of this page. That gap is what the safety checks and the machinery to describe them cost, and it is the honest reason the rest of the site does not carry them. It downloads only when you press Run.

The general rule behind this, what each build mode turns off and what you are promising when you choose one, is When the Checks Are Off.

Check yourself

The program checks index >= numbers.len. Why not index > numbers.len?

Because the indexes of four elements are 0, 1, 2 and 3. Index 4 is the first one past the end, so it has to be rejected. Off-by-one at exactly this boundary is common enough to have a name, the fencepost error, and it is the reason to prefer for (slice) |item| over an index and a counter whenever you do not actually need the number.

If you have written C

This is the difference that shows up first and matters most.

int row[4] = {10, 20, 30, 40};
int x = row[4];        /* reads something. no error, no warning, no crash */

C’s array does not carry a length, so a function receiving int *row cannot check anything; the length has to be passed alongside and kept correct by hand. Reading past the end is undefined behaviour, which in practice means it usually works, sometimes returns nonsense, and occasionally corrupts something that fails much later somewhere unrelated.

Zig’s equivalents are the slice, which carries the length so it cannot get out of step with the data, and the safety check, which turns “usually works” into a message with a line number. Both are what make this material learnable: you find out at the moment you make the mistake rather than an hour later.

The C-shaped pointer, the one without a length, still exists in Zig for talking to C, and it is spelled differently on purpose: many-item pointers.

Next: When the Checks Are Off, which is the promise you make when you turn a check off.