Home / Guides / Find unused internal NuGet packages

Find internal NuGet packages nobody uses any more, across all your repositories

Over the years your team has published dozens of its own NuGet packages. Some were replaced, some were merged into others, and some were only ever used by an app that was retired. They still sit in a repository, still get built, and still show up in the feed. This guide shows how to find them: a PowerShell script that reads every project file under the folders where your repositories are cloned, a check of the feed's own download figures in Azure Artifacts, how to retire a package safely, and where doing it by hand stops.

Short answer.

The .NET SDK has no command for this: dotnet nuget why starts from a consumer and asks which packages it uses, not the other way round. List every package your projects produce, list every reference to it (project files, shared .props and .targets files, packages.config and local tool manifests), and the packages with no reference left are the candidates. Then check the feed's last download date before you unlist anything.

"Nobody references it" only covers the repositories you scanned. Treat the result as a list to confirm, not a list to delete.

Step 1: compare what you produce with what you reference

A package is unused when no project, other than its own, references it. So the script needs two lists from your clones: the packages your projects publish, and every package id that anything references. A project counts as a package when it says so: it has a <PackageId>, <GeneratePackageOnBuild>true or <IsPackable>true, and not <IsPackable>false. Its id is the PackageId; without one, dotnet pack uses the AssemblyName, which is the project name unless you set it.

References are read from more places than the project files, because a package can be used without a PackageReference in any project:

  • PackageReference and PackageDownload items in project files, and in shared Directory.Build.props or .targets files.
  • packages.config in older .NET Framework projects.
  • dotnet-tools.json, the manifest of local .NET tools: an internal package that is a tool is used there, not in a project.

Save the script as C:\Scripts\Find-UnusedPackages.ps1:

param(
    [string[]]$Root   = @('C:\Repos'),
    [string[]]$Prefix = @('Contoso.')       # the start of your own package ids
)

$skip = '\\(bin|obj|node_modules|packages|\.git|\.vs)\\'
$isInternal = { param($name) foreach ($p in $Prefix) { if ($name -like "$p*") { return $true } }; $false }
$files = foreach ($r in $Root) {
    Get-ChildItem -Path $r -Recurse -File -Include *.csproj, *.vbproj, *.fsproj, *.props, *.targets, packages.config, dotnet-tools.json -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -notmatch $skip }
}

# 1. Who produces which internal package, and who references which.
$producers = @{}                                   # package id -> project file
$refs = [System.Collections.Generic.List[object]]::new()
foreach ($f in $files) {
    if ($f.Name -eq 'dotnet-tools.json') {         # local .NET tools: the package ids are the keys under "tools"
        try { $tools = (Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json).tools }
        catch { Write-Warning "Not readable: $($f.FullName)"; continue }
        if ($tools) { foreach ($t in $tools.PSObject.Properties.Name) { $refs.Add([pscustomobject]@{ Id = $t; From = $f.FullName }) } }
        continue
    }
    try { [xml]$xml = Get-Content -LiteralPath $f.FullName -Raw }
    catch { Write-Warning "Not readable: $($f.FullName)"; continue }

    if ($f.Name -eq 'packages.config') {
        foreach ($n in $xml.SelectNodes('//*[local-name()="package"]')) { $refs.Add([pscustomobject]@{ Id = $n.GetAttribute('id'); From = $f.FullName }) }
        continue
    }
    foreach ($n in $xml.SelectNodes('//*[local-name()="PackageReference" or local-name()="PackageDownload"]')) {
        $id = $n.GetAttribute('Include')
        if ($id -and $id -notmatch '\$\(') { $refs.Add([pscustomobject]@{ Id = $id.Trim(); From = $f.FullName }) }
    }
    if ($f.Extension -notin '.csproj', '.vbproj', '.fsproj') { continue }

    # a project publishes a package when it says so (PackageId, GeneratePackageOnBuild or IsPackable true), unless IsPackable is false
    $value = { param($tag) @($xml.SelectNodes("//*[local-name()='$tag']") | ForEach-Object { $_.InnerText.Trim() }) |
                           Where-Object { $_ -and $_ -notmatch '\$\(' } | Select-Object -First 1 }
    $packable = & $value 'IsPackable'
    if ($packable -eq 'false') { continue }
    $pkgId = & $value 'PackageId'
    if (-not ($pkgId -or (& $value 'GeneratePackageOnBuild') -eq 'true' -or $packable -eq 'true')) { continue }
    if (-not $pkgId) { $pkgId = & $value 'AssemblyName' }           # dotnet pack's default id: AssemblyName, else the project name
    if (-not $pkgId) { $pkgId = $f.BaseName }
    if (& $isInternal $pkgId) { $producers[$pkgId] = $f.FullName }
}

# 2. Users of each produced package (a project referencing its own package does not count).
$users = @{}
foreach ($id in $producers.Keys) {
    $users[$id] = @($refs | Where-Object { $_.Id -eq $id -and $_.From -ne $producers[$id] } | ForEach-Object From | Sort-Object -Unique)
}

# 3. Unused: nothing references it, or everything that does is itself an unused package. Repeat until nothing changes.
$unused = @{}
foreach ($id in $producers.Keys) { if ($users[$id].Count -eq 0) { $unused[$id] = 'Referenced by nothing' } }
do {
    $changed = $false
    $deadFiles = @($unused.Keys | ForEach-Object { $producers[$_] })
    foreach ($id in $producers.Keys) {
        if ($unused.ContainsKey($id) -or $users[$id].Count -eq 0) { continue }
        if (@($users[$id] | Where-Object { $_ -notin $deadFiles }).Count -eq 0) { $unused[$id] = 'Used only by unused packages'; $changed = $true }
    }
} while ($changed)

$unused.GetEnumerator() | Sort-Object Value, Name | ForEach-Object {
    [pscustomobject]@{ Package = $_.Name; Finding = $_.Value; Project = $producers[$_.Name] }
}

# 4. The other way round: internal ids that are referenced but that no project under the roots produces (renamed, retired, or built elsewhere).
$refs | Where-Object { (& $isInternal $_.Id) -and -not $producers.ContainsKey($_.Id) } | Group-Object Id | Sort-Object Name | ForEach-Object {
    [pscustomobject]@{ Package = $_.Name; Finding = 'Referenced, but not produced here'; Project = ($_.Group.From | Sort-Object -Unique) -join '; ' }
}

Run it with the folders where your repositories are cloned and the start of your own package ids:

C:\Scripts\Find-UnusedPackages.ps1 -Root C:\Repos -Prefix 'Contoso.' | Format-Table -AutoSize -Wrap

# several clone folders and two prefixes, saved for a spreadsheet:
C:\Scripts\Find-UnusedPackages.ps1 -Root C:\Repos, D:\Work -Prefix 'Contoso.', 'Fabrikam.' |
    Export-Csv C:\Temp\unused-packages.csv -NoTypeInformation

On a test folder of made-up repositories it printed:

Package         Finding                           Project
-------         -------                           -------
Contoso.Legacy  Referenced by nothing             C:\Repos\Legacy\Contoso.Legacy\Contoso.Legacy.csproj
Contoso.Self    Referenced by nothing             C:\Repos\Self\Contoso.Self\Contoso.Self.csproj
Contoso.Helpers Used only by unused packages      C:\Repos\Legacy\Contoso.LegacyHelpers\Contoso.LegacyHelpers.csproj
Contoso.Retired Referenced, but not produced here C:\Repos\App\Contoso.Web\Contoso.Web.csproj

WARNING: Not readable: C:\Repos\Broken\Broken.csproj
  • Referenced by nothing: no project file, shared file, packages.config or tool manifest under the roots names it. A project that references its own package (it happens after a copy and paste) does not count as a user.
  • Used only by unused packages: something references it, but only packages that are themselves unused. Retire those and this one is unused too. The script repeats the check until nothing changes, so a whole chain of dead packages shows up at once.
  • Referenced, but not produced here: the opposite problem. A project uses one of your ids, but no project under the roots builds it any more. It was renamed or retired, or it is built in a repository you have not cloned. That reference will break once the old version is deleted from the feed.

Package ids are compared without regard to case, as NuGet does. Third-party packages are left out by the prefix. A project file that is not valid XML is reported as a warning and skipped.

Tested on Windows PowerShell 5.1 on Windows 11 against a folder of test repositories (26 checks): a package referenced by nothing, one used only by a dead package, a self-reference, a reference written in lower case, a package used only through Directory.Build.props, packages.config or dotnet-tools.json, a project with IsPackable false, a copy in a bin folder, a broken project file and a missing root folder. The script only reads files.

Step 2: ask the feed when each package was last downloaded

The repository scan tells you who references a package in source. The feed tells you whether anybody still restores it, which also catches consumers you have not cloned. In Azure Artifacts, the REST API gives each package's download count and the date of its last download. This script lists your own NuGet packages in a feed with those figures, oldest download first:

param(
    [Parameter(Mandatory)] [string]$Organization,      # e.g. contoso
    [string]$Project,                                  # leave empty for an organization-scoped feed
    [Parameter(Mandatory)] [string]$Feed,              # feed name or id
    [string[]]$Prefix = @('Contoso.'),
    [string]$Pat = $env:AZDO_PAT                       # Packaging (Read & write): the metrics call needs it
)

$base    = "https://feeds.dev.azure.com/$Organization" + $(if ($Project) { "/$Project" } else { '' }) + "/_apis/packaging/Feeds/$Feed"
$headers = @{ Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Pat")) }

# 1. Every NuGet package in the feed (latest version only), 500 at a time, keeping your own ids.
$packages = @(); $skipCount = 0
do {
    $page = Invoke-RestMethod -Headers $headers -Uri "$base/packages?protocolType=NuGet&`$top=500&`$skip=$skipCount&api-version=7.1"
    $packages += @($page.value | Where-Object { $n = $_.name; @($Prefix | Where-Object { $n -like "$_*" }).Count -gt 0 })
    $skipCount += 500
} while (@($page.value).Count -eq 500)

# 2. Download metrics for those packages, 100 per call.
$metrics = @{}
for ($i = 0; $i -lt $packages.Count; $i += 100) {
    $ids  = @($packages[$i..([Math]::Min($i + 99, $packages.Count - 1))].id)
    $body = @{ packageIds = $ids } | ConvertTo-Json
    $res  = Invoke-RestMethod -Method Post -Headers $headers -ContentType 'application/json' -Body $body -Uri "$base/packagemetricsbatch?api-version=7.1"
    foreach ($m in @(if ($res.value) { $res.value } else { $res })) { $metrics[$m.packageId] = $m }   # the list may come wrapped in 'value'
}

# 3. Least recently downloaded first; packages never downloaded have no date and come first.
$packages | ForEach-Object {
    $m = $metrics[$_.id]
    [pscustomobject]@{
        Package        = $_.name
        Latest         = @($_.versions)[0].version
        Downloads      = if ($m) { [int]$m.downloadCount } else { 0 }
        LastDownloaded = if ($m -and $m.lastDownloaded) { ([datetimeoffset]$m.lastDownloaded).UtcDateTime.ToString('yyyy-MM-dd') } else { 'never' }
    }
} | Sort-Object @{ e = { if ($_.LastDownloaded -eq 'never') { '0000' } else { $_.LastDownloaded } } }, Package
# save it as C:\Scripts\Get-FeedDownloads.ps1; the token is read from AZDO_PAT
$env:AZDO_PAT = Read-Host 'Azure DevOps token'
C:\Scripts\Get-FeedDownloads.ps1 -Organization contoso -Project Tools -Feed internal -Prefix 'Contoso.'
Package        Latest Downloads LastDownloaded
-------        ------ --------- --------------
Contoso.Legacy 1.0.4          0 never
Contoso.Old    1.2.0          4 2025-03-02
Contoso.Core   2.1.0        812 2026-11-12
  • The token needs the Packaging scope. Listing packages needs read access, but the documented scope of the metrics call is Packaging read & write, so create a short-lived token for this and delete it afterwards.
  • Leave out -Project for an organization-scoped feed.
  • The figures cover all versions of a package together, and a machine that already has a version in its local NuGet cache does not download it again. An old date is a strong hint, not proof that nobody uses the package.

The REST calls were tested on Windows PowerShell 5.1 against a stand-in for the API (no real organization): two pages of packages, the batch call for the metrics, a feed with one package, project and organization feeds, and packages that were never downloaded. The routes, parameters and fields are the ones in the Azure DevOps REST API reference, version 7.1.

Step 3: retire a package safely

A package that is in neither list, nor downloaded for months, is ready to go. Do it in two steps so that a consumer you missed can still build:

  1. Remove it from source. Archive its repository or delete the project, and stop its pipeline, so no new versions are published.
  2. Unlist it in the feed. In Azure Artifacts, open the feed, select the package and choose Unlist. Unlisting hides it from search; the package stays in the feed. nuget.exe delete against an Azure Artifacts feed also only unlists:
    nuget.exe delete Contoso.Legacy 1.0.4 -Source https://pkgs.dev.azure.com/contoso/Tools/_packaging/internal/nuget/v3/index.json -ApiKey az
  3. Delete it later. Deleting makes the package unavailable for installation and moves it to the feed's Recycle Bin, where a feed owner can restore it for 30 days. Package versions in Azure Artifacts are immutable, so a deleted version number can never be reused.

Common problems

  • A package shows as unused, but an app restores it. The app is not under the folders you scanned (another team's repository, an old branch, a script that runs nuget install). The feed's last download date is the check for this.
  • A package shows as used, but only by its tests. The script counts test projects as users. If a package is referenced only by its own test project, it is unused in practice; search the results by project name.
  • The id is built from a property, such as $(IdPrefix).Core. Only MSBuild can work that out. The script ignores such values and falls back to the assembly or project name.
  • Projects that pack without saying so. SDK-style projects can be packed by dotnet pack even without any of the properties above. If your pipelines pack such projects, add a PackageId to them, or they will not be listed.

Where doing it by hand stops

  • Only what you cloned. The scan is as complete as the folders you point it at; keeping every repository cloned and pulled is part of the job.
  • Text, not MSBuild. Conditions, imports and properties are not evaluated, so a reference that only applies in one configuration counts for all of them.
  • One question at a time. The script answers "what is unused"; who uses an old version, what the build order is, and what a change to one package touches are separate scripts.
  • A snapshot. It is right on the day you run it, and someone has to run it again.

Questions

How do I find NuGet packages that nobody uses?

List the packages your projects produce (PackageId, GeneratePackageOnBuild or IsPackable true), then search every project file, shared .props and .targets file, packages.config and dotnet-tools.json under your clone folders for references to them. The ones with no reference left, or referenced only by other unused packages, are the candidates. The PowerShell script in this guide does both steps.

Is there a dotnet command that lists unused packages?

No. dotnet nuget why and dotnet package list start from a project and show what it uses. Nothing in the .NET SDK starts from a package and tells you that no project in any repository uses it, so you compare the two lists yourself.

Can Azure Artifacts tell me when a package was last downloaded?

Yes. The Query Package Metrics REST call (packagemetricsbatch, api-version 7.1) returns downloadCount, downloadUniqueUsers and lastDownloaded per package. A machine that already has the version in its local NuGet cache does not download it again, so an old date is a hint, not proof.

What is the difference between unlisting and deleting a package in Azure Artifacts?

Unlisting hides the package version from search; it stays in the feed. Deleting makes it unavailable for installation and moves it to the Recycle Bin, where a feed owner can restore it for 30 days. nuget.exe delete against an Azure Artifacts feed only unlists. A deleted version number can never be reused.

Does Switchyard find unused packages?

Yes, for the projects under the folders you scan: its Unused tab, and switchyard-cli unused, list the packages nothing references and the packages only used by unused ones. It reads PackageReference entries in project files and skips test projects by default. The free edition scans up to 10 projects; see the Switchyard page.

Related

Sources (Microsoft Learn): dotnet nuget why, NuGet pack and restore as MSBuild targets, packages.config reference, PackageDownload, .NET local tools, Azure Artifacts: Get Packages, Azure Artifacts: Query Package Metrics, Personal access tokens, Delete and recover packages, nuget delete, NuGet global packages folder.

An unhandled error has occurred. 🗙