Overview
Chapter 20 settled the vocabulary distinguishing modules from programs, packages, and libraries; this chapter shows how zig init bootstraps that vocabulary into actual files, and how build.zig.zon codifies package identity, version constraints, and dependency metadata so the build system and package manager can resolve imports reliably. See 20 and v0.15.2.
We focus on package metadata structure before diving into build graph authoring in Chapter 22, ensuring you understand what each field in build.zig.zon controls and why Zig’s fingerprint mechanism replaced earlier UUID-based schemes. See 22, build.zig.zon, and Build.zig.
Learning Goals
- Use
zig initandzig init --minimalto scaffold new projects with appropriate boilerplate for modules, executables, and tests. - Interpret every field in
build.zig.zon: name, version, fingerprint, minimum Zig version, dependencies, and paths. - Distinguish remote dependencies (URL + hash), local dependencies (path), and lazy dependencies (deferred fetch).
- Explain why fingerprints provide globally unique package identity and how they prevent hostile fork confusion.
Scaffolding projects with
Zig 0.15.2 updated the default zig init template to encourage splitting reusable modules from executable entry points, addressing a common newcomer confusion where library code was unnecessarily compiled as static archives instead of being exposed as pure Zig modules. See build.zig.
Default Template: Module + Executable
Running zig init in an empty directory generates four files demonstrating the recommended pattern for projects that want both a reusable module and a CLI tool:
$ mkdir myproject && cd myproject
$ zig init
info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
info: see `zig build --help` for a menu of optionsThe generated structure separates concerns:
src/root.zig: Reusable module exposing public API (e.g.,bufferedPrint,add)src/main.zig: Executable entry point importing and using the modulebuild.zig: Build graph wiring both the module and executable artifactsbuild.zig.zon: Package metadata including name, version, and fingerprint
This layout makes it trivial for external packages to depend on your module without inheriting unnecessary executable code, while still providing a convenient CLI for local development or distribution. 20
If you only need a module or only need an executable, delete the files you don’t need and simplify build.zig accordingly—the template is a starting point, not a mandate.
Minimal Template: Stub for Experienced Users
For users who know the build system and want minimal boilerplate, zig init --minimal generates only build.zig.zon and a stub build.zig:
$ mkdir minimal-project && cd minimal-project
$ zig init --minimal
info: successfully populated 'build.zig.zon' and 'build.zig'The resulting build.zig.zon is compact:
.{
.name = .minimal_project,
.version = "0.0.1",
.minimum_zig_version = "0.15.2",
.paths = .{""},
.fingerprint = 0x52714d1b5f619765,
}The stub build.zig is equally terse:
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b; // stub
}This mode is intended for cases where you have a clear build strategy in mind and want to avoid deleting boilerplate comments and example code.
Anatomy of
Zig Object Notation (ZON) is a strict subset of Zig syntax used for data literals; build.zig.zon is the canonical file the build runner parses to resolve package metadata before invoking your build.zig script. See zon.zig and Zoir.zig.
How ZON files are parsed
From the parser’s point of view, .zon manifests are just another mode of Ast.parse(). The tokenizer is shared between .zig and .zon files, but .zig is parsed as a container of declarations while .zon is parsed as a single expression—exactly what build.zig.zon contains.
- Zig mode (
.zigfiles): Parses a full source file as a container with declarations - ZON mode (
.zonfiles): Parses a single expression (Zig Object Notation)
Sources: lib/std/zig/Parse.zig:192-205, lib/std/zig/Parse.zig:208-228
Required Fields
Every build.zig.zon must define these core fields:
.{
.name = .myproject,
.version = "0.1.0",
.minimum_zig_version = "0.15.2",
.paths = .{""},
.fingerprint = 0xa1b2c3d4e5f60718,
}
.name: A symbol literal (e.g.,.myproject) used as the default dependency key; conventionally lowercase, omitting redundant "zig" prefixes since the package already lives in the Zig namespace..version: A semantic version string ("MAJOR.MINOR.PATCH") that the package manager will eventually use for deduplication. SemanticVersion.zig.minimum_zig_version: The earliest Zig release that this package supports; older compilers will refuse to build it..paths: An array of file/directory paths (relative to the build root) included in the package’s content hash; only these files are distributed and cached..fingerprint: A 64-bit hexadecimal integer serving as the package’s globally unique identifier, generated once by the toolchain and never changed (except in hostile fork scenarios).
The following demo shows how these fields map to runtime introspection patterns (though in practice the build runner handles this automatically):
const std = @import("std");
pub fn main() !void {
// Demonstrate parsing and introspecting build.zig.zon fields
// In practice, the build runner handles this automatically
const zon_example =
\\.{
\\ .name = .demo,
\\ .version = "0.1.0",
\\ .minimum_zig_version = "0.15.2",
\\ .fingerprint = 0x1234567890abcdef,
\\ .paths = .{"build.zig", "src"},
\\ .dependencies = .{},
\\}
;
std.debug.print("--- build.zig.zon Field Demo ---\n", .{});
std.debug.print("Sample ZON structure:\n{s}\n\n", .{zon_example});
std.debug.print("Field explanations:\n", .{});
std.debug.print(" .name: Package identifier (symbol literal)\n", .{});
std.debug.print(" .version: Semantic version string\n", .{});
std.debug.print(" .minimum_zig_version: Minimum supported Zig\n", .{});
std.debug.print(" .fingerprint: Unique package ID (hex integer)\n", .{});
std.debug.print(" .paths: Files included in package distribution\n", .{});
std.debug.print(" .dependencies: External packages required\n", .{});
std.debug.print("\nNote: Zig 0.15.2 uses .fingerprint for unique identity\n", .{});
std.debug.print(" (Previously used UUID-style identifiers)\n", .{});
}
$ zig run zon_field_demo.zig=== build.zig.zon Field Demo ===
Sample ZON structure:
.{
.name = .demo,
.version = "0.1.0",
.minimum_zig_version = "0.15.2",
.fingerprint = 0x1234567890abcdef,
.paths = .{"build.zig", "src"},
.dependencies = .{},
}
Field explanations:
.name: Package identifier (symbol literal)
.version: Semantic version string
.minimum_zig_version: Minimum supported Zig
.fingerprint: Unique package ID (hex integer)
.paths: Files included in package distribution
.dependencies: External packages required
Note: Zig 0.15.2 uses .fingerprint for unique identity
(Previously used UUID-style identifiers)Zig 0.15.2 replaced the old UUID-style .id field with the more compact .fingerprint field, simplifying generation and comparison while maintaining global uniqueness guarantees.
Fingerprint: Global Identity and Fork Detection
The .fingerprint field is the linchpin of package identity: it is generated once when you first run zig init, and should never change for the lifetime of the package unless you are deliberately forking it into a new identity.
Changing the fingerprint of an actively maintained upstream project is considered a hostile fork—an attempt to hijack the package’s identity and redirect users to different code. Legitimate forks (where the upstream is abandoned) should regenerate the fingerprint to establish a new identity, while maintaining forks (backports, security patches) preserve the original fingerprint to signal continuity.
const std = @import("std");
pub fn main() !void {
std.debug.print("--- Package Identity Validation ---\n\n", .{});
// Simulate package metadata inspection
const pkg_name = "mylib";
const pkg_version = "1.0.0";
const fingerprint: u64 = 0xabcdef1234567890;
std.debug.print("Package: {s}\n", .{pkg_name});
std.debug.print("Version: {s}\n", .{pkg_version});
std.debug.print("Fingerprint: 0x{x}\n\n", .{fingerprint});
// Validate semantic version format
const version_valid = validateSemVer(pkg_version);
std.debug.print("Version format valid: {}\n", .{version_valid});
// Check fingerprint uniqueness
std.debug.print("\nFingerprint ensures:\n", .{});
std.debug.print(" - Globally unique package identity\n", .{});
std.debug.print(" - Unambiguous version detection\n", .{});
std.debug.print(" - Fork detection (hostile vs. legitimate)\n", .{});
std.debug.print("\nWARNING: Changing fingerprint of a maintained project\n", .{});
std.debug.print(" is considered a hostile fork attempt!\n", .{});
}
fn validateSemVer(version: []const u8) bool {
// Simplified validation: check for X.Y.Z format
var parts: u8 = 0;
for (version) |c| {
if (c == '.') parts += 1;
}
return parts == 2; // Must have exactly 2 dots
}
$ zig run fingerprint_demo.zig=== Package Identity Validation ===
Package: mylib
Version: 1.0.0
Fingerprint: 0xabcdef1234567890
Version format valid: true
Fingerprint ensures:
- Globally unique package identity
- Unambiguous version detection
- Fork detection (hostile vs. legitimate)
WARNING: Changing fingerprint of a maintained project
is considered a hostile fork attempt!The inline comment // Changing this has security and trust implications. in the generated .zon file is deliberately preserved to surface during code review if someone modifies the fingerprint without understanding the consequences.
Dependencies: Remote, Local, and Lazy
The .dependencies field is a struct literal mapping dependency names to fetch specifications; each entry is either a remote URL dependency, a local filesystem path dependency, or a lazily-fetched optional dependency.
Annotated Dependency Examples
.{
// Package name: used as key in dependency tables
// Convention: lowercase, no "zig" prefix (redundant in Zig namespace)
.name = .mylib,
// Semantic version for package deduplication
.version = "1.2.3",
// Globally unique package identifier
// Generated once by toolchain, then never changes
// Allows unambiguous detection of package updates
.fingerprint = 0xa1b2c3d4e5f60718,
// Minimum supported Zig version
.minimum_zig_version = "0.15.2",
// External dependencies
.dependencies = .{
// Remote dependency with URL and hash
.example_remote = .{
.url = "https://github.com/user/repo/archive/tag.tar.gz",
// Multihash format: source of truth for package identity
.hash = "1220abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
},
// Local path dependency (no hash needed)
.example_local = .{
.path = "../sibling-package",
},
// Lazy dependency: only fetched if actually used
.example_lazy = .{
.url = "https://example.com/optional.tar.gz",
.hash = "1220fedcba0987654321fedcba0987654321fedcba0987654321fedcba098765",
.lazy = true,
},
},
// Files included in package hash
// Only these files/directories are distributed
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"LICENSE",
"README.md",
},
}
- Remote dependencies specify
.url(a tarball/zip archive location) and.hash(a multihash-format content hash). The hash is the source of truth: even if the URL changes or mirrors are added, the package identity remains tied to the hash. - Local dependencies specify
.path(a relative directory from the build root). No hash is computed because the filesystem is the authority; this is useful for monorepo layouts or during development before publishing. - Lazy dependencies add
.lazy = trueto defer fetching until the dependency is actually imported by a build script. This reduces bandwidth for optional features or platform-specific code paths.
Dependency Types in Practice
const std = @import("std");
pub fn main() !void {
std.debug.print("--- Dependency Types Comparison ---\n\n", .{});
// Demonstrate different dependency specification patterns
const deps = [_]Dependency{
.{
.name = "remote_package",
.kind = .{ .remote = .{
.url = "https://example.com/pkg.tar.gz",
.hash = "122012345678...",
} },
.lazy = false,
},
.{
.name = "local_package",
.kind = .{ .local = .{
.path = "../local-lib",
} },
.lazy = false,
},
.{
.name = "lazy_optional",
.kind = .{ .remote = .{
.url = "https://example.com/opt.tar.gz",
.hash = "1220abcdef...",
} },
.lazy = true,
},
};
for (deps, 0..) |dep, i| {
std.debug.print("Dependency {d}: {s}\n", .{ i + 1, dep.name });
std.debug.print(" Type: {s}\n", .{@tagName(dep.kind)});
std.debug.print(" Lazy: {}\n", .{dep.lazy});
switch (dep.kind) {
.remote => |r| {
std.debug.print(" URL: {s}\n", .{r.url});
std.debug.print(" Hash: {s}\n", .{r.hash});
std.debug.print(" (Fetched from network, cached locally)\n", .{});
},
.local => |l| {
std.debug.print(" Path: {s}\n", .{l.path});
std.debug.print(" (No hash needed, relative to build root)\n", .{});
},
}
std.debug.print("\n", .{});
}
std.debug.print("Key differences:\n", .{});
std.debug.print(" - Remote: Uses hash as source of truth\n", .{});
std.debug.print(" - Local: Direct filesystem path\n", .{});
std.debug.print(" - Lazy: Only fetched when actually imported\n", .{});
}
const Dependency = struct {
name: []const u8,
kind: union(enum) {
remote: struct {
url: []const u8,
hash: []const u8,
},
local: struct {
path: []const u8,
},
},
lazy: bool,
};
$ zig run dependency_types.zig=== Dependency Types Comparison ===
Dependency 1: remote_package
Type: remote
Lazy: false
URL: https://example.com/pkg.tar.gz
Hash: 122012345678...
(Fetched from network, cached locally)
Dependency 2: local_package
Type: local
Lazy: false
Path: ../local-lib
(No hash needed, relative to build root)
Dependency 3: lazy_optional
Type: remote
Lazy: true
URL: https://example.com/opt.tar.gz
Hash: 1220abcdef...
(Fetched from network, cached locally)
Key differences:
- Remote: Uses hash as source of truth
- Local: Direct filesystem path
- Lazy: Only fetched when actually importedUse local paths during active development across multiple packages in the same workspace, then switch to remote URLs with hashes when publishing for external consumers. 24
Chapter 24 revisits these concepts in depth by walking through a package resolution pipeline that starts from build.zig.zon. 24
Paths: Controlling Package Distribution
The .paths field specifies which files and directories are included when computing the package hash and distributing the package; everything not listed is excluded from the cached artifact.
Typical patterns:
.paths = .{
"build.zig", // Build script is always needed
"build.zig.zon", // Metadata file itself
"src", // Source code directory (recursive)
"LICENSE", // Legal requirement
"README.md", // Documentation
}Listing a directory includes all files within it recursively; listing the empty string "" includes the build root itself (equivalent to listing every file individually, which is rarely desired).
Exclude generated artifacts (zig-cache/, zig-out/), large assets not needed for compilation, and internal development tools from .paths to keep package downloads small and deterministic.
Under the hood: ZON files in dependency tracking
The compiler’s incremental dependency tracker treats ZON files as a distinct dependee category alongside source hashes, embedded files, and declaration-based dependencies. The core storage is an InternPool that owns multiple maps into a shared dep_entries array:
The dependency tracking system uses multiple hash maps to look up dependencies by different dependee types. All maps point into a shared dep_entries array, which stores the actual DepEntry structures forming linked lists of dependencies.
Sources: src/InternPool.zig:34-85
Each category tracks a different kind of dependee:
| Dependee Type | Map Name | Key Type | When Invalidated |
|---|---|---|---|
| Source Hash | src_hash_deps | TrackedInst.Index | ZIR instruction body changes |
| Nav Value | nav_val_deps | Nav.Index | Declaration value changes |
| Nav Type | nav_ty_deps | Nav.Index | Declaration type changes |
| Interned Value | interned_deps | Index | Function IES changes, container type recreated |
| ZON File | zon_file_deps | FileIndex | ZON file imported via @import changes |
| Embedded File | embed_file_deps | EmbedFile.Index | File content accessed via @embedFile changes |
| Full Namespace | namespace_deps | TrackedInst.Index | Any name added/removed in namespace |
| Namespace Name | namespace_name_deps | NamespaceNameKey | Specific name existence changes |
| Memoized State | memoized_state_*_deps | N/A (single entry) | Compiler state fields change |
Sources: src/InternPool.zig:34-71
Minimum Zig Version: Compatibility Bounds
The .minimum_zig_version field declares the earliest Zig release that the package can build with; older compilers will refuse to proceed, preventing silent miscompilations due to missing features or changed semantics.
When the language stabilizes at 1.0.0, this field will interact with semantic versioning to provide compatibility guarantees; before 1.0.0, it serves as a forward-looking compatibility declaration even though breaking changes happen every release.
Version: Semantic Versioning for Deduplication
The .version field currently documents the package’s semantic version but does not yet enforce compatibility ranges or automatic deduplication; that functionality is planned for post-1.0.0 when the language stabilizes.
Follow semantic versioning conventions:
- MAJOR: Increment for incompatible API changes
- MINOR: Increment for backward-compatible feature additions
- PATCH: Increment for backward-compatible bug fixes
This discipline will pay off once the package manager can auto-resolve compatible versions within dependency trees. 24
Practical Workflow: From Init to First Build
A typical project initialization sequence looks like this:
$ mkdir mylib && cd mylib
$ zig init
info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
$ zig build
$ zig build test
All 3 tests passed.
$ zig build run
All your codebase are belong to us.
Run `zig build test` to run the tests.At this point, you have:
A reusable module (
src/root.zig) exposingbufferedPrintandaddAn executable (
src/main.zig) importing and using the moduleTests for both the module and executable
Package metadata (
build.zig.zon) ready for publishing
To share your module with other packages, you would publish the repository with a tagged release, document the URL and hash, and consumers would add it to their .dependencies table.
Notes & Caveats
- The fingerprint is generated from a random seed; regenerating
build.zig.zonwill produce a different fingerprint unless you preserve the original. - Changing
.namedoes not change the fingerprint; the name is a convenience alias while the fingerprint is the identity. - Local path dependencies bypass the hash-based content addressing entirely; they are trusted based on filesystem state at build time.
- The package manager caches fetched dependencies in a global cache directory; subsequent builds with the same hash skip re-downloading.
Exercises
- Run
zig initin a new directory, then modifybuild.zig.zonto add a fake remote dependency with a placeholder hash; observe the error when runningzig build --fetch. - Create two packages in sibling directories, configure one as a local path dependency of the other, and verify that changes in the dependency are immediately visible without re-fetching.
- Generate a
build.zig.zonwithzig init --minimal, then manually add a.dependenciestable and compare the resulting structure with the annotated example in this chapter. - Fork a hypothetical package by regenerating the fingerprint (delete the field and run
zig build), then document in a README why this is a new identity rather than a hostile takeover.
Caveats, alternatives, edge cases
- If you omit
.paths, the package manager may include unintended files in the distribution, inflating download size and exposing internal implementation details. - Remote dependency URLs can become stale if the host moves or removes the archive; consider mirroring critical dependencies or using content-addressed storage systems. 24
- The
zig fetch --save <url>command automates adding a remote dependency to.dependenciesby downloading, hashing, and inserting the correct entry—use it instead of hand-typing hashes. - Lazy dependencies require build script cooperation: if your
build.zigunconditionally references a lazy dependency without checking availability, the build will fail with a "dependency not available" error.