.NET

.NET 8 vs .NET 10: What Actually Changes for Developers

.NET 8 vs .NET 10: What Actually Changes for Developers

If you’re running production services on .NET 8, this comparison is no longer theoretical. .NET 8 is in its maintenance phase, and Microsoft’s support policy lists its end of support as November 10, 2026. .NET 10 is the current long-term support (LTS) release and is supported until November 14, 2028. In this post I go through what actually changes for backend developers between the two LTS releases: the language, the runtime, ASP.NET Core, the SDK. I finish with an upgrade checklist you can use on a real service.

I’ll stick to what’s documented in Microsoft’s official release notes. Where something is a headline feature with no number behind it, I won’t invent one. Measure your own workloads.

Why compare two LTS releases

Most teams I’ve worked with only run LTS versions in production. A standard-term release in between is fine for side projects, but for services that need predictable patching, the LTS-to-LTS jump is the one that matters. The current policy is simple:

  • LTS releases (even numbers) get free support and patches for three years.
  • STS releases (odd numbers) get two years.
  • A new major version ships every November.
  • The last six months of any release’s life is the maintenance phase, when it receives security fixes only.

Here’s how the two versions line up according to the official support policy page:

VersionReleasedTypeEnd of support
.NET 8November 14, 2023LTSNovember 10, 2026
.NET 10November 11, 2025LTSNovember 14, 2028

One detail people miss: .NET 9 (STS) also reaches end of support on November 10, 2026. If you hopped to 9 as a stepping stone, you’re on the same deadline as .NET 8. The upgrade target either way is .NET 10.

Language changes: C# 12 to C# 14 features you’ll actually use

.NET 8 shipped with C# 12; .NET 10 ships with C# 14. If you’re coming from 8, C# 12 features like primary constructors and collection expressions are probably already in your code:

// C# 12 (.NET 8): primary constructor + collection expression
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
    private readonly string[] _allowedStatuses = ["Pending", "Paid", "Shipped"];

    public async Task<Order?> GetAsync(int id)
    {
        logger.LogInformation("Loading order {OrderId}", id);
        return await repository.FindAsync(id);
    }
}

C# 14 adds several features that remove real boilerplate in backend code. These are the ones I expect to use most.

The field keyword

You no longer need to declare a private backing field just to add validation or normalisation to one accessor. The field contextual keyword refers to the compiler-generated backing field:

public class Customer
{
    // Before C# 14 this needed a private _email field.
    public string Email
    {
        get;
        set => field = value?.Trim().ToLowerInvariant() ?? string.Empty;
    } = string.Empty;
}

One caveat from the docs: if a type already has a member named field, use @field or this.field to disambiguate, or rename it.

Null-conditional assignment

The ?. and ?[] operators can now appear on the left side of an assignment or compound assignment. The right side is only evaluated when the left side isn’t null:

// Before
if (order is not null)
{
    order.UpdatedAt = DateTime.UtcNow;
}

// C# 14
order?.UpdatedAt = DateTime.UtcNow;

Increment and decrement (++, --) aren’t allowed with this form.

Extension members

The new extension block lets you declare extension properties and static extension members, not just extension methods:

public static class StringExtensions
{
    extension(string value)
    {
        // Extension property: "abc".IsBlank
        public bool IsBlank => string.IsNullOrWhiteSpace(value);

        // Extension method
        public string Truncate(int max) =>
            value.Length <= max ? value : value[..max];
    }
}

Smaller quality-of-life changes

  • nameof accepts unbound generic types: nameof(List<>) returns "List".
  • Lambda parameters can take modifiers like out, ref or in without explicit types: (text, out result) => int.TryParse(text, out result).
  • More implicit conversions between Span<T>, ReadOnlySpan<T> and arrays.
  • Partial instance constructors and partial events, plus user-defined compound assignment operators.

Runtime and performance: what improved, and how to measure it yourself

Microsoft’s .NET 10 overview lists runtime improvements in JIT inlining, method devirtualization and stack allocations, along with AVX10.2 support, NativeAOT enhancements, better code generation for struct arguments and improved loop inversion. The libraries also gained new APIs across cryptography (including expanded post-quantum support), JSON serialization options such as disallowing duplicate properties, and WebSocketStream.

I’m deliberately not quoting percentages. Runtime gains depend heavily on your workload, so the honest approach is to benchmark your own hot paths on both targets. BenchmarkDotNet makes this easy because one benchmark class can run against multiple runtimes:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Text.Json;

[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[MemoryDiagnoser]
public class SerializationBenchmarks
{
    private readonly Order _order = new(42, "Paid", 199.99m);

    [Benchmark]
    public string Serialize() => JsonSerializer.Serialize(_order);
}

public record Order(int Id, string Status, decimal Total);

public static class Program
{
    public static void Main(string[] args) =>
        BenchmarkRunner.Run<SerializationBenchmarks>();
}

For this to work, multi-target the benchmark project (<TargetFrameworks>net8.0;net10.0</TargetFrameworks>), run it in Release mode, and benchmark the code that actually matters to your service rather than a toy method.

ASP.NET Core and minimal APIs: what’s different day to day

For backend APIs, these are the ASP.NET Core 10 changes worth knowing about.

Built-in validation for minimal APIs

Minimal APIs can now validate query, header and body data using DataAnnotations. Register it once and failed validation returns a 400 Bad Request with error details:

using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();

var app = builder.Build();

app.MapPost("/products", (Product product) => TypedResults.Ok(product));

app.Run();

public record Product(
    [Required] string Name,
    [Range(1, 1000)] int Quantity);

The docs note that AddValidation uses a source generator that only discovers validatable types in the assembly where it’s called, and that .DisableValidation() opts a specific endpoint out.

Server-Sent Events

TypedResults.ServerSentEvents streams an IAsyncEnumerable to the client over a single HTTP connection. It’s a lightweight option for progress updates or live feeds when you don’t need full WebSockets:

app.MapGet("/ticks", (CancellationToken ct) =>
    TypedResults.ServerSentEvents(Ticks(ct), eventType: "tick"));

static async IAsyncEnumerable<string> Ticks(
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        yield return DateTime.UtcNow.ToString("O");
        await Task.Delay(1000, ct);
    }
}

OpenAPI 3.1 by default

Generated OpenAPI documents now default to version 3.1, and the underlying Microsoft.OpenApi library moved to 2.0. If you have custom document, operation or schema transformers, expect to update them. For example, OpenApiAny is gone in favour of JsonNode. The document can also be served as YAML by mapping an endpoint ending in .yaml.

Other changes worth knowing

  • Cookie auth on API endpoints: unauthenticated and unauthorized requests to known API endpoints now get 401/403 instead of a redirect to a login page. This is a breaking change if anything relied on the redirect.
  • Testing top-level programs: a source generator now emits public partial class Program, so you no longer have to add it by hand for WebApplicationFactory tests.
  • Memory pools: Kestrel’s memory pools now automatically release memory when the app is idle.
  • JSON Patch: a new System.Text.Json-based implementation is available in Microsoft.AspNetCore.JsonPatch.SystemTextJson. It isn’t a drop-in replacement for the Newtonsoft version.

Tooling and SDK: CLI, containers and build changes

  • Noun-first CLI aliases: dotnet package add, dotnet package list, dotnet reference add and so on. The old verb-first forms still work.
  • One-shot tools: dotnet tool exec and the new dnx script run a .NET tool without installing it, which is handy in CI.
  • File-based apps: a single .cs file can be run and published with dotnet publish app.cs, and file-based apps target native AOT by default.
  • Containers: console apps can create container images with dotnet publish /t:PublishContainer, and a new property controls whether images use Docker or OCI format.
  • Package pruning: for projects targeting .NET 10, NuGet now prunes framework-provided package references. That means fewer restored packages and fewer false positives from NuGet Audit.
  • Testing: dotnet test supports Microsoft.Testing.Platform when you enable it in global.json.

Breaking changes and gotchas to check before upgrading

Based on the official release notes, these are the items I’d check first on a typical backend:

  • OpenAPI transformers that use OpenAPI.NET 1.x types. These need updating even if you pin the document version to 3.0.
  • Code that expects cookie-auth redirects on API endpoints, which now return 401/403.
  • Custom JsonConverters. MVC and minimal APIs now deserialize through PipeReader, and a converter that doesn’t handle Utf8JsonReader.HasValueSequence can fail. The temporary workaround is the Microsoft.AspNetCore.UseStreamBasedJsonParsing AppContext switch.
  • Exception-handler logging. Exceptions handled by an IExceptionHandler are no longer logged by the middleware by default.
  • Identifiers named field inside property accessors, which now clash with the C# 14 keyword.

Always read the full “Breaking changes in .NET 10” list on Microsoft Learn for the libraries you actually use.

A step-by-step upgrade checklist

  1. Install the .NET 10 SDK and update global.json if you pin the SDK.
  2. Change the target framework: <TargetFramework>net10.0</TargetFramework>. Using a Directory.Build.props makes this a one-line change across a solution.
  3. Update packages: move all Microsoft.* and System.* packages to their 10.x versions with dotnet package list --outdated.
  4. Build with warnings visible and fix new analyzer warnings and obsoletions.
  5. Run the full test suite, especially integration tests around auth, JSON serialization and OpenAPI generation.
  6. Update Docker base images from 8.0 to 10.0 tags, and CI images to the new SDK.
  7. Benchmark your hot paths on both runtimes, as above.
  8. Roll out gradually, one service or one environment at a time, watching error rates and memory.

The gap between .NET 8 and .NET 10 isn’t dramatic in day-to-day code, and that’s a good thing. Most of the work is mechanical. With .NET 8 support ending on November 10, 2026, now is the time to do it.

Sources

Leave a Reply

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

Keep reading

Related articles