NuGet Versioning and Semantic Versioning (SemVer) in .NET
Getting nuget versioning wrong has real consequences. Consumers of your library will hit surprise build errors on upgrade. Their transitive dependency trees will fall apart. And once you publish a version to nuget.org, you cannot take it back. Understanding semver dotnet conventions before you publish your first package saves a lot of pain down the road.
This guide covers everything a .NET library author needs to know about NuGet versioning -- from the three-part SemVer 2.0 scheme through setting version properties in your .csproj, to what those version numbers actually mean for binary compatibility at the runtime level.
SemVer 2.0: The Three Parts Explained
Semantic Versioning 2.0 (SemVer 2.0) is the versioning standard at the heart of NuGet. NuGet has supported SemVer 2.0 fully since NuGet 4.3 / Visual Studio 2017 version 15.3. Every NuGet package version follows this format:
MAJOR.MINOR.PATCH
Each segment carries a specific promise to consumers:
- MAJOR -- The public API has changed in a breaking way. Consumers must do work when upgrading.
- MINOR -- New features were added in a backward-compatible way. Consumers can upgrade safely.
- PATCH -- Backward-compatible bug fixes only. Safe to upgrade with no code changes required.
The spec is published at semver.org. It is short. Worth reading in full. The key insight is that version numbers are a communication contract between you and every developer who depends on your package.
A version like 3.7.2 tells consumers: "This is the third major generation of this library, seventh feature set, second patch release." They know what to expect upgrading from 3.6.0 to 3.7.2 -- new features, no breakage. Upgrading from 2.0.0 to 3.0.0 signals: "Read the release notes first."
When to Bump Each Version Segment
The decision of which segment to bump is where teams consistently get into trouble. Here is a concrete decision guide.
Bump MAJOR when:
- You remove or rename a public type, method, property, or interface member
- You change a method signature (parameters, return type, or exceptions thrown)
- You change the visibility of a public member (public → internal)
- You change semantics significantly enough to break existing callers even without a signature change
Bump MINOR when:
- You add new public types, methods, or interface members (to non-sealed classes)
- You add new optional parameters with defaults
- You add new features that existing callers do not need to use
Bump PATCH when:
- You fix a bug in existing behavior
- You improve performance without changing observable behavior
- You update internal implementation details with no API impact
A common mistake is treating PATCH as "everything small" and MAJOR as "big rewrites." The actual threshold is simpler -- did the public contract change in a way that breaks existing callers? If yes, MAJOR. Full stop.
This is directly related to how you think about public API contracts in plugin-based systems -- every interface your library exposes is a commitment to your consumers. The Open Closed Principle is a great mental model here: adding behavior without modifying existing contracts is exactly what a MINOR version bump represents.
Pre-Release Versions in NuGet
Before you ship a stable release, you will almost certainly want to publish pre-release versions for testing and feedback. NuGet supports pre-release identifiers as a hyphen-separated suffix:
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1
NuGet treats any version with a pre-release suffix as unstable. Package consumers won't see pre-release versions unless they explicitly opt in -- either by checking "Include prerelease" in the NuGet package manager UI, or by using --include-prerelease on the CLI.
Pre-release identifiers must be alphanumeric plus hyphens, and can be dot-separated for multiple parts. Common conventions in .NET:
| Suffix | Meaning |
|---|---|
-alpha.1 |
Early internal/external testing, API may still change |
-beta.1 |
Feature complete, external testing, API mostly stable |
-rc.1 |
Release candidate, should be production-ready |
-preview.1 |
Microsoft's preferred prefix for preview SDK builds |
NuGet sorts pre-release versions alphabetically within the same base version, so alpha < beta < rc in alphabetical order -- which conveniently matches the stability progression. Numeric parts like .1, .2 sort numerically, so 1.0.0-alpha.2 is correctly higher than 1.0.0-alpha.1.
Setting Versions in Your .csproj
Modern .NET projects set version information directly in the .csproj file using MSBuild properties. The most common approach uses the single <Version> property:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Sets NuGet version, AssemblyVersion, FileVersion, and InformationalVersion -->
<Version>2.1.0</Version>
<!-- Recommended: keep AssemblyVersion stable across MINOR/PATCH releases -->
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<!-- Optional: human-readable informational version with build metadata -->
<InformationalVersion>2.1.0+build.42</InformationalVersion>
</PropertyGroup>
</Project>
The <Version> property is the master control. When you set it, MSBuild automatically derives AssemblyVersion, FileVersion, and InformationalVersion from it -- unless you override them explicitly. You should almost always override AssemblyVersion explicitly (more on that shortly).
This is the foundation of complete NuGet package creation in .NET -- getting your version properties right in the project file is step one before thinking about metadata, README embedding, or publishing.
VersionPrefix and VersionSuffix for CI Flexibility
The <Version> property works great for local development, but it is a single hardcoded string. CI pipelines often need to inject the pre-release suffix dynamically -- for instance, building a -beta.3 package from a specific branch without changing the committed .csproj. That is what <VersionPrefix> and <VersionSuffix> are for.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Stable base version committed to source control -->
<VersionPrefix>2.1.0</VersionPrefix>
<!-- Empty for stable releases; override from CI for pre-release builds -->
<!-- Result: "2.1.0-beta.3" when VersionSuffix = "beta.3" -->
<!-- Result: "2.1.0" when VersionSuffix is empty or unset -->
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
Then in your CI pipeline, pass the suffix on the command line:
# Produces package version 2.1.0-beta.3
dotnet pack --configuration Release /p:VersionSuffix=beta.3
# Produces stable package version 2.1.0 (no suffix)
dotnet pack --configuration Release
When both are set, NuGet combines them as {VersionPrefix}-{VersionSuffix}. When VersionSuffix is empty or unset, the result is just {VersionPrefix} -- your stable release version.
Important: If
<Version>is also set in your.csproj, it takes precedence over<VersionPrefix>and<VersionSuffix>-- the suffix injected via/p:VersionSuffix=...will be silently ignored. This is a common footgun in CI pipelines where a global<Version>property exists alongside the prefix/suffix approach. Use one approach or the other, not both.
This split is the foundation for automated release workflows. You commit the base version to source control, and your pipeline injects the environment-specific suffix at build time. If you want to take this further and fully automate the publish workflow, automating NuGet publishing with GitHub Actions walks through exactly that.
If you want to eliminate manual version bumps entirely, the MinVer package (github.com/adamralph/minver) derives the NuGet version automatically from Git tags using SemVer. Instead of setting <VersionPrefix> in your csproj, you tag v2.1.0 in Git and MinVer sets the version at build time -- including pre-release suffixes from commits after the last tag. It is widely used in the .NET open-source ecosystem and pairs well with the GitHub Actions publish workflow.
AssemblyVersion vs FileVersion vs InformationalVersion
This is the part that trips up the most library authors. There are three version attributes embedded in a .NET assembly, and they serve distinct purposes:
// Typically generated from MSBuild properties, but you can also set these
// directly in AssemblyInfo.cs (less common in SDK-style projects)
[assembly: AssemblyVersion("2.0.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]
[assembly: AssemblyInformationalVersion("2.1.0+git.abc1234")]
Or equivalently in .csproj (the preferred modern approach):
<PropertyGroup>
<!-- .NET runtime uses this for strong-name binding and GAC resolution -->
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<!-- Shows in Windows Explorer "Details" tab; typically matches NuGet version -->
<FileVersion>2.1.0.0</FileVersion>
<!-- Free-form string; can include SemVer pre-release + build metadata -->
<InformationalVersion>2.1.0+git.abc1234</InformationalVersion>
</PropertyGroup>
Here is what each one does in practice:
| Property | Format | Used By | Recommended Pattern |
|---|---|---|---|
AssemblyVersion |
X.Y.Z.W | .NET strong naming, runtime binding | Use MAJOR.0.0.0 -- change only on MAJOR bumps |
FileVersion |
X.Y.Z.W | Windows file properties (Explorer, sigcheck) | Match MAJOR.MINOR.PATCH.BuildNumber |
InformationalVersion |
Any string | Display only, diagnostics | Full SemVer + build metadata |
The AssemblyVersion rule deserves special attention. If you ship MyLib.dll with AssemblyVersion 2.1.0.0 in one package and AssemblyVersion 2.0.0.0 in a prior package, a consumer who has both versions in their dependency graph gets a binding conflict at runtime. Their app throws a FileLoadException unless they add binding redirects in app.config (.NET Framework only). In .NET 5+ applications, binding redirects are not used, but keeping AssemblyVersion stable still prevents runtime load failures from strong-name mismatches in mixed-version dependency graphs.
The safe convention: set AssemblyVersion to MAJOR.0.0.0. Bump it only on MAJOR version changes. That way, all 2.x.x releases of your library are binary-compatible at the .NET runtime level -- not just at the source/API level.
This stability principle applies equally to interface design in C# -- your binary interfaces carry the same weight as your API interfaces in terms of what consumers depend on.
Version Ranges in PackageReference (Consumers)
So far the focus has been on the library author side. Now let's look at the consumer side -- what those version specifications in <PackageReference> actually mean at resolution time.
Most of the time you see this pattern:
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
That 13.0.3 is not an exact version match. It is a minimum version floor: "give me version 13.0.3 or higher, preferring the lowest compatible version." NuGet's default resolution strategy is minimum-version semantics, not "latest matching." This is an important nuance -- it means NuGet won't automatically pull in 13.0.4 if 13.0.3 satisfies all constraints.
<!-- Minimum version (default): >= 1.0.0, prefers lowest compatible version -->
<PackageReference Include="MyLib" Version="1.0.0" />
<!-- Equivalent bracket notation for minimum version -->
<PackageReference Include="MyLib" Version="[1.0.0,)" />
NuGet Version Ranges: Minimum, Exact, and Ranges
NuGet supports a full version range syntax using mathematical interval notation. Square brackets mean inclusive, parentheses mean exclusive:
<!-- Minimum (default): >= 1.0.0 -->
<PackageReference Include="MyLib" Version="1.0.0" />
<!-- Exact match: = 1.0.0 only -->
<PackageReference Include="MyLib" Version="[1.0.0]" />
<!-- Inclusive range: >= 1.0.0 and <= 2.0.0 -->
<PackageReference Include="MyLib" Version="[1.0.0,2.0.0]" />
<!-- Exclusive range: > 1.0.0 and < 2.0.0 -->
<PackageReference Include="MyLib" Version="(1.0.0,2.0.0)" />
<!-- Mixed exclusive upper: >= 1.0.0 and < 2.0.0 (most common range pattern) -->
<PackageReference Include="MyLib" Version="[1.0.0,2.0.0)" />
<!-- Floating: any 1.x.x patch -- avoid in production -->
<PackageReference Include="MyLib" Version="1.0.*" />
A few practical notes worth knowing:
Exact match ([1.0.0]) is useful when you absolutely cannot tolerate a different version -- for instance, when a dependency has a known breaking behavior in later patches. Use it sparingly. It creates diamond dependency problems for anyone downstream who also depends on that package at a different version.
Exclusive upper bound ([1.0.0,2.0.0)) is the most useful range pattern. It says "I work with any 1.x.x version but will not accept the next major." This prevents your users from accidentally pulling in a 2.0.0 that breaks your integration.
Floating versions (1.0.*) are useful in monorepo contexts where packages are versioned together. Avoid them in published packages or production lock files -- they make builds non-reproducible across machines and CI runs.
If you want to debug exactly why NuGet resolved a particular version in your project, run:
dotnet nuget why MyProject.csproj SomePackage
This traces the full dependency resolution chain and tells you which package pulled in which version at which version constraint. (dotnet nuget why requires .NET 8 SDK or later. On earlier SDKs, inspect obj/project.assets.json directly.)
Breaking Changes and Binary Compatibility
Understanding breaking changes is non-negotiable for nuget versioning decisions. There are two distinct kinds to track.
Source-level breaking changes: Code that compiled against the old version no longer compiles against the new version. Renaming a method, changing a parameter type, adding a required parameter -- all source-breaking. These always require a MAJOR bump.
Binary-level breaking changes: An assembly that compiled against the old version of your library throws at runtime when combined with the new version. This is more subtle. You can introduce a binary breaking change even without touching the public API text -- for example, if you increment AssemblyVersion, any assembly compiled against the old version will fail to load the new one without a binding redirect.
The most common gotcha is adding a new member to an interface. This is a source-breaking change for existing implementations -- any class implementing the interface outside your assembly will no longer compile. In modern C# (C# 8+ targeting .NET Core 3.0+, .NET Standard 2.1+, or .NET 5+), you can use default interface members to add methods to interfaces without breaking existing implementors. Note: default interface members are not supported when targeting .NET Framework.
A practical rule of thumb: if you are unsure whether a change is breaking, treat it as breaking and bump MAJOR. Incorrectly using MINOR for a breaking change permanently erodes trust with consumers. They will start pinning exact versions of your library and never upgrade.
The Interface Segregation Principle is directly relevant here -- smaller, focused interfaces are easier to evolve without breaking changes, because you have fewer consumers of each contract. The Dependency Inversion Principle reinforces this: consumers who depend on abstractions rather than concretions are more insulated from breaking changes in implementation details.
Frequently Asked Questions
What is the difference between <Version> and <PackageVersion> in a .csproj?
<Version> sets the version for both the assembly and the NuGet package. <PackageVersion> sets only the NuGet package version and does not affect AssemblyVersion or FileVersion. In most cases, <Version> is the right choice unless you need the package version and assembly version to diverge intentionally -- for example, if you are incrementing AssemblyVersion for strong-naming reasons independently of your NuGet release cycle.
Should I start my first public release at 0.1.0 or 1.0.0?
SemVer specifies that a MAJOR version of 0 (0.x.y) signals "initial development -- the API may change at any time." Many open-source .NET projects use 0.x.y while the API is still unstable, then bump to 1.0.0 when committing to stability. Starting at 1.0.0 carries a stronger promise to consumers, so use it when you are genuinely ready to maintain backward compatibility across MINOR and PATCH releases.
Can I delete or unlist a NuGet package version after publishing?
You cannot delete a version from nuget.org. You can unlist it, which hides it from search results and prevents new direct installs, but it remains downloadable so existing consumers are not broken. This is exactly why correct nuget versioning before publishing matters -- those decisions are permanent once made.
What happens when two dependencies require different versions of the same package?
NuGet uses minimum-version resolution. If Package A requires MyLib >= 1.0.0 and Package B requires MyLib >= 1.2.0, NuGet selects 1.2.0 -- the higher minimum that satisfies both. If Package B requires exactly [1.0.0], NuGet cannot satisfy both and reports a conflict. This is why exact version pins in published packages are strongly discouraged for library authors.
How do I check what version of a package NuGet actually resolved?
Run dotnet nuget why <YourProject.csproj> <PackageId> to trace the dependency resolution chain. You can also inspect the project.assets.json file in your obj/ folder, which contains the complete resolved dependency graph with every version constraint from every participating package.
What are NuGet version metadata suffixes like +build.123?
Build metadata follows the + character in SemVer 2.0: 1.0.0+build.123. NuGet records it in the informational version but ignores it for version comparison and package resolution. Two packages 1.0.0+build.1 and 1.0.0+build.2 are treated as identical by NuGet. Use build metadata in InformationalVersion for traceability and diagnostics, not in the NuGet package version string itself where it would be meaningless for resolution.
When should I use -rc vs -beta pre-release suffixes for semver dotnet packages?
Convention matters more than spec here. The common .NET pattern: -alpha for early unstable builds where the API may still change significantly, -beta for feature-complete builds under external testing where the API is mostly stable, -rc for release candidates that should be production-ready pending final sign-off. NuGet sorts these alphabetically, so alpha < beta < rc -- which conveniently matches the intended stability progression. Pick a convention and stick to it consistently across releases.
Wrapping Up
NuGet versioning is a communication contract with every developer who depends on your library. Semver dotnet practices exist to make those contracts predictable and trustworthy. The core rules are simple: bump MAJOR for breaking changes, MINOR for new backward-compatible features, PATCH for bug fixes. Use <VersionPrefix> and <VersionSuffix> in your .csproj to enable CI-driven version injection. Keep AssemblyVersion stable at MAJOR.0.0.0 across MINOR and PATCH releases to avoid runtime binding conflicts.
The details matter -- version ranges, pre-release conventions, and binary compatibility gotchas are where library authors consistently slip up. But with a solid foundation in how NuGet versioning works, you can make versioning decisions confidently and ship libraries that consumers will trust.
For the complete picture of creating production-ready packages, The Complete Guide to Creating NuGet Packages in .NET is the natural next step.

