Native AOT compiles a .NET app straight to a native executable at publish time, with no JIT and no .NET runtime install required to run it. It is the right choice for a growing set of workloads β CLIs, serverless functions, sidecars, containers β where startup time, memory footprint and image size matter more than the flexibility a JIT gives you. This guide is a practical walkthrough for developers who already ship .NET apps and want to know exactly what Native AOT buys you, what it takes away, and how to make ordinary application code compatible with it. It assumes familiarity with how the JIT and tiered compilation work, since Native AOT is best understood as removing that machinery entirely rather than optimizing it further.
What Is Native AOT?#
"AOT" stands for ahead-of-time compilation: instead of shipping IL that the JIT compiles at run time, publish runs a whole-program compiler, ILCompiler, that turns your app and every dependency it can reach into native machine code for one specific operating system and architecture. The output is a single native executable (plus a handful of native support files) that starts running immediately β there is no managed assembly loading, no JIT warm-up and no dotnet host resolving a shared framework.
This is a different trade-off from ReadyToRun, which precompiles code but still ships a full runtime capable of JIT-compiling anything R2R did not cover. Native AOT ships no JIT at all, which is what makes it fast to start and small to deploy, and also what makes it strict: everything the app can ever do has to be knowable at publish time.
How Native AOT Works#
Publishing with Native AOT replaces the normal dotnet publish output with a multi-stage, whole-program build:
- IL is analyzed as one program, not as separately deployable assemblies. The compiler looks at every reachable method starting from
Mainand everything reflection metadata says might be reachable. - Trimming removes unused code. Types, methods and even individual generic instantiations that the analysis cannot prove are reachable are cut from the output. Native AOT publishing always trims β there is no way to opt out of it.
- ILCompiler generates native code for everything that remains, using RyuJIT as its code-generation backend, the same compiler CoreCLR uses at run time.
- A minimal, native runtime is linked in alongside your code: garbage collector, type system support, exception handling β but not a JIT, and not most of reflection's dynamic capabilities.
The result is one native binary per target platform. Because the compiler has to prove reachability for the whole program, anything that only becomes reachable through reflection at run time β a type loaded by name, a method invoked dynamically β needs either an explicit hint or a source generator that makes the dependency visible at compile time instead.
Getting Started: Publishing a Native AOT App#
Enable Native AOT with the PublishAot property, then publish for a specific runtime identifier:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
</PropertyGroup>
</Project>dotnet publish -c Release -r linux-x64PublishAot implies self-contained, single-file, trimmed publishing, so you do not set those separately. The output under bin/Release/net10.0/linux-x64/publish/ is a native executable with no managed .dll you need to ship alongside it. Publishing for a different OS than the one you are building on generally needs a matching build environment or container for that target β unlike an ordinary framework-dependent publish, Native AOT output is not portable across operating systems.
For a library rather than an app, mark it AOT-compatible instead of publishing it directly:
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>IsAotCompatible turns on the trim and AOT analyzers for that project, so incompatible code shows up as build warnings for consumers to see before they ever try to publish.
Trimming and Trim Warnings#
Trimming is the analysis phase that decides what code survives into the final binary. It works from static analysis: anything reachable through ordinary calls, virtual dispatch resolvable to known types, and explicitly declared reflection usage is kept; anything the trimmer cannot prove reachable is removed. Reflection is where this breaks down, because Type.GetType(someString) or Activator.CreateInstance(typeName) describe a dependency the trimmer cannot see at compile time.
When the trimmer or the AOT compiler finds code it cannot safely analyze, it emits an IL2xxx (trimming) or IL3xxx (AOT) warning rather than silently doing the wrong thing:
| Warning | Meaning | Typical fix |
|---|---|---|
IL2026 | Code path calls a method marked RequiresUnreferencedCode | Avoid the call, or mark your own method RequiresUnreferencedCode too and document why |
IL2070 / IL2075 | A reflection call needs specific members of a type, but the type reaching it is not annotated to guarantee they survive trimming | Annotate the parameter or generic type with DynamicallyAccessedMembers |
IL3050 | Code path requires generating code at run time, which Native AOT cannot do | Replace it with a source generator or a static equivalent, or mark the method RequiresDynamicCode |
Two attributes let you describe reflection-heavy code honestly instead of suppressing the warning blindly:
using System.Diagnostics.CodeAnalysis;
public static class PluginLoader
{
// Tells callers and the trimmer this method is fundamentally incompatible
// with trimming; the warning now surfaces at every call site instead of here.
[RequiresUnreferencedCode("Loads plugin types by name; incompatible with trimming.")]
public static object CreatePlugin(string typeName) =>
Activator.CreateInstance(Type.GetType(typeName)!)!;
// Tells the trimmer to keep public constructors on whatever type flows in here,
// so this specific pattern stays trim-safe without a blanket suppression.
public static T Create<T>(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
where T : class =>
(T)Activator.CreateInstance(type)!;
}Reach for UnconditionalSuppressMessageAttribute only as a last resort, on code you have manually verified is safe β for example, a call whose type argument is always a compile-time constant even though the API's signature does not express that. Suppressing without verifying just turns a build-time warning into a run-time crash.
Making Your Code AOT-Friendly: Source Generators#
The practical way to avoid reflection-related trim and AOT warnings is to replace reflection with a source generator that does the equivalent work at compile time. Three cover most application code.
System.Text.Json generates a serializer from a JsonSerializerContext instead of building one by reflecting over your types at run time:
using System.Text.Json.Serialization;
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(OrderDto[]))]
internal sealed partial class AppJsonContext : JsonSerializerContext
{
}
public sealed record OrderDto(int Id, string Sku, decimal Amount);
// Program.cs
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);
var parsed = JsonSerializer.Deserialize(json, AppJsonContext.Default.OrderDto);Configuration binding has a source generator too, enabled per project because it is opt-in:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>// With the generator enabled, this Bind call is intercepted at compile time and
// no longer relies on reflecting over OrderOptions at run time.
var options = new OrderOptions();
builder.Configuration.GetSection("Orders").Bind(options);Logging avoids building object[] argument arrays and format strings at run time with the LoggerMessage source generator:
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Warning, Message = "Order {OrderId} exceeded {ThresholdUsd:C} threshold")]
public static partial void OrderExceededThreshold(this ILogger logger, int orderId, decimal thresholdUsd);
}
logger.OrderExceededThreshold(order.Id, 10_000m);Regex is a fourth common source: GeneratedRegexAttribute produces a compiled matcher at build time instead of building one via Regex.Compile at run time, which is both AOT-friendly and faster to start using.
public static partial class OrderPatterns
{
[GeneratedRegex(@"^ORD-\d{6}$")]
public static partial Regex OrderNumber();
}ASP.NET Core and Native AOT#
ASP.NET Core supports Native AOT starting in .NET 8, through a slimmer startup path built for it. WebApplication.CreateSlimBuilder sets up the minimum ASP.NET Core needs to run, leaving out HTTPS configuration, HTTP/3, IIS integration and several logging providers that a typical AOT deployment β usually a container behind a load balancer that terminates TLS β does not need:
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id, IOrderStore store) =>
store.TryGet(id, out var order) ? Results.Ok(order) : Results.NotFound());
app.MapPost("/orders", (OrderDto order, IOrderStore store) =>
{
store.Add(order);
return Results.Created($"/orders/{order.Id}", order);
});
app.Run();The dotnet new webapiaot template starts from exactly this shape, with PublishAot and the JSON source generator already wired up. As of .NET 8 and later, three ASP.NET Core app types are supported end to end: Minimal APIs, gRPC services and worker services. Within those, CORS, health checks, HTTP logging, static files, WebSockets, response compression and caching, rate limiting and JWT bearer authentication all work under Native AOT. MVC, Razor Pages, Blazor Server, OData, session state, SPA integrations and authentication schemes other than JWT are not supported, because they lean on runtime reflection or dynamic assembly loading that Native AOT cannot provide; SignalR has only partial support for the same reason. Every type an endpoint reads or returns as JSON must be registered on a JsonSerializerContext, or serialization falls back to reflection and fails at run time rather than at build time.
Size, Startup and Memory: What You Actually Get#
The headline benefits are startup time, working-set memory and disk footprint, in roughly that order of impact for most services: a Native AOT minimal API typically starts in single-digit milliseconds instead of the tens of milliseconds a framework-dependent app spends resolving the host and JIT-compiling its startup path, and it uses less memory because there is no JIT, no R2R metadata to keep around and less reflection metadata overall. Gains are largest for workloads that run many short-lived instances β serverless functions, autoscaled containers, CLI tools invoked repeatedly β where JIT warm-up cost is paid over and over.
Disk size is smaller than a framework-dependent deployment because there is no shared framework to ship, but a Native AOT binary still statically links the parts of the runtime it uses, so it is usually larger than a minimal script in a language with no runtime at all. <OptimizationPreference>Size</OptimizationPreference> asks the compiler to favor smaller output over the fastest possible code where the two disagree. Beyond that, the biggest remaining wins usually come from feature switches that remove optional runtime subsystems your app does not use β globalization data being the most common, since most services do not need culture-aware string comparison:
<PropertyGroup>
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>
<InvariantGlobalization>true</InvariantGlobalization>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
</PropertyGroup>Each switch trades away something: InvariantGlobalization disables culture-specific comparison and formatting (fine for services that only handle invariant or English text; risky for user-facing apps that sort or format in a specific culture), UseSystemResourceKeys replaces exception and diagnostic message text with short resource keys, and turning off EventSourceSupport or HttpActivityPropagationSupport removes diagnostics you may still want in production. Measure the size and behavior impact of each before shipping it.
Limitations: What Native AOT Cannot Do#
Everything below follows from the same root cause: there is no JIT, so nothing can generate or load new code at run time.
- No runtime code generation.
System.Reflection.Emit, dynamic proxies built by emitting IL, and similar techniques do not work. - No dynamic assembly loading.
Assembly.LoadFile,Assembly.LoadFromand plugin architectures that load arbitrary assemblies at run time are unsupported. - Reflection is restricted to what trimming can see. General-purpose reflection over arbitrary types still compiles, but only works reliably for types the trimmer proved reachable; anything else needs an annotation or a source generator.
System.Linq.Expressionscompiles to an interpreter, not native code, because building a delegate from an expression tree is itself a form of run-time code generation. Expression-heavy libraries (some ORMs, some mapping libraries) run, but more slowly than under a JIT.- COM interop (Windows) and C++/CLI are unsupported. P/Invoke works, but should use source-generated marshalling (
LibraryImport) rather than the reflection-based marshalling behind plainDllImport. - Debugging and profiling tooling is more limited than for a JIT-compiled process; some tools built around IL-level inspection do not apply to a native binary.
Best Practices#
- Set
PublishAotearly in a project's life, not as a late migration, so trim and AOT warnings surface as you write reflection-heavy code rather than in a large batch at the end. - Mark shared libraries
IsAotCompatibleso their own warnings are visible to every consumer, not just the one that eventually tries to publish with AOT. - Prefer source generators over reflection for JSON, configuration binding, logging and regular expressions; treat each
IL2026/IL3050warning as a signal to replace the underlying reflection, not just to silence it. - Test the actual published output, not just
dotnet run. Some trimming and AOT problems only appear once code has actually been removed. - Measure before adding size-optimization switches.
InvariantGlobalizationandUseSystemResourceKeyschange observable behavior, not just binary size.
Common Pitfalls#
- Treating
PublishTrimmedandPublishAotas the same thing.PublishAotalways trims, but plain trimming without AOT still runs on a normal JIT and keeps reflection mostly working; the failure modes are different. - Suppressing trim warnings with
UnconditionalSuppressMessageAttributewithout verifying the code path, which trades a build warning for a run-timeNotSupportedException. - Assuming a third-party package is AOT-ready just because it targets a recent TFM. Check for
IsAotCompatibleor explicit AOT documentation, and publish-test early. - Reaching for AOT on a plugin-based app. If the architecture depends on loading arbitrary assemblies at run time, that is a fundamental mismatch, not a warning to fix.
- Forgetting
JsonSerializerContextregistration for a type used only indirectly, such as one nested inside a generic response wrapper.
Native AOT vs ReadyToRun vs Framework-Dependent#
| Framework-dependent | Self-contained + ReadyToRun | Native AOT | |
|---|---|---|---|
| Runtime required on target | Yes | No | No |
| JIT present at run time | Yes | Yes | No |
| Typical startup | Slowest | Faster | Fastest |
| Reflection and dynamic codegen | Full | Full | Restricted |
| Deployment size | Smallest (shares framework) | Largest (runtime + R2R code) | Small to medium |
| Cross-platform build output | One build runs anywhere with the runtime | RID-specific | RID-specific |
Framework-dependent deployment stays the right default for most line-of-business apps, where operational simplicity and full reflection support matter more than startup time. ReadyToRun is a middle ground when you need faster startup but still rely on reflection, runtime code generation or plugins. Native AOT is the choice when startup and footprint are the dominant cost β see the .NET Aspire and container-hosting guides for how that plays out in cloud-native deployments.
When Not to Use Native AOT#
Skip Native AOT for apps that depend on runtime plugin loading, heavy use of System.Reflection.Emit or dynamic proxies, ORMs and mapping libraries that build expression trees on the hot path without an AOT-friendly mode, or any app that must run on a platform Native AOT does not target. It is also often not worth the migration cost for internal line-of-business apps with modest instance counts, where framework-dependent deployment's simpler build and full reflection support outweigh a startup time nobody is actually measuring.
Frequently Asked Questions#
What is the difference between Native AOT and trimming?#
Trimming removes unused IL from a normal, JIT-based deployment; the app still ships a full runtime and can still use most reflection. Native AOT always trims, but goes further by compiling everything to native code ahead of time and removing the JIT entirely, which is what makes its reflection and dynamic-codegen restrictions stricter than plain trimming's.
Can I use reflection at all with Native AOT?#
Yes, but only for types and members the trimmer can prove are reachable, typically through DynamicallyAccessedMembers annotations or a source generator that describes the dependency at compile time. Fully general reflection over arbitrary, run-time-supplied type names is not supported.
Does Native AOT work with Entity Framework Core?#
Partially, and it depends heavily on the provider and query patterns used; EF Core has historically relied on reflection and dynamically compiled expressions for query translation. Check the current EF Core documentation and your provider's AOT guidance, and publish-test real queries before committing to it, rather than assuming compatibility from the package version alone.
Why did my app work in dotnet run but fail after publishing with Native AOT?#
dotnet run uses the normal JIT-based runtime, so reflection-based code that trimming or AOT cannot analyze still works there. It can then fail at run time in the trimmed, AOT-compiled output. Publish and run the actual AOT binary as part of your regular testing, not just as a final release step.
Is Native AOT faster than ReadyToRun?#
For cold start, generally yes, because there is no JIT fallback path or R2R metadata to load. For steady-state throughput on long-running processes, the two are closer, since ReadyToRun code is eventually replaced by profile-guided tier-1 JIT output that can out-optimize AOT's ahead-of-time decisions in some workloads. Choose based on which phase of your app's lifetime β startup or steady state β matters more.
Summary#
- Native AOT compiles a whole program to a single native executable at publish time using
PublishAot, removing the JIT and shrinking startup time, memory and (usually) disk size. - Trimming is mandatory under Native AOT;
IL2xxxandIL3xxxwarnings flag reflection and dynamic-codegen usage the compiler cannot prove safe. - Source generators for JSON, configuration binding, logging and regular expressions replace the reflection patterns that most commonly trigger those warnings.
- ASP.NET Core supports Native AOT for Minimal APIs, gRPC and worker services via
CreateSlimBuilder, but not MVC, Razor Pages, Blazor Server or non-JWT authentication. - Skip Native AOT for plugin-based architectures or libraries that fundamentally depend on runtime code generation.
Further Reading#
- JIT Compilation, Tiered Compilation and Dynamic PGO in .NET
- .NET Aspire: Cloud-Native Orchestration for .NET
- Native AOT and Trimming Interview Questions
- .NET Diagnostics Toolkit: Counters, Traces and Dumps
- Native AOT deployment overview (Microsoft Learn)
- Native AOT support in ASP.NET Core (Microsoft Learn)