Build Stack Review

Lockfile Strategies for Reproducible Builds Across Go and Rust Projects

Lockfile adoption varies wildly across ecosystems, but committing one is only half the battle.

Senior Writer · · 12 min read
Cover illustration for “Lockfile Strategies for Reproducible Builds Across Go and Rust Projects”
Reproducible Development Environments · September 21, 2026 · 12 min read · 2,609 words

A lockfile records the exact version of every dependency that made it into a build. That distinction is the whole game: without it, two people running the same install command on the same day can end up with different code. This piece looks at how Go and Rust each solve that problem, where the guarantees run out, and what teams actually need to do beyond committing a file to get builds that reproduce reliably. Lockfiles do three jobs: they cut build times by skipping re-resolution, they verify package integrity through stored hashes, and they support reproducibility across machines and time. But the guarantee is narrower than most people assume. Same inputs produce the same resolution, full stop. Nothing about a lockfile promises the same build environment, the same toolchain, or the same build process, and conflating those things is where a lot of "reproducible build" efforts quietly fail.

Gamage et al., in a study published in Empirical Software Engineering (the first comprehensive cross-ecosystem look at lockfile practices), interviewed developers about why they valued lockfiles, with respondents citing multiple practical and security-related motivations. The same developers flagged persistent friction points around day-to-day lockfile use. Both halves of that finding matter here.

How lockfile adoption looks across ecosystems, and where Go and Rust sit

Go leads every ecosystem in the Gamage et al. dataset, with 99.7% of studied projects committing go.sum. Gradle is at the other extreme, at 0.9%. That gap shows how differently ecosystems have internalized the value of a lockfile.

Rust's number is murkier, and deliberately so. Cargo's convention splits behavior by project type: applications are expected to commit Cargo.lock, libraries are conventionally expected not to. So a raw commit-rate figure for the Rust ecosystem understates how many projects are doing the right thing, because a library correctly omitting Cargo.lock looks, in a flat count, identical to a project that never bothered. Understanding the context behind the number changes how it should be read here.

Zooming out, the Gamage et al. study found that across all seven package managers studied, 81.4% of projects had a committed lockfile as of March 28, 2025, and 916 additional projects, 18.8% more, added one over the study period. That trend line suggests lockfile adoption tracks project maturity: teams pick it up as the project ages and the cost of drift becomes real. High adoption in Go means the file exists, not that Go projects are using go.sum correctly. It means the file exists. Whether it's actually doing its job, whether CI enforces it, whether the checksum database behind it is even active, are separate questions, and they're the ones worth spending time on.

Go's two-file model: what go.mod and go.sum each do

Go does not have a single lockfile in the way most ecosystems mean the term. It splits the job across two files with different responsibilities. go.mod declares what a project wants: dependencies and their version constraints. go.sum records what the project actually got, verified: cryptographic checksums for the dependencies that contribute to the build.

That split has a practical consequence. A mismatch or missing entry in go.sum surfaces as a build error rather than a silent success with unverified code. And because go.sum is updated through deliberate dependency management commands rather than silently during a normal build, any change to it rides alongside a deliberate, visible change to dependencies. There's no quiet drift here the way there can be with looser lockfile formats.

The module graph itself isn't stored as a flat resolved list the way some other ecosystems do it. Go resolves it implicitly, drawing on go.mod together with the module proxy and checksum database. That's a structurally different choice from what Rust does with Cargo.lock (more on that shortly): when auditing a Go project, there is no single file you can open and see the entire resolved tree laid out. Both files need to be committed together. go.sum without go.mod is meaningless on its own, and go.mod without go.sum throws away the integrity guarantee entirely.

Diagram: Lockfile Adoption Across Ecosystems. Visualizes: Show the stark contrast in lockfile commit rates across ecosystems using the Gamage et al.

The Go Checksum Database: a supply chain control most Go developers never think about

Every time go get adds an entry to go.sum, it pulls that hash from sum.golang.org, the Go Checksum Database, operated by Google, along with cryptographic proof of the database's own integrity. It's a global, append-only, cryptographically verifiable log, and the architecture predates the current wave of Sigstore-style attestation tooling. It has run in production for years with very few user-facing incidents.

The design goal was for this to be invisible. A well-behaved developer running go get never thinks about the sumdb at all; it just works in the background. That's also precisely why misconfigurations slip through unnoticed.

Independent verification exists outside Google's own infrastructure. Andrew Ayer's Source Spotter project has continuously fetched and cryptographically verified the sumdb's Merkle tree since 2020, and as of October 2025 it also verifies toolchain reproducibility, with 2,672 toolchains successfully reproduced since Go 1.21.0. On the attestation front, signed attestation schemes beyond the checksum database have been discussed in the broader ecosystem, but as of 2026 none is standard for Go modules, and the checksum database remains the primary integrity mechanism. That stance will likely shift as signed attestations become more normal across other ecosystems, but it hasn't yet.

None of this matters if the verification can be turned off. It can be, easily, and often without anyone noticing.

How Go's checksum verification can be silently disabled in CI

Three environment variables can weaken or fully disable sumdb verification: GOSUMDB=off, GOPRIVATE, and GOINSECURE. Setting GOSUMDB=off disables checksum database verification entirely, globally, for every module. GOPRIVATE exists for a legitimate reason, exempting genuinely private modules from public checksum checks, but when its pattern is set too broadly, it silently exempts public modules too. GOINSECURE skips TLS verification for specific module paths, a distinct bypass vector from the other two but no less consequential.

These are underappreciated risks precisely because most software composition analysis tooling scans dependency manifests, looking only at what versions are declared, leaving CI environment variable configuration unexamined. A dependency scanner can report a clean bill of health on a pipeline that has quietly turned off the one mechanism verifying those dependencies weren't tampered with in transit.

The practical fix is an explicit audit: go through CI environment variables by hand and confirm none of the three is set in a way that weakens verification. The absence of a warning message proves nothing. Go doesn't loudly announce when the sumdb is off; it just stops checking. This is a genuinely separate attack surface from dependency version drift. A pipeline can be pulling the exact correct version of every dependency, the "right" version by every measure a version-pinning audit would check, while the integrity verification behind that version has been disabled the entire time.

Reproducible Go toolchains: what changed in Go 1.21

Go 1.21.0 was the first Go toolchain release with builds that reproduce perfectly. Earlier toolchains could, in theory, be reproduced, but only with real effort, and it's fair to say almost nobody actually did it in practice.

Go 1.21 also introduced toolchain auto-download, a feature that lets the go command fetch and run a newer toolchain on demand when a project requires one. Convenient, certainly, but it raises an obvious question for anyone paying attention to supply chain risk: what stops a downloaded toolchain from being tampered with between the source and the binary a developer's machine executes?

Go's answer is the same checksum database used for modules. Every toolchain zip archive's checksum is published to the sumdb, and the go command checks that a downloaded checksum appears in that public, append-only log before trusting it. Anyone can go further and verify this independently using golang.org/x/build/cmd/gorebuild, which rebuilds a toolchain from source and compares the resulting hash against what was published. The results of this process, run daily, are published at go.dev/rebuild.

What that proves is narrow but valuable: if the rebuilt binary's hash matches the posted binary's hash, nothing was injected between the source code and the distributed artifact. Nobody needs to disassemble a binary to catch a compromise; they just need the hashes to disagree. Source Spotter runs this same verification independently, in AWS Lambda, rebuilding each toolchain via make.bash -distpack and comparing against the sumdb-published checksum. Darwin toolchains require an extra step, stripping Google's code-signing signature before comparison, since Source Spotter has no way to replicate a private signing key it was never given. A compromise has to live in the source code, where it's visible to anyone who looks, rather than getting quietly stitched into a binary after the fact.

Cargo.lock: how Rust pins the full dependency tree

Cargo.lock records the exact version of every crate in the dependency tree, direct and transitive both, not merely the crates a Cargo.toml file names outright. That completeness is what gives Cargo's dependency resolution a stronger reproducibility guarantee, at least at the resolution layer, than lockfile formats that only pin the top level.

The rule that trips people up most often is the application-versus-library split. Binary applications should commit Cargo.lock, because nothing downstream re-resolves an application's dependencies; the application owns its full tree and should pin it exactly. Libraries, by convention, should not commit Cargo.lock. The consuming application's own lockfile governs what actually gets built, and a library-level lockfile doesn't propagate downstream anyway, so committing one mostly creates false confidence about a version pin that has no real effect once the crate is pulled into someone else's project.

There's a real exception, though. Some distribution channels, including certain Linux package repositories, rely on the upstream project's own committed Cargo.lock to build reproducibly. So if a library also ships as a packaged binary, not just as a dependency for other Rust code, the calculus changes and committing the lockfile can be the right call.

The failure mode without any lockfile at all is semver drift. A line like serde = "1.0" in Cargo.toml means "any 1.x release Cargo considers compatible." Absent Cargo.lock, a teammate cloning the repository next month may resolve a newer minor or patch release than the one used yesterday, and the build changes underneath them without a single line of code being touched. Cargo.lock says nothing about which Rust toolchain built the project. That's a distinct problem, and it needs a distinct fix.

Enforcing Cargo.lock in CI and pinning the Rust toolchain

Committing Cargo.lock accomplishes nothing if CI is free to ignore it. The fix is the --locked flag, passed to cargo build, cargo test, and cargo check in CI. With it set, Cargo refuses to update Cargo.lock on its own; if the lockfile is out of date relative to Cargo.toml, the build fails loudly instead of silently re-resolving to something new. Without --locked, CI can end up building a different dependency tree than what a developer tested locally, which defeats the entire purpose of having a lockfile in the first place. Deliberately let Cargo.lock go stale and confirm CI actually fails rather than quietly patching around it.

Toolchain pinning is handled through a rust-toolchain.toml file. Without one, rustup just uses whatever toolchain happens to be current on a given machine, and builds shift as that default moves forward over time. The recommended approach pins to an exact version, something like channel = "1.78.0", with target triples specified explicitly rather than left to inference. Because rustup reads this file automatically, including when invoked indirectly through its shim layer, there's no CI script rewrite required beyond committing the file itself.

Even with a locked lockfile and a pinned toolchain, non-determinism can sneak in from places that have nothing to do with either. A build.rs script calling std::time::SystemTime::now() bakes a timestamp into the build, which means two builds run seconds apart produce different output. That's best avoided in build scripts entirely. Proc macros and code generation have a similar trap: iterating over a HashMap doesn't guarantee a consistent order, so identical inputs can produce differently ordered generated code across runs. Swapping in BTreeMap for proc macros and build scripts closes that gap.

Supply chain controls beyond the lockfile: cargo-audit, cargo-deny, cargo-vet, and SBOM generation

Pinning a version tells you nothing about whether that version is safe. That's a separate question, and it needs a separate layer of tooling. He, Vasilescu, and Kästner make this case directly in "Pinning Is Futile: You Need More Than Local Dependency Versioning to Defend Against Supply Chain Attacks" (Proc. A tooling and software engineering venue. Eng. 2, FSE 2025, DOI: 10.1145/3715728): local version pinning, on its own, does not close off supply chain attack vectors. It's a narrower guarantee than it often gets credit for.

The Rust ecosystem has built a fairly complete stack around that gap. cargo-audit scans Cargo.lock against the RustSec advisory database and flags known-vulnerable versions. cargo-deny goes further, enforcing project-wide dependency policy in a configurable way that can cover multiple concerns in a single check. cargo-vet, maintained by Mozilla, takes a different approach entirely, requiring a human-reviewed audit before a new third-party crate enters a build. Pre-existing dependencies can be deferred through an exemptions list rather than blocking everything on day one, and completed audits get recorded in supply-chain/audits.toml. Organizations don't have to start from zero here either; they can import audit sets Mozilla or Google have already published rather than re-auditing crates that have already been vetted elsewhere.

SBOM generation rounds this out. cargo-cyclonedx produces SBOMs in a widely used interchange format, in JSON or XML, from the resolved dependency tree, and cargo-sbom can generate both SPDX and that same interchange format from that same resolved tree. cargo-auditable takes a different tack, embedding dependency metadata directly into the compiled binary itself, which matters when the SBOM file and the shipped artifact aren't guaranteed to travel together. An SBOM should be a CI artifact generated on every build, not a document someone remembers to produce by hand before an audit.

Go's SBOM picture: why the source graph and the shipped binary tell different stories

Go's build model complicates SBOM generation in a way that's easy to miss. Go binaries are typically single, statically linked executables, aggressively optimized, with dead code eliminated at compile time. That means the dependency graph recorded in go.mod and go.sum can list packages whose code never actually makes it into the final binary a team ships.

That gap creates two genuinely different SBOM questions, and answering one doesn't answer the other. Source dependency visibility, generated from go.mod and go.sum using a tool like Syft, shows what was resolved at build time: everything the module graph pulled in, whether or not it survived the linker. Release-accurate inventory, by contrast, comes from scanning the built binary or container image directly, and shows what actually got linked and shipped to production.

Neither answer is wrong. They're answering different questions, and a team doing CVE triage needs to know which one it's looking at. If a CVE is in a package that shows up in the source-level SBOM but was eliminated by the linker before the binary was built, treating that as a live production risk wastes time chasing a vulnerability that was never shipped. The reverse mistake, assuming the source graph is complete because it's the only SBOM anyone generated, is worse.

Go's advantage here is that the source side of this equation is unusually clean. Because go.sum enforces a deterministic, machine-readable dependency graph, automating SBOM generation from source is straightforward in a way it isn't in ecosystems with looser version resolution. That doesn't erase the binary-level gap, but it does mean teams building Go SBOM pipelines are working with clean, verifiable inputs from the start, provided they're honest with themselves about which of the two questions their SBOM is actually answering.

Sources

  1. Go Reproducible Build Report - The Go Programming Language
  2. The design space of lockfiles across package managers | Empirical Software Engineering | Springer Nature Link
  3. I'm Independently Verifying Go's Reproducible Builds
  4. Perfectly Reproducible, Verified Go Toolchains - The Go Programming Language
  5. The Design Space of Lockfiles Across Package Managers
  6. safeguard.sh
  7. github.com
  8. blog.rust-lang.org

More in Reproducible Development Environments