Chapter 21Zig Init And Package Metadata

Zig Init & Package Metadata

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 init and zig init --minimal to 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:

Shell
$ 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 options

The 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 module
  • build.zig: Build graph wiring both the module and executable artifacts
  • build.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:

Shell
$ 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:

Zig
.{
    .name = .minimal_project,
    .version = "0.0.1",
    .minimum_zig_version = "0.15.2",
    .paths = .{""},
    .fingerprint = 0x52714d1b5f619765,
}

The stub build.zig is equally terse:

Zig
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.

graph TD START["Ast.parse()"] --> TOKENIZE["Tokenize source"] TOKENIZE --> MODE{Mode?} MODE -->|".zig"| PARSEROOT["Parse.parseRoot()"] MODE -->|".zon"| PARSEZON["Parse.parseZon()"] PARSEROOT --> CONTAINERMEMBERS["parseContainerMembers()"] CONTAINERMEMBERS --> ROOTAST["Root AST<br/>(container decls)"] PARSEZON --> EXPR["expectExpr()"] EXPR --> EXPRAST["Root AST<br/>(single expression)"] ROOTAST --> ASTRETURN["Return Ast struct"] EXPRAST --> ASTRETURN
  • Zig mode (.zig files): Parses a full source file as a container with declarations
  • ZON mode (.zon files): 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:

Zig
.{
    .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):

Zig
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", .{});
}
Run
Shell
$ zig run zon_field_demo.zig
Output
Shell
=== 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.

Zig
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
}
Run
Shell
$ zig run fingerprint_demo.zig
Output
Shell
=== 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

Zig
.{
    // 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 = true to 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

Zig
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,
};
Run
Shell
$ zig run dependency_types.zig
Output
Shell
=== 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 imported

Use 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:

Zig
.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:

graph TB subgraph "InternPool - Dependency Storage" SRCHASHDEPS["src_hash_deps<br/>Map: TrackedInst.Index → DepEntry.Index"] NAVVALDEPS["nav_val_deps<br/>Map: Nav.Index → DepEntry.Index"] NAVTYDEPS["nav_ty_deps<br/>Map: Nav.Index → DepEntry.Index"] INTERNEDDEPS["interned_deps<br/>Map: Index → DepEntry.Index"] ZONFILEDEPS["zon_file_deps<br/>Map: FileIndex → DepEntry.Index"] EMBEDFILEDEPS["embed_file_deps<br/>Map: EmbedFile.Index → DepEntry.Index"] NSDEPS["namespace_deps<br/>Map: TrackedInst.Index → DepEntry.Index"] NSNAMEDEPS["namespace_name_deps<br/>Map: NamespaceNameKey → DepEntry.Index"] FIRSTDEP["first_dependency<br/>Map: AnalUnit → DepEntry.Index"] DEPENTRIES["dep_entries<br/>ArrayListUnmanaged<DepEntry>"] FREEDEP["free_dep_entries<br/>ArrayListUnmanaged<DepEntry.Index>"] end subgraph "DepEntry Structure" DEPENTRY["DepEntry<br/>{depender: AnalUnit,<br/>next_dependee: DepEntry.Index.Optional,<br/>next_depender: DepEntry.Index.Optional}"] end SRCHASHDEPS --> DEPENTRIES NAVVALDEPS --> DEPENTRIES NAVTYDEPS --> DEPENTRIES INTERNEDDEPS --> DEPENTRIES ZONFILEDEPS --> DEPENTRIES EMBEDFILEDEPS --> DEPENTRIES NSDEPS --> DEPENTRIES NSNAMEDEPS --> DEPENTRIES FIRSTDEP --> DEPENTRIES DEPENTRIES --> DEPENTRY FREEDEP -.->|"reuses indices from"| DEPENTRIES

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

graph LR subgraph "Source-Level Dependencies" SRCHASH["Source Hash<br/>TrackedInst.Index<br/>src_hash_deps"] ZONFILE["ZON File<br/>FileIndex<br/>zon_file_deps"] EMBEDFILE["Embedded File<br/>EmbedFile.Index<br/>embed_file_deps"] end subgraph "Nav Dependencies" NAVVAL["Nav Value<br/>Nav.Index<br/>nav_val_deps"] NAVTY["Nav Type<br/>Nav.Index<br/>nav_ty_deps"] end subgraph "Type/Value Dependencies" INTERNED["Interned Value<br/>Index<br/>interned_deps<br/>runtime funcs, container types"] end subgraph "Namespace Dependencies" NSFULL["Full Namespace<br/>TrackedInst.Index<br/>namespace_deps"] NSNAME["Namespace Name<br/>NamespaceNameKey<br/>namespace_name_deps"] end subgraph "Memoized State" MEMO["Memoized Fields<br/>panic_messages, etc."] end

Each category tracks a different kind of dependee:

Dependee TypeMap NameKey TypeWhen Invalidated
Source Hashsrc_hash_depsTrackedInst.IndexZIR instruction body changes
Nav Valuenav_val_depsNav.IndexDeclaration value changes
Nav Typenav_ty_depsNav.IndexDeclaration type changes
Interned Valueinterned_depsIndexFunction IES changes, container type recreated
ZON Filezon_file_depsFileIndexZON file imported via @import changes
Embedded Fileembed_file_depsEmbedFile.IndexFile content accessed via @embedFile changes
Full Namespacenamespace_depsTrackedInst.IndexAny name added/removed in namespace
Namespace Namenamespace_name_depsNamespaceNameKeySpecific name existence changes
Memoized Statememoized_state_*_depsN/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:

Shell
$ 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:

  1. A reusable module (src/root.zig) exposing bufferedPrint and add

  2. An executable (src/main.zig) importing and using the module

  3. Tests for both the module and executable

  4. 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.zon will produce a different fingerprint unless you preserve the original.
  • Changing .name does 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 init in a new directory, then modify build.zig.zon to add a fake remote dependency with a placeholder hash; observe the error when running zig 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.zon with zig init --minimal, then manually add a .dependencies table 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 .dependencies by downloading, hashing, and inserting the correct entry—use it instead of hand-typing hashes.
  • Lazy dependencies require build script cooperation: if your build.zig unconditionally references a lazy dependency without checking availability, the build will fail with a "dependency not available" error.

Help make this chapter better.

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