Central Package Management in practice: Directory.Packages.props, ManagePackageVersionsCentrally, PackageVersion items, VersionOverride, GlobalPackageReference, transitive pinning, NU1008 and NU1507, migration steps and governance.
I'd add a Directory.Packages.props at the repo root that sets ManagePackageVersionsCentrally to true and lists one PackageVersion per package. Then I'd strip the Version from every PackageReference, or restore fails with NU1008. First I inventory the current versions with dotnet list package and choose one version for each package, usually the highest that the tests pass with. Analysers go in as GlobalPackageReference items. Transitive pinning lets the central file also patch vulnerable transitive packages. VersionOverride covers genuine exceptions, but only with review. The pitfalls: nested Directory.Packages.props files hiding the root one, NU1507 when there are several feeds without source mapping, and upgrades that become "big bang" unless Dependabot or Renovate update the central file in small batches.
"I'd make one file decide versions: a
Directory.Packages.propsat the repo root withManagePackageVersionsCentrallyand onePackageVersionper package, while projects keepPackageReferencewithout a version, so any leftoverVersionfails restore with NU1008. I inventory every version first, pick one per package with the full test suite, migrate in one pull request, move analysers toGlobalPackageReference, and turn on transitive pinning and lock files. I watch for a nestedDirectory.Packages.propshiding the root one, because the nearest file wins, and NU1507 when there's more than one feed without package source mapping; the mistake I see is lettingVersionOverridespread until CPM means little, so I allow overrides only with review.For example, [our 80-project payments repo at company] had drift so bad that two services crashed at start-up with
MissingMethodExceptionafter a shared library upgrade. My task was to stop the drift and make security patching fast. I inventoried every version withdotnet list package, agreed one version per package with the [five] teams, and migrated in one pull request with all tests passing. I also enabled transitive pinning, lock files and grouped Dependabot updates, so upgrades don't turn into one big bang. As a result, distinct package versions dropped by [about 70%], the start-up failures were gone, and the next transitive vulnerability was patched across every service in [one day] with a one-line change.For me CPM is really about who decides versions: one owned file, a clear exception process, and every project moving together, so a team that needs a different major version has to justify it, which is usually a healthy conversation."
<!-- Directory.Packages.props at the repo root -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="xunit.v3" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.1.118" />
</ItemGroup>
</Project>
<!-- Any project -->
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
dotnet list package --format json across the solution (or a script over all project files) to find every package and every version in use.Directory.Packages.props with those versions; remove Version attributes from all PackageReferences. Restore will fail with NU1008 anywhere one is left, which makes the change easy to complete.GlobalPackageReference.RestorePackagesWithLockFile) with --locked-mode in CI.Directory.Packages.props; group related packages (all Microsoft.EntityFrameworkCore.*) so they move together.Directory.Packages.props in a sub-folder replaces the root one for projects below it unless it imports the parent.VersionOverride sprawl: one-off overrides accumulate until CPM means little. Allow them only with a comment and an owner, or disable them with CentralPackageVersionOverrideEnabled=false.In an 80-project payments platform, there were 23 different versions of Newtonsoft.Json and System.Text.Json across projects, and two services failed at run time with MissingMethodException because of version mismatches between shared libraries. Moving to CPM with one version per package fixed both, cut the number of distinct package versions by about 70%, and made a later urgent upgrade of a vulnerable transitive package a one-line change with transitive pinning.
Directory.Packages.props with CODEOWNERS so a platform or security group reviews version changes.Probes hands-on experience with dependency governance at scale.
Green flags: knows the files, properties and NU1008; plans the migration step by step; mentions transitive pinning, lock files, source mapping and automated grouped updates; controls VersionOverride.
Red flags: "just put versions in Directory.Build.props"; no migration plan; unaware of transitive pinning or nearest-file behaviour.
BEFORE (80 project files decide versions) AFTER (one file decides)
-------------------------------------------- -------------------------------------
Api.csproj Serilog.AspNetCore 8.0.1 Directory.Packages.props
Worker.csproj Serilog.AspNetCore 9.0.0 PackageVersion Serilog.AspNetCore 9.0.0
Billing.csproj Serilog.AspNetCore 8.0.0 PackageVersion Polly 8.5.0
Shared.csproj Polly 7.2.4 GlobalPackageReference StyleCop.Analyzers
Reports.csproj Polly 8.5.0
... Each .csproj: <PackageReference Include="Polly" />
Projects still say which packages they use. The central file says which version. That split is the whole idea.
Orders.Api.csproj
<PackageReference Include="Serilog.AspNetCore" /> (no version)
|
v
Directory.Packages.props (nearest file, searched upwards from the project)
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
+--> VersionOverride on the PackageReference? --> use that (if overrides are allowed)
|
v
NuGet restore: Serilog.AspNetCore 9.0.0
|
`-- transitive Serilog >= 4.2.0
|
+-- transitive pinning on, and a PackageVersion for Serilog 4.3.0 exists
| --> Serilog 4.3.0, treated as if referenced directly
|
`-- otherwise --> normal NuGet resolution (lowest applicable: 4.2.0)
GlobalPackageReference --> added to every project as a private, build-time-only reference
| Setting | Where | Effect |
|---|---|---|
ManagePackageVersionsCentrally |
Directory.Packages.props |
Turns CPM on for every project below the file |
PackageVersion |
Directory.Packages.props |
Declares the version for a package; doesn't add a reference by itself |
GlobalPackageReference |
Directory.Packages.props |
Adds a reference to every project, as a private asset (analysers, Source Link) |
CentralPackageTransitivePinningEnabled |
Directory.Packages.props |
Uses central versions for transitive packages too |
VersionOverride |
On a PackageReference |
One project uses a different version |
CentralPackageVersionOverrideEnabled=false |
Props or project | Forbids VersionOverride |
CentralPackageFloatingVersionsEnabled |
Props | Allows floating versions such as 9.* (off by default) |
ImportDirectoryPackagesProps=false / DirectoryPackagesPropsPath |
Project or Directory.Build.props |
Opt a project out, or point it at a specific file |
| Code | Meaning | Fix |
|---|---|---|
| NU1008 | A PackageReference still has a Version |
Remove it, or use VersionOverride if it's a real exception |
| NU1010 | A PackageReference has no matching PackageVersion |
Add a PackageVersion to the central file |
| NU1507 | More than one package source and no package source mapping | Add <packageSourceMapping> to nuget.config |
| NU1605 | Downgrade, often caused by a VersionOverride or a pin |
Raise the version so the graph doesn't go down |
1. Inventory. Export every package and version:
dotnet list Contoso.slnx package --format json > packages.json
Group by package id and list distinct versions. Expect surprises: several major versions of the same package, packages nobody remembers adding, and packages.config projects that need converting first.
2. Decide one version per package. Usually the highest version in use, after reading release notes for breaking changes. If teams genuinely need different major versions, that's a design discussion about the shared code, not something CPM should hide.
3. Create the central file. Add ManagePackageVersionsCentrally, one PackageVersion per package, and move analysers to GlobalPackageReference. Community tools can generate a first draft, but review every version.
4. Strip versions from project files. Restore now fails with NU1008 wherever a Version remains, which makes the change easy to finish. Remove analyser PackageReferences that are now global.
5. Build and test everything. Versions that moved will break some tests or show new warnings. Fix them in the same pull request, so main never has a half-migrated state.
6. Harden. Turn on lock files (RestorePackagesWithLockFile) with --locked-mode in CI, add package source mapping if you use more than one feed, and decide whether to enable transitive pinning.
7. Govern. CODEOWNERS on Directory.Packages.props, grouped automated update pull requests (Dependabot or Renovate both understand the file), and a rule for VersionOverride.
For a very large repo you can migrate folder by folder with nested central files, but finish quickly; two systems side by side confuse everyone.
<PackageReference Include="Polly" VersionOverride="7.2.4" />
| Effect | Why it matters |
|---|---|
| Every project that references this one gets 7.2.4 transitively | Can cause NU1605 downgrades or different versions in different services |
| Shared libraries are tested with one version but run with another | MissingMethodException or behaviour changes at run time |
| Overrides rarely get removed | Over time CPM stops meaning "one version" |
Allow overrides only with a comment linking a ticket and an expiry date, list them in CI (search for VersionOverride in project files), and forbid them in shared libraries with CentralPackageVersionOverrideEnabled=false.
Without pinning, the central file only controls packages that projects reference directly. A vulnerable transitive package stays at whatever the parent requests.
With CentralPackageTransitivePinningEnabled, any package that has a PackageVersion is pinned to that version wherever it appears in the graph.
| Benefit | Cost |
|---|---|
| Patch a vulnerable transitive package in one line for every project | The pinned version may be one the parent package was never tested with |
| One version of a package across the whole repo | Pins are easy to forget; remove them when the parent catches up |
For packable libraries, pinned packages become direct dependencies in the generated .nuspec |
Pin deliberately, mostly for security fixes, and label the pins (for example an ItemGroup Label="Security pins") with the reason and date.
| File | Decides |
|---|---|
Directory.Build.props |
Build properties (TFM, analysers settings, metadata) |
Directory.Packages.props |
Requested package versions |
packages.lock.json |
The exact resolved graph, for reproducible restores |
nuget.config with package source mapping |
Which feed each package may come from |
global.json |
The SDK, and with it the NuGet version doing the restore |
Directory.Packages.props is found the same way as Directory.Build.props: nearest file wins, so a nested one must import the parent:
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
Versions can also be conditional, for example a different PackageVersion per target framework for a multi-targeted library, using Condition="'$(TargetFramework)' == 'net8.0'".
Microsoft.EntityFrameworkCore*, Microsoft.Extensions*, OpenTelemetry*) so they move together, weekly, in small pull requests.PackageVersion entries, expired overrides and pins that are no longer needed.dotnet list package --outdated, --vulnerable --include-transitive) keeps the debt visible.Directory.Packages.props and nuget.config with CODEOWNERS so a security or platform group reviews version and source changes.--locked-mode make sure the reviewed versions are the ones restored.CPM is a NuGet feature and is stable. Watch the NuGet notes in the .NET 11 SDK for changes to audit, pruning and CLI support for the central file. Keep production on the .NET 10 SDK until you've tested an upgrade.
Directory.Packages.props with ManagePackageVersionsCentrally and one PackageVersion.dotnet restore and read the NU1008 errors, then remove the Version attributes.PackageVersion and read NU1010.PackageVersion for a transitive package at a higher version, enable transitive pinning, and check dotnet list package --include-transitive.VersionOverride to one project, build a project that references it, and see which version the referencing project gets.nuget.config and read NU1507; then add package source mapping and see it disappear.<!-- Directory.Packages.props -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup Label="Runtime">
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>
<ItemGroup Label="Security pins for transitive packages">
<!-- Pinned after a NuGet Audit warning; remove when the parent package catches up -->
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
</ItemGroup>
<ItemGroup Label="Analysers for every project">
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.1.118" />
</ItemGroup>
</Project>
<!-- src/Billing.Data/Billing.Data.csproj -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
</ItemGroup>
# .github/dependabot.yml: grouped updates of the central file
version: 2
updates:
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
groups:
ef-core:
patterns: ["Microsoft.EntityFrameworkCore*"]
Mistake: leaving Version on some PackageReference items and suppressing NU1008.
Those projects keep their own versions, so drift continues and the central file is only partly true.
Instead: finish the migration in one pull request: remove every Version, and use VersionOverride only for documented exceptions.
Mistake: adding a nested Directory.Packages.props that doesn't import the root one.
The nearest file wins, so projects below it lose every central version and fail with NU1010 or use different versions.
Instead: keep one root file, or start any nested file with an <Import> of the parent using GetPathOfFileAbove.
Mistake: using VersionOverride for every disagreement.
Overrides flow to every project that references the overriding one, cause downgrades and run-time mismatches, and rarely get removed.
Instead: require a comment with a ticket and an expiry date, list overrides in CI, and forbid them in shared libraries with CentralPackageVersionOverrideEnabled=false.
Mistake: pinning many transitive packages "just in case". Each pin overrides what a parent package was tested with, and stale pins block upstream fixes from arriving. Instead: pin transitive packages deliberately, mainly for security fixes, label each pin with its reason and date, and remove it when the parent package catches up.
Mistake: upgrading everything in one pull request because it's now one file. A single large bump mixes dozens of breaking changes, so failures are hard to trace and the change is hard to roll back. Instead: use grouped automated updates (Dependabot or Renovate) in small, related batches, with the full test suite on each.
Mistake: ignoring NU1507.
With several feeds and no source mapping, NuGet takes a package from whichever feed answers, so an attacker who publishes your internal package name on nuget.org can win (dependency confusion), and restores differ between machines.
Instead: add package source mapping in nuget.config (internal prefixes to the internal feed, everything else to nuget.org) and treat NU1507 as an error.
Mistake: putting package versions in Directory.Build.props.
It mixes build settings with dependency decisions and doesn't give you CPM's checks (NU1008, NU1010) or transitive pinning.
Instead: keep build properties in Directory.Build.props and versions in Directory.Packages.props.
Mistake: picking versions without testing. Choosing the highest version for every package is reasonable, but moving dozens of packages at once breaks code that relied on old behaviour. Instead: read release notes for major jumps, run the full test suite in the migration pull request, and fix breakages there rather than adding overrides.
Mistake: copying stale versions into the central file.
Versions from old projects or examples age quickly, so the migration bakes in outdated and sometimes vulnerable packages.
Instead: take current versions from your feed, run dotnet list package --outdated and --vulnerable --include-transitive right after migrating, and let automated updates keep the file fresh.
Mistake: no owner for the central file.
Anyone can change a version that affects all 80 projects, with no review of the impact.
Instead: protect Directory.Packages.props with CODEOWNERS and review version changes like shared infrastructure.
CPM is less about XML and more about who decides versions. A senior engineer uses it to make version decisions explicit, reviewable and fast: one file, an owner, grouped automated updates, and a clear exception process. The payoff shows up during incidents: when a vulnerability lands in a transitive package, a pinned PackageVersion plus lock files and a pipeline run patches every service at once, with an audit trail. The trade-off is coupling: every project moves together, so a team that genuinely needs a different major version has to justify it, which is usually a healthy conversation about shared dependencies.
A PackageReference pulls a specific version of a NuGet package from a feed. A ProjectReference builds another project in the same repo and references its output. You use project references inside a codebase and package references across teams or repos that release separately. Both are transitive by default: whoever references your project also gets your dependencies. PrivateAssets="all" stops that for things consumers shouldn't inherit, such as analysers or Source Link. A plain Version="4.2.0" means "4.2.0 or higher". NuGet then picks the lowest version that satisfies every constraint, and the nearest declaration wins. dotnet list package --include-transitive shows the full graph.
Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.
First I read the exact error. NU1301 means a source couldn't be reached, 401 or 403 means authentication, and NU1101 means the package isn't on any configured source. It works on my machine because I have cached packages and stored credentials, while CI starts clean. Then I check which nuget.config CI actually uses, since config files are merged from the repo upwards. I make the repo's config explicit with <clear />, and I authenticate CI the supported way, for example the Azure Artifacts credential provider or a token from the pipeline's secret store, never a committed password. I also add package source mapping and lock files so restores are reproducible and protected against dependency confusion.
How to tell it (STAR)
**Situation:** Our [80-project] payments repo at [company] had version drift so bad that two services crashed at start-up with MissingMethodException after a shared library upgrade. **Task:** I proposed and led the move to Central Package Management to stop the drift and make security patching fast. **Action:** I inventoried every package version with dotnet list package, agreed one version per package with the [five] teams, migrated in one pull request with all tests passing, and enabled transitive pinning, lock files and grouped Dependabot updates. **Result:** Distinct package versions dropped by [about 70%], the start-up failures were gone, and when the next transitive vulnerability appeared we patched every service in [one day] with a one-line change.
Last updated 1 Nov 2026.