AlgoMaster Logo

NuGet Packages

Medium Priority25 min readUpdated June 6, 2026

NuGet is the official package manager for .NET. It's how a project picks up third-party libraries (JSON serializers, HTTP clients, logging frameworks, database drivers) without copying DLLs into the repo by hand. This lesson covers what a NuGet package actually is on disk, how the dotnet CLI installs and restores packages, how versions and transitive dependencies are resolved, where the packages come from, and how to produce and publish your own.

What NuGet Is

NuGet has three parts that beginners often blur together.

The first part is the package format: a .nupkg file, which is just a ZIP archive containing one or more compiled assemblies, a manifest describing the package (id, version, dependencies, authors, license), and optional content files. Opening one with any unzip tool reveals a folder structure with lib/net8.0/, lib/netstandard2.0/, and a .nuspec XML file at the root.

The second part is the feed: a place that hosts those .nupkg files and serves them over HTTP. The default public feed is nuget.org. Anyone can publish to it at no cost, and it's where almost every popular .NET library lives. Companies also run private feeds (Azure Artifacts, GitHub Packages, MyGet, JFrog Artifactory) for proprietary code that shouldn't be public.

The third part is the tooling: dotnet CLI commands, the NuGet UI inside Visual Studio and Rider, and the underlying NuGet client library that knows how to talk to feeds, download packages, resolve dependencies, and drop the right DLLs into your build output.

You install a package once and from then on your project just references it by id and version. The actual .nupkg file lives in a per-machine cache, and dotnet build wires it into your output folder.

The arrows show the normal install-and-build path. dotnet add package writes an entry in the csproj, fetches the package from a feed if it's not already cached, and the next dotnet build resolves that entry into actual references for the compiler.

Anatomy of a NuGet Package

A .nupkg file is a ZIP archive with a documented layout. The important pieces:

Path inside the .nupkgPurpose
<id>.nuspecXML manifest with id, version, authors, description, dependencies, license
lib/<framework>/Compiled assemblies per target framework (e.g., lib/net8.0/, lib/netstandard2.0/)
ref/<framework>/Reference assemblies (compile-time only) when they differ from the runtime ones
runtimes/<rid>/Native or platform-specific assets (e.g., runtimes/win-x64/native/)
content/ and contentFiles/Files copied into the consuming project (less common in modern packages)
build/ and buildTransitive/MSBuild props and targets the package contributes to the consumer's build
_rels/, [Content_Types].xml, package/OPC archive metadata; the tooling generates this for you

One package can ship assemblies for multiple target frameworks. When your project targets .NET 8, the NuGet client picks the best matching lib/ folder by walking the framework compatibility table. So a package with lib/net6.0/, lib/net8.0/, and lib/netstandard2.0/ will hand .NET 8 consumers the assembly under lib/net8.0/, while a .NET Framework 4.7.2 consumer will get the netstandard2.0 one.

The .nuspec file is the manifest. A trimmed example for a hypothetical package looks like this:

The <dependencies> section is what lets one package pull in others. We'll come back to how those transitive dependencies are resolved.

Installing a Package

The everyday way to add a package to a .NET 8 project is the dotnet CLI:

That command does three things. It contacts the configured feed (nuget.org by default), looks up the latest stable version of Newtonsoft.Json, and writes a <PackageReference> entry into your csproj. It also runs a restore so the package is downloaded into the global cache and made available for the next build.

You can pin a specific version with --version:

If you'd rather see what's available before installing, dotnet package search (added in .NET 9 / NuGet 6.10) queries any configured feed:

Visual Studio and Rider have GUI equivalents. In Visual Studio it's Manage NuGet Packages... on the project's right-click menu, with a search box, a "Browse" tab against nuget.org, and an "Installed" tab listing what the project already has. Rider has a similar NuGet tool window. Both UIs call the same NuGet client and produce the same <PackageReference> entries in the csproj. The CLI is the most portable and the easiest to script in CI, so that's what the rest of this lesson uses.

A simple usage example. Consider an e-commerce project with a Product class that needs to serialize as JSON for an export file. Without a serializer it's pages of by-hand string building. With Newtonsoft.Json installed, it's two lines:

The using Newtonsoft.Json; directive works only because the package was installed first; the namespace is defined in Newtonsoft.Json.dll, which the package brought in.

PackageReference in the csproj

In SDK-style projects (which is every modern .NET project), package references live directly in the csproj as <PackageReference> elements. After dotnet add package Newtonsoft.Json --version 13.0.3, the csproj looks like this:

Each <PackageReference> has at minimum an Include (the package id) and a Version (a version or version range). The file can be edited by hand or managed via dotnet add and dotnet remove. They're equivalent; the file is the source of truth.

A few attributes show up often on <PackageReference>:

AttributePurpose
VersionThe version or version range to use. Required.
IncludeAssetsWhich asset types to consume (compile, runtime, contentFiles, build, analyzers, native, all, none).
ExcludeAssetsWhich asset types to skip.
PrivateAssetsWhich assets do not flow transitively to projects that reference this one. Common pattern: PrivateAssets="all" on analyzer or build-tool packages so they don't infect downstream projects.

The most common use of PrivateAssets is to keep development-time tooling like Microsoft.CodeAnalysis.NetAnalyzers or StyleCop.Analyzers from flowing into consuming libraries:

That package runs in your build for code-style warnings, but a downstream project that references your library doesn't need StyleCop pulled in.

Restore: How Packages Reach Your Build

dotnet add package writes a line to the csproj. It does not, by itself, copy any DLLs into your output folder. The step that turns a <PackageReference> into actual binaries on disk is called restore.

Restore reads the csproj, resolves the dependency graph (more on that shortly), downloads any missing packages into the global cache, and writes a project.assets.json file under obj/ describing exactly which assembly from which package satisfies each reference. dotnet build, dotnet run, and dotnet test all call restore automatically before they compile, so it usually runs implicitly.

It runs on its own with:

This is useful in CI pipelines, where a single restore can be cached and then built without network access. It's also useful when a teammate's commit adds a new package and a build isn't needed yet.

The global package cache lives at ~/.nuget/packages on macOS and Linux, and %userprofile%\.nuget\packages on Windows. Every package version installed on the machine is there. Multiple projects on the same machine share this cache, so adding the same package to a second project is essentially free (no download, just a lookup).

The diagram shows what happens between editing the csproj and seeing a DLL appear in bin/. The csproj's <PackageReference> is the declaration; dotnet restore resolves it against the cache (filling the cache from a feed if needed) and writes project.assets.json; the compiler reads project.assets.json to know which DLLs to reference; and dotnet build copies the runtime assets into bin/.

First restore on a new machine or a clean CI runner downloads every package over the network. Cache the ~/.nuget/packages folder between CI runs and restore drops from minutes to a few seconds. Most CI providers have a built-in option for this.

Versions and Version Ranges

NuGet versions follow Semantic Versioning (semver). A version is MAJOR.MINOR.PATCH, optionally with a pre-release label like 1.2.3-beta.4 or build metadata.

The rules a well-behaved package author follows:

  • MAJOR changes when the package breaks backwards compatibility. Method removed, signature changed, behavior reversed.
  • MINOR changes when new features are added in a backwards-compatible way. New methods, new optional parameters with defaults.
  • PATCH changes for backwards-compatible bug fixes only.

In practice, packages on nuget.org range from disciplined (semver followed strictly) to relaxed (breaking changes show up in minor versions). Reading the changelog before upgrading is always worth it.

When you specify a version in <PackageReference>, you can be exact or use a range:

SyntaxMeaning
13.0.3Minimum version 13.0.3. Restore picks the lowest version >= 13.0.3 that satisfies all constraints. This is the default and what most projects should use.
[13.0.3]Exactly 13.0.3. No other version is acceptable.
[13.0.0,14.0.0)At least 13.0.0, less than 14.0.0. Lets minor and patch updates in, blocks the next major.
(13.0.0,)Strictly greater than 13.0.0. Open upper bound.
13.*Highest 13.x version available. Allows 13.0.5, 13.1.0, etc. Avoid in production unless you understand the implications.
*Any version. Almost never what you want.

The plain 13.0.3 form (no brackets) is by far the most common and what dotnet add package --version 13.0.3 produces. It means "I need at least 13.0.3, restore can use a higher version if some other dependency forces it." This is called a floor version.

Floating versions like 13.* look convenient ("automatically pick up patches") but make builds non-deterministic. Two builds of the same commit on different days can pull different package versions, and a regression in a new patch will silently show up. Most teams prefer pinned floors and explicit upgrades.

Lock Files for Reproducible Restores

Floor versions give you minimum-version guarantees but not exact-version guarantees. If a transitive dependency bumps its requirement, your resolved graph can shift. For builds that need to produce the exact same binaries every time (CI, production releases, security audits), NuGet supports lock files.

Enable lock files with one property in the csproj:

The next dotnet restore writes a file called packages.lock.json next to the csproj. It contains every resolved package id, the exact version chosen, a SHA-512 content hash, and the dependency relationships. Check this file into source control.

A trimmed packages.lock.json for a project with one direct dependency looks like this:

With a lock file present, restore enforces what's in the file. If packages.lock.json says 13.0.3 and a teammate edits the csproj to ask for 13.0.5 without regenerating the lock, restore fails until they update the lock file. In CI, pass --locked-mode to make a missing or stale lock file fail the build instead of silently regenerating:

Lock files are how you get reproducible NuGet restores. They're optional because most projects don't need them, and they add a small amount of friction (you have to remember to regenerate them when you change dependencies). For libraries published to nuget.org they're usually skipped; for applications deployed to production they're often worth it.

Transitive Dependencies

Packages depend on other packages. When you install Microsoft.EntityFrameworkCore.SqlServer, you're not just adding one package; you're adding everything that package depends on, and everything those dependencies depend on. Those indirect references are called transitive dependencies.

NuGet flattens the dependency graph during restore. Every package that ends up in the build, whether direct or transitive, is listed in project.assets.json. From the consumer's perspective, types in transitive packages are visible to your code (you can using their namespaces) just like types in direct dependencies.

A simple example: an e-commerce project adds the JWT package for parsing auth tokens.

That one direct reference pulls in Microsoft.IdentityModel.JsonWebTokens, Microsoft.IdentityModel.Tokens, Microsoft.IdentityModel.Logging, and a handful more, each of which has its own transitive subtree. You can inspect the full list with:

The output groups direct references first and transitive ones after. The flattened graph for the e-commerce auth example looks something like this:

The project has one direct edge (to JWT), but the resolved graph contains five packages because of transitive pulls. Two different paths reach Microsoft.IdentityModel.Tokens. That's normal, and NuGet handles it by picking one version that satisfies both paths.

Version conflict resolution. When two paths through the graph ask for different versions of the same package, NuGet applies nearest-wins with highest-version as a tiebreaker. "Nearest" means the version requested by a direct reference (distance 1 from the root) beats one requested by a transitive (distance 2 or more). If two paths tie on distance, the higher version wins.

Sometimes the resolver picks a version that doesn't satisfy one of the constraints, and you get a warning like NU1605 ("Detected package downgrade"). The fix is usually to add an explicit <PackageReference> to the package in question at a version that satisfies every consumer.

Transitive packages are loaded into the app's process at runtime exactly like direct ones. They count toward startup time, memory usage, and security exposure. A small package can drag in fifteen others; running dotnet list package --include-transitive once on every new direct dependency shows what's actually being included.

Package Sources and nuget.config

dotnet add package queries package sources that the tooling knows about. By default the only source is nuget.org. Most real projects need at least one more (a private feed for company-internal packages, a CI artifact feed, a mirror).

Sources are configured in a nuget.config file. NuGet looks for one starting from the project folder and walking up the directory tree, eventually checking machine-wide and user-wide locations. The closest one wins, so a nuget.config in your repo overrides the system default.

A typical nuget.config at the root of an e-commerce repo:

A few details. <clear /> removes any inherited sources from parent nuget.config files. Without it, the system default still applies alongside the repo's settings, which is rarely the intent. The protocolVersion="3" attribute tells NuGet to use the v3 API (newer, JSON-based) instead of v2 (older, XML-based); nuget.org supports both.

The <packageSourceCredentials> section stores credentials for authenticated feeds. The %MYSHOP_FEED_TOKEN% syntax reads an environment variable, the correct way to handle secrets. Never commit a plaintext password into a nuget.config that goes into source control; use environment variables, the OS credential manager, or dotnet nuget update source --username --password which encrypts on Windows.

Common feeds you'll meet outside nuget.org:

FeedWhat it's for
Azure ArtifactsMicrosoft's hosted feed for organizations. Tight Azure DevOps and GitHub integration.
GitHub PackagesPer-repository feed tied to GitHub auth.
MyGetHosted feeds with fine-grained access control. Free tier for open source.
JFrog ArtifactorySelf-hosted multi-format artifact manager. Common in enterprise environments.
Local folderA directory on disk listed as a source. Useful for testing a package you just built before publishing.

You can list and add sources from the CLI too, which beats hand-editing XML:

The flag names spell out exactly what they do, which is rare for tooling.

Creating Your Own Package

Eventually you'll have code worth packaging. A shared MyShop.Catalog library, an internal HTTP client wrapper, a reusable validation rule set. Producing a NuGet package from a class library is a one-property change plus a CLI command.

Start with a normal class library project:

Open the csproj and add package metadata. The minimum useful set:

A few notes on these properties:

  • PackageId is what consumers will use in dotnet add package. It defaults to the assembly name if omitted, but setting it explicitly is clearer.
  • Version is the version that ends up in the .nupkg filename and the manifest. Bump it before every release.
  • Description and PackageTags show up in nuget.org search results. Take them seriously.
  • RepositoryUrl lets consumers click through to your source. nuget.org displays it on the package page.
  • PackageLicenseExpression uses SPDX identifiers (MIT, Apache-2.0, BSD-3-Clause). Required for nuget.org submissions; without one your package is treated as "unspecified license" and many consumers will refuse to use it.
  • PackageReadmeFile adds a README to the package, displayed on nuget.org. The <None Include="README.md" Pack="true" /> line copies it into the package.

Build the package with:

That writes a MyShop.Catalog.1.0.0.nupkg file into bin/Release/. You can inspect it by unzipping (it's a ZIP) or by looking at the file structure with any archive tool. Inside is your assembly under lib/net8.0/, a .nuspec matching the metadata you set, and the embedded README.

If you want every build to produce a package automatically, add <GeneratePackageOnBuild>true</GeneratePackageOnBuild> to the csproj. Most teams prefer to keep dotnet pack as an explicit step run in CI rather than every developer build.

Publishing a Package

Once you have a .nupkg you trust, push it to a feed with dotnet nuget push:

The --api-key value comes from your nuget.org account (or your private feed's auth system). Treat it like a password; store it as an environment variable or in a secret manager, never check it into source. For an internal Azure Artifacts feed, the equivalent push command points at the feed's index URL and uses a personal access token or a build pipeline credential.

An important rule: nuget.org doesn't allow re-publishing the same version. Once 1.0.0 is up, another 1.0.0 with different contents can't be pushed. Unlisting hides it from search but keeps it available for existing consumers, but the version number is permanently consumed. Always bump the version before pushing a fix.

For private feeds the rules vary. Most allow overwriting versions, which is convenient during development but dangerous in production. Treat the version number as permanent regardless of what the feed allows.

Inspecting and Auditing Packages

A few useful dotnet list package flags:

List all direct dependencies of the current project:

Include transitive dependencies too:

Show packages that have newer versions available:

Show packages with known security vulnerabilities (CVEs reported in the GitHub Advisory Database):

A typical output for --vulnerable against a real project:

Run this command (or its CI-friendly variant --vulnerable --include-transitive) in the build pipeline. Failing the build on a High or Critical advisory catches dependencies that need an upgrade. Newer SDK versions also support dotnet list package --deprecated for packages the author has marked as deprecated.

--vulnerable makes an HTTP call to GitHub's vulnerability database during execution. It's fast, but it does need network access. Cache the result in CI when running it across many projects in the same pipeline.

Quiz

NuGet Packages Quiz

10 quizzes