NuGet Central Package Management: Declare Every Package Version Once
Declare every NuGet package version once in Directory.Packages.props and let each .csproj reference packages by name only. How CPM works, what transitive pinning buys you, and the mistakes that trigger NU1008 restore errors.
20 May 2026, 16:25 UTC

By the time a .NET solution reaches a dozen projects, the same package usually appears in several .csproj files — and not always at the same version. One project references Newtonsoft.Json 13.0.3 while another still declares 12.0.2, restore resolves both, and you find out through a runtime bug or a security audit instead of a build failure. The fix is NuGet's Central Package Management (CPM): declare every package version exactly once in a Directory.Packages.props file at the repo root, and remove Version attributes from project files entirely.
This guide explains how the mechanism works, walks through a complete configuration, and covers the limits and mistakes — NU1008 restore errors, silent overrides, transitive pinning surprises — that come up when teams adopt it.
What central package management changes
CPM splits two concerns that normally live together in a PackageReference: which packages a project uses, and which versions the repository allows. Versions move into PackageVersion items in Directory.Packages.props; project files keep only the package names.
The file works through MSBuild's automatic imports. MSBuild — the build engine behind dotnet build and Visual Studio — walks up the directory tree from each project and imports the nearest Directory.Packages.props it finds, the same convention as Directory.Build.props. At restore time (the step where NuGet downloads packages and resolves the dependency graph), each versionless PackageReference is matched to its PackageVersion entry. One package name maps to one version per file, which is exactly what kills diamond-version drift.
A worked configuration
Place this file at the repository root, next to your .sln:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="xunit" Version="2.6.6" />
</ItemGroup>
</Project>The version numbers are illustrative — pin whatever your team has vetted. Then strip versions from the project files:
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="xunit" />
</ItemGroup>Every project in the solution now resolves Newtonsoft.Json 13.0.3. An upgrade becomes a one-line edit in one file followed by one restore, instead of a find-and-replace across thirty projects.
To apply it, run dotnet restore from the repo root (or the folder containing the .sln). You need write access to each project's obj folder and to the global packages cache (typically %USERPROFILE%\.nuget\packages on Windows, ~/.nuget/packages elsewhere); on CI you also need credentials for any private feeds. A successful restore exits cleanly with no NU1xxx errors. Confirm the resolution with dotnet list package run inside a project directory — it prints the requested and resolved versions for each reference.
Overrides: permitted by default, worth restricting
A single project can still step outside the central file:
<PackageReference Include="Serilog" VersionOverride="3.0.1" />That is occasionally useful for a migration in flight, but every override is a private fork of the version plan, so drift returns through the back door. To forbid overrides repo-wide, add this property to the same Directory.Packages.props:
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>With that set, any VersionOverride fails restore instead of silently diverging. For shared repositories, strict mode is usually the better default.
Pinning transitive dependencies
A transitive dependency is a package you never reference directly — it arrives because one of your direct packages depends on it. Normally NuGet negotiates its version from the whole dependency graph, and you cannot control it from your project file. CPM has an escape hatch:
<PropertyGroup>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>Once enabled, a PackageVersion entry can name a package that appears only transitively, and restore will force the declared version. This is the fastest way to pick up a patched version of an indirect package after a security advisory, without adding a direct reference in every project.
Two caveats. First, a pin raises the resolved version but does not create a direct dependency edge: if the direct package that pulled the transitive one in is later removed, the pin entry simply stops matching anything. Second, forcing a version can break the direct dependency that expected an older one — restore may succeed while compilation or tests fail. Treat every pin as a temporary measure until the upstream dependency ships the fix, and verify with dotnet list package --include-transitive plus a test run.
Limits to know before adopting
CPM needs reasonably current tooling. Older versions ignore Directory.Packages.props, and because the PackageReference items then have no versions at all, restore fails with a confusing "version not found"-style error rather than anything mentioning central management.
| Tool | Minimum version for CPM |
|---|---|
| NuGet | 6.2 |
| .NET SDK | 6.0.300 |
| Visual Studio | 2022 17.2 |
These are the releases where the feature shipped; check the current NuGet documentation if you must support older toolchains.
Other constraints:
- SDK-style projects only. CPM works with
PackageReference. Legacy projects usingpackages.config(the old .NET Framework format) are not covered. - Nearest file wins, nothing merges. A
Directory.Packages.propsin a subfolder replaces — not extends — the one at the repo root. Monorepos that want per-directory version scopes must explicitly import the parent file:<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />. - One version per package per file. If part of your codebase genuinely needs two different major versions of the same package (plugin hosts, for example), CPM works against you; scope those projects behind their own file or accept controlled overrides.
- It changes version management, not caching. The global packages folder and optional lock files such as
packages.lock.jsonbehave exactly as before.
Common mistakes
- Leaving
Versionon aPackageReference. With CPM enabled this fails restore with an error in the NU1008 family. All versions must move toPackageVersionentries. - A reference with no central entry. A
PackageReferencewhose name has no matchingPackageVersionalso fails restore — the fix is to add the entry, not to re-add a local version. - Assuming nested files merge. Teams add a subfolder
Directory.Packages.propsfor one solution and discover the root entries no longer apply to it. - Pinning without testing. A forced transitive version can restore cleanly and still fail at runtime if the direct dependency relies on APIs that moved.
- Editing versions in
.csprojout of habit. After adoption, the project file is the wrong place for any version change; make that explicit in review checklists.
Checking the result
A quick acceptance pass: run dotnet restore at the repo root and confirm it succeeds; run dotnet list package in a couple of projects and confirm the resolved versions match the central file; then deliberately add Version="1.0.0" to one PackageReference and confirm restore fails — that proves CPM is actually enforcing the file rather than being silently ignored. Remove the attribute again afterwards.
If you need to back out, the change is fully reversible: delete Directory.Packages.props and restore the Version attributes in the project files — in practice, revert the adoption commit. Nothing outside the repository (feeds, caches, installed SDKs) is modified by enabling CPM.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.