Moving a .NET 8 API to .NET 10: What Breaks Between Two LTS Releases

.NET 8 leaves support on 10 November 2026, and .NET 9 leaves on the same day. For the many services that sit on .NET 8 because it is the long-term support release, the next stop is .NET 10, which shipped last November and is supported until November 2028. That is a jump across two releases, and it collects the breaking changes of both.

Microsoft documents those changes thoroughly, in two long lists. The lists tell you what changed in the platform. They cannot tell you which of the changes your code will meet, or whether you will find out from the compiler, from an exception, or from a customer. This article takes one codebase, builds and runs it on .NET 8 and on .NET 10, and records every difference: twenty probes of run-time behaviour in an ASP.NET Core project, and nine small projects of the kind of code that older services contain.

Three legs, not two

An upgrade changes two things that are easy to conflate: the SDK that builds the code and the framework the code targets. They can be changed separately, so the comparison has three legs.

LegSDKTargetRuntimeWhat it stands for
A8.0.417net8.08.0.23Where the service is today
B10.0.102net8.08.0.23Step one: new SDK, same runtime
C10.0.102net10.010.0.2Step two: new target

The project multi-targets and sets no language version, so each target gets its default, C# 12 for net8.0 and C# 14 for net10.0, as an upgraded project would.

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- No LangVersion: each target gets its default, C# 12 for net8.0 and C# 14 for net10.0, as an upgraded project would. -->
    <ServerGarbageCollector>true</ServerGarbageCollector>
    <EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
    <NoWarn>$(NoWarn);SYSLIB0011;CS0618</NoWarn>
  </PropertyGroup>
</Project>

One detail of the method matters more than it looks. A machine with several runtimes installed rolls an application forward to the newest patch of its major version, so a build agent with a later .NET 8 runtime would quietly test something other than what was intended. The pipeline installs each SDK into its own directory and turns multi-level lookup off.

#!/usr/bin/env bash
# Installs the two SDKs into private directories, so nothing rolls forward to a newer runtime on the machine,
# then runs the checks. Usage: ./ci.sh <output directory>
set -uo pipefail
out="$(mkdir -p "$1" && cd "$1" && pwd)"
cd "$(dirname "$0")"
SDK8="8.0.417"; RUNTIME8="8.0.23"     # 13 January 2026
SDK10="10.0.102"                      # 13 January 2026, carries runtime 10.0.2
root="$(mktemp -d)"
curl -fsSL -o "$root/dotnet-install.sh" https://dot.net/v1/dotnet-install.sh || exit 2
bash "$root/dotnet-install.sh" --version "$SDK8" --install-dir "$root/sdk8" --no-path > "$out/install.txt" 2>&1 || exit 2
bash "$root/dotnet-install.sh" --version "$SDK10" --install-dir "$root/sdk10" --no-path >> "$out/install.txt" 2>&1 || exit 2
bash "$root/dotnet-install.sh" --runtime aspnetcore --version "$RUNTIME8" --install-dir "$root/sdk10" --no-path >> "$out/install.txt" 2>&1 || exit 2
{ for d in sdk8 sdk10; do echo "[$d]"; DOTNET_ROOT="$root/$d" DOTNET_MULTILEVEL_LOOKUP=0 "$root/$d/dotnet" --list-sdks; DOTNET_ROOT="$root/$d" DOTNET_MULTILEVEL_LOOKUP=0 "$root/$d/dotnet" --list-runtimes | grep -v WindowsDesktop; done; } | sed "s|$root/||" | tee "$out/versions.txt"

status=0
python -u upgrade_check.py "$root/sdk8" "$root/sdk10" "$out" recorded.json 2>&1 | tee "$out/upgrade-check.txt"; [ "${PIPESTATUS[0]}" -eq 0 ] || status=1

echo "== container base images" | tee "$out/images.txt"
for tag in 8.0 10.0; do
  printf 'aspnet:%s  ' "$tag" | tee -a "$out/images.txt"
  docker run --rm --entrypoint /bin/sh "mcr.microsoft.com/dotnet/aspnet:$tag" -c '. /etc/os-release; echo "$PRETTY_NAME"' 2>/dev/null | tee -a "$out/images.txt" || status=1
done
exit $status
Three build configurations: SDK 8 targeting net8.0, SDK 10 targeting net8.0, and SDK 10 targeting net10.0. Moving to the new SDK alone produced one difference. Changing the target produced 27 more: 13 silent behaviour changes, 6 new run-time exceptions, 7 new build warnings and 1 build error.
Twenty-eight of twenty-nine observations differ between the legs. All but one of them appear when the target changes.

Step one changes almost nothing

Moving from leg A to leg B, the new SDK building the old target, produced one difference in twenty-nine: dotnet new sln now writes App.slnx, the XML solution format, where the .NET 8 SDK wrote App.sln. Every behaviour probe gave the same result and every compile sample built clean.

That is useful for planning. The stricter defaults in the .NET 10 SDK are keyed to the target framework: the language version follows the target, and so does the new NuGet audit default. A team can therefore put the .NET 10 SDK on its build agents and developer machines, pin it with global.json, and keep shipping net8.0 binaries with no change in behaviour. It separates “is our tooling ready” from “is our code ready”, and it means the second step is the only one with risk in it.

Step two: what the build tells you

Changing the target to net10.0 is where the other twenty-seven differences appear. Eight of them are reported by the build.

Older codeOn net10.0Since
The System.Linq.Async package with LINQ over IAsyncEnumerableError CS0121, ambiguous call.NET 10
A member named field used inside a property accessorWarning CS9258, and CS0169 because the member is now unusedC# 14
new X509Certificate2(bytes)Warning SYSLIB0057.NET 9
new Rfc2898DeriveBytes(...)Warning SYSLIB0060.NET 10
WebHost.CreateDefaultBuilder and IWebHostWarning ASPDEPR008.NET 10
ForwardedHeadersOptions.KnownNetworks and the ASP.NET Core IPNetworkWarning ASPDEPR005.NET 10
.WithOpenApi() on an endpointWarning ASPDEPR002.NET 10
A package whose dependency has a known advisoryWarning NU1903 for the transitive package.NET 10 SDK, for net10.0 targets

The error is the friendly one, because nothing ships until it is fixed. LINQ for IAsyncEnumerable is now part of the framework, so the same extension methods exist twice.

// The community System.Linq.Async package, used for LINQ over IAsyncEnumerable before the framework had its own.
List<int> numbers = await Numbers().Where(n => n % 2 == 0).ToListAsync();
Console.WriteLine(numbers.Count);

static async IAsyncEnumerable<int> Numbers()
{
    for (int i = 0; i < 10; i++) { await Task.Yield(); yield return i; }
}

Microsoft’s guidance is to remove the package reference or move to version 7 of it, and, where another package brings it in transitively, to exclude its compile assets.

The warnings need more care than their severity suggests. Most services build with warnings as errors, in which case seven warnings are seven build breaks. The one that deserves a second look is CS9258.

// A class with a member called "field", read and written from a property accessor. Common in older code.
Console.WriteLine(new Reading { Value = 42 }.Value);

internal sealed class Reading
{
    private int field;

    public int Value
    {
        get { return field; }
        set { field = value; }
    }
}

In C# 14, field inside a property accessor is a keyword for a compiler-generated backing field. This class still compiles and still prints 42, but the accessor no longer touches the member the author declared, which is why the compiler also reports that member as unused. Anything else that reads the declared member, a constructor, a serializer or a ToString, now sees a different variable from the one the property writes. A team that has silenced warnings to get an upgrade through would ship that. The fix the compiler suggests is this.field or @field; renaming the member is better.

The NuGet warning is a policy change. Restore has audited direct package references since .NET 8. For projects that target .NET 10 it audits transitive ones as well, so a dependency of a dependency with a known advisory now fails a warnings-as-errors build. In the sample the direct reference is clean and the package it pulls in, an old Newtonsoft.Json, is not.

What the build does not tell you: six new exceptions

Nineteen differences produce no diagnostic at all. Six of them are code that ran on .NET 8 and throws on .NET 10. They are at least loud, provided a test or a request reaches them.

Probe.NET 8.NET 10Since
GetKeyedService with KeyedService.AnyKeyReturns the AnyKey registrationInvalidOperationException.NET 10
A property with the same name as the JSON type discriminatorSerializes {"Type":"dog","Type":"Dog"}InvalidOperationException.NET 10
array.Contains(x) in an expression tree, compiled with interpretationBinds to Enumerable.Contains and runsBinds to MemoryExtensions.Contains; ArgumentExceptionC# 14
new MailAddress("first..last@example.org")AcceptedFormatException.NET 10
BinaryFormatter with the opt-in switch setSerializesPlatformNotSupportedException.NET 9
HostBuilder in Development with a singleton that depends on a scoped serviceBuildsAggregateException at build.NET 9

Two of these are improvements that happen to break things. The JSON one replaces invalid output with an early error, and the host one finds a real lifetime bug that .NET 8 let through outside WebApplication. The expression tree change is the one with reach. It comes from the language, not the runtime: C# 14 lets span-based extension methods bind to arrays, so Contains on an array inside an expression lambda now resolves to the span overload, and anything that interprets the tree instead of compiling it fails.

await Probe("language.array_contains_in_expression_tree", () =>
{
    Expression<Func<int[], int, bool>> contains = (values, wanted) => values.Contains(wanted);
    string bound = ((MethodCallExpression)contains.Body).Method.DeclaringType!.Name;
    string interpreted;
    try { interpreted = contains.Compile(preferInterpretation: true)(new[] { 1, 2, 3 }, 2) ? "interprets" : "interprets wrongly"; }
    catch (Exception error) { interpreted = $"interpreter throws {error.GetType().Name}"; }
    return Task.FromResult($"binds to {bound}.Contains, {interpreted}");
});

Query providers, rule engines and mocking libraries that walk expression trees are the code to check. The documented way to keep the old binding is to call Enumerable.Contains explicitly or to cast the array to IEnumerable<T>.

What nothing tells you: thirteen silent changes

The remaining thirteen differences throw nothing and warn about nothing. The service builds, starts and answers, and does something different from what it did the day before.

Six behaviours that differ between .NET 8 and .NET 10 with no warning: an unauthenticated call to a cookie-protected API returns 401 instead of a 302 redirect; an exception handled by IExceptionHandler is no longer logged by the middleware; synchronous work in ExecuteAsync no longer blocks start-up; null in JSON configuration binds as null; an out-of-range double cast to int saturates; HttpClientFactory redacts logged header values.
Six of the thirteen. None produces a warning or an exception.
Probe.NET 8.NET 10Since
Unauthenticated request to a cookie-protected API endpoint302 to /Account/Login401.NET 10
Exception handled by IExceptionHandlerOne error entry logged by the middlewareNone.NET 10
Synchronous work before the first await in ExecuteAsyncHost start-up waits for itStart-up returns first.NET 10
null in JSON configurationName='', Retries=3, Hosts=['', '']Name=null, Retries=null, Hosts=[null, null].NET 10
Default trace propagator header fieldsIncludes Request-IdNo Request-Id.NET 10
[Obsolete] property with XmlSerializerLeft outSerialized.NET 10
A URI longer than 65,519 charactersUriFormatExceptionAccepted.NET 10
HttpClientFactory logging of request headersValues written to the logValues redacted.NET 9
HttpClientFactory primary handlerHttpClientHandlerSocketsHttpHandler.NET 9
Nullable JsonDocument property given JSON nullnullA document of kind Null.NET 9
(int)1e20, (int)NaN, (uint)-1.0-2147483648, -2147483648, 42949672952147483647, 0, 0.NET 9
SetEnvironmentVariable(name, "")Deletes the variableKeeps it, empty.NET 9
Server GC adaptation mode01: dynamic adaptation on by default.NET 9

Four of these change what an operations team sees, and they are worth taking one at a time.

Cookie-protected APIs answer 401. ASP.NET Core 10 recognises API endpoints and stops redirecting them to a login page.

await Probe("aspnet.cookie_auth_on_api_endpoint", async () =>
{
    WebApplicationBuilder builder = QuietBuilder();
    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
    builder.Services.AddAuthorization();
    await using WebApplication app = builder.Build();
    app.UseAuthentication();
    app.UseAuthorization();
    app.MapGet("/api/orders", () => new Order(1, "open")).RequireAuthorization();
    await app.StartAsync();
    using HttpClient client = new(new HttpClientHandler { AllowAutoRedirect = false }) { BaseAddress = new Uri(Address(app)) };
    using HttpResponseMessage response = await client.GetAsync("/api/orders");
    return $"{(int)response.StatusCode}" + (response.Headers.Location is { } location ? $", Location {location.AbsolutePath}" : ", no Location header");
});
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
    participant C as API client
    participant A as API on .NET 8
    participant B as API on .NET 10
    C->>A: GET /api/orders, no cookie
    A-->>C: 302 Found, Location /Account/Login
    Note over C: Follows the redirect and receives a login page
    C->>B: GET /api/orders, no cookie
    B-->>C: 401 Unauthorized
    Note over C: Has to handle 401 itself

For a single-page application calling its own API this is the behaviour it wanted all along. For a client that relied on being redirected, or a test that asserts a 302, it is a break. A redirect can be restored for one endpoint with AllowCookieRedirect(), or for the whole application with the Microsoft.AspNetCore.Authentication.Cookies.IgnoreRedirectMetadata switch.

Handled exceptions disappear from the log. When an IExceptionHandler returns true, the middleware no longer logs the exception or tags the request-duration metric with an error type.

await Probe("aspnet.handled_exception_is_logged", async () =>
{
    CapturingLoggerProvider logs = new();
    WebApplicationBuilder builder = QuietBuilder();
    builder.Logging.AddProvider(logs).SetMinimumLevel(LogLevel.Information);
    builder.Services.AddExceptionHandler<HandlesEverything>();
    await using WebApplication app = builder.Build();
    app.UseExceptionHandler(_ => { });
    app.MapGet("/boom", string () => throw new InvalidOperationException("synthetic failure"));
    await app.StartAsync();
    using HttpClient client = new() { BaseAddress = new Uri(Address(app)) };
    using HttpResponseMessage response = await client.GetAsync("/boom");
    int errors = logs.Entries.Count(e => e.Level >= LogLevel.Error && e.Category.Contains("ExceptionHandlerMiddleware"));
    return $"{(int)response.StatusCode}, {errors} error log entr{(errors == 1 ? "y" : "ies")} from the middleware";
});

The response is the same 500 on both. What changes is that an alert built on the middleware’s error log, or on the error.type tag, stops firing after the upgrade while the failures continue. Either log inside the handler, which is the better home for it, or set SuppressDiagnosticsCallback on ExceptionHandlerOptions to return false.

Background services no longer hold up start-up. On .NET 8 the synchronous part of ExecuteAsync ran on the starting thread, and some services leaned on that to warm a cache or open a connection before the host accepted traffic.

internal sealed class SlowToFirstAwait : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Thread.Sleep(1500); // stands for synchronous start-up work: loading a cache, opening a connection
        return Task.CompletedTask;
    }
}

On .NET 10 the whole method runs in the background and StartAsync returns without waiting for it. A readiness probe that passed only after that work had finished can now pass before it has. Work that must complete before the service is ready belongs in StartAsync or an IHostedLifecycleService.

Null in configuration means null. A property with a default and a JSON value of null kept its default on .NET 8, or became an empty string. On .NET 10 it is null.

await Probe("configuration.null_in_json", () =>
{
    const string json = """{ "Settings": { "Name": null, "Retries": null, "Hosts": [null, null] } }""";
    IConfiguration configuration = new ConfigurationBuilder().AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))).Build();
    Settings settings = configuration.GetSection("Settings").Get<Settings>() ?? new Settings();
    string hosts = settings.Hosts is null ? "null" : "[" + string.Join(", ", settings.Hosts.Select(h => h is null ? "null" : $"'{h}'")) + "]";
    return Task.FromResult($"Name={(settings.Name is null ? "null" : $"'{settings.Name}'")}, Retries={settings.Retries?.ToString() ?? "null"}, Hosts={hosts}");
});

Retries going from 3 to null is the kind of difference that surfaces as a NullReferenceException three layers away, or as a retry policy that has stopped retrying. Search configuration files for explicit nulls before the upgrade.

⚠️
NUMERIC CODE

The floating-point change is the one to raise with anyone who owns calculations. An out-of-range conversion used to produce whatever the processor produced, which on x64 was the minimum value. Since .NET 9 it saturates, and NaN converts to zero. Code that tested for the old sentinel value, or that depended on wrap-around, gives different answers with no error. In a clinical or financial calculation that is a correctness defect, so the conversion sites need to be found by search and reviewed, not left for a test to stumble on.

One that did not change

The twenty-ninth observation is worth a line. Reverse() on an array, written expecting LINQ’s new reversed sequence, is exposed in principle to the same C# 14 rule that moved Contains, since a span has an in-place Reverse of its own. On the released .NET 10 it builds without a warning and prints the same output as on .NET 8: the reversed sequence, with the array untouched. A probe that comes back unchanged is still information. It is one item off the list of things to worry about.

The container moves too

The default .NET container images changed their base. Reading /etc/os-release from each: mcr.microsoft.com/dotnet/aspnet:8.0 is Debian 12, and aspnet:10.0 is Ubuntu 24.04. A Dockerfile that only copies the published output will not notice. One that installs packages with apt-get, pins a package version, adds a certificate authority or applies a hardening baseline written for Debian needs to be read again, and so does the allow-list in whatever scans images for the organisation.

A sequence for the upgrade

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A["Leg A
SDK 8, net8.0, today"] --> B["Leg B
SDK 10, net8.0
pinned with global.json"] B --> W["Clear the new warnings
on a branch that targets net10.0"] W --> T["Change the target to net10.0
and move packages to 10.x"] T --> R["Run the behaviour checks
on both targets"] R --> D{"A difference
nobody expected?"} D -- "yes" --> F["Adapt the code, or opt out
with the documented switch"] F --> R D -- "no" --> I["Move the container image
Debian 12 to Ubuntu 24.04"] I --> S["Release through the usual
progressive rollout"] classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E classDef db fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202 classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B class A,B db class W,T,R,I compute class D,F alert class S store
  • Adopt the SDK first. Put the .NET 10 SDK on agents and workstations, pin it in global.json, keep targeting net8.0. This step is safe to release.
  • Clear warnings before behaviour. On a branch, target net10.0 and fix the build with warnings as errors still on. Do not suppress CS9258.
  • Multi-target while you compare. For the length of the migration, build both targets and run the same checks on both, as the sample does. Differences then show up in a pull request instead of in production.
  • Review the silent list against your own code. Search for cookie-protected API endpoints, IExceptionHandler implementations, synchronous work in ExecuteAsync, explicit nulls in configuration, and narrowing conversions from floating point.
  • Check what your alerts are built on. Two of the changes remove log lines or change header logging. An alert with no data looks the same as a healthy service.
  • Move the image last, as its own change, so a problem with the base image is not confused with a problem in the runtime.

Where the effort goes

WorkSizeWhy
SDK adoptionSmallOne observed difference, and it concerns new solution files only
Build fixesSmall to mediumMechanical, and the compiler lists every site
Package updatesMediumASP.NET Core, EF Core and extension packages move to 10.x together; transitive advisories surface for the first time
Behaviour reviewMedium to largeNo tool lists the sites. It scales with how much the service depends on defaults
ObservabilitySmall, and easy to forgetAlerts and dashboards built on log lines that no longer appear
Container imageSmall to mediumDepends on how much the Dockerfile does beyond copying output

There is no licence cost in the move, and staying has one: after 10 November, .NET 8 receives no security fixes, which for a regulated workload is a finding in its own right.

Decision framework

SituationApproach
A service on .NET 8 with good test coverageSDK first, then target, in two releases
A service on .NET 8 with thin testsAdd behaviour checks for the silent list before changing the target. The upgrade is the reason to write them
A service already on .NET 9The .NET 9 rows above are behind you. Review only those marked .NET 10 and C# 14
A shared library used by services on bothMulti-target net8.0 and net10.0 until the last consumer has moved
A service due for retirement within the yearWeigh the upgrade against bringing the retirement forward. Running out of support is the option to rule out
Heavy use of expression trees or a custom LINQ providerTest with C# 14 early; this is the change most likely to need design work

Key takeaways

  • Changing the SDK and changing the target are separate steps. The first produced one difference in twenty-nine, and the second produced the other twenty-seven.
  • The build reported eight of them. Nineteen came with no diagnostic: six new exceptions and thirteen silent changes in behaviour.
  • The silent changes cluster around defaults: authentication challenges, exception logging, hosted service start-up, configuration binding and numeric conversion.
  • An upgrade can switch off an alert. Check what monitoring is built on before trusting a quiet dashboard.
  • Run both targets side by side during the migration, with runtimes pinned so that nothing rolls forward.

If your upgrade turned up a behaviour change that is not in the tables above, I would like to add it to the probe set. The comments are open.

ℹ️
HOW THIS WAS CHECKED

Every observation comes from building and running the sample in GitHub Actions on Linux x64, with .NET SDK 8.0.417 (runtime 8.0.23) and .NET SDK 10.0.102 (runtime 10.0.2) installed into private directories. The run compares its results with a recorded set and fails on any drift. The base images were inspected by running them. Each probe is one small case, so a behaviour that is unchanged here can still differ in other code, and nothing was measured for performance or memory. Entity Framework Core, Blazor, OpenAPI generation, trimming and Native AOT are outside the sample. The attribution of each change to .NET 9, .NET 10 or C# 14 follows Microsoft’s compatibility lists. The workflow run has the logs.

Glossary

  • LTS— Long-term support release, supported for three years. .NET 8 and .NET 10 are LTS releases.
  • Target framework— The framework version a project compiles against and runs on, such as net8.0 or net10.0.
  • Roll forward— The host’s choice of the newest installed patch of the required runtime version.
  • Multi-targeting— Building one project for more than one target framework.
  • Expression tree— Code represented as data, which a library can compile, interpret or translate, as LINQ providers do.
  • Known API endpoint— An endpoint ASP.NET Core 10 identifies as an API, for which cookie authentication returns 401 or 403 instead of redirecting.
  • Transitive package— A package a project depends on only through another package.
  • Saturating conversion— A numeric conversion that clamps an out-of-range value to the nearest representable one.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.