Chapter 01Boot Basics

Boot & Basics

Overview

Zig treats every source file as a namespaced module, and the compilation model revolves around explicitly wiring those units together with @import, keeping dependencies and program boundaries discoverable at a glance, as described in #Compilation Model. This chapter builds the first mile of that journey by showing how the root module, std, and builtin cooperate to produce a runnable program from a single file while preserving explicit control over targets and optimization modes.

We also establish the ground rules for data and execution: how const and var guide mutability, why literals such as void {} matter for API design, how Zig handles default overflow, and how to select the right printing surface for the job, as described in #Values. Along the way, we preview the release mode variants and buffered output helpers you will rely on in later chapters; see #Build-Mode.

Learning Goals

  • Explain how Zig resolves modules through @import and the role of the root namespace.
  • Describe how std.start discovers main and why entry points commonly return !void, as described in #Entry Point.
  • Use const, var, and literal forms such as void {} to express intent about mutability and unit values.
  • Choose between std.debug.print, unbuffered writers, and buffered stdout depending on the output channel and performance needs.

Starting from a Single Source File

The fastest way to get something on screen in Zig is to lean on the default module graph: the root file you compile becomes the canonical namespace, and @import lets you reach everything from the standard library to compiler metadata. You will use these hooks constantly to align runtime behavior with build-time decisions.

Entry Point Selection

The Zig compiler exports different entry point symbols based on the target platform, linking mode, and user declarations. This selection happens at compile time in lib/std/start.zig:28-100.

Entry Point Symbol Table

PlatformLink ModeConditionsExported SymbolHandler Function
POSIX/LinuxExecutableDefault_start_start()
POSIX/LinuxExecutableLinking libcmainmain()
WindowsExecutableDefaultwWinMainCRTStartupWinStartup() / wWinMainCRTStartup()
WindowsDynamic LibraryDefault_DllMainCRTStartup_DllMainCRTStartup()
UEFIExecutableDefaultEfiMainEfiMain()
WASIExecutable (command)Default_startwasi_start()
WASIExecutable (reactor)Default_initializewasi_start()
WebAssemblyFreestandingDefault_startwasm_freestanding_start()
WebAssemblyLinking libcDefault__main_argc_argvmainWithoutEnv()
OpenCL/VulkanKernelDefaultmainspirvMain2()
MIPSAnyDefault__start(same as _start)

Compile-Time Entry Point Logic

graph TB Start["comptime block<br/>(start.zig:28)"] CheckMode["Check builtin.output_mode"] CheckSimplified["simplified_logic?<br/>(stage2 backends)"] CheckLinkC["link_libc or<br/>object_format == .c?"] CheckWindows["builtin.os == .windows?"] CheckUEFI["builtin.os == .uefi?"] CheckWASI["builtin.os == .wasi?"] CheckWasm["arch.isWasm() &&<br/>os == .freestanding?"] ExportMain["@export(&main, 'main')"] ExportWinMain["@export(&WinStartup,<br/>'wWinMainCRTStartup')"] ExportStart["@export(&_start, '_start')"] ExportEfi["@export(&EfiMain, 'EfiMain')"] ExportWasi["@export(&wasi_start,<br/>wasm_start_sym)"] ExportWasmStart["@export(&wasm_freestanding_start,<br/>'_start')"] Start --> CheckMode CheckMode -->|".Exe or has main"| CheckSimplified CheckSimplified -->|"true"| Simple["Simplified logic<br/>(lines 33-51)"] CheckSimplified -->|"false"| CheckLinkC CheckLinkC -->|"yes"| ExportMain CheckLinkC -->|"no"| CheckWindows CheckWindows -->|"yes"| ExportWinMain CheckWindows -->|"no"| CheckUEFI CheckUEFI -->|"yes"| ExportEfi CheckUEFI -->|"no"| CheckWASI CheckWASI -->|"yes"| ExportWasi CheckWASI -->|"no"| CheckWasm CheckWasm -->|"yes"| ExportWasmStart CheckWasm -->|"no"| ExportStart

Modules and Imports

The root module is just your top-level file, so any declarations you mark pub are immediately re-importable under @import("root"). Pair that with @import("builtin") to inspect the target chosen by your current compiler invocation, as described in #Builtin-Functions.

Zig
// File: chapters-data/code/01__boot-basics/imports.zig

// Import the standard library for I/O, memory management, and core utilities
const std = @import("std");
// Import builtin to access compile-time information about the build environment
const builtin = @import("builtin");
// Import root to access declarations from the root source file
// In this case, we reference app_name which is defined in this file
const root = @import("root");

// Public constant that can be accessed by other modules importing this file
pub const app_name = "Boot Basics Tour";

// Main entry point of the program
// Returns an error union to propagate any I/O errors during execution
pub fn main() !void {
    // Allocate a fixed-size buffer on the stack for stdout operations
    // This buffer batches write operations to reduce syscalls
    var stdout_buffer: [256]u8 = undefined;
    // Create a buffered writer wrapping stdout
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    // Get the generic writer interface for polymorphic I/O operations
    const stdout = &stdout_writer.interface;

    // Print the application name by referencing the root module's declaration
    // Demonstrates how @import("root") allows access to the entry file's public declarations
    try stdout.print("app: {s}\n", .{root.app_name});
    
    // Print the optimization mode (Debug, ReleaseSafe, ReleaseFast, or ReleaseSmall)
    // @tagName converts the enum value to its string representation
    try stdout.print("optimize mode: {s}\n", .{@tagName(builtin.mode)});
    
    // Print the target triple showing CPU architecture, OS, and ABI
    // Each component is extracted from builtin.target and converted to a string
    try stdout.print(
        "target: {s}-{s}-{s}\n",
        .{
            @tagName(builtin.target.cpu.arch),
            @tagName(builtin.target.os.tag),
            @tagName(builtin.target.abi),
        },
    );
    
    // Flush the buffer to ensure all accumulated output is written to stdout
    try stdout.flush();
}
Run
Shell
$ zig run imports.zig
Output
Shell
app: Boot Basics Tour
optimize mode: Debug
target: x86_64-linux-gnu

Actual target identifiers depend on your host triple; the important part is seeing how @tagName exposes each enum so you can branch on them later.

Because the buffered stdout writer batches data, always call flush() before exiting so the terminal receives the final line.

Reach for @import("root") to surface configuration constants without baking extra globals into your namespace.

Entry Points and Early Errors

Zig’s runtime glue (std.start) looks for a pub fn main, forwards command-line state, and treats an error return as a signal to abort with diagnostics. Because main commonly performs I/O, giving it the !void return type keeps error propagation explicit.

Zig
// File: chapters-data/code/01__boot-basics/entry_point.zig

// Import the standard library for I/O and utility functions
const std = @import("std");
// Import builtin to access compile-time information like build mode
const builtin = @import("builtin");

// Define a custom error type for build mode violations
const ModeError = error{ReleaseOnly};

// Main entry point of the program
// Returns an error union to propagate any errors that occur during execution
pub fn main() !void {
    // Attempt to enforce debug mode requirement
    // If it fails, catch the error and print a warning instead of terminating
    requireDebugSafety() catch |err| {
        std.debug.print("warning: {s}\n", .{@errorName(err)});
    };

    // Print startup message to stdout
    try announceStartup();
}

// Validates that the program is running in Debug mode
// Returns an error if compiled in Release mode to demonstrate error handling
fn requireDebugSafety() ModeError!void {
    // Check compile-time build mode
    if (builtin.mode == .Debug) return;
    // Return error if not in Debug mode
    return ModeError.ReleaseOnly;
}

// Writes a startup announcement message to standard output
// Demonstrates buffered I/O operations in Zig
fn announceStartup() !void {
    // Allocate a fixed-size buffer on the stack for stdout operations
    var stdout_buffer: [128]u8 = undefined;
    // Create a buffered writer wrapping stdout
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    // Get the generic writer interface for polymorphic I/O
    const stdout = &stdout_writer.interface;
    // Write formatted message to the buffer
    try stdout.print("Zig entry point reporting in.\n", .{});
    // Flush the buffer to ensure message is written to stdout
    try stdout.flush();
}
Run
Shell
$ zig run entry_point.zig
Output
Shell
Zig entry point reporting in.

In release modes (zig run -OReleaseFast …​), the ModeError.ReleaseOnly branch fires and the warning surfaces before the program continues, neatly demonstrating how catch converts errors into user-facing diagnostics without suppressing later work.

How Return Types Are Handled

Zig’s startup code in std.start inspects your main() function’s return type at compile time and generates appropriate handling logic. This flexibility allows you to choose the signature that best fits your program’s needs—whether you want simple success/failure semantics with !void, explicit exit codes with u8, or an infinite event loop with noreturn. The callMain() function orchestrates this dispatch, ensuring errors are logged and exit codes propagate correctly to the operating system.

callMain Return Type Handling

The callMain() function handles different return type signatures from the user’s main():

graph TB Start["callMain()"] GetRetType["ReturnType = @TypeOf(root.main)<br/>.return_type"] CheckType["switch ReturnType"] Void["void"] CallVoid["root.main()<br/>return 0"] NoReturn["noreturn"] CallNoReturn["return root.main()"] U8["u8"] CallU8["return root.main()"] ErrorUnion["error union"] CheckInner["@TypeOf(result)?"] InnerVoid["void"] ReturnZero["return 0"] InnerU8["u8"] ReturnResult["return result"] Invalid["@compileError"] CallCatch["result = root.main()<br/>catch |err|"] LogError["Log error name<br/>and stack trace<br/>(lines 707-712)"] ReturnOne["return 1"] Start --> GetRetType GetRetType --> CheckType CheckType --> Void CheckType --> NoReturn CheckType --> U8 CheckType --> ErrorUnion CheckType --> Invalid Void --> CallVoid NoReturn --> CallNoReturn U8 --> CallU8 ErrorUnion --> CallCatch CallCatch --> CheckInner CallCatch --> LogError LogError --> ReturnOne CheckInner --> InnerVoid CheckInner --> InnerU8 CheckInner --> Invalid InnerVoid --> ReturnZero InnerU8 --> ReturnResult

Valid return types from main():

  • void - Returns exit code 0
  • noreturn - Never returns (infinite loop or explicit exit)
  • u8 - Returns exit code directly
  • !void - Returns 0 on success, 1 on error (logs error with stack trace)
  • !u8 - Returns exit code on success, 1 on error (logs error with stack trace)

The !void signature used in our examples provides the best balance: explicit error handling with automatic logging and appropriate exit codes.

Naming and Scope Preview

Variables obey lexical scope: every block introduces a new region where you may shadow or extend bindings, while const and var signal immutability versus mutability and help the compiler reason about safety, as described in #Blocks. Zig defers a deeper discussion of style and shadowing to Chapter 38, but keep in mind that thoughtful naming at the top level (often via pub const) is the idiomatic way to share configuration between files; see #Variables.

Working with Values and Builds

Once you have an entry point, the next stop is data: numeric types come in explicitly sized flavors (iN, uN, fN), literals infer their type from context, and Zig uses debug safety checks to trap overflows unless you opt into wrapping or saturating operators. Build modes (-O flags) decide which checks remain in place and how aggressively the compiler optimizes.

Optimization Modes

Zig provides four optimization modes that control the trade-offs between code speed, binary size, and safety checks:

ModePrioritySafety ChecksSpeedBinary SizeUse Case
DebugSafety + Debug Info✓ All enabledSlowestLargestDevelopment and debugging
ReleaseSafeSpeed + Safety✓ All enabledFastLargeProduction with safety
ReleaseFastMaximum Speed✗ DisabledFastestMediumPerformance-critical production
ReleaseSmallMinimum Size✗ DisabledFastSmallestEmbedded systems, size-constrained

The optimization mode is specified via the -O flag and affects:

  • Runtime safety checks (overflow, bounds checking, null checks)
  • Stack traces and debug information generation
  • LLVM optimization level (when using the LLVM backend)
  • Inlining heuristics and code generation strategies
graph TB subgraph "Optimization Mode Effects" OptMode["optimize_mode: OptimizeMode"] OptMode --> SafetyChecks["Runtime Safety Checks"] OptMode --> DebugInfo["Debug Information"] OptMode --> CodegenStrategy["Codegen Strategy"] OptMode --> LLVMOpt["LLVM Optimization Level"] SafetyChecks --> Overflow["Integer overflow checks"] SafetyChecks --> Bounds["Bounds checking"] SafetyChecks --> Null["Null pointer checks"] SafetyChecks --> Unreachable["Unreachable assertions"] DebugInfo --> StackTraces["Stack traces"] DebugInfo --> DWARF["DWARF debug info"] DebugInfo --> LineInfo["Source line information"] CodegenStrategy --> Inlining["Inlining heuristics"] CodegenStrategy --> Unrolling["Loop unrolling"] CodegenStrategy --> Vectorization["SIMD vectorization"] LLVMOpt --> O0["Debug: -O0"] LLVMOpt --> O2Safe["ReleaseSafe: -O2 + safety"] LLVMOpt --> O3["ReleaseFast: -O3"] LLVMOpt --> Oz["ReleaseSmall: -Oz"] end

In this chapter we use Debug (the default) for development and preview ReleaseFast to demonstrate how optimization choices affect behavior and binary characteristics.

Values, Literals, and Debug Printing

std.debug.print writes to stderr and is perfect for early experiments; it accepts any value you throw at it, revealing how @TypeOf and friends reflect on literals.

Zig
// File: chapters-data/code/01__boot-basics/values_and_literals.zig
const std = @import("std");

pub fn main() !void {
    // Declare a mutable variable with explicit type annotation
    // u32 is an unsigned 32-bit integer, initialized to 1
    var counter: u32 = 1;
    
    // Declare an immutable constant with inferred type (comptime_int)
    // The compiler infers the type from the literal value 2
    const increment = 2;
    
    // Declare a constant with explicit floating-point type
    // f64 is a 64-bit floating-point number
    const ratio: f64 = 0.5;
    
    // Boolean constant with inferred type
    // Demonstrates Zig's type inference for simple literals
    const flag = true;
    
    // Character literal representing a newline
    // Single-byte characters are u8 values in Zig
    const newline: u8 = '\n';
    
    // The unit type value, analogous to () in other languages
    // Represents "no value" or "nothing" explicitly
    const unit_value = void{};

    // Mutate the counter by adding the increment
    // Only var declarations can be modified
    counter += increment;

    // Print formatted output showing different value types
    // {} is a generic format specifier that works with any type
    std.debug.print("counter={} ratio={} safety={}\n", .{ counter, ratio, flag });
    
    // Cast the newline byte to u32 for display as its ASCII decimal value
    // @as performs explicit type coercion
    std.debug.print("newline byte={} (ASCII)\n", .{@as(u32, newline)});
    
    // Use compile-time reflection to print the type name of unit_value
    // @TypeOf gets the type, @typeName converts it to a string
    std.debug.print("unit literal has type {s}\n", .{@typeName(@TypeOf(unit_value))});
}
Run
Shell
$ zig run values_and_literals.zig
Output
Shell
counter=3 ratio=0.5 safety=true
newline byte=10 (ASCII)
unit literal has type void

Treat void {} as a communicative literal indicating "nothing to configure," and remember that debug prints default to stderr so they never interfere with stdout pipelines.

Buffering stdout and Build Modes

When you want deterministic stdout with fewer syscalls, borrow a buffer and flush once—especially in release profiles where throughput matters. The example below shows how to set up a buffered writer around std.fs.File.stdout() and highlights the differences between build modes.

Zig
// File: chapters-data/code/01__boot-basics/buffered_stdout.zig
const std = @import("std");

pub fn main() !void {
    // Allocate a 256-byte buffer on the stack for output batching
    // This buffer accumulates write operations to minimize syscalls
    var stdout_buffer: [256]u8 = undefined;
    
    // Create a buffered writer wrapping stdout
    // The writer batches output into stdout_buffer before making syscalls
    var writer_state = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &writer_state.interface;

    // These print calls write to the buffer, not directly to the terminal
    // No syscalls occur yet—data accumulates in stdout_buffer
    try stdout.print("Buffering saves syscalls.\n", .{});
    try stdout.print("Flush once at the end.\n", .{});
    
    // Explicitly flush the buffer to write all accumulated data at once
    // This triggers a single syscall instead of one per print operation
    try stdout.flush();
}
Run
Shell
$ zig build-exe buffered_stdout.zig -OReleaseFast
$
$ ./buffered_stdout
Output
Shell
Buffering saves syscalls.
Flush once at the end.

Using a buffered writer mirrors the standard library’s own initialization template and keeps writes cohesive; always flush before exiting to guarantee the OS sees your final message.

Notes & Caveats

  • std.debug.print targets stderr and bypasses stdout buffering, so reserve it for diagnostics even in simple tools.
  • Wrapping (%`) and saturating (`|) arithmetic are available when you deliberately want to skip overflow traps; the default operators still panic in Debug mode to catch mistakes early, as documented in #Operators.
  • std.fs.File.stdout().writer(&buffer) mirrors the patterns used by zig init and requires an explicit flush() to push buffered bytes downstream.

Exercises

  • Extend imports.zig to print the pointer size reported by @sizeOf(usize) and compare targets by toggling -Dtarget values on the command line.
  • Refactor entry_point.zig so that requireDebugSafety returns a descriptive error union (error{ReleaseOnly}![]const u8) and have main write the message to stdout before rethrowing.
  • Build buffered_stdout.zig with -OReleaseSafe and -OReleaseSmall, measuring the binary sizes to see how optimization choices impact deployment footprints.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.