What Is a NuGet Package? .nupkg Format and the NuGet Registry Explained
If you've written C# for any length of time, you've installed a nuget package. You typed a name into Visual Studio, clicked install, and it just worked. But if you've ever wondered what actually happened behind the scenes -- or you're getting ready to create your own packages -- understanding the mechanics pays off.
A nuget package is not magic. It's a ZIP file with a specific internal structure, fetched from a well-defined registry, and managed by tooling that knows how to resolve, restore, and reference it during a build. Once you see the internals, the whole system becomes much less of a black box. That clarity is useful when things go wrong -- restore failures, version conflicts, dependency resolution surprises -- and it's essential groundwork before you start distributing your own code as a reusable library.
This article walks through exactly what a nuget package is, what lives inside a .nupkg file, how NuGet.org operates as a registry, and how dotnet restore actually works under the hood.
What Is a NuGet Package?
A nuget package is a distributable unit of .NET code and metadata. Think of it as a standardized container format for libraries, tools, and build extensions that .NET projects can consume.
Every nuget package has three core properties that define its identity:
- Package ID -- a unique name like
Newtonsoft.JsonorMicrosoft.Extensions.DependencyInjection - Version -- following semantic versioning (e.g.,
13.0.3) - Target frameworks -- the .NET versions the package supports (e.g.,
net6.0,net8.0,netstandard2.0)
These three things together tell the .NET toolchain exactly which binaries to reference and whether they're compatible with your project. When you write <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> in your .csproj, you're giving the toolchain everything it needs to find and use the right package.
This is fundamentally different from a raw .dll reference, and we'll come back to why that distinction matters later.
Inside a .nupkg File
Here's something worth knowing: a .nupkg file is literally a ZIP archive. You can rename any .nupkg to .zip and open it directly in Windows Explorer or any archive tool. What you'll find inside follows a predictable structure.
Here's what the internals of a typical nuget package look like:
Newtonsoft.Json.13.0.3.nupkg
├── [Content_Types].xml
├── _rels/
│ └── .rels
├── Newtonsoft.Json.nuspec
├── lib/
│ ├── net20/
│ │ └── Newtonsoft.Json.dll
│ ├── net45/
│ │ └── Newtonsoft.Json.dll
│ ├── netstandard2.0/
│ │ └── Newtonsoft.Json.dll
│ └── net6.0/
│ └── Newtonsoft.Json.dll
└── build/
└── (optional .props/.targets files)
Let's look at each piece.
[Content_Types].xml and _rels/ are boilerplate from the Open Packaging Conventions (OPC) format that .nupkg inherits. You won't interact with them directly.
.nuspec is the heart of the nuget package. It's an XML manifest file that describes the package -- its ID, version, authors, description, license, target frameworks, and dependencies. Every package has exactly one .nuspec. Here's a simplified example:
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>MyLibrary</id>
<version>1.0.0</version>
<authors>Nick Cosentino</authors>
<description>A reusable utility library for .NET projects.</description>
<dependencies>
<group targetFramework="net8.0">
<dependency id="Microsoft.Extensions.Logging.Abstractions" version="8.0.0" />
</group>
</dependencies>
</metadata>
</package>
lib/ is where the compiled assemblies live. The subdirectory names are target framework monikers (TFMs) -- net6.0, netstandard2.0, net8.0, and so on. When NuGet installs a package, it picks the most compatible lib/ folder for your project's target framework. This is how a single nuget package can simultaneously support .NET 6, .NET 8, and .NET Standard projects.
build/ contains optional .props and .targets files that hook into the MSBuild pipeline. Packages that provide build-time functionality -- source generators, code analyzers, build tools -- use this folder to inject MSBuild logic into consuming projects.
content/ is a legacy folder for copying files into a consuming project. It's rarely used in modern packages -- the contentFiles/ folder replaced it for SDK-style projects.
analyzers/ is where Roslyn analyzers and source generators live. If you've ever installed a nuget package that gave you new compiler warnings or generated code at build time, that logic came from this folder. You can read more about source generators specifically in C# Reflection vs Source Generators in .NET 10: Which Should You Choose?.
The NuGet Registry and NuGet.org
A nuget package doesn't do anything useful sitting on a local disk by itself. It needs a registry -- a service that hosts packages and lets tooling find and download them.
NuGet.org is the default public registry for the .NET ecosystem. It's the same registry Visual Studio and the dotnet CLI query out of the box. When you run dotnet add package Serilog, the CLI queries NuGet.org for the Serilog package, finds the latest compatible version, and downloads it.
Packages on NuGet.org are organized by ID and version. Every package has a canonical URL following this pattern:
https://www.nuget.org/packages/{PackageId}/{Version}
For example, Serilog 4.0.2 lives at https://www.nuget.org/packages/Serilog/4.0.2. You can browse the metadata, view the package contents, read release notes, and inspect the dependency graph directly from that URL.
NuGet.org is not the only option. Teams often set up private registries for internal packages -- Azure Artifacts and GitHub Packages are common choices. This is useful when you're distributing proprietary code that shouldn't be public, or when you want a curated set of approved package versions for your organization.
You configure which registries NuGet uses through a nuget.config file. Here's an example that configures both the public feed and a private Azure Artifacts feed:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org"
value="https://api.nuget.org/v3/index.json"
protocolVersion="3" />
<add key="company-feed"
value="https://pkgs.dev.azure.com/myorg/_packaging/mycompany/nuget/v3/index.json"
protocolVersion="3" />
</packageSources>
<packageSourceCredentials>
<company-feed>
<add key="Username" value="myusername" />
<!-- In practice, use environment variables or a credential provider (e.g. Azure Artifacts Credential Provider) instead of hardcoding tokens -->
<add key="ClearTextPassword" value="mytoken" />
</company-feed>
</packageSourceCredentials>
</configuration>
The nuget.config file can live at the solution root, the project folder, or globally in %APPDATA%NuGet. NuGet searches from the innermost location outward and merges configurations, so project-level settings override user-level defaults.
How Package Restore Works in .NET
Package restore is the process of downloading and preparing all the packages a project needs. It runs automatically when you build -- but understanding what it actually does helps you debug restore failures and reason about CI/CD builds.
When you run dotnet restore (or build a project that triggers restore automatically), here's the sequence:
- The .NET SDK reads the
<PackageReference>entries in your.csprojfiles - It queries the configured package sources for the specified package IDs and versions
- It downloads the
.nupkgfiles to a local cache -- typically%USERPROFILE%.nugetpackageson Windows - It resolves the full dependency graph (including transitive dependencies)
- It writes a
project.assets.jsonfile to theobj/folder
That last step is important. project.assets.json is the lock file that records exactly which packages were resolved, which versions were chosen, and where on disk the assemblies live. The build step reads this file -- not the network -- to determine what to reference and compile against. This is why builds are fast after the first restore; everything is already cached locally.
Here's what a typical PackageReference block looks like in an SDK-style .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- Direct nuget package dependencies -->
<PackageReference Include="Serilog" Version="4.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
</Project>
PackageReference is the modern way to declare nuget package dependencies. It replaced the older packages.config file format. One key difference: PackageReference is evaluated at build time by MSBuild, while packages.config was evaluated separately by the NuGet client. SDK-style projects with PackageReference are also more portable because the restore metadata flows into project.assets.json rather than a separate file in the solution structure.
NuGet Packages vs Direct Assembly References
Before NuGet became the standard, the common approach was adding a direct .dll reference to a project. You'd right-click the project in Visual Studio, click "Add Reference," browse to a folder, and pick a .dll file.
That approach came with significant friction. You had to manually track which version of the .dll you were using. Teammates needed the same .dll on the same path, or you'd end up checking binaries into source control -- a practice most teams rightly avoid. Updating a library meant manually replacing the file and hoping nothing broke.
A nuget package solves all of that. The package ID and version are captured in the .csproj. The actual binaries are fetched from a registry and cached locally. Version history is tracked by the registry. Metadata like target framework compatibility, license information, and transitive dependencies is embedded in the package itself.
There's still a place for direct .dll references in specific scenarios -- internal build systems, proprietary SDKs that aren't distributed as packages, or development-time situations where you're consuming a locally built assembly. But for anything distributed beyond a single repository -- shared libraries, open-source packages, or internal packages consumed across multiple repos -- a nuget package is the right choice. In a monorepo, project references (<ProjectReference>) are usually preferable.
This is exactly why popular infrastructure libraries like Autofac (a dependency injection container) and Scrutor (an assembly scanning utility) are distributed as packages. Those packages carry their own metadata, their own dependency declarations, and work seamlessly with the restore pipeline. You can see how those packages fit into real project setups in Dependency Injection: How to Start with Autofac the Easy Way and Scrutor in C# - 3 Simple Tips to Level Up Dependency Injection.
Transitive Dependencies
One of the most important capabilities of a nuget package system is transitive dependency resolution. When you add a package to your project, you're not just getting that package's code -- you're getting its dependencies, and their dependencies, recursively.
Here's a simple example. Suppose you add Serilog.Sinks.Elasticsearch to your project. That package depends on Serilog and Elasticsearch.Net. NuGet resolves the full dependency graph and ensures all required packages are downloaded and available, even ones you never explicitly listed.
This graph resolution happens during dotnet restore, and the full result is recorded in project.assets.json. The MSBuild SDK then uses this to set up the correct compile-time and runtime references.
Transitive dependencies can get complicated quickly. Two packages might both depend on the same library but request different versions. NuGet uses a "nearest wins" strategy by default -- it selects the version closest to the root project in the dependency graph. In practice this usually works well, but version conflicts can surface as runtime errors when assemblies load and find unexpected versions.
You can inspect dependency chains with the dotnet nuget why command (requires .NET 8 SDK or later -- run dotnet --version to check):
# Show why a specific package is included in your project
dotnet nuget why MyProject.csproj Newtonsoft.Json
Understanding transitive dependencies is particularly important when building systems that load code dynamically. When plugins are distributed as nuget packages, each plugin can carry its own dependency tree, which the runtime then needs to reconcile. The Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications covers how this works in depth, and Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection digs into the assembly isolation mechanics that make it possible. The How Dependency Injection Containers Use Reflection Internally in C# article also shows how package-delivered infrastructure wires into the .NET DI system at runtime.
Wrapping Up
A nuget package is a ZIP archive with a well-defined internal structure -- a .nuspec manifest, compiled assemblies organized by target framework in lib/, optional MSBuild artifacts in build/, and Roslyn tooling in analyzers/. NuGet.org is the default public registry that hosts and serves these packages. The dotnet restore command resolves the full dependency graph and writes the result to project.assets.json, which the build pipeline reads for compilation references.
Understanding this machinery makes you a more effective .NET developer. You know why restore fails when a package source is unreachable. You understand what's happening when a transitive dependency conflict surfaces a build warning. And you have the foundation to go further -- creating your own packages, publishing to private feeds, and configuring custom sources.
If you're ready to take the next step and build a nuget package yourself, the Complete Guide to Creating NuGet Packages in .NET picks up exactly where this article leaves off.
Frequently Asked Questions
What is a NuGet package in simple terms?
A nuget package is a compressed archive file (.nupkg extension) that contains compiled .NET assemblies, metadata, and optional build instructions. It's how .NET developers distribute and consume reusable code. When you add a package to your project, the NuGet tooling downloads this archive, extracts the assemblies compatible with your target framework, and wires them into your build.
What is inside a .nupkg file?
A .nupkg file is a ZIP archive. Inside you'll find a .nuspec XML manifest describing the package identity and dependencies, a lib/ folder containing compiled assemblies organized by target framework, and optionally a build/ folder with MSBuild .props and .targets files, an analyzers/ folder for Roslyn analyzers and source generators, and a contentFiles/ folder for files to be copied into consuming projects.
What is NuGet.org and how does it work?
NuGet.org is the public package registry for the .NET ecosystem. It hosts hundreds of thousands of nuget packages and provides a search and download API that Visual Studio and the dotnet CLI use by default. Every package has a URL at https://www.nuget.org/packages/{PackageId}/{Version}. The registry is free to publish to and free to consume from for open-source and commercial projects alike.
What is the difference between PackageReference and packages.config?
PackageReference is the modern SDK-style way to declare nuget package dependencies, placed directly inside a .csproj file. It replaced the older packages.config approach where dependencies were tracked in a separate XML file. With PackageReference, restore metadata flows into project.assets.json in the obj/ folder, making projects more portable and enabling transitive dependency flattening. All new SDK-style .NET projects use PackageReference by default.
How does dotnet restore work?
dotnet restore reads your project's PackageReference entries, queries the configured package sources, downloads the required .nupkg files to the local cache at %USERPROFILE%.nugetpackages on Windows, resolves the complete transitive dependency graph, and writes the result to project.assets.json. Subsequent builds read from this local cache, so no network access is needed until the cache is invalidated or a new package version is requested.
What are transitive dependencies in NuGet?
Transitive dependencies are the packages that your direct dependencies themselves depend on. When you add a nuget package to your project, NuGet also resolves and downloads all of that package's dependencies recursively. The full resolved graph is recorded in project.assets.json. You can inspect the dependency chain for any package using dotnet nuget why MyProject.csproj PackageName.
Can I use a custom NuGet feed instead of NuGet.org?
Yes. Private nuget feeds let you host packages internally without making them public. Azure Artifacts, GitHub Packages, and JFrog Artifactory are popular choices for private feeds. You configure custom sources in a nuget.config file by adding entries to the <packageSources> section. Multiple sources can be active simultaneously, and NuGet searches all of them when resolving packages.

