PackageDownload: NuGet’s Forgotten Power Tool
NuGet has been the backbone of .NET dependency management for over a decade. It’s mature. It’s reliable. It mostly works.
And then there’s PackageDownload — a feature introduced in 2018 that solves a legitimate problem, but in a way that makes you wonder whether anyone thought about how it would integrate with the rest of the ecosystem.
PackageDownload lets you download NuGet packages to your build environment without adding assembly references. That’s useful. It’s not glamorous, but it fills a gap. The problem is how it does it: with mandatory version range syntax, zero integration with Central Package Management, and documentation that assumes you already know what you’re doing.
This article isn’t about celebrating NuGet. It’s about understanding PackageDownload — what it does well, where it fails, and why those failures matter.
What PackageDownload Actually Does
When you add a package reference with <PackageReference>, NuGet does two things simultaneously: it downloads the package to your local cache and adds its assemblies to your project’s compilation and runtime dependencies. That’s fine for libraries, frameworks, and application dependencies. But what if you need the package contents during the build process without those assemblies polluting your dependency graph?
That’s where PackageDownload comes in.
The Basic Syntax
PackageDownload is defined in your .csproj file:
<ItemGroup>
<PackageDownload Include="Newtonsoft.Json" Version="[13.0.1]" />
</ItemGroup>
Unlike <PackageReference>, this downloads the package but does not reference its assemblies. The package sits in your NuGet cache, available for MSBuild tasks or custom build logic, but it doesn’t touch your dependency tree.
Simple enough. Until you hit the version requirement.
Why You’d Use This
PackageDownload isn’t a mainstream feature. Most developers will never need it. But when you do, it’s the only clean option.
1. Build-Time Tools and Analyzers
Some packages contain Roslyn analyzers or code generators that run during compilation. You need the package on disk for MSBuild to find it, but you don’t want it as a runtime dependency.
<PackageDownload Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="[7.0.0]" />
The analyzer runs during the build. It doesn’t ship with your application.
2. Non-Code Assets
If you’re distributing build scripts, configuration files, or schemas via NuGet, PackageDownload lets you pull them down without dragging in unnecessary assemblies.
<PackageDownload Include="CompanyBuildTools" Version="[2.3.0]" />
3. Avoiding Transitive Dependency Conflicts
In complex solutions, pulling in a package for its metadata or documentation can trigger unwanted transitive dependencies. PackageDownload sidesteps that entirely.
<PackageDownload Include="XmlSchemas.Library" Version="[2.1.0]" />
4. Version Pinning for Build Reproducibility
When you need an exact package version available during the build — not approximately, not “compatible with,” but exactly that version — PackageDownload enforces it.
How It Works
When MSBuild encounters a <PackageDownload> element, NuGet resolves the specified version and downloads the package to the global cache — typically %USERPROFILE%\.nuget\packages on Windows or ~/.nuget/packages on Linux and macOS. Crucially, no assembly references are added to your project. The package contents sit there, available for custom MSBuild tasks, targets, or extraction logic, but they don’t touch your dependency tree.
That’s straightforward. The frustration starts with the version syntax.
The Version Range Requirement: A Painful Design Choice
Here’s the part that trips up everyone who tries PackageDownload for the first time:
You must specify the version using range notation.
The Hard Requirement
Unlike <PackageReference>, which accepts a simple version like Version="13.0.1", PackageDownload demands version ranges:
<!-- This does NOT work -->
<PackageDownload Include="Newtonsoft.Json" Version="13.0.1" />
<!-- You must use this -->
<PackageDownload Include="Newtonsoft.Json" Version="[13.0.1]" />
The square brackets [13.0.1] mean exactly version 13.0.1. No flexibility. No approximation. That specific version, or the restore fails.
Why This Is a Problem
This requirement creates unnecessary friction in several ways. First, the syntax is unintuitive — developers familiar with <PackageReference> expect the same syntax to work, but it doesn’t. The version range requirement isn’t obvious, and the error messages when you get it wrong are cryptic at best.
Second, and more frustratingly, there’s no integration with Central Package Management. When Microsoft introduced CPM in 2022, it promised to centralize version control across solutions. Define versions once in Directory.Packages.props, reference them everywhere. PackageDownload doesn’t care.
<!-- Directory.Packages.props -->
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
<!-- Project file - this FAILS -->
<ItemGroup>
<PackageDownload Include="Newtonsoft.Json" />
<!-- Still requires: Version="[13.0.1]" -->
</ItemGroup>
You still need to manually specify the version in every <PackageDownload> entry. CPM is ignored completely. This creates manual maintenance overhead — if you’re using PackageDownload across multiple projects, updating a version means editing every single file. There’s no centralized control. It defeats the entire purpose of modern dependency management.
The Missed Opportunity
PackageDownload was introduced in 2018. CPM arrived in 2022. As of 2025, they still don’t work together. This isn’t an oversight — it’s a conscious decision not to invest in making older features compatible with newer workflows. And it shows.
The result is a bifurcated system where you use CPM for <PackageReference> (modern, clean, centralized) but inline versions for <PackageDownload> (legacy, manual, error-prone). It’s frustrating because it didn’t have to be this way.
Real-World Scenarios
Despite the rough edges, PackageDownload has legitimate use cases.
Roslyn Analyzers in Multi-Project Solutions
If you’re using StyleCop or custom analyzers that should run during the build but not ship with your application:
<ItemGroup>
<PackageDownload Include="StyleCop.Analyzers" Version="[1.2.0-beta.435]" />
</ItemGroup>
The analyzer is downloaded, applied during compilation, and ignored at runtime.
Extracting Package Contents
Custom MSBuild tasks can extract specific files from downloaded packages:
<ItemGroup>
<PackageDownload Include="CompanyAssets" Version="[2.5.0]" />
</ItemGroup>
<Target Name="ExtractAssets" AfterTargets="Restore">
<Copy SourceFiles="$(NuGetPackageRoot)companyassets\2.5.0\content\config.json"
DestinationFolder="$(OutputPath)" />
</Target>
This turns NuGet into a distribution mechanism for non-code assets.
Build Tools with Exact Versions
For reproducible builds, you might need specific tool versions:
<ItemGroup>
<PackageDownload Include="GitVersion.Tool" Version="[5.12.0]" />
</ItemGroup>
PackageDownload guarantees that exact version is available, no matter what.
The Broader Pattern: Incomplete Evolution
PackageDownload is emblematic of how mature platforms evolve — slowly, incrementally, and often without full integration.
Consider the timeline:
- 2010: NuGet 1.0 launches
- 2018: PackageDownload is introduced in NuGet 4.8
- 2022: Central Package Management arrives
- 2025: PackageDownload still doesn’t integrate with CPM
This reveals a fundamental challenge: maintaining backward compatibility while adding new capabilities. Every feature must coexist with a decade of existing workflows. Sometimes that means compromise. Other times it means neglect.
What Should Have Happened
PackageDownload should have been updated when CPM launched. At minimum, it should respect CPM versions, allowing PackageDownload to read from Directory.Packages.props and falling back to inline versions only when necessary. The version syntax should have been simplified to support both simple versions and ranges, with clear guidance on when each applies. Visual Studio and the CLI should provide first-class support for managing PackageDownload entries, and the official docs should explain the version requirement prominently, not bury it in footnotes.
None of that happened. PackageDownload works. But it doesn’t integrate.
Practical Guidelines
If you’re using PackageDownload, here’s how to avoid the pain points.
When to Use It
PackageDownload makes sense for build-time tools or analyzers that shouldn’t be runtime dependencies, for non-code assets distributed via NuGet, for custom MSBuild tasks requiring specific package versions, and in scenarios where transitive dependencies would create conflicts. These are real use cases where PackageDownload genuinely solves problems.
When to Avoid It
Don’t use PackageDownload if you need the package’s assemblies — that’s what <PackageReference> is for. Don’t expect CPM integration because it doesn’t exist. And be aware that automatic version updates via Dependabot get complicated when you’re using version ranges.
Best Practices
Document your intent by adding comments explaining why you’re using PackageDownload instead of PackageReference. It saves confusion later. Since CPM doesn’t work, centralize versions manually using MSBuild properties:
<!-- Using PackageDownload to avoid runtime dependency on StyleCop -->
<PackageDownload Include="StyleCop.Analyzers" Version="[1.2.0]" />
This approach at least keeps versions in one place, even if it’s not as elegant as CPM. And always test in clean environments — PackageDownload failures often appear only during initial restore, not in your local development setup where everything’s already cached.
Final Thoughts: A Tool That Works, With Caveats
PackageDownload solves a real problem. It enables scenarios that would otherwise require awkward workarounds or custom scripting. For teams managing complex build pipelines, it’s indispensable.
But its limitations aren’t minor inconveniences. The version range requirement is unintuitive. The lack of CPM integration is inexcusable. And the documentation assumes you already know what you’re doing.
This is what happens when platforms evolve without a coherent strategy. Features get added. They solve problems. But they don’t integrate. They coexist, awkwardly, creating friction for developers who just want things to work.
PackageDownload is powerful. It’s also a reminder that mature ecosystems carry baggage. Sometimes that baggage is worth the trade-off. Other times, it’s just frustrating.
Know when you need it. Understand its limitations. And hope that someday, Microsoft decides to make it work with the rest of the tooling.
Until then, it’s another tool in your arsenal — useful, imperfect, and occasionally infuriating.
