Skip to main content
SEMastery
.NET Corebeginner

Central Package Management in .NET: Simplify NuGet Dependencies

Learn Central Package Management (CPM) in .NET to manage all NuGet versions in one Directory.Packages.props file. Simple guide with diagrams and examples.

12 min readUpdated November 7, 2025

A shared shopping list for the whole family

Imagine a big joint family living in one house. Everybody buys groceries. The eldest brother buys rice, the younger sister buys rice, and the cousin upstairs also buys rice. Each person goes to a different shop and pays a slightly different price. Soon the kitchen has five bags of rice, three of them are different brands, and nobody knows which one to use.

Now imagine the family fixes this. They keep one shopping list on the fridge. The list says: "Rice — Brand A, 5 kg." Everyone reads the same list. Nobody guesses. If the family wants to switch to a cheaper brand, they change one line on that list, and the whole house follows.

Central Package Management (CPM) in .NET does exactly this for your NuGet packages. Instead of each project picking its own version of a package, you keep one list at the top of your repository. Every project reads that list. Change one line, and every project updates together.

In this guide we will go slow and use small, clear examples. By the end you will know what CPM is, why it saves you from version headaches, and how to switch your solution over in a few minutes.

The problem before CPM

A .NET solution often has many projects. A web project, a core library, a tests project, maybe a worker service. Each project has its own .csproj file. And in the old way, each .csproj lists its own package versions.

Here is what a normal project file looked like:

<!-- MyApp.Web.csproj -->
<ItemGroup>
  <PackageReference Include="Serilog" Version="3.1.1" />
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  <PackageReference Include="FluentValidation" Version="11.9.0" />
</ItemGroup>

That looks fine for one project. The trouble starts when you have ten projects. Now the same package, say Newtonsoft.Json, is listed in many files. One project says 13.0.1, another says 13.0.3, a third forgot to update and is still on 12.0.2.

This mismatch is called version drift. It causes real bugs that are hard to find. Two projects in the same app load two different versions of the same library, and suddenly things break at runtime for no obvious reason.

Before CPM: each project picks its own version, and they drift apart over time

To upgrade Serilog everywhere, you had to open every project file, find the line, and edit it by hand. Miss one, and the drift comes back. On a big solution this is slow and easy to get wrong.

The CPM idea in one picture

CPM moves all the versions into one file. That file is called Directory.Packages.props and it sits at the root of your repository. Each project keeps its <PackageReference> lines, but those lines no longer carry a version. The version lives only in the central file.

How CPM splits the job

Project files
Central file
Restore

Steps

1

Project files

List package names only, no versions

2

Central file

Directory.Packages.props sets every version

3

Restore

NuGet joins name + version at build time

Names live in each project, versions live in one central file

So the project file answers "which packages do I need," and the central file answers "what version does the whole repo use." Two clear jobs, two clear places.

Setting it up step by step

Let us turn on CPM for a solution. It takes three small steps.

Step 1: Create the central file

Create a file named Directory.Packages.props at the root of your repo, next to your .sln file. The name and spelling matter, so copy it carefully.

You can create it by hand, or let the CLI do it for you:

dotnet new packagesprops

Inside the file, set one switch and then list your versions:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageVersion Include="Serilog" Version="3.1.1" />
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
    <PackageVersion Include="FluentValidation" Version="11.9.0" />
    <PackageVersion Include="xunit" Version="2.7.0" />
  </ItemGroup>
</Project>

Notice the tag is <PackageVersion>, not <PackageReference>. The ManagePackageVersionsCentrally switch set to true is what turns the whole feature on.

Step 2: Remove versions from each project

Now open each .csproj and delete the Version from every package line. Keep the Include part. That is all.

<!-- MyApp.Web.csproj -->
<ItemGroup>
  <PackageReference Include="Serilog" />
  <PackageReference Include="Newtonsoft.Json" />
  <PackageReference Include="FluentValidation" />
</ItemGroup>

The project still says it needs these three packages. But it no longer says which version. NuGet will read the version from the central file.

Step 3: Restore and build

Run a restore and build as usual:

dotnet restore
dotnet build

NuGet reads Directory.Packages.props, matches each package name to its central version, and pulls in the right files. If you left a Version on a PackageReference while CPM is on, NuGet warns you, because that version is now ignored or causes an error. The fix is simple: remove it.

The three setup steps, in order

What changed, side by side

Here is the same package in both styles, so you can see the move clearly.

TopicOld way (per project)CPM way (central)
Where versions liveIn every .csprojIn one Directory.Packages.props
Tag used for versionPackageReference ... Version=PackageVersion ... Version=
Project referenceName and versionName only
Upgrade a packageEdit every file by handEdit one line, once
Risk of version driftHighAlmost none

The biggest win is the upgrade row. To move Serilog from 3.1.1 to 4.0.0 across twenty projects, you now change exactly one line in the central file. Every project follows on the next restore.

How NuGet decides the version

It helps to picture what NuGet does during restore. For each <PackageReference> it finds, it looks up the matching <PackageVersion> in the central file and uses that version. The flow is the same for every project.

What NuGet does for each package during restore

Because every project asks the same central file, every project gets the same answer. That is how CPM kills version drift at the source.

Overriding a version for one project

Sometimes one project genuinely needs a different version. Maybe a test project needs an older library to reproduce a bug, or one service is not ready for a newer release. CPM allows this through VersionOverride.

<!-- This one project pins its own version -->
<ItemGroup>
  <PackageReference Include="Serilog" VersionOverride="3.0.1" />
</ItemGroup>

Now this single project uses 3.0.1, while every other project still uses the central 3.1.1. The override wins only inside the file where you write it.

Use this carefully. If half your projects use overrides, you are back to the old mess. Treat VersionOverride as a rare, short-term escape hatch, not a habit. A good rule: every override should have a comment saying why it exists and when it can be removed.

Transitive pinning: control the packages you did not ask for

Every package you install can pull in other packages behind the scenes. These hidden helpers are called transitive dependencies. You never named them, but they come along for the ride.

Think of ordering a thali. You ask for the thali, and the kitchen also brings rice, dal, roti, and pickle that you did not order one by one. Those extras are transitive. Usually that is fine. But what if one of those extras has a security problem and you must force a safer version?

That is what transitive pinning does. You turn it on in the central file:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
 
  <ItemGroup>
    <!-- A package you never reference directly, pinned for safety -->
    <PackageVersion Include="System.Text.Json" Version="8.0.5" />
  </ItemGroup>
</Project>

With pinning on, if any of your packages tries to pull in an older System.Text.Json, NuGet promotes your central version and uses 8.0.5 instead. You patched a hidden dependency from one place, across the whole repo.

Transitive pinning to the rescue

Direct package
Pulls in old helper
Pin overrides it

Steps

1

Direct package

You install a library you need

2

Pulls in old helper

It drags an old, risky transitive package

3

Pin overrides it

Central pin forces the safe version everywhere

Force a safe version on a package you never named directly

Global package references for build-only tools

Some packages are needed by every project, not for your app logic, but for the build itself. Things like analyzers, source generators, or Microsoft.SourceLink.GitHub. Instead of adding them to each project, you can declare them once in the central file with <GlobalPackageReference>.

<ItemGroup>
  <GlobalPackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
</ItemGroup>

Now this package is applied to every project in the repo automatically. This is great for tooling that should be consistent everywhere and that no single project should be allowed to skip.

Here is a quick map of the three special features and when to reach for each.

FeatureWhat it doesReach for it when
VersionOverrideLets one project use a different versionOne project must differ, just for now
Transitive pinningForces a version on hidden dependenciesYou must patch an indirect package safely
GlobalPackageReferenceAdds a package to every projectA build tool must run everywhere

A real folder layout

It can help to see where everything sits. CPM follows MSBuild's "directory" convention: the Directory.Packages.props file applies to every project below it in the folder tree.

Where the central file sits in a typical repository

Because the file lives at the root, every project under it is covered without any extra wiring. If you ever need a different set of versions for one sub-folder, you can place another Directory.Packages.props inside that folder, and it takes over for the projects below it. Most teams never need that, though. One file at the root is usually enough.

Tips for a smooth move

Switching an existing solution to CPM is mostly mechanical, but a few habits make it painless.

  • Move in one commit. Create the central file, strip versions from every project, and restore, all in one change. A half-converted repo is confusing.
  • Let tools help. Modern Visual Studio, Rider, and the dotnet CLI can convert a solution for you. Look for the "manage packages centrally" or migration option rather than editing by hand.
  • Sort the central file. Keep <PackageVersion> lines in alphabetical order. It makes the file easy to scan and keeps merge conflicts small.
  • Group related versions. When several packages from one family must move together (like all the Microsoft.Extensions.* packages), upgrade them in the same commit so they stay in step.
  • Watch the warnings. If NuGet warns that a Version is being ignored, that is a leftover from a project file. Remove it.

One more comfort: CPM is not all-or-nothing across your machine. It is turned on per repository by that one switch. If you do not create the central file, nothing changes. So you can try it on a small repo first and grow your confidence.

A note on package licensing

While you tidy up dependencies, it is a good moment to check licenses too. Some popular .NET packages changed their terms recently. For example, MediatR and MassTransit are now commercially licensed for many uses. CPM does not change licensing in any way, but since you will be looking at your whole package list in one file, it is a handy chance to confirm each package still fits your project's rules.

Quick recap

  • Central Package Management keeps all NuGet versions in one file, Directory.Packages.props, at the root of your repo.
  • Turn it on with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>.
  • List versions there using <PackageVersion Include="..." Version="..." />.
  • In each project, keep <PackageReference Include="..." /> with no version.
  • This stops version drift and makes upgrades a one-line change.
  • Use VersionOverride when one project truly needs a different version, but use it rarely.
  • Use transitive pinning to force safe versions on hidden, indirect dependencies.
  • Use <GlobalPackageReference> for build tools that every project needs.
  • You need NuGet 6.2+ (transitive pinning needs 6.4+), which any modern .NET SDK has.

CPM is a small change with a big payoff. One shopping list on the fridge, and the whole house stays in sync.

References and further reading

Related Posts