NativeAOT & Trimming
Key Points
- NativeAOT compiles your entire app + runtime into a single native binary at publish time. No JIT, no
Reflection.Emit, no dynamic loading. - Trimming removes unreferenced IL and types from your app at publish time. Smaller, but doesn't go all the way to native.
- AOT implies trimming; trimming does not imply AOT.
- The cost: no runtime code generation. Reflection-heavy patterns break (or get warned at build time). Source generators replace them.
IsAotCompatible=trueon a library opts in to trim/AOT analyzers — the contract for shipping AOT-safe libraries.[DynamicallyAccessedMembers]is how you tell the trimmer "preserve these members ofTbecause someone reflects on them."[RequiresUnreferencedCode]/[RequiresDynamicCode]/[RequiresAssemblyFiles]mark APIs that are fundamentally incompatible — callers get warnings, propagating up the call graph.
Concepts (deep dive)
What you actually get with PublishAot=true
That's it. No dotnet runtime needed on the target. No *.dll for your app. No JIT.
PublishAot implies: - SelfContained=true (always) - PublishTrimmed=true (full trimming required) - IlcInvariantGlobalization=true by default unless overridden - EnableTrimAnalyzer=true, EnableAotAnalyzer=true
Why "no JIT" matters
Without a JIT, the runtime can't compile new IL at runtime. Anything that requires emitting IL fails:
System.Reflection.EmitExpression<T>.Compile()(interpreter-mode-only fallback exists in some scenarios; very limited)DynamicMethodType.MakeGenericType(typeof(T))for instantiations not seen at compile time
Existing types you can reflect on, if the trimmer kept them. Calling members through reflection works. Producing brand-new types/methods at runtime does not.
What trimming actually does
The trimmer is a static analyzer that walks from your app's entry point and marks every type, method, field, and assembly that's reachable. Anything unreferenced is removed.
Application entry point (Main)
│
▼
Trimmer reachability analysis
│
┌───────────┼───────────┐
▼ ▼ ▼
kept types kept methods kept attributes
│
▼
Output: trimmed assemblies
The challenge: reflection. If your code reflects on a type, the trimmer can't see that without help. So:
// ❌ Trimmer warning IL2026: reflection on T not visible to trimmer
public static T MakeDefault<T>()
=> (T)Activator.CreateInstance(typeof(T))!;
// ✅ Tell the trimmer T's constructor must be preserved
public static T MakeDefault<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
where T : new()
=> new T();
The [DynamicallyAccessedMembers] annotation is the key vocabulary. It propagates: if you pass typeof(Foo) to a method that has [DynamicallyAccessedMembers] on its Type parameter, you must annotate at the call site too.
[RequiresUnreferencedCode] etc.
Some APIs are fundamentally incompatible with trimming or AOT. They mark themselves with attributes that warn callers:
| Attribute | Meaning | Examples |
|---|---|---|
[RequiresUnreferencedCode("reason")] | This method may access types/members the trimmer didn't see. | Type.GetType(string), default-constructed reflection-based serializers |
[RequiresDynamicCode("reason")] | This method may emit IL at runtime — won't work in AOT. | Expression<T>.Compile(), Activator.CreateInstance(Type, ...) for unknown generics |
[RequiresAssemblyFiles("reason")] | This method depends on assembly files on disk — won't work in single-file publish. | Assembly.Location, Assembly.CodeBase |
Callers get warnings (IL2026, IL3050, IL3000). The way out: either annotate up the chain, switch to a trim-friendly alternative (e.g., source-generated serialization), or suppress with [UnconditionalSuppressMessage] if you've manually proven safety.
Library author contract: IsAotCompatible
<PropertyGroup>
<IsAotCompatible Condition="'$(TargetFramework)' == 'net10.0'">true</IsAotCompatible>
</PropertyGroup>
Setting this on a library: 1. Turns on IsTrimmable, EnableTrimAnalyzer, EnableAotAnalyzer. 2. Gives consumers compile-time confidence: this library has been built with the trim/AOT analyzers active and zero warnings.
Required for serious open-source library authors targeting .NET 8+.
Generic specialization in AOT
In a JIT-based runtime, generics are lazy: List<int>, List<string>, List<MyType> get specialized only when used at runtime. In AOT, every instantiation must exist at compile time. The compiler discovers them by walking the call graph.
This means:
// ✅ Works in AOT — int and string are referenced concretely.
var ints = new List<int>();
var strings = new List<string>();
// ❌ AOT problem — runtime types unknown to the compiler.
public List<T> MakeList<T>() => new();
var t = Type.GetType("MyApp.Foo")!;
var listType = typeof(List<>).MakeGenericType(t); // RequiresDynamicCode warning
⚠️ Code-size implication: generic struct instantiations specialize per type.
List<int>,List<long>,List<DateTime>all generate distinct native code. Excessive struct-generic usage can balloon binary size in AOT.
What's hard to make AOT-safe
- Reflection-heavy ORMs without source generators (EF Core 8+ has source-gen support; older EF Core requires effort).
- Reflection-based DI that scans assemblies for handlers (
MediatR, etc.) — newer versions are increasingly source-gen-friendly. - JSON.NET (Newtonsoft) — fundamentally reflection-based; use
System.Text.Jsonsource-gen instead. - Mocking libraries (Moq, NSubstitute) — runtime IL emit. Use only in tests, not production.
- Plugin systems loading external assemblies — see Assembly Loading & LoadContext.
Trim modes
| Mode | Effect |
|---|---|
partial (default for libraries) | Trim only assemblies marked IsTrimmable=true |
full (default with PublishAot/PublishTrimmed) | Trim everything; library opt-out via attributes |
Set via <TrimMode>full</TrimMode> (or partial).
Suppressing warnings — the escape hatch
If you've manually verified that an API is safe (despite the trimmer's static analysis being unable to see it):
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "Caller-provided type T is constrained to MyBase, and we only access public properties.")]
public static void Process(Type t) { /* ... */ }
⚠️ Suppressions are dangerous. They silence the warning, not the underlying issue. Use only when you can articulate exactly why the call is safe.
How it works under the hood
NativeAOT uses a static compiler called ILC (the IL Compiler) that takes IL + a set of generic instantiations and produces native code via a backend (RyuJIT in AOT mode, or LLVM in some configurations).
┌──────────────────────────────────────────────────────────────┐
│ Roslyn → IL │
└──────────────────────────────────┬───────────────────────────┘
│
▼
┌────────────────────────────────┐
│ ILC (IL Compiler) │
│ - reachability analysis │
│ - generic discovery │
│ - method body codegen via JIT │
│ - small runtime + GC linked in │
└────────────────┬───────────────┘
│
▼
┌────────────────────────────────┐
│ Native object file + runtime │
│ + linker → single executable │
└────────────────────────────────┘
The output is a real native binary you can objdump, nm, or strip. The .NET runtime that ships with the binary is a stripped-down version: GC, threading, exception handling. No type system metadata for unreferenced types, no JIT.
Code: correct vs wrong
❌ Wrong: relying on reflection for serialization in an AOT app
public string Serialize<T>(T value)
=> JsonSerializer.Serialize(value); // ❌ default reflection-based path
Trim/AOT warning: IL2026: Using member 'JsonSerializer.Serialize<T>(T, JsonSerializerOptions?)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
✅ Correct: System.Text.Json source generator
[JsonSerializable(typeof(MyDto))]
[JsonSerializable(typeof(List<MyDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }
public string Serialize(MyDto value)
=> JsonSerializer.Serialize(value, AppJsonContext.Default.MyDto);
The source generator emits all the serialization code at build time. Trimmer-safe; AOT-safe.
❌ Wrong: dynamic dependency injection scanning
foreach (var t in Assembly.GetExecutingAssembly().GetTypes())
if (typeof(IHandler).IsAssignableFrom(t))
services.AddTransient(typeof(IHandler), t);
// ❌ Trimmer can't predict T — IHandler implementations may be removed.
✅ Correct: explicit registration, or source-gen DI
services.AddTransient<IHandler, OrderHandler>();
services.AddTransient<IHandler, ShipmentHandler>();
// Or use a source-generated DI library that emits the registrations at compile time.
❌ Wrong: Activator.CreateInstance(Type) from configuration
var typeName = Configuration["StrategyType"];
var t = Type.GetType(typeName)!;
var instance = Activator.CreateInstance(t)!; // ❌ trimmer warning, AOT warning
✅ Correct: closed-set factory
public static IStrategy Create(string name) => name switch
{
"fast" => new FastStrategy(),
"safe" => new SafeStrategy(),
_ => throw new ArgumentException(nameof(name))
};
Design patterns for this topic
Pattern 1 — "Source generators replace reflection"
- Intent: anywhere you'd reach for reflection at runtime, see if a source generator (or compile-time alternative) exists.
- When to use it: AOT-target apps; libraries claiming
IsAotCompatible. - Code sketch: see "JSON source-gen" above.
- Trade-offs: more code at compile-time; sometimes a less elegant API. Worth it for AOT.
Pattern 2 — "Annotate the type parameter, not the call site"
- Intent: push trimmer hints upstream so the analyzer can prove safety throughout the call chain.
- When to use it: generic helper methods that reflect on their type parameter.
- Code sketch:
public static T Create<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
where T : new()
=> new T();
Pattern 3 — "Closed-set factory over reflection-driven plugin"
- Intent: trade extensibility for trim safety.
- When to use it: when you have a finite, known set of strategies/handlers/types.
- When NOT to use it: when extensibility through external plugins is a hard requirement (then NativeAOT may not be your target).
Pattern 4 — "Library authors: opt-in incrementally"
- Intent: make a library AOT-friendly without rewriting it.
- When to use it: evolving an existing library.
- Strategy:
- Set
EnableTrimAnalyzer=truefirst; fix or annotate warnings. - Then
EnableAotAnalyzer=true; addressRequiresDynamicCodewarnings. - Finally
IsAotCompatible=trueonce all warnings are resolved.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| NativeAOT | Smallest binary; fastest startup; no JIT cost; no runtime needed | No reflection-emit; stricter analyzers; rebuild for each RID |
| Trimming | Smaller binary; faster startup | Reflection patterns may break |
| Source generators | Replace reflection; AOT-friendly | More build complexity; new tooling |
| Closed-set factories | Trim-safe | Less extensible |
| AggressiveInlining (here too) | Forces inline; smaller native | Bloats native code |
When to use / when to avoid
- Use NativeAOT for:
- Cloud Run / Lambda / Azure Container Apps (cold-start sensitive).
- CLI tools you ship as single-file binaries.
- Edge/IoT scenarios where binary size and startup matter.
- Use trimming (without AOT) when you want size reduction but still need JIT (rarely the case in 2026 — usually go all the way to AOT).
- Avoid NativeAOT for:
- ASP.NET Core apps with heavy plugin loading (improving but still rough edges).
- Apps using EF Core <8 (older versions don't have source-gen).
- Anything depending on
Reflection.Emit(DryIoc reflection-mode, IL emitters).
Interview Q&A
Q1. What's the difference between trimming and NativeAOT? Trimming removes unreferenced IL/types at publish; the JIT still runs. NativeAOT compiles everything (including the runtime) to a single native binary at publish; no JIT at all. AOT implies trimming.
Q2. What is [DynamicallyAccessedMembers]? A type-system annotation that tells the trimmer which members of a Type parameter must be preserved. Without it, the trimmer can't see what your reflection code accesses and may remove members at trim time. Comes in flavors: PublicConstructors, PublicProperties, All, etc.
Q3. What's [RequiresUnreferencedCode]? An attribute on an API that says "I might use unreferenced code at runtime — callers should know I'm not trim-safe." Callers get a warning. Used by JsonSerializer.Serialize<T> (the reflection path), Assembly.LoadFrom, etc.
Q4. How does NativeAOT handle generics? Every generic instantiation must be discoverable at compile time. The ILC walks the call graph and specializes for each. Open generics (typeof(List<>).MakeGenericType(...)) at runtime are not supported.
Q5. Why does NativeAOT typically forbid Expression<T>.Compile()? Because compiling an expression tree generates IL at runtime — which requires a JIT. NativeAOT has no JIT. Use source generators or precomputed delegates instead.
Q6. What's IsAotCompatible=true and why does it matter for libraries? A library-author signal that the library has been built with trim and AOT analyzers active and produces no warnings. Consumers can take it as a contract that the library is trim/AOT-safe. Without it, consumers see warnings cascading from the library's reflection.
Q7. How would you make EF Core work with NativeAOT? Use EF Core 8+ with the compiled-models source generator. EF still has some hard cases, but the source-gen path covers the typical workload of fixed entity types and known queries.
Q8. What's a "trimming root"? The starting point for the trimmer's reachability analysis — typically Main. From there, every called method, type, and field is marked. Anything unreached is candidate for removal. Roots can be added via TrimmerRootDescriptor files for assemblies that need to keep specific types alive even if static analysis can't see them.
Q9. Are there scenarios where you'd combine <PublishTrimmed>true</PublishTrimmed> without <PublishAot>true</PublishAot>? Increasingly rare. It made sense when AOT was experimental — get the size benefit without committing to AOT's stricter rules. In 2026 with AOT mature, you usually go all-in.
Q10. The trimmer leaves a warning IL2070. What does it mean? The base IL2070 indicates a Type parameter passed without sufficient [DynamicallyAccessedMembers] annotations. Look at the call site: you're handing a Type to a method that demands particular members preserved, but you haven't told the trimmer what to keep.
Q11. How big is a typical NativeAOT ASP.NET Core "Hello World"? About 8–12 MB on linux-x64 in 2026. Cold start: tens of milliseconds. Compare to ~80 MB framework-dependent or 65+ MB self-contained without trimming.
Q12. When would source-generated regex matter for AOT? Always. [GeneratedRegex] produces compile-time native-friendly regex code. The runtime Regex constructor with RegexOptions.Compiled requires Reflection.Emit and won't work in AOT.
Gotchas / common mistakes
- ⚠️ Suppressing trim warnings without proof — silences the message, not the bug. The runtime crash will arrive in production.
- ⚠️
Activator.CreateInstance(Type)for generic instantiations — won't work in AOT. Use closed-set factories. - ⚠️
Assembly.GetTypes()to find handlers — reflection-based discovery; trimmer can't see results. Use explicit registration or source-gen DI. - ⚠️ Mixing
IsAotCompatible=truelibraries with non-compatible ones — your AOT app inherits the un-safe behavior. Audit dependency tree. - ⚠️ NativeAOT bin size growing fast — heavy use of struct generics + many closed types can balloon the native code. Profile with
dotnet-symbol-based tooling. - ⚠️
Expression.Compile()in libraries marked AOT-compatible — makes the analyzer warn even if you "won't actually call it" in some configs. Either remove the path or guard it with conditional compilation. - ⚠️ Forgetting
<TrimmerRootAssembly>for plugins you load dynamically — the trimmer will eat them. Add explicit roots.