The ticket says Upgrade to .NET 10 and carries a three-point estimate, because
.NET 8 end of support looks like a one-line change: net8.0 becomes
net10.0 in a project file. For a plain ASP.NET Core API, that estimate is
roughly right. For an Azure Function still on the in-process model, it's wrong by an
order of magnitude: that app can't run on .NET 10 at all, so it has to move to a
different execution model before it can be upgraded. For a Durable Functions app, it's
wrong in a third way. Its real deadline comes before November 10.
The date itself is fixed. .NET 8 and .NET 9 both leave support on November 10, 2026; .NET 9 lands on the same day because Microsoft extended Standard Term Support from 18 months to 24. EF Core 8 and 9 end that day too, and so does the Azure Functions in-process model, which never ran anything newer than .NET 8. Four deadlines on one date turn a version bump into a portfolio problem: the question stops being how to upgrade and becomes what to upgrade first.
This article gives you an order of work. It sorts every service on two axes — how exposed it is if an unpatched vulnerability lands, and which of four migration classes it falls into — and turns that into start dates and finish lines. It also covers where this upgrade breaks without failing a build: a serializer that changed underneath your message contracts, staging slots that consume production messages, orchestrations that can't move while they're running, and a hosting plan that can't run .NET 10.
What .NET 8 End of Support Actually Switches Off
Four things reach end of support on November 10, 2026. They usually have different owners, which is how one of them gets missed.
| What ends | Why on that date | Move to |
|---|---|---|
| .NET 8 (LTS): runtime, ASP.NET Core, SDK | The end of the three-year LTS window that began with its November 2023 release | .NET 10 (LTS), supported until November 14, 2028 |
| .NET 9 (STS) | STS support was extended to 24 months, which puts it on the same day | .NET 10. Not a stepping stone: it's the same cliff |
| EF Core 8.0 and 9.0 | EF Core support follows .NET's. EF Core 9 targets .NET 8, so upgrading EF alone bought no time | EF Core 10 |
| Azure Functions in-process model | It only ever supported LTS releases, ending with .NET 8 | The isolated worker model |
Sources: the .NET support policy, the EF Core release table and Microsoft's comparison of the in-process and isolated worker models. That comparison also records a constraint that behaves like a fifth deadline: .NET 10 can't run on the Linux Consumption plan, so function apps hosted there must move to Flex Consumption as well.
"End of support" is narrower than it sounds, and the narrowness is the trap. Nothing switches off. Microsoft's end-of-support announcement confirms that .NET 8 and 9 apps keep running; what stops is security fixes and technical support. November 10 is itself a Patch Tuesday, and each version may get one last update that day if a critical issue is known. After that, there's nothing to install.
Azure App Service follows the same policy with a sharper edge. Apps on an out-of-support stack keep running unchanged, but App Service stops patching that runtime, and an app on an unsupported version has to be upgraded before it's eligible for App Service support. The day you most need a support ticket is the day you learn that step one of the fix is the upgrade you postponed.
The next vulnerability isn't hypothetical. In October 2025, Microsoft fixed CVE-2025-55315, a request-smuggling flaw in Kestrel that MSRC scored 9.9 because it can undermine how applications enforce authentication and authorization. For .NET 8, the fix shipped as ASP.NET Core 8.0.21. The next one published after November 10 will be fixed only in supported versions.
Exposure Sets the Finish Line. Migration Class Sets the Start.
Most upgrade plans rank services by importance and work down the list. That gets the order wrong, because importance mixes two questions: what it costs if a service misses the date, and how long it takes to move. The first sets the finish line. The second sets the start date, and it's the one that blows deadlines, because some of this work waits on the calendar rather than on engineers.
Axis 1: Exposure
Ask two questions of every service:
- Does untrusted input reach it? "Internet-facing" is the wrong test: a queue-triggered function that parses partner files has no public endpoint and full exposure. Count public HTTP, partner webhooks, uploads, and any message that starts outside your trust boundary.
- Is it in audit scope? Payments, personal data, financial records: anything a PCI assessor, an auditor or a regulator's IT examination will look at.
Two yeses make it High, one makes it Medium, and none makes it Low.
Axis 2: Migration class
What's running today decides what the move involves. The effort column is rough; the day-one retarget below makes it honest.
| Class | What's running today | What the move involves | Rough effort |
|---|---|---|---|
| 1 · Retarget | ASP.NET Core APIs and worker services on App Service, Container Apps, AKS or VMs; isolated-worker functions on plans that run .NET 10 | Target framework, packages, base image, pipeline, stack setting | Days per service |
| 2 · Rehost | Isolated-worker functions on Linux Consumption | Class 1, plus a new Flex Consumption app: no in-place migration, no deployment slots | One to two weeks |
| 3 · Re-model | In-process Azure Functions | New entry point, new binding packages, a different serializer default, different log configuration | Weeks, mostly testing |
| 4 · Re-model and drain | In-process Durable Functions | Class 3, plus orchestrations already running on the old app | Weeks, plus your longest orchestration's lifetime |
Combine them, and each service gets a start from its class and a finish line from its exposure:
| Exposure | Class 1 · Retarget | Class 2 · Rehost | Class 3 · Re-model | Class 4 · Re-model and drain |
|---|---|---|---|---|
| High | Wave 2 · done by Nov 10 | Wave 1 · done by Nov 10 | Wave 1 · done by Nov 10 | Wave 1 · live by Nov 10 − L |
| Medium | Wave 3 · done by Nov 10 | Wave 2 · done by Nov 10 | Wave 1 · done by Nov 10 | Wave 1 · live by Nov 10 − L |
| Low | Wave 3 · dated exception | Wave 3 · dated exception | Wave 2 · dated exception | Wave 2 · live by exception end − L |
Wave 1 starts this week, Wave 2 within two weeks, Wave 3 once Wave 1 is in testing. L is the longest time an already-running orchestration can keep running. A dated exception is a written risk acceptance with an owner, compensating controls and an end date no more than a quarter away; Medium-exposure services get one only for a named blocker.
The cells that look wrong are the ones that matter. A public, regulated API that's a plain retarget starts after an internal Durable app nobody thinks is urgent: the API takes days and can finish in the last fortnight, while the Durable app has a drain period no amount of staffing shortens. A new Flex Consumption app behaves the same way on a smaller scale, because its networking, identity and certificates mostly wait on other teams. Start the work that waits. Finish the work that's exposed.
Day one: inventory, then a throwaway retarget
The matrix is only as good as the inventory under it, and project files are the least
reliable place to look. The runtime version also lives in global.json,
Dockerfile base images, the SDK your pipeline installs, each app's stack setting, and the
Bicep or Terraform that will quietly set it back on the next deploy. For function apps,
FUNCTIONS_WORKER_RUNTIME gives you the class (dotnet is
in-process, dotnet-isolated is isolated), and Microsoft's
migration guide
includes a PowerShell script that lists the in-process apps in a subscription.
Repositories tell you what should be running. Telemetry tells you what is:
// Program.cs (ASP.NET Core): record the runtime that's actually executing,
// not the one the .csproj asks for. RuntimeInformation is in System.Runtime.InteropServices.
app.Logger.LogInformation(
"Runtime {DotnetRuntime} on {OSDescription}",
RuntimeInformation.FrameworkDescription, // e.g. ".NET 8.0.21"
RuntimeInformation.OSDescription);
// Application Insights: a week of restarts covers everything that's running.
traces
| where timestamp > ago(7d) and isnotempty(customDimensions.DotnetRuntime)
| summarize LastSeen = max(timestamp)
by cloud_RoleName, Runtime = tostring(customDimensions.DotnetRuntime)
| order by Runtime asc
Then give each repository an afternoon: retarget everything to net10.0 in a
throwaway branch and run the tests. It isn't the upgrade. It's a classification check,
and the cheapest moment to find the Class 1 services that aren't.
In-Process Functions: Three Breaks That Compile Cleanly
The
migration guide
covers the mechanical part well: [FunctionName] becomes
[Function], Program.cs replaces FunctionsStartup,
WebJobs extension packages get worker equivalents, and
IAsyncCollector<T> outputs become return values. For .NET 10 targets
the .NET Upgrade Assistant makes many of those edits, and the compiler catches the rest.
Neither will show you three changes that build, deploy and start without an error.
1. The serializer changed underneath your contracts
The in-process model bound trigger payloads with Newtonsoft.Json. The isolated worker
defaults to System.Text.Json, which doesn't read Newtonsoft's attributes: a property
mapped with [JsonProperty] gets no value, keeps its default, and nothing
reports it. Here's a function ported strictly by the book that still passes a smoke test:
using Newtonsoft.Json; // the attributes below are Newtonsoft's
public sealed class SettlementInstruction
{
[JsonProperty("settlement_id")] public Guid SettlementId { get; set; }
[JsonProperty("amount_minor")] public long AmountMinor { get; set; }
[JsonProperty("currency")] public string Currency { get; set; } = "";
}
// Ported by the book: [Function], an injected logger, a CancellationToken.
public sealed class SettleFunction(ILedger ledger, ILogger<SettleFunction> logger)
{
[Function("Settle")]
public async Task Run(
[ServiceBusTrigger("settlements", Connection = "ServiceBus")] SettlementInstruction instruction,
CancellationToken ct)
{
// System.Text.Json ignores [JsonProperty]. "currency" still binds, because the
// worker matches property names case-insensitively. "settlement_id" and
// "amount_minor" don't: SettlementId is Guid.Empty, AmountMinor is 0,
// and the message completes.
await ledger.PostAsync(instruction.SettlementId, instruction.AmountMinor, instruction.Currency, ct);
logger.LogInformation("Posted settlement {SettlementId}", instruction.SettlementId);
}
}
One field binds because its JSON name happens to match the property name, which is
exactly why the smoke test passes. The other two post zeros. If the ledger deduplicates
on SettlementId, as an idempotent consumer should, it gets worse: the first
message posts a zero under Guid.Empty and every later one is discarded as
its duplicate. The rule runs outbound too: a field you kept out of a message with
Newtonsoft's [JsonIgnore], an account number for instance, now gets
serialized into the output binding.
The fix is to move the contract onto System.Text.Json and make every field that carries identity or money required:
using System.Text.Json.Serialization;
/// <summary>
/// Settlement message contract. Fields that carry identity or money are required:
/// a payload that doesn't bind them throws during deserialization instead of
/// defaulting to Guid.Empty or 0.
/// </summary>
public sealed class SettlementInstruction
{
[JsonPropertyName("settlement_id")] public required Guid SettlementId { get; init; }
[JsonPropertyName("amount_minor")] public required long AmountMinor { get; init; }
[JsonPropertyName("currency")] public required string Currency { get; init; }
}
When a required property is missing, System.Text.Json
throws a JsonException
instead of defaulting it. The invocation fails, the trigger abandons the message, and
Service Bus retries and eventually dead-letters it. A dead-lettered message is an
incident you can see today; a zero in a ledger is one you find at month-end. Don't go
further and reject unknown fields, or the day a producer adds one, every consumer
breaks. Require what you depend on and ignore the rest: Martin Fowler's
Tolerant Reader
(2011), applied to one contract.
Before cutover, capture a sample of real messages from the in-process app's queue and replay them through the settings the worker will use:
public sealed class SettlementContractTests
{
// Mirrors the isolated worker's default: System.Text.Json, case-insensitive names.
// If Program.cs customizes JsonSerializerOptions, test with the same settings.
private static readonly JsonSerializerOptions WorkerDefaults = new() { PropertyNameCaseInsensitive = true };
// Real messages captured from the in-process app's queue, personal data scrubbed.
public static IEnumerable<object[]> CapturedPayloads() =>
Directory.GetFiles("Payloads/Settlements", "*.json")
.Select(path => new object[] { File.ReadAllText(path) });
[Theory]
[MemberData(nameof(CapturedPayloads))]
public void Captured_payload_binds_identity_and_amount(string json)
{
var instruction = JsonSerializer.Deserialize<SettlementInstruction>(json, WorkerDefaults);
Assert.NotNull(instruction);
Assert.NotEqual(Guid.Empty, instruction.SettlementId);
Assert.NotEqual(0L, instruction.AmountMinor);
}
}
2. Your staging slot is already consuming production messages
Microsoft's recommended cutover runs through a staging slot: set
FUNCTIONS_WORKER_RUNTIME to dotnet-isolated there, deploy, test,
swap. But a slot created in the portal starts with a copy of every production app
setting, including the connection your Service Bus trigger listens on, and a slot is a
live app whose triggers run. The code you're still testing competes with production for
real messages from the moment it's deployed.
The failure mode is lopsided. A message the new code throws on is abandoned and retried,
possibly by production. A message it processes wrongly, like the zero above, is completed
and gone. Disable the slot's message-triggered functions with a slot-specific
AzureWebJobs.<FunctionName>.Disabled setting, or point them at a copy
of the queue, and test with traffic you control. That's what Microsoft's
slot guidance
means by making event-source settings slot-specific before the first swap.
3. host.json no longer controls your log levels
In the in-process model, host.json governed logging for your code too. In
the isolated worker it covers only the Functions host; your code's levels come from
Program.cs or appsettings.json, and the migration guide warns
that without those rules, the levels in your telemetry can change. If a log query has
ever been shown to an auditor as evidence that a control runs, rerun it after cutover
and compare the counts with the week before. Evidence that quietly thins out is a
finding waiting for its audit.
Durable Functions: Why Your Deadline Comes Before November 10
Durable Functions adds the one constraint in this upgrade that engineering can't compress: orchestrations that are already running.
An orchestrator rebuilds its state by replaying its recorded history every time it
resumes, so Microsoft's
versioning guidance
treats a changed activity signature or a reordered sequence of calls as breaking for
in-flight instances: replay stops matching the code, and the instance fails with a
non-determinism error or sits in Running indefinitely. The move from
in-process to isolated changes far more than a signature. The context type changes,
retry options move to a new API, entity proxies are gone, ContinueAsNew
now keeps unprocessed events by default, and the history in your task hub was written
with Newtonsoft.Json while the isolated worker defaults to System.Text.Json. Microsoft's
Durable migration guide
tells you to test serialization carefully. It doesn't promise that an isolated app can
resume an instance the in-process app started.
So don't migrate in-flight orchestrations. Drain them. The versioning guidance's default recommendation, built-in orchestration versioning, relies on new code replaying old history, which is exactly what's in doubt here. The option it calls most fail-proof is side-by-side deployment: stand up the isolated app on its own task hub, send every new orchestration to it, and let the in-process app finish what it started. Disable the old app's starters, but never its orchestrator or activity functions, because those are what drain. Name the new hub explicitly:
{
"version": "2.0",
"extensions": {
"durableTask": {
"hubName": "SettlementsIsolated"
}
}
}
That's the arithmetic behind your real deadline: the isolated app must be taking
new instances by November 10 minus L, where L is the longest time an in-flight
orchestration can keep running. An approval that can wait 30 days on an external event
puts that at October 11. An eternal orchestration looping on ContinueAsNew
never drains at all: stop it on the old app and restart it on the new one just after an
iteration completes.
If the arithmetic says the date is already gone, say a 45-day approval window and four weeks of migration work, it's far better to learn that now than in November. The question becomes how small to make the tail: new instances on the isolated app as early as possible, the old app draining with nothing new starting on it, and a dated exception that covers only that. Moving to Flex Consumption as well? Check your storage backend first: Flex supports only the Azure Storage and Durable Task Scheduler backends.
Linux Consumption: The Plan That Can't Run .NET 10
Isolated-worker apps on the Linux Consumption plan look like Class 1, because the code change is a retarget. The plan is the problem: .NET 10 can't run on it, it gets no new language versions, and it retires on September 30, 2028. Microsoft's route is Flex Consumption (in-process apps on Linux Consumption carry both problems at once), and Flex differs in the ways that matter for a cutover:
- It's a new app, not a setting. There's no in-place migration, so you create a Flex Consumption app and redeploy. A system-assigned managed identity comes back as a new identity that needs every role assignment again, and custom domains and certificates are rebound by hand.
- There are no deployment slots. Zero-downtime deployment uses rolling updates, still in public preview, so the slot-based cutover above isn't available.
- Blob triggers must use Event Grid. Container-polling triggers aren't supported and have to be converted first.
- Timer schedules run in UTC. Flex doesn't support
WEBSITE_TIME_ZONE. Linux Consumption apps already ran in UTC, but consolidate a Windows-hosted app onto Flex at the same time and a job scheduled for 2:00 a.m. Eastern fires at 2:00 a.m. UTC: the evening before, while the business day it's meant to close is still open.
For the cutover, the plan-migration guide recommends a new queue or topic for the new app, repointing senders, and retiring the old app once its queue is empty, with handlers that can safely see a message twice while both apps run. If yours can't yet, that's idempotent message handling work, and it belongs before the cutover rather than after the first duplicate.
The Plain Retarget: Four Ways It Stops Being Plain
Moving from .NET 8 to .NET 10 also crosses everything that changed in .NET 9, and a few of those changes reclassify a service on contact:
- BinaryFormatter. From .NET 9 the built-in implementation
throws on every call,
and the settings that re-enabled it on .NET 8 are gone. A service that opted back in
to read an old cache or session format still compiles, then fails at runtime. Search
project files for
EnableUnsafeBinaryFormatterSerializationbefore trusting its estimate. - EF Core migrations. Since EF Core 9,
applying migrations throws
when the model has pending changes (seed data built with
DateTime.NoworGuid.NewGuid()is the usual cause) and when you wrapMigratein your own transaction, as a commonly copied resilient-migration pattern does. - EF Core with Azure Cosmos DB. The EF Core 9 provider renamed the
discriminator to
$typeand dropped it fromid. Queries against documents written by EF Core 8 fail until you configure the old behavior or migrate the data. - Container images. The default
10.0tags are Ubuntu 24.04, and no Debian images ship for .NET 10. A Dockerfile pinned to a-bookwormtag, or installing packages by their Debian names, needs rework.
Each of these turns a three-day retarget into a two-week one. Finding out on day one costs an afternoon. Finding out in the first week of November costs the date.
If a Service Is Going to Miss the Date
Some will. The goal then is to make the miss small, visible and short.
- Don't stop at .NET 9. It's a smaller jump and it ends the same day, so it's an upgrade you'd do twice.
- Shrink the exposure. Take the service off public ingress where you can, restrict the network paths that reach it, and remove access to regulated data it doesn't strictly need. Moving a service from High to Medium changes its finish line honestly; adding a dashboard doesn't.
- Write the exception down. Owner, compensating controls, end date, and an early-exit trigger: a critical runtime vulnerability ends it immediately. An exception without an end date is a decision to stay.
- Check what your audit framework expects. PCI DSS v4.0 added Requirement 12.3.4, a review of the hardware and software technologies in use at least every 12 months, and the PCI SSC's summary of changes lists it as a best practice only until March 31, 2025. An in-scope service on an unsupported runtime is exactly what that review is for. Your assessor decides what's acceptable, but a documented plan with a date is a very different conversation from a surprise.
What This Plan Doesn't Cover
.NET Framework isn't on this clock. .NET Framework 4.8 is a Windows component and follows the operating system's lifecycle rather than a fixed end date (Microsoft's lifecycle FAQ). Moving off it is a modernization decision with a different shape; start with the rebuild vs refactor seam test, not this matrix.
Dependencies you don't control. A NuGet package targeting
net8.0 generally loads fine in a net10.0 app, so most
third-party libraries won't block you. The exception is anything built for the in-process
host: an isolated app shouldn't reference Microsoft.Azure.WebJobs.* packages
at all, so a vendor SDK that depends on them needs a replacement, not an upgrade.
Exposure is a judgment, not a scan. The matrix is only as honest as its exposure column. Have whoever would own the incident sign each row.
The next deadline. .NET 10's support ends on November 14, 2028. The lasting fix is making that upgrade boring: runtime versions in infrastructure code rather than the portal, contract tests around every serialized payload, and one place per repository that sets the target framework.
Frequently Asked Questions
- What happens to .NET 8 applications after November 10, 2026?
- They keep running. End of support doesn't switch anything off; it ends security fixes and technical support. November 10 is itself a Patch Tuesday, and .NET 8 and .NET 9 may each receive one final update that day if a critical issue is known. After that, vulnerabilities are fixed only in supported versions. On Azure App Service, apps on an out-of-support stack also keep running, but App Service stops patching that runtime, and an app has to be upgraded to a supported version before it's eligible for App Service support. So the practical risk isn't an outage on November 11. It's the first critical vulnerability disclosed afterwards, when a fix exists for .NET 10 and there's no 8.0.x patch to install.
- Should we upgrade from .NET 8 to .NET 9 or .NET 10?
- .NET 10. .NET 9 is a Standard Term Support release, and since Microsoft extended STS support from 18 to 24 months, it ends on November 10, 2026, the same day as .NET 8. Upgrading to .NET 9 now means doing the work twice within weeks. .NET 10 is the current Long Term Support release, supported until November 14, 2028. The jump from 8 to 10 does cross the breaking changes introduced in .NET 9, such as BinaryFormatter throwing on every call and EF Core 9's stricter migration checks, so budget for them. There's no benefit to stopping at 9 on the way. For Azure Functions, Microsoft's migration guidance likewise targets .NET 10, or .NET Framework 4.8 only for apps with Framework-only dependencies.
- Can Azure Functions in-process apps run on .NET 10?
- No. The in-process model only ever supported Long Term Support releases up to .NET 8, and its own support ends on November 10, 2026. To reach .NET 10, an in-process app has to migrate to the isolated worker model: a Program.cs replaces FunctionsStartup, each WebJobs extension package is swapped for its worker equivalent, output bindings become return values, and FUNCTIONS_WORKER_RUNTIME changes from dotnet to dotnet-isolated. Two changes compile cleanly and still alter behavior: System.Text.Json replaces Newtonsoft.Json as the default serializer, and host.json stops controlling your code's log levels. If the app runs on the Linux Consumption plan there's a second move, because .NET 10 can't run there. It needs a new Flex Consumption app, which has no deployment slots.
- How long does a .NET 8 to .NET 10 upgrade take?
- It depends on the migration class more than the size of the codebase. A plain ASP.NET Core service usually takes days: target framework, packages, base image, pipeline and stack setting, plus whatever a trial retarget turns up, such as BinaryFormatter usage, EF Core migration changes or a Debian-based container. An in-process Azure Functions app takes weeks, most of them spent testing behavior that compiles but changed, like JSON binding and log levels. A Durable Functions app takes that plus the lifetime of its longest-running orchestration, because in-flight instances should drain on the old app rather than move. Test coverage is the biggest variable: a service with contract tests on its serialized payloads upgrades far faster than one whose only test is production.
- Is running .NET 8 after end of support a compliance problem?
- For audited systems, usually yes, though your assessor makes the final call. PCI DSS v4.0 added Requirement 12.3.4, a review of the hardware and software technologies in use at least once every 12 months, and it became mandatory on March 31, 2025. An in-scope service on a runtime that no longer receives security fixes is exactly what that review exists to surface. What makes the finding manageable is documentation: the service's exposure, the compensating controls in place, a named owner, and a dated plan to upgrade. A .NET 8 service discovered during an audit with none of that is a very different conversation from a written exception with an end date.
When to Bring in External Help
The hard part of this upgrade isn't the code. It's the classification: which services are secretly Class 3, which Durable apps have orchestrations that outlive the deadline, and which exposure ratings the security team will actually sign. Those are easy to get wrong from the inside, because the plan usually gets written before anyone runs the trial retarget.
If your backend shows any of the patterns above, our Discovery Sprint is a two-week diagnostic that tells you exactly what to fix and in what order. For this deadline, that's the matrix above filled in for your estate: every service classified, drain dates calculated from your real orchestration lifetimes, and the trial retargets already run, so the weeks that remain go to migrations in the right order. The retail catalog automation case study shows the kind of system involved: an event-driven pipeline on consumption-plan Azure Functions, built on the in-process model because that was the standard when it was designed.
If the plan is already clear and the constraint is people, the embedded architect vs consulting agency comparison covers that decision.