Skip to main content

Command Palette

Search for a command to run...

Why don't we have .lock files in .NET

Updated
3 min read
Why don't we have .lock files in .NET
O

Passionate about cloud computing, distributed systems, and system design.

If you ever worked with Node, Go (precisely a checksum), or Rust, you’ve seen lock files like Cargo.lock, yarn.lock. However, it’s not that common in .NET, and everything just works. So, to understand why .NET doesn’t need them (mostly), we first need to understand what problem lock files solve.

Setup

Your project has a manifest file - Cargo.toml, package.json, .csproj - where you declare your dependencies. In most ecosystems, you specify a version range, not an exact version:

serde = "1.0" # means >= 1.0.0, <2.0.0

But why? Why not just specify the exact version in manifest file?

There are a few good reasons for it:

  • semver is a contract: The whole point of semantic versioning is that 1.0.x patches and 1.x.0 minor updates are backward compatible. When you write serde = “1.0”, you’re saying: “I depend on public API of serde 1.x, any compatible version also works.”

  • diamond dependency problem: Say your project depends on foo and bar, and both of them depend on serde. If foo pins 1.0.97, and bar pins 1.0.98, you’re stuck - two incompatible versions of the same package. So giving ranges allow dependency manager to select a version that satisfies both.

  • you won’t get security patches: If serde ships a fix in 1.0.98, every package (crate) that pinned 1.0.97 would need to publish a new release just to bump the number. Multiply that across the whole dependency tree, and you’ve got a cascading update nightmare.

So what’s the issue?

Ranges are great, but they introduce instability. Without a lock file, cargo build today might resolve to serde 1.0.97. Tomorrow, after a new release, it resolves to 1.0.98. Same code, same manifest, different dependencies. Now you are in - “it works on my machine, but breaks in CI” world.

A lock file fixes this by recording the exact resolved version of every dependency in your tree. Once it’s generated, subsequent builds use those exact versions. It only changes when you explicitly run something like npm update.

So the division is clean: the manifest is your human decision about API compatibility, while the lock file is a machine-generated snapshot.

Now back to .NET

NuGet actually does support lock files - you can enable packages.lock.json by adding:

<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

But almost nobody does. And the reason is that NuGet's resolution strategy makes them mostly unnecessary.

Here's the key difference. Cargo and npm resolve to the newest compatible version. If versions 1.0.0 through 1.0.4 exist on the registry and you depend on >= 1.0.0, you get 1.0.4. Tomorrow, someone publishes 1.0.5, and suddenly your build resolves differently. That's why you need a lock file — to freeze the resolution.

NuGet does the opposite. It resolves to the lowest applicable version. Same scenario, you get 1.0.0. When 1.0.5 shows up on the feed, nothing changes. 1.0.0 still satisfies the constraint, so NuGet keeps using it. Your build is already stable without a lock file.

NuGet's resolution is inherently sticky, so the ecosystem never felt the pain that made lock files essential elsewhere.

The tradeoff

There's a catch, though. If 1.0.0 has a security vulnerability and the fix ships in 1.0.4, NuGet will happily keep resolving to the vulnerable version forever. You'll never get the fix unless you explicitly update.

Cargo and npm have the opposite problem — you get fixes automatically, but your builds drift without a lock file.

Neither approach is strictly better. Cargo chose "safe by default, need lock files for stability." NuGet chose "stable by default, need vigilance for security."

If you're on .NET, it's worth knowing about dotnet list package --vulnerable. It checks your dependencies against known vulnerability databases.

You also have different options to work around updates. For example, you could enable packages.lock.json and run dotnet restore --locked-mode in CI. Another option could be to run an automated dependency update tool on some schedule (e.g., GitHub Dependabot, Renovate).