Chapter 20Concept Primer Modules Vs Programs Vs Packages Vs Libraries

Concept Primer

Overview

Chapter 19 mapped the compiler’s module graph; this chapter names the roles those modules can play so you know when a file is merely a helper, when it graduates to a program, and when it becomes the nucleus of a reusable package or library.

We will also preview how the Zig CLI registers modules for consumers, setting the stage for build graph authoring in Chapter 21 and in build.zig.

Learning Goals

  • Distinguish modules, programs, packages, and libraries, and explain how Zig treats each during compilation.
  • Use the --dep and -M flags (and their build graph equivalents) to register named modules for consumers.
  • Apply a practical checklist for picking the right unit when starting a new artifact or refactoring an existing one. 19

Building a shared vocabulary

Before you wire build scripts or register dependencies, settle on consistent language: In Zig, a module is any compilation unit returned by @import, a program is a module graph with an entry point, a package bundles modules plus metadata, and a library is a package intended for reuse without a root main. start.zig

Modules and programs in practice

This demo starts with a root module that exports a manifest for a library but also declares main, so the runtime treats the graph as a program while the helper module introspects public symbols to keep terminology honest. 19

Zig

// This module demonstrates how Zig's module system distinguishes between different roles:
// programs (with main), libraries (exposing public APIs), and hybrid modules.
// It showcases introspection of module characteristics and role-based decision making.

const std = @import("std");
const roles = @import("role_checks.zig");
const manifest_pkg = @import("pkg/manifest.zig");

/// List of public declarations intentionally exported by the root module.
/// This array defines the public API surface that other modules can rely on.
/// It serves as documentation and can be used for validation or tooling.
pub const PublicSurface = [_][]const u8{
    "main",
    "libraryManifest",
    "PublicSurface",
};

/// Provide a canonical manifest describing the library surface that this module exposes.
/// Other modules import this helper to reason about the package-level API.
/// Returns a Manifest struct containing metadata about the library's public interface.
pub fn libraryManifest() manifest_pkg.Manifest {
    // Delegate to the manifest package to construct a sample library descriptor
    return manifest_pkg.sampleLibrary();
}

/// Entry point demonstrating module role classification and vocabulary.
/// Analyzes both the root module and a library module, printing their characteristics:
/// - Whether they export a main function (indicating program vs library intent)
/// - Public symbol counts (API surface area)
/// - Role recommendations based on module structure
pub fn main() !void {
    // Use a fixed-size stack buffer for stdout to avoid heap allocation
    var stdout_buffer: [768]u8 = undefined;
    var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &file_writer.interface;

    // Capture snapshots of module characteristics for analysis
    const root_snapshot = roles.rootSnapshot();
    const library_snapshot = roles.librarySnapshot();
    // Retrieve role-based decision guidance
    const decisions = roles.decisions();

    try stdout.print("== Module vocabulary demo ==\n", .{});
    
    // Display root module role determination based on main export
    try stdout.print(
        "root exports main? {s} → treat as {s}\n",
        .{
            if (root_snapshot.exports_main) "yes" else "no",
            root_snapshot.role,
        },
    );
    
    // Show the number of public declarations in the root module
    try stdout.print(
        "root public surface: {d} declarations\n",
        .{root_snapshot.public_symbol_count},
    );
    
    // Display library module metadata: name, version, and main export status
    try stdout.print(
        "library '{s}' v{s} exports main? {s}\n",
        .{
            library_snapshot.name,
            library_snapshot.version,
            if (library_snapshot.exports_main) "yes" else "no",
        },
    );
    
    // Show the count of public modules or symbols in the library
    try stdout.print(
        "library modules listed: {d}\n",
        .{library_snapshot.public_symbol_count},
    );
    
    // Print architectural guidance for different module design goals
    try stdout.print("intent cheat sheet:\n", .{});
    for (decisions) |entry| {
        try stdout.print("  - {s} → {s}\n", .{ entry.goal, entry.recommendation });
    }

    // Flush buffered output to ensure all content is written
    try stdout.flush();
}
Run
Shell
$ zig run module_role_map.zig
Output
Shell
== Module vocabulary demo ==
root exports main? yes → treat as program
root public surface: 3 declarations
library 'widgetlib' v0.1.0 exports main? no
library modules listed: 2
intent cheat sheet:
  - ship a CLI entry point → program
  - publish reusable code → package + library
  - share type definitions inside a workspace → module

Keep root exports minimal and document them in one place (PublicSurface here) so helper modules can reason about intent without relying on undocumented globals.

Under the hood: entry points and programs

Whether a module graph behaves as a program or a library depends on whether it ultimately exports an entry point symbol. std.start decides which symbol to export based on platform, link mode, and a few builtin fields, so the presence of main is only part of the story.

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)

Sources:start.zig

Compile-time entry point logic

At compile time, std.start runs a small decision tree over builtin.output_mode, builtin.os, link_libc, and the target architecture to export exactly one of the symbols above:

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

Sources:lib/std/start.zig:28-100

Library manifests and internal reuse

The manifest recorded in pkg/manifest.zig models what eventually becomes package metadata: a name, semantic version, a list of modules, and an explicit statement that no entry point is exported.

Packages as distribution contracts

Packages are agreements between producers and consumers: producers register module names and expose metadata; consumers import those names without touching filesystem paths, trusting the build graph to supply the right code.

Registering modules with -M and --dep

Zig 0.15.2 replaces legacy --pkg-begin/--pkg-end syntax with -M (module definition) and --dep (import table entry), mirroring what std.build does when it wires workspaces (see Build.zig).

Zig
// Import the standard library for common utilities and types
const std = @import("std");
// Import builtin module to access compile-time information about the build
const builtin = @import("builtin");
// Import the overlay module by name as it will be registered via --dep/-M on the CLI
const overlay = @import("overlay");

/// Entry point for the package overlay demonstration program.
/// Demonstrates how to use the overlay_widget library to display package information
/// including build mode and target operating system details.
pub fn main() !void {
    // Allocate a fixed-size buffer on the stack for stdout operations
    // This avoids heap allocation for simple output scenarios
    var stdout_buffer: [512]u8 = undefined;
    // Create a buffered writer for stdout to improve performance by batching writes
    var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &file_writer.interface;

    // Populate package details structure with information about the current package
    // This includes compile-time information like optimization mode and target OS
    const details = overlay.PackageDetails{
        .package_name = "overlay",
        .role = "library package",
        // Extract the optimization mode name (e.g., Debug, ReleaseFast) at compile time
        .optimize_mode = @tagName(builtin.mode),
        // Extract the target OS name (e.g., linux, windows) at compile time
        .target_os = @tagName(builtin.target.os.tag),
    };

    // Render the package summary to stdout using the overlay library
    try overlay.renderSummary(stdout, details);
    // Ensure all buffered output is written to the terminal
    try stdout.flush();
}
Zig
const std = @import("std");

/// Summary of a package registration as seen from the consumer invoking `--pkg-begin`.
pub const PackageDetails = struct {
    package_name: []const u8,
    role: []const u8,
    optimize_mode: []const u8,
    target_os: []const u8,
};

/// Render a formatted summary that demonstrates how package registration exposes modules by name.
pub fn renderSummary(writer: anytype, details: PackageDetails) !void {
    try writer.print("registered package: {s}\n", .{details.package_name});
    try writer.print("role advertised: {s}\n", .{details.role});
    try writer.print("optimize mode: {s}\n", .{details.optimize_mode});
    try writer.print("target os: {s}\n", .{details.target_os});
    try writer.print(
        "resolved module namespace: overlay → pub decls: {d}\n",
        .{moduleDeclCount()},
    );
}

fn moduleDeclCount() usize {
    // Enumerate the declarations exported by this module to simulate API surface reporting.
    return std.meta.declarations(@This()).len;
}
Run
Shell
$ zig build-exe --dep overlay -Mroot=package_overlay_demo.zig -Moverlay=overlay_widget.zig -femit-bin=overlay_demo && ./overlay_demo
Output
Shell
registered package: overlay
role advertised: library package
optimize mode: Debug
target os: linux
resolved module namespace: overlay → pub decls: 2

--dep overlay must precede the module declaration that consumes it; otherwise the import table stays empty and the compiler cannot resolve @import("overlay").

Case study: compiler bootstrap command

The Zig compiler itself is built using the same -M/--dep machinery. During the bootstrap from zig1 to zig2, the command line wires multiple named modules and their dependencies:

zig1 <lib-dir> build-exe -ofmt=c -lc -OReleaseSmall \
  --name zig2 \
  -femit-bin=zig2.c \
  -target <host-triple> \
  --dep build_options \
  --dep aro \
  -Mroot=src/main.zig \
  -Mbuild_options=config.zig \
  -Maro=lib/compiler/aro/aro.zig

Here, each --dep line queues a dependency for the next -M module declaration, just like in the small overlay demo but at compiler scale.

From CLI flags to build graph

Once you move from ad-hoc zig build-exe commands to a build.zig file, the same concepts reappear as std.Build and std.Build.Module nodes in a build graph. The diagram below summarizes how the native build system’s entry point wires compiler compilation, tests, docs, and installation.

graph TB subgraph "Build Entry Point" BUILD_FN["build(b: *std.Build)"] --> OPTIONS["Parse Build Options"] OPTIONS --> COMPILER["addCompilerStep()"] OPTIONS --> TEST_SETUP["Test Suite Setup"] OPTIONS --> DOCS["Documentation Steps"] end subgraph "Compiler Compilation" COMPILER --> EXE["std.Build.CompileStep<br/>(zig executable)"] EXE --> COMPILER_MOD["addCompilerMod()"] EXE --> BUILD_OPTIONS["build_options<br/>(generated config)"] EXE --> LLVM_INTEGRATION["LLVM/Clang/LLD<br/>linking"] end subgraph "Test Steps" TEST_SETUP --> TEST_CASES["test-cases<br/>tests.addCases()"] TEST_SETUP --> TEST_MODULES["test-modules<br/>tests.addModuleTests()"] TEST_SETUP --> TEST_UNIT["test-unit<br/>compiler unit tests"] TEST_SETUP --> TEST_STANDALONE["test-standalone"] TEST_SETUP --> TEST_CLI["test-cli"] end subgraph "Documentation" DOCS --> LANGREF_GEN["generateLangRef()<br/>(tools/docgen.zig)"] DOCS --> STD_DOCS["autodoc_test<br/>(lib/std/std.zig)"] end subgraph "Installation" EXE --> INSTALL_BIN["stage3/bin/zig"] INSTALL_LIB_DIR["lib/ directory"] --> INSTALL_LIB_TARGET["stage3/lib/zig/"] LANGREF_GEN --> INSTALL_LANGREF["stage3/doc/langref.html"] STD_DOCS --> INSTALL_STD_DOCS["stage3/doc/std/"] end

Documenting package intent

Beyond the CLI flags, intent lives in documentation: describe which modules are public, whether you expect downstream entry points, and how the package should be consumed by other build graphs (see Module.zig).

Choosing the right unit fast

Use the cheat sheet below when deciding what to create next; it is intentionally opinionated so teams develop shared defaults. 19

You want to…PreferRationale
Publish reusable algorithms with no entry pointPackage + libraryBundle modules with metadata so consumers can import by name and stay decoupled from paths.
Ship a command-line toolProgramExport a main (or _start) and keep helper modules private unless you intend to share them.
Share types across files inside one repoModuleUse plain @import to expose namespaces without coupling build metadata prematurely. 19

Artifact types at a glance

The compiler’s output_mode and link_mode choices determine the concrete artifact form that backs each conceptual role. Programs usually build as executables, while libraries use Lib outputs that can be static or dynamic.

graph LR subgraph "Output Mode + Link Mode = Artifact Type" Exe_static["output_mode: Exe<br/>link_mode: static"] --> ExeStatic["Static executable"] Exe_dynamic["output_mode: Exe<br/>link_mode: dynamic"] --> ExeDynamic["Dynamic executable"] Lib_static["output_mode: Lib<br/>link_mode: static"] --> LibStatic["Static library (.a)"] Lib_dynamic["output_mode: Lib<br/>link_mode: dynamic"] --> LibDynamic["Shared library (.so/.dll)"] Obj["output_mode: Obj<br/>link_mode: N/A"] --> ObjFile["Object file (.o)"] end

Sources:Config.zig, main.zig, builtin.zig

You can combine the vocabulary from this chapter with these artifact types using a simple mapping:

RoleTypical artifactNotes
Programoutput_mode: Exe (static or dynamic)Exposes an entry point; may also export helper modules internally.
Library packageoutput_mode: Lib (static or shared)Intended for reuse; no root main, consumers import modules by name.
Internal moduleDepends on contextOften compiled as part of an executable or library; exposed via @import rather than a standalone artifact.

Notes & Caveats

  • Record manifest-like data even in ad-hoc modules so later promotion to a package is mechanical.
  • When you convert a program into a library, delete or guard the entry point; otherwise consumers get conflicting roots. 19
  • The -M/--dep workflow is a thin veneer over std.build.Module, so prefer the build graph once your project exceeds a single binary. 21

Exercises

  • Extend module_role_map.zig so the cheat sheet is driven by data loaded from a JSON manifest, then compare the ergonomics with direct Zig structs. 12, json.zig
  • Modify the overlay demo to register two external modules and emit their declaration counts, reinforcing how --dep queues multiple imports.
  • Draft a zig build script that wraps the overlay example, verifying that the CLI flags map cleanly to b.addModule and module.addImport. 21

Caveats, alternatives, edge cases

  • Cross-compiling packages may expose target-specific modules; document conditional imports to prevent surprise name resolution failures.
  • If you register a module name twice in the same build graph, the zig CLI reports a collision—treat that as a signal to refactor rather than relying on ordering. 19
  • Some tooling still expects the deprecated --pkg-begin syntax; upgrade scripts in tandem with the compiler to keep dependency registration consistent. v0.15.2

Help make this chapter better.

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