This is the first in a series of posts working through individual package managers against a fixed set of headings, so they can be compared directly. The headings come from earlier posts: the client and registry categorisations, the governance post, and the threat model.
Zig’s package manager has been built into the zig binary since 0.11 in August 2023, with no separate tool and no central registry. A build.zig.zon file lists dependencies as URLs with content hashes, and zig build fetches and compiles everything together. The language and the tool are both run by the Zig Software Foundation, a 501(c)(3).
How it works
A Zig project has a build.zig program at its root, describing targets, compile flags, and which dependencies to link where, alongside a build.zig.zon data file listing metadata and dependencies. Despite the similar names, build.zig.zon is inert data that can be parsed without running anything, while build.zig is arbitrary Zig code that gets compiled and executed. Which of the two an operation touches is where most of the security properties come from.
.{
.name = .example,
.version = "0.3.1",
.fingerprint = 0x6a8091f57c7f07ff,
.minimum_zig_version = "0.16.0",
.dependencies = .{
.known_folders = .{
.url = "https://github.com/ziglibs/known-folders/archive/refs/tags/1.1.0.tar.gz",
.hash = "known_folders-1.1.0-Fy-PJtnVAAC1Qq48Hf6_4er0Ku98mFvx99UUwo9-mrJd",
},
.tracy = .{
.url = "git+https://github.com/wolfpld/tracy#v0.11.1",
.hash = "N-V-__8AAKw3UgOhKDsrn8hRlOoGmVBl5x91fMi0WQwVaokf",
.lazy = true,
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"LICENSE",
},
}
The .zon extension is Zig Object Notation, a subset of Zig’s syntax restricted to literals and anonymous structs with no expressions, imports, or function calls. The fingerprint is a 64-bit integer combining a random 32-bit id with a checksum of the name, generated once when the package is created. It stays the same through renames and repo moves, so it’s the package’s identity independent of whichever URL currently serves it.
Each dependency is either a .url and .hash pair or a local .path. The hash is computed over the extracted files after applying the .paths inclusion list from the dependency’s own manifest, so a package author decides which files count towards its identity. The documentation is explicit that the hash is the identity and the URL is just one place to get it: “packages do not come from a url; they come from a hash. url is just one of many possible mirrors.”
The hash string is $name-$version- followed by URL-safe base64 packing the fingerprint id, decompressed size, and 200 bits of truncated SHA-256, so a zig-pkg/ directory listing stays readable without a lookup table. A dependency with no build.zig.zon of its own, like tracy above, is a “naked” package and gets the placeholders N-V- and a sentinel id instead. .lazy = true defers fetching until the dependency is referenced from build.zig.
Dependencies are fetched by downloading the URL, verifying it against the pinned hash, unpacking into a project-local zig-pkg/ directory named by the hash, and recursing into that dependency’s own .zon. A canonical recompressed tarball is kept in the global cache so a second project using the same hash doesn’t redownload. zig fetch <url> on its own downloads one URL and prints the computed hash for pasting into a manifest, without recursing. Supported schemes are http(s):// for tarballs and git bundles, git+http(s):// for the git smart protocol with a ref in the fragment, file://, and bare paths.
zig build compiles build.zig together with every dependency’s build.zig into a single executable and runs it, so a transitive dependency’s build code is linked into the same program as yours rather than invoked as a separate process per package. Calling into a dependency from your build script runs that dependency’s build script, which can do the same for its own dependencies.
Every .zon in the tree pins its direct dependencies by content hash, so there’s no version resolution step and the full tree is determined once the root’s hashes are fixed. Two dependencies that pin different hashes of the same upstream both get fetched and both exist, with deduplication by hash only. Version selection using the fingerprint field is planned and expected to look like Go’s minimal version selection, but as of 0.16 the only cross-tree check is that the same fingerprint and version can’t appear with two different hashes.
0.14 introduced the current hash format and the fingerprint field. 0.16 moved extracted packages from the global cache into a project-local zig-pkg/ directory and added --fork for overriding a package across the whole tree. On 30 June the fetching logic, HTTP client, TLS, and git protocol were moved out of the compiler binary into the build-system process, shipped as source.
Categorisation
Client
Resolution algorithm. None: each manifest pins exact content hashes with no version ranges. Closest to the explicit-dependencies bucket alongside Nix and Guix, though without a content-addressed store underneath, and with minimal version selection planned.
Lockfile. Manifest is the lock: build.zig.zon pins content hashes for every direct dependency and each dependency does the same for its own, with no separate file. Doesn’t fit any of the existing buckets cleanly: reproducible without a lockfile like Go, but by pinning hashes in the manifest rather than by deterministic resolution.
Build hooks. Allowed: build.zig is a Zig program compiled and run at build time with every dependency’s build.zig compiled into the same process, comparable to Cargo’s build.rs, Gradle, or Swift’s Package.swift.
Tuesday test. Fails, i.e. the install can observe inputs the manifest doesn’t declare: build.zig is Turing-complete and runs for every dependency, though .zon on its own is pure data and would pass alone.
Manifest format. Custom for metadata (.zon), host language for build logic (build.zig), where most tools in the categorisation put both in one file. Being a bespoke format rather than JSON or TOML, .zon needs its own parser in every cross-ecosystem tool that reads manifests, ecosyste.ms and git-pkgs included.
Registry
Architecture. Source host as registry, alongside Go modules, Deno, and Carthage, with no central index and packages served as tarballs or git refs at whatever URL the author chose.
Review model. None: whoever controls the URL controls what’s served at it.
Namespacing. URL-based for location, but identity is the name plus a self-generated 64-bit fingerprint, which doesn’t match any bucket in the namespacing categorisation.
Distribution model. Source only, compiled on the client.
Ecosystem scope. Language-specific, though Zig’s C interop and bundled zig cc mean C and C++ libraries are commonly packaged as Zig dependencies too.
Version retention. Delegated to the source host, so a GitHub release stays available until the repo owner deletes it or the account goes away, and there’s no yank mechanism.
Size. Without a central index there’s no canonical count, though Zigistry scrapes GitHub for repos with a build.zig.zon and indexes what it finds.
Mirroring. Trivial in principle since the client accepts any URL and verifies by hash, though each .zon names one URL per dependency, so if that URL goes dead the depender has to edit their manifest.
Governance
The Zig Software Foundation governs the language and the tool, funded by donations, but package hosting and name authority are outside its remit since there’s no registry for it to operate.
Name ownership rests on the fingerprint, and the manifest documentation says a fork of a maintained project should regenerate it, calling a fork that keeps the upstream’s value “hostile, attempting to take control over the original project’s identity”. Nothing enforces that and there’s no arbiter to appeal to. Availability, account recovery, DMCA takedowns and abuse handling all fall to whichever source host the URL points at, which in practice is overwhelmingly GitHub. Removal of a malicious version is something only the repo owner or the source host can do, and neither has any way to signal downstream .zon files that already pin its hash. Changes to the package manager itself go through the Zig issue tracker.
Loris Cro of the ZSF wrote in 2022 that “we don’t plan to create an official package index”, so the absence of a governance provider is a design choice rather than a gap.
Comparisons
The closest existing designs are Go modules and pre-JSR Deno (when it imported dependencies directly by URL): URL-addressed source, no publish step, no central registry, integrity by content hash. Go’s go.sum and checksum database are the nearest equivalent to .zon hashes, though Go verifies against a public log rather than only what the depending project committed. Swift Package Manager is close on the client side, with a host-language build manifest and git-URL dependencies, but resolves version ranges against tags rather than pinning a single hash. Bazel’s http_archive takes a URL list plus a sha256 with the hash as the identity and the URLs as interchangeable mirrors, then evaluates a hermetic Starlark BUILD file rather than a Turing-complete build program.
The fingerprint is what none of those have: a package identity that survives a URL change, whereas in Go the identity is the module path and moving a repo creates a new module. The --fork flag uses it to substitute a local checkout for every occurrence of a package across the whole dependency tree, regardless of which URLs the intermediate .zon files pinned. It is a CLI argument only, not a manifest field, and the release notes call that “appropriately ephemeral”.
There is no build.zig.zon field equivalent to Cargo’s [patch], Go’s replace, npm’s overrides or pub’s dependency_overrides, and no update subcommand for direct dependencies either.
With exact-hash pins at every level and no ranges to re-resolve, a fix in a low-level dependency reaches a project only when every intermediate .zon on the path has been re-released with the new hash. Until then the root’s options are forking the intermediates, vendoring a patched zig-pkg/ into source control, or passing --fork on every build. The flag isn’t recorded anywhere, so the same commit builds a different tree depending on whether it was passed, in a design where the committed manifest is otherwise a complete lock. Once minimal version selection lands, listing the fixed version in the root .zon would be enough, as it is in go.mod.
The .paths filter is close to the files array in package.json or include in Cargo.toml, except that the hash is computed after the filter is applied rather than over a tarball the author uploaded, so the inclusion list is part of the identity rather than a packaging convenience.
Threat model
Client
Code execution at install time. zig build --fetch runs nothing from the package: it downloads each URL, verifies the hash, extracts and parses each dependency’s .zon as data, and recurses without compiling build.zig. zig build compiles your build.zig and every dependency’s build.zig into one executable and runs it, so a dependency’s build script executes with your privileges the first time you build. There’s no flag to disable that, since the build script is how a dependency exposes its artifacts. A .lazy = true dependency isn’t fetched, and its build.zig isn’t compiled in, until b.lazyDependency is called for it.
Code execution before install time. Since .zon is data, parsing a manifest and running zig fetch on an untrusted URL are safe modulo the extractor, but zig build on an untrusted checkout compiles and runs build.zig, and so do zig build -h and --list-steps, since the step list comes from executing the configure phase.
Lockfile guarantees by design. The manifest pins 200 bits of SHA-256 for every direct dependency, and each of those pins its own, so the whole tree is fixed by content and every install is a locked install. The guarantee is per-project and starts at first fetch: the hash protects a project from the URL’s contents changing later, but there’s no independent record of what the URL served at the moment the hash was first written down. Go’s checksum database is the counterexample, a public log that records what each module path served so a substituted first fetch would disagree with everyone else’s. Without an equivalent, two Zig projects adding the same URL a week apart can pin different hashes with nothing to flag the discrepancy. Plain http:// URLs are accepted without a warning, and an already-unpacked zig-pkg/<hash>/ directory is not re-hashed before reuse.
Package name identity. name is a bare Zig identifier, at most 32 bytes, case-sensitive, ASCII only, so the usual normalisation gaps don’t apply. Deduplication and --fork match on name plus fingerprint, so two packages sharing a name with different fingerprints coexist in the same tree.
Resolution across multiple sources. Each dependency entry names exactly one URL, so there’s no source ordering to get wrong and the dependency-confusion pattern doesn’t apply. The related failure mode is a stale URL: the .zon records where the author fetched from, and if that host disappears there’s no automatic fallback even though any mirror serving the same bytes would satisfy the hash.
Registry
Namespace allocation. Fingerprints are self-asserted rather than allocated, so a fork can write the upstream’s value into its own manifest verbatim and be matched as the same package. Typosquatting on the URL is the source host’s problem.
Maintainer lifecycle. Maintainership is control of the source repo, so adding a maintainer means adding a GitHub collaborator, and nothing signals that to anyone with the package in their tree. Account recovery is whatever the source host provides, and a lapsed custom domain in a .url is a straightforward takeover, mitigated only by the hash pin in existing dependers’ manifests.
Immutability of published versions. The hash in a depender’s .zon guarantees they get the bytes they locked but not their continued availability, and the tag or release the URL points at can be replaced with something else that new dependers will then pin. Git tags can be force-pushed, GitHub release assets can be replaced, and there’s no transparency log recording what a URL served when.
Provenance from source to artifact. The artifact is the source, so there’s no build step to attest. The hash proves you got what the depender pinned but not who wrote it or whether the URL’s repo matches the tarball, and trusted publishing would need somewhere to publish to.
The minimum viable publish credential. This is whatever the source host issues: for GitHub, a token or SSH key with push access to the repo, with scoping, expiry and 2FA on GitHub’s side and no separate publish surface for a Zig-specific credential to protect.
Blast radius and detection. Anomaly detection on publish, malicious-version markers that clients check, and central audit logging are all registry functions that nothing here stands in for. A compromised release spreads only as fast as dependers update their .hash fields, which is slower than a registry ecosystem with floating ranges but also means there’s no single place to pull it from once it’s out, and detection falls to whoever’s reading diffs on the source host.
The tool’s own supply chain
The Zig compiler’s own build.zig.zon declares zero external dependencies, with the only entries being .path references into the source tree. The toolchain components it needs are vendored into the release tarballs rather than fetched. Since the 30 June change the HTTP client, TLS stack, git protocol and decompressors ship as Zig source rather than compiled into the zig binary, which the devlog notes runs in ReleaseSafe so safety checks stay on for the networking code.
Summary
Closest relatives are Go modules and pre-JSR Deno on the registry side, Swift Package Manager on the client side, and Bazel’s http_archive on the fetch model. Treating the content hash as the identity, with the URL just one place to fetch it, is the right way round, and .zon being pure data means the dependency graph can be read without executing anything. The compiler declaring zero external dependencies of its own is a supply chain position I’d like to see more of, and moving the networking stack out into ReleaseSafe source that can be patched without rebuilding the compiler is a good recent change.
.zon being a bespoke format rather than JSON or TOML means I’ve had to write a parser for it in both ecosyste.ms and git-pkgs, and so will everyone else building cross-ecosystem tooling. The tool ends up on the wrong side of the Tuesday test because build.zig is a full Zig program, when the pure-data .zon had it most of the way to passing. The governance functions a registry would provide fall to GitHub instead, which is now the arbiter of availability, account recovery and abuse handling for Zig packages without either party having chosen that.
Nothing in the manifest can override a transitive dependency, so if a low-level package ships a security fix and an intermediate hasn’t re-released, the options are forking the intermediate or passing --fork on every build, and the flag leaves nothing in the repo to record that it was passed. Cargo, Go, npm, pub, Mix and Bazel all have a manifest field for this, and exact-hash pinning makes its absence matter more here than it would with version ranges.