Upgrading .NET versions without drama
Every two years like clockwork, a new Long-Term Support (LTS) release lands, and a few months later, Short-Term Support (STS) versions ship in between. If you're managing production services, the question isn't whether to upgrade—it's how to do it smoothly. I've seen teams paralyze themselves with major version jumps, and I've watched others cruise through upgrades by following a methodical approach.
The upgrade cadence: LTS and STS
Before diving into the mechanics, let's clarify the release rhythm. .NET 8 (current LTS as of my writing) gets three years of support, and .NET 9 (STS) gets eighteen months. This pattern repeats every two years with a new LTS. The sweet spot for most production systems is: upgrade to each LTS within the first year of its release, and sample STS versions in non-critical paths if they introduce features you need urgently.
Why does this matter? Because a drift of three or four versions behind means accumulated breaking changes, security patches you've missed, and performance improvements you've left on the table. Staying current isn't perfectionism—it's maintenance hygiene.
Read the breaking changes doc before upgrading
I learned this the hard way early in my career. A colleague confidently bumped our project file from .NET 5 to .NET 6, ran a quick build, and declared victory. Ninety minutes later in production, Entity Framework lazy-loading behaved differently, and our reporting module melted. The breaking changes were documented. We just hadn't read them.
Microsoft publishes breaking changes for every release on their official documentation site. Don't skim it. Print it, read it carefully, and cross-reference it against your codebase. Look for:
- API removals and deprecations: Methods marked obsolete in the previous version might be gone now.
- Behavior changes: JSON serialization defaults, nullable reference handling, async method return types—these shift how your code runs without changing its syntax.
- Dependency updates: A new .NET version often bumps libraries like Newtonsoft.Json or System.Text.Json, which can have their own breaking changes.
- Platform-specific issues: If you target both Windows and Linux, some changes only affect one or the other.
I keep a simple checklist in a GitHub issue when planning an upgrade. For the last three platforms I've upgraded (an e-commerce service, a CRM backend, and a trading engine), I dedicated two hours to reading and annotating the breaking changes docs. That investment paid for itself ten times over in avoided production incidents.
Update global.json and Docker base images together
Your local machine, your CI/CD pipeline, and your production containers all need to agree on the .NET version. Mismatches introduce a classic "works on my machine" nightmare. I tackle this atomically:
Step 1: Update global.json
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}
The rollForward setting is crucial. latestFeature means your local SDK can use 8.0.101 or 8.1.0 if 8.0.100 isn't installed, but won't jump to 9.x. This prevents surprises on dev machines while staying flexible for patch updates.
Step 2: Update your Dockerfile
# Old
FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine AS runtime
# New
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
Match the base image tag to your global.json version. If you're using a multi-stage build, update both the SDK and runtime stages. I've also started pinning patch versions (e.g., 8.0.1-alpine instead of 8.0-alpine) to ensure reproducible builds across environments.
Step 3: Update your project files
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
</PropertyGroup>
Bump both TargetFramework and LangVersion together. The language version matters because new C# features often require a newer runtime. Commit these changes as a single pull request. This makes the upgrade intent crystal clear in your git history and keeps CI/CD happy.
Run the full test suite and a performance smoke test
Tests are your upgrade insurance policy. A comprehensive test suite (unit, integration, and end-to-end) will catch most breaking changes immediately. I run three layers:
Layer 1: Compile and run unit tests
This is the speed layer. Your CI pipeline should do this in under five minutes. Most behavioral changes surface here. If your code compiles but tests fail, you've found a breaking change that your linter didn't catch.
Layer 2: Integration and API tests against a disposable database
Spin up a containerized PostgreSQL or SQL Server, seed test data, and run your full API contract tests. This catches Entity Framework quirks, async/await subtleties, and JSON serialization surprises. For my e-commerce platform, this layer took twenty minutes but found a critical issue with how we were handling database connection pooling in .NET 6.
Layer 3: Performance smoke test
This is where I see the most value. Upgrades often bring performance improvements, but sometimes they introduce regressions. I write a lightweight load test that hits your application's critical paths:
public class UpgradePerformanceTest
{
private readonly HttpClient _client = new();
[Fact]
public async Task HighVolumeLookup_CompletesWithinBaseline()
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10000; i++)
{
var response = await _client.GetAsync($"/api/product/{i % 100}");
response.EnsureSuccessStatusCode();
}
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 5000,
$"Expected < 5s, got {sw.ElapsedMilliseconds}ms");
}
}
Run this before and after the upgrade. If throughput drops by more than 5-10%, investigate before shipping to production. I've caught subtle GC pressure increases and thread pool starvation this way.
Small, regular upgrades beat big-bang jumps
The worst approach I've witnessed is skipping from .NET 5 to .NET 8, hoping everything just works. It doesn't. You're stacking breaking changes across three major versions, and when something breaks, you can't pinpoint which version introduced it.
My practice: upgrade annually, usually to the latest LTS. If you're on .NET 6, aim for .NET 8 this year. Next year, .NET 10 will be LTS, and you'll upgrade then. This creates a predictable rhythm where each jump is small enough to reason about.
Here's a realistic timeline for a mid-sized service:
- Week 1: Read breaking changes, audit your codebase against them.
- Week 2: Update global.json, Dockerfile, and project files. Run tests locally.
- Week 3: Merge to a feature branch. Let CI run full test suite and performance tests overnight.
- Week 4: Code review, merge to main, deploy to staging environment.
- Week 5: Smoke tests in staging. Deploy to production during a quiet window.
Five weeks might sound slow, but for a production system, it's exactly right. Rushing an upgrade is how you end up debugging at 2 AM.
Common mistakes
Mistake 1: Updating dependencies without testing Upgrading .NET often means NuGet packages get updated too. A library that worked on .NET 7 might have a breaking change in its .NET 8 version. Always check package changelogs, not just your own code.
Mistake 2: Forgetting about docker-compose.yml or Kubernetes manifests Your Dockerfile might be updated, but if your orchestration layer still references the old image tag, you'll deploy the wrong version. Check every configuration file that pins a .NET version.
Mistake 3: Skipping the performance test I get it—you're eager to ship. But one slow query magnified across 10,000 concurrent users is a catastrophe. The performance test takes an hour to write and saves hours of incident response.
Mistake 4: Upgrading in production without a rollback plan Have a previous version of your Docker image built and ready. Have the previous global.json committed to git. If something goes sideways, you should be able to rollback in minutes, not hours.
Key takeaways
- Read the breaking changes documentation thoroughly before every upgrade; don't assume your code will "just work."
- Update global.json, Dockerfile, and project files in a single atomic commit to keep your environments in sync.
- Run unit tests, integration tests, and a performance smoke test in that order to catch issues early.
- Upgrade annually to the latest LTS version; small, regular upgrades are safer and easier to reason about than big-bang jumps.
- Plan five weeks for a production upgrade: one week research, two weeks development and testing, two weeks staging and deployment.
- Always have a rollback plan—keep the previous Docker image built and the previous global.json committed.






Comments
Loading comments…