Home / Guides / NuGet package dependency graph and build order
Which projects use this NuGet package, and in what order do you build them?
Your team keeps its own NuGet packages, and one of the low-level ones needs a fix. Before you change it you want to know which packages and
apps use it, directly or through another package, and in what order to build and publish them so that each one picks up the new version.
This guide covers what the .NET SDK answers inside one solution (dotnet nuget why and dotnet package list), a
PowerShell search across many repositories, a small script that works out the build order, the problems you will meet, and where doing it
by hand stops.
Short answer.
In one solution, dotnet nuget why MySolution.sln Contoso.Core shows every path by which each project depends on the package,
direct or transitive (.NET SDK 8.0.4xx and later). dotnet package list --include-transitive lists everything a project
pulls in. Across many repositories, search the project files for the PackageReference, then order your packages in tiers:
tier 0 uses none of your other packages, and every other package sits one tier above the highest package it uses.
The text search only finds direct references, and none of this reads MSBuild the way a build does, so treat the result as a checklist to confirm, not a guarantee.
Inside one solution: dotnet nuget why
dotnet nuget why shows the dependency graph of one package for a project or a whole solution. It is part of the .NET SDK
from 8.0.4xx on. Give it the solution (or a project, or a folder that holds one) and the package id:
# every project in the solution
dotnet nuget why C:\Repos\Contoso.Web\Contoso.Web.sln Contoso.Licensing
# one project, one target framework
dotnet nuget why C:\Repos\Contoso.Web\Contoso.Web\Contoso.Web.csproj Contoso.Licensing --framework net10.0-windows
We ran it on a solution of our own (package names changed here). The web app references Contoso.Extensions and
Contoso.Util itself, and gets Contoso.Licensing only through them, by three different paths. A project that does not
use the package at all says so:
Project 'Contoso.Web' has the following dependency graph(s) for 'Contoso.Licensing':
[net10.0-windows]
[net10.0-windows/win-x64]
├── Contoso.Extensions (v3.2.7)
│ └── Contoso.Licensing (v3.3.17)
└── Contoso.Util (v3.2.13)
├── Contoso.Extensions (v3.2.7)
│ └── Contoso.Licensing (v3.3.17)
└── Contoso.Data (v3.2.16)
└── Contoso.Extensions (v3.2.7)
└── Contoso.Licensing (v3.3.17)
Project 'Contoso.Build' does not have a dependency on 'Contoso.Licensing'.
Use --framework to see one target framework when a project has several. It reads the restore results, so the solution must
restore first (a build does that). It does not change any file.
Everything a project pulls in: dotnet package list
The other direction, "what does this project use", is dotnet package list. With --include-transitive it adds the
packages that come in through other packages. In .NET 10 the command is dotnet package list; with the .NET 9 SDK or earlier
write it the old way, dotnet list package (both forms work in .NET 10).
# .NET 10 and later
dotnet package list --project C:\Repos\Contoso.Web\Contoso.Web\Contoso.Web.csproj --include-transitive --no-restore
# .NET 9 SDK and earlier
dotnet list C:\Repos\Contoso.Web\Contoso.Web\Contoso.Web.csproj package --include-transitive
# for a script
dotnet package list --project C:\Repos\Contoso.Web\Contoso.Web.sln --include-transitive --no-restore --format json
Since .NET 10 the command restores first if it needs to. Restoring writes the obj folder and needs access to your package feed,
so add --no-restore to list from the last restore without touching anything. --format json gives output a script
can read, and --outdated (which asks the feed) shows newer versions. For references between projects in the same solution,
dotnet reference list lists a project's ProjectReference entries.
Tested with the .NET SDK 10.0.403 on Windows 11, on this website's own solution: dotnet nuget why on the solution and on one
project, and dotnet package list (both forms, with --include-transitive, --no-restore and
--format json), and dotnet reference list. The obj folder's restore files had the same timestamps
before and after, so nothing was changed. dotnet nuget why also ran on a .slnx solution.
Across many repositories: search the project files
dotnet nuget why stops at the edge of one solution. When your packages and apps live in many repositories, the quickest
first answer is a text search of every project file under the folder where they are cloned. Select-String is not
case-sensitive by default, which suits package ids:
Get-ChildItem -Path C:\Repos -Recurse -File -Include *.csproj, *.vbproj, *.fsproj -ErrorAction SilentlyContinue |
Where-Object FullName -notmatch '\\(bin|obj|node_modules)\\' |
Select-String -Pattern '<PackageReference\s+Include="Contoso\.Data"' |
Select-Object @{ n = 'Project'; e = { Split-Path $_.Path -Leaf } }, LineNumber, @{ n = 'Line'; e = { $_.Line.Trim() } }
On our own repositories (about 40, names changed here) it took 8 seconds and found nine projects:
Project LineNumber Line
------- ---------- ----
Contoso.Alerts.csproj 54 <PackageReference Include="Contoso.Data" Version="3.2.16" />
Contoso.Collector.csproj 121 <PackageReference Include="Contoso.Data" Version="3.2.16" />
Contoso.Api.csproj 46 <PackageReference Include="Contoso.Data" Version="3.2.16" />
Contoso.Util.csproj 35 <PackageReference Include="Contoso.Data" Version="3.2.16" />
... (five more)
It did not find our web app, although dotnet nuget why showed above that the app uses that package. The app gets it through
another package, and a text search only sees direct references. To get the full list, take each direct user that is itself a package and
search again for its id, until nothing new turns up, or run dotnet nuget why in each solution:
Get-ChildItem -Path C:\Repos -Recurse -File -Include *.sln, *.slnx -ErrorAction SilentlyContinue |
Where-Object FullName -notmatch '\\(bin|obj|node_modules)\\' |
ForEach-Object { dotnet nuget why $_.FullName Contoso.Data }
That loop needs every solution restored on your machine, with access to your feed, and it prints a graph per solution for you to read; it does not add anything up.
The build order, worked out by a script
The order is a matter of tiers. A package that uses none of your other packages is tier 0, and can be built first. Every other package is one tier above the highest tier it uses. Everything in one tier can be built at the same time, and once a tier is published, the next one can pick up the new versions. This script reads every project file under the folders you give it, keeps the packages whose ids start with your prefix, and prints them by tier with the internal packages each one uses:
param(
[string[]]$Root = @('C:\Repos'),
[string[]]$Prefix = @('Contoso.') # the start of your own package ids
)
# 1. Read every project file once: what it produces, what it references.
$skip = '\\(bin|obj|node_modules|\.git|\.vs)\\'
$projects = foreach ($r in $Root) {
Get-ChildItem -Path $r -Recurse -File -Include *.csproj, *.vbproj, *.fsproj -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch $skip } |
ForEach-Object {
$file = $_
try { [xml]$xml = Get-Content -LiteralPath $file.FullName -Raw }
catch { Write-Warning "Not readable: $($file.FullName)"; return }
$id = @($xml.SelectNodes('//*[local-name()="PackageId"]') | ForEach-Object { $_.InnerText.Trim() }) |
Where-Object { $_ -and $_ -notmatch '\$\(' } | Select-Object -First 1
if (-not $id) { # dotnet pack's default id: AssemblyName, else the project name
$id = @($xml.SelectNodes('//*[local-name()="AssemblyName"]') | ForEach-Object { $_.InnerText.Trim() }) |
Where-Object { $_ -and $_ -notmatch '\$\(' } | Select-Object -First 1
}
if (-not $id) { $id = $file.BaseName }
[pscustomobject]@{
Project = $file.BaseName
Path = $file.FullName
PackageId = $id
References = @($xml.SelectNodes('//*[local-name()="PackageReference"]') |
ForEach-Object { $_.GetAttribute('Include') } | Where-Object { $_ })
}
}
}
# 2. Keep only the internal packages, and only the references between them.
$isInternal = { param($name) foreach ($p in $Prefix) { if ($name -like "$p*") { return $true } }; $false }
$byId = @{}
foreach ($p in $projects) { if (& $isInternal $p.PackageId) { $byId[$p.PackageId] = $p } }
# 3. Tier = 0 for a package with no internal references, else 1 + the highest tier it references.
$tier = @{}
$pending = [System.Collections.Generic.List[string]]::new([string[]]@($byId.Keys))
while ($pending.Count -gt 0) {
$done = @()
foreach ($id in $pending) {
$deps = @($byId[$id].References | Where-Object { $byId.ContainsKey($_) -and $_ -ne $id })
if (@($deps | Where-Object { -not $tier.ContainsKey($_) }).Count -eq 0) {
$tier[$id] = if ($deps.Count) { 1 + ($deps | ForEach-Object { $tier[$_] } | Measure-Object -Maximum).Maximum } else { 0 }
$done += $id
}
}
if ($done.Count -eq 0) { Write-Warning "Dependency loop between: $($pending -join ', ')"; break }
foreach ($id in $done) { [void]$pending.Remove($id) }
}
$tier.GetEnumerator() |
Sort-Object Value, Name |
Select-Object @{ n = 'Tier'; e = { $_.Value } }, @{ n = 'Package'; e = { $_.Name } },
@{ n = 'Uses'; e = { ($byId[$_.Name].References | Where-Object { $byId.ContainsKey($_) }) -join ', ' } }
Save it as C:\Scripts\Get-PackageBuildOrder.ps1 and run it with your own folders and package prefix:
C:\Scripts\Get-PackageBuildOrder.ps1 -Root C:\Repos -Prefix 'Contoso.' | Format-Table -AutoSize -Wrap
# several clone folders and two prefixes:
C:\Scripts\Get-PackageBuildOrder.ps1 -Root C:\Repos, D:\Work -Prefix 'Contoso.', 'Fabrikam.'
On our own repositories it found 32 internal packages in six tiers in about 10 seconds. A shortened copy of the result, names changed:
Tier Package Uses
---- ------- ----
0 Contoso.Licensing
0 Contoso.Models
...
1 Contoso.Extensions Contoso.Licensing
1 Contoso.Settings Contoso.Models
2 Contoso.Data Contoso.Extensions
3 Contoso.Util Contoso.Extensions, Contoso.Models, Contoso.Data
4 Contoso.Collector Contoso.Extensions, Contoso.Models, Contoso.Data, Contoso.Util
5 Contoso.Contracts Contoso.Collector, Contoso.Messaging
-Root: one or more folders where the repositories are cloned.-Prefix: the start of your own package ids. Packages from nuget.org are left out.- A package's id is its
<PackageId>; without one,dotnet packuses the<AssemblyName>, which is the project name unless you set it. The script does the same. - If two packages depend on each other, neither can come first: the script names them in a warning and leaves them, and everything that depends on them, out of the list.
- A project file that is not valid XML is reported as a warning and skipped.
To plan a change to one package, read the list from that package's tier upwards: everything that uses it, directly or through others, sits in a higher tier, and the apps that use any of them come last.
Tested on Windows PowerShell 5.1 on Windows 11 against our own repositories (read-only): the search and the script read the project files
and changed nothing. On a small test folder the script also reported a two-package loop and a broken project file, and took the id from
<AssemblyName> or the project name when there was no <PackageId>. The script does not follow apps (only packages) and does not
read shared MSBuild files; see the limits below.
Common problems
dotnet nuget whyis not recognized. The SDK in use is older than 8.0.4xx. Check withdotnet --versionin the repository folder: aglobal.jsonthere can pin an older SDK.- A 401 or error NU1301 from
dotnet package list. The automatic restore in .NET 10 could not read your private feed, usually for lack of credentials. Add--no-restoreto use the last restore, or set up the feed's credential provider and restore once. - The search finds nothing for a package you know is used. The reference may be written as
<PackageReference Update="...">, kept in a sharedDirectory.Build.propsor.targetsfile, or in an old-style project that lists its packages inpackages.config. Search those files too. For an old (non-SDK) project that usesPackageReference,dotnet nuget why(from the .NET 9 SDK) can read theproject.assets.jsonthat a Visual Studio ormsbuild.exerestore leaves in itsobjfolder. - The version is missing from the project file. With central package management the version lives in
Directory.Packages.propsand the project has<PackageReference Include="Contoso.Core" />without one. The search above still finds the reference; look in that file for the version. - A package id that is a property, such as
$(PackageIdPrefix).Core. Only MSBuild can work that out. The script ignores such ids and falls back to the assembly or project name.
Where doing it by hand stops
- It reads text, not MSBuild. Conditions, properties, imported
.propsfiles and central package versions are not evaluated. A reference that only applies to one target framework counts for all of them. - Direct and indirect are two different questions. The search shows who references the package itself (they must update their reference); the ones that only get it through another package just need a rebuild once that one is published. Working that out across repositories means repeating the search per level.
- No versions. It does not tell you which projects pin an older version than the one in source, which packages still have a placeholder version, or which packages nothing uses any more.
- A snapshot. It is right on the day you run it. Every new repository, package or reference means running it again, and reading the output again.
Questions
How do I find which projects use a NuGet package?
Inside one solution, run dotnet nuget why MySolution.sln PackageId: it shows every project that depends on the package and the path, direct or through other packages. Across many repositories, search the project files for <PackageReference Include="PackageId" with PowerShell's Select-String, then repeat the search for each package that turns up, because the text search only finds direct references.
Which .NET SDK has dotnet nuget why?
The .NET SDK 8.0.4xx and later, including .NET 9 and .NET 10. Check yours with dotnet --version. The solution must restore first, because the command reads the restore results.
What is the difference between dotnet list package and dotnet package list?
They are the same command. .NET 10 added the noun-first form dotnet package list; the older dotnet list package still works. In .NET 10 it also restores the project first if needed, so add --no-restore to list from the last restore without contacting your feed.
How do I work out the build order of internal NuGet packages?
Put them in tiers. A package that uses none of your other packages is tier 0; every other package is one tier above the highest package it uses. Build and publish tier by tier, then the apps. The script in this guide does that from the project files.
Does a text search of csproj files find every user of a package?
No. It finds direct PackageReference entries in project files. It misses projects that get the package through another package, references kept in shared .props or .targets files, the Update form, and old projects that use packages.config, and it does not evaluate MSBuild conditions or properties.
Is Switchyard free?
Switchyard has a free edition that scans up to 10 projects, with no time limit and no sign-up. A license removes the limit. See the Switchyard page for what it shows and the price.
Related
- Switchyard for .NET: know what a package change touches
- Find unpushed Git commits and stashes in all your repos
- All guides
Sources (Microsoft Learn): dotnet nuget why, dotnet package list, dotnet package list performs restore (.NET 10), .NET 10 noun-first commands, dotnet reference list, PackageReference in project files, Central package management, NuGet pack target (PackageId), Select-String, Get-ChildItem.