Imagine two builds of the exact same commit. Same source code, same branch, same .csproj. One runs on a developer's laptop on Friday afternoon, the other on the CI server on Monday morning. They produce different binaries – because over the weekend, a transitive dependency three levels down shipped a new patch version, and NuGet happily picked it up.
Nothing in your repository changed. Yet the thing you ship changed. That's the gap version locking closes.
This is not an exotic edge case. It's the default behavior of any package manager that resolves versions at restore time – and NuGet is no exception. The good news: .NET has had a clean, built-in solution for years. The surprising part is how few teams actually use it.
What "version locking" actually means
When you reference a package, you usually don't pin one exact version of everything. You write something like:
<PackageReference Include="Serilog" Version="3.1.1" />
That looks precise. But Serilog itself depends on other packages, which depend on others. NuGet resolves this entire transitive graph at restore time, and for transitive dependencies it generally takes the lowest version that satisfies all constraints – which can change the moment any package in that graph publishes a new release. Add a floating version like Version="3.*" and even your direct dependencies become a moving target.
Version locking means freezing that fully-resolved graph – every direct and transitive package, at an exact version – into a file that lives in your repository. From then on, restore reads from that file instead of re-resolving from the feed. Same input, same output, every time.
In NuGet, that file is packages.lock.json.
How it works in NuGet
Enabling lock files is a single property in your project (or in Directory.Build.props for the whole solution):
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
The next dotnet restore generates a packages.lock.json next to your project. Commit it to source control. It captures three things that matter:
- Every direct dependency at its resolved version
- Every transitive dependency – the parts you never wrote down but absolutely ship
- A content hash (SHA-512) for each package, so a restored package can be verified against what was locked
Here's a trimmed example:
{
"version": 1,
"dependencies": {
"net8.0": {
"Serilog": {
"type": "Direct",
"requested": "[3.1.1, )",
"resolved": "3.1.1",
"contentHash": "tk..."
},
"Serilog.Sinks.File": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "..."
}
}
}
}
Locked mode: making CI fail loudly
By default, if the dependency graph changes, NuGet quietly updates the lock file. That's fine on a developer machine, but on a build server it defeats the whole point. There you want restore to either reproduce the locked versions exactly or fail – never silently drift.
That's locked mode:
dotnet restore --locked-mode
Or, cleaner, scope it to CI via MSBuild so local development stays frictionless:
<PropertyGroup>
<RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>
Now, if a packages.lock.json is out of sync with the project files, the CI build stops with an error instead of building something nobody reviewed. When you intentionally change a dependency, you regenerate the lock file locally with dotnet restore --force-evaluate, review the diff in the pull request, and commit it. Every version change becomes a visible, reviewable line in your history.
Lock files have been supported since NuGet 4.9 / .NET SDK 2.1.500 / Visual Studio 2017 15.9 – so this is not new tooling you need to wait for. Source: Microsoft .NET Blog · NuGet Wiki
The floating-version trap
It's worth being explicit about why "just pin every version" isn't enough on its own. Floating versions (*, 3.*, [3.1,4.0)) are convenient – they pull bug fixes automatically – but they make restore non-deterministic by design. Worse, NuGet's HTTP and no-op caches mean two machines can resolve the same floating reference to different versions depending on what each has cached. There are long-standing reports of Version="*" resolving to a stale version rather than the newest one.
NuGet's own guidance is to avoid floating versions precisely because "every upgrade to a package should be backed by a commit in your repository." A lock file enforces that discipline even when a floating range slips in: the float resolves once, gets frozen, and only changes when you deliberately re-evaluate. Source: Microsoft Learn
Pairing it with Central Package Management
Since NuGet 6.2, Central Package Management (CPM) lets you declare every package version once, in a single Directory.Packages.props at the repo root:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
</Project>
Project files then reference packages without a version:
<PackageReference Include="Serilog" />
CPM and lock files are complementary, not competing. CPM gives you one place to set and review versions across dozens of projects (no more the-same-package-at-three-versions). Lock files give you the frozen transitive graph and the integrity hashes. Notably, CPM does not allow floating versions at all – it's explicitly designed for deterministic, secure restores – and CentralPackageTransitivePinningEnabled lets you pin a transitive package to a fixed version without adding a direct reference (handy for forcing a patched version of something deep in the graph).
Source: Microsoft .NET Blog · Bart Wullems
Why bother: three concrete payoffs
1. Reproducible builds
This is the headline. A locked graph means the build of commit abc123 today produces the same dependency set as the build of abc123 next year – on any machine, in any pipeline. That's the foundation for trustworthy CI, reliable rollbacks, and debugging a production issue against the exact code that shipped, transitive packages included.
2. Supply-chain integrity
The contentHash for every package turns the lock file into a tamper-check. If a restored package's bytes don't match the recorded hash – because a feed was compromised, a version was re-published, or a mirror is serving something different – restore fails instead of building it in. Combined with locked mode, an attacker can't slip a new or altered transitive package into your build by publishing it upstream; the graph is frozen and verified.
A lock file is not a complete defense – it can't tell you a locked version is itself vulnerable (that's what NuGetAudit and tools like Dependabot are for), and it doesn't protect the lock file itself if your repo is compromised. But it eliminates the single largest source of "we shipped a dependency nobody chose."
Source: Endor Labs
3. Controlled, visible updates
Without a lock file, dependency updates are invisible – they just happen. With one, every version bump is an explicit diff in a pull request. You decide when to take an update, you test it deliberately, and if a build breaks you can point at the exact commit that changed the exact package. Updates become a deliberate engineering decision rather than a side effect of the calendar.
Where this is already standard practice
Here's the slightly uncomfortable part for the .NET world: lock files are the norm almost everywhere else.
- npm has
package-lock.json, committed by default since npm 5. - Yarn has
yarn.lock; pnpm haspnpm-lock.yaml. - Rust/Cargo has
Cargo.lock. - Ruby/Bundler has
Gemfile.lock. - Python has
poetry.lock/Pipfile.lock. - Go pins via
go.sumwith checksums.
In all of these, checking in the lock file and installing from it in CI is simply how responsible teams work – it's the default, often the path of least resistance. Source: Snyk
NuGet, by contrast, made lock files opt-in, and adoption shows it: one analysis of ~2.5 million C# projects on GitHub found only about 4,900 (0.2%) using lock files. So if you adopt this, you're not chasing a fad – you're catching .NET up to a baseline the rest of the industry settled on years ago. Source: Endor Labs
Adopting it: a no-drama checklist
- Turn on lock files repo-wide via
Directory.Build.props:<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> - Restore once to generate
packages.lock.json, then commit every one of them. - Enable locked mode in CI only, conditioned on
ContinuousIntegrationBuild, so local dev stays fast and forgiving. - Adopt Central Package Management for the version-in-one-place benefit, and enable transitive pinning.
- Make lock-file updates a reviewed step: regenerate with
--force-evaluate, eyeball the diff, merge it like any other change. - Layer on
NuGetAuditso locked versions are still checked for known vulnerabilities – locking is about control, not immunity.
None of these steps is risky or hard to reverse. The cost is a few properties and one extra file per project in your repo. The payoff is builds that mean the same thing on every machine, on every day, forever.
Conclusion
A build is supposed to be a function: same inputs, same outputs. Unlocked dependency resolution quietly breaks that contract – it lets the calendar, a cache, or an upstream publisher decide what you ship. Version locking restores the contract. packages.lock.json freezes the full transitive graph, content hashes make it verifiable, and locked-mode restore makes your CI refuse to drift.
The rest of the software world figured this out a long time ago. For .NET, the tooling is built in, mature, and free. The only real question is why your packages.lock.json isn't in your repo yet.
