⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eWhat's newOn an older Zig?

Exiting

The snippet writes two lines and registers two defers. One line comes out.

const std = @import("std");

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;

    // Registered now, and every one of them is skipped below.
    defer std.debug.print("this defer never runs\n", .{});
    defer out.flush() catch {};

    try out.writeAll("first line, flushed by hand\n");
    try out.flush();

    try out.writeAll("second line, still sitting in the buffer\n");

    // The process stops here. Not a return: no defer runs, no buffer is
    // drained, and the second line above is lost with the memory holding it.
    std.process.exit(0);
}

What exit skips

defer std.debug.print("this defer never runs\n", .{});
defer out.flush() catch {};

try out.writeAll("second line, still sitting in the buffer\n");
std.process.exit(0);

std.process.exit is noreturn in the strongest sense available: it does not return to the caller, so nothing between the call and the end of main ever happens. No defer at any depth of the stack. No errdefer. No flush.

The lost line is the part worth internalising. Those bytes were in a buffer in your process’s memory, the operating system never heard about them, and the process ended. This is the most common way output goes missing from a program that “definitely printed it”, and it is why the second defer above, the one that would have flushed, is a trap: it looks like the safety net and it is not attached to anything.

Returning from main normally does run every defer, which is why main returning an error and exit(1) are not interchangeable. The first unwinds and cleans up; the second stops the process where it stands.

The status you leave

exit takes a u8. Zero means success and every other value is yours to define, which is the same one-bit protocol the parent sees as Term in Spawning a Process.

Returning an error from main sets a nonzero status for you and prints the error name with a stack trace in debug builds. That is the right default for a tool. Choosing a specific code is for programs whose callers branch on it, like grep returning 1 for “no match” and 2 for “something went wrong”, a distinction a shell script can act on and an error message cannot.

cleanExit, and why it does nothing in Debug

pub fn cleanExit(io: Io) void {
    if (builtin.mode == .debug) return;
    _ = io.lockStderr(&.{}, .no_color) catch {};
    exit(0);
}

A program about to exit does not need to free anything. The kernel reclaims the whole address space, and walking a tree of allocations to release memory that is about to vanish is work that only makes the program slower to quit.

std.process.cleanExit is that shortcut, with the one condition that makes it safe to use: in Debug it returns and lets your teardown run, so the leak detector still sees a program that freed everything and can still tell you about the leak that matters. In release builds it exits immediately. The Zig compiler itself calls it.

That mode dependence is why this page describes the call rather than running it. A snippet whose output changed between -Doptimize=Debug and the ReleaseSmall this site ships would be a snippet that passes CI and lies to half its readers.

abort is not an exit

std.process.abort ends the process abnormally: it raises SIGABRT, which runs a handler if you installed one, and then makes sure the process dies anyway. No status code, no cleanup, and typically a core dump.

That is the correct response to a broken invariant, where continuing would do damage and there is nothing sensible to report. It is the wrong response to a file that was not found. The distinction the reader should carry: exit is a program finishing, abort is a program admitting it should not have got here.