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
@importand the role of the root namespace. - Describe how
std.startdiscoversmainand why entry points commonly return!void, as described in #Entry Point. - Use
const,var, and literal forms such asvoid {}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
| Platform | Link Mode | Conditions | Exported Symbol | Handler Function |
|---|---|---|---|---|
| POSIX/Linux | Executable | Default | _start | _start() |
| POSIX/Linux | Executable | Linking libc | main | main() |
| Windows | Executable | Default | wWinMainCRTStartup | WinStartup() / wWinMainCRTStartup() |
| Windows | Dynamic Library | Default | _DllMainCRTStartup | _DllMainCRTStartup() |
| UEFI | Executable | Default | EfiMain | EfiMain() |
| WASI | Executable (command) | Default | _start | wasi_start() |
| WASI | Executable (reactor) | Default | _initialize | wasi_start() |
| WebAssembly | Freestanding | Default | _start | wasm_freestanding_start() |
| WebAssembly | Linking libc | Default | __main_argc_argv | mainWithoutEnv() |
| OpenCL/Vulkan | Kernel | Default | main | spirvMain2() |
| MIPS | Any | Default | __start | (same as _start) |
Compile-Time Entry Point Logic
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.
// 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();
}
$ zig run imports.zigapp: Boot Basics Tour
optimize mode: Debug
target: x86_64-linux-gnuActual 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.
// 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();
}
$ zig run entry_point.zigZig 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():
Valid return types from main():
void- Returns exit code 0noreturn- 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:
| Mode | Priority | Safety Checks | Speed | Binary Size | Use Case |
|---|---|---|---|---|---|
Debug | Safety + Debug Info | ✓ All enabled | Slowest | Largest | Development and debugging |
ReleaseSafe | Speed + Safety | ✓ All enabled | Fast | Large | Production with safety |
ReleaseFast | Maximum Speed | ✗ Disabled | Fastest | Medium | Performance-critical production |
ReleaseSmall | Minimum Size | ✗ Disabled | Fast | Smallest | Embedded 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
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.
// 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))});
}
$ zig run values_and_literals.zigcounter=3 ratio=0.5 safety=true
newline byte=10 (ASCII)
unit literal has type voidTreat 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.
// 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();
}
$ zig build-exe buffered_stdout.zig -OReleaseFast
$
$ ./buffered_stdoutBuffering 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.printtargets 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 byzig initand requires an explicitflush()to push buffered bytes downstream.
Exercises
- Extend
imports.zigto print the pointer size reported by@sizeOf(usize)and compare targets by toggling-Dtargetvalues on the command line. - Refactor
entry_point.zigso thatrequireDebugSafetyreturns a descriptive error union (error{ReleaseOnly}![]const u8) and havemainwrite the message to stdout before rethrowing. - Build
buffered_stdout.zigwith-OReleaseSafeand-OReleaseSmall, measuring the binary sizes to see how optimization choices impact deployment footprints.