Choosing a NuGet Versioning Strategy: Pinned, Floating, or Central Package Management
Compare pinned, floating, and centrally managed NuGet versions, with a concrete CPM plus lock-file setup and restore-based checks to confirm what actually resolved.
15 Feb 2026, 20:15 UTC

Every .NET team eventually hits the same problem: a shared internal library gets updated, and now a dozen consuming projects are either pinned to a stale version or silently pulling in breaking changes. The decision is not whether to version your packages — it is how strictly to pin them, and where the version numbers live. This guide compares the three supported approaches and shows how to validate whichever one you pick.
The decision and its constraints
You are choosing between three properties that pull against each other:
- Determinism: the same commit restores the same package bits on every machine and every CI run.
- Maintenance cost: how many files you touch when a dependency ships a patch.
- Change propagation speed: how quickly a bug fix in a shared library reaches consumers.
Assumptions in this article: SDK-style projects using PackageReference (the modern standard, replacing packages.config), .NET 6 SDK or later, and a private feed such as Azure Artifacts or GitHub Packages for internal packages. Verify behavior against your SDK version, since NuGet resolution rules occasionally change.
Comparing the supported options
| Approach | Example | Determinism | Maintenance cost | Best fit |
|---|---|---|---|---|
| Pinned exact version | Version="1.4.2" | High (with lock files, highest) | High — every update is a manual edit | Production services, regulated environments |
| Floating version | Version="1.4.*" | Low — restores can differ over time | Low — patches flow automatically | Fast-moving internal libraries, dev tooling |
| Version range | Version="[1.0,2.0)" | Low — resolves to highest match | Low | Rarely; mostly for tooling compatibility |
| Central Package Management (CPM) | Directory.Packages.props | Same as the pinning style you use inside it | Lowest — one file for the whole repo | Any multi-project repository |
CPM is not an alternative to pinning or floating — it is a location for version numbers. You still choose pinned or floating versions inside the central file.
Trade-offs that actually bite
Floating versions break reproducibility. Two developers who check out the same commit a week apart can restore different package versions. If a floating patch introduces a regression, the failure appears in CI with no corresponding code change, which makes it painful to bisect. NuGet also caches resolved versions, so a floating reference may resolve differently on a fresh CI agent than on a developer machine with a warm cache.
Strict pinning breaks nothing, but slowly. Patch fixes in a shared library never reach consumers until someone edits each project. Teams compensate with bulk-update tooling or Dependabot/Renovate, which is a reasonable answer — but it is still a process you must run.
Version ranges are the worst of both for applications. A range like [1.0, 2.0) resolves to the highest available version, so a new minor release with a behavioral change lands in your build uninvited. Ranges exist mainly for library authors expressing compatibility, not for applications pinning dependencies.
Circular dependencies fail regardless of strategy. If package A depends on B and B depends on A, restore fails or produces confusing resolution graphs. No versioning policy fixes this — split the shared types into a third package.
A pragmatic default: CPM with pinned versions and lock files
For most internal repositories, the combination that balances all three constraints is Central Package Management with exact versions, plus NuGet lock files for determinism.
Create Directory.Packages.props at the repository root:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Contoso.Shared.Logging" Version="1.4.2" />
<PackageVersion Include="Serilog" Version="4.0.1" />
</ItemGroup>
</Project>Project files then reference packages without versions:
<ItemGroup>
<PackageReference Include="Contoso.Shared.Logging" />
</ItemGroup>Enable lock files by setting this in a project or in Directory.Build.props:
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>Run dotnet restore --force-evaluate locally (no special permissions needed) to generate packages.lock.json files, and commit them. In CI, run dotnet restore --locked-mode so the build fails if the lock file does not match the declared references, instead of silently resolving something new. Risk to note: enabling lock files on a large existing solution generates many new files and may surface latent version conflicts you must resolve before the first locked restore succeeds.
Where does floating still fit? A reasonable hybrid: float on the patch component (1.4.*) only for internal packages whose owners follow SemVer strictly, and pin everything from third parties. Accept that this trades some reproducibility for less toil.
Validating the result
Do not trust the .csproj text — verify what actually resolved.
- Run
dotnet restorein the solution directory, then openobj/project.assets.jsonfor a project. Search for the package name and confirm the exact resolved version underlibraries. - If you use a floating reference, run
dotnet restoreon two machines (or delete the local NuGet cache withdotnet nuget locals all --clearand restore again) and compare the resolved versions inproject.assets.json. A difference confirms the determinism risk is real in your setup. - With lock files enabled, deliberately change a
PackageVersionwithout regenerating the lock file, then rundotnet restore --locked-mode. It should fail — that failure is the guarantee working. - Check for accidental downgrade or conflict warnings in restore output (
NU1603,NU1605); these often reveal that two projects disagreed on a version before CPM consolidated them.
Limitations
Lock files add merge-conflict surface in busy repositories. CPM requires all projects in scope to omit inline versions, so migrating a large solution is a mechanical but wide-reaching change. Floating resolution behavior depends on feed state and cache state, so exact behavior can vary by SDK version — verify against the SDK you actually run in CI rather than assuming the rules above are version-independent.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.