Trimming and Native AOT
Trimming removes code nothing calls and Native AOT compiles ahead of time; both need code a tool can follow without reflection.
<PublishAot>true</PublishAot> then: dotnet publish -c Release -r linux-x64A self-contained app carries the whole runtime library, most of which your code never touches. Trimming walks out from your entry point and deletes what nothing reaches. Native AOT goes further: it compiles the app to a native executable at publish time, so there is no just-in-time compiler at run time. Startup is faster and memory use lower, which matters when you run many copies of a service.
Turning them on
Trimming is <PublishTrimmed>true</PublishTrimmed> in the project file, which is the same setting as -p:PublishTrimmed=true on the command line. Prefer the project file, because it also disables trim-hostile features during dotnet build. Trimming only applies to a self-contained publish. Native AOT is <PublishAot>true</PublishAot> plus dotnet publish -c Release -r linux-x64, and it needs a C toolchain present: clang and the zlib development package on Linux, the C++ workload in Visual Studio on Windows.
What they cannot see
Both tools decide what to keep by reading your code. A type named only by a string at run time is invisible to them, and the publish prints a warning instead: IL2xxx for trimming, IL3xxx for AOT. Native AOT also forbids Assembly.LoadFile and System.Reflection.Emit, implies trimming and single-file, and only interprets System.Linq.Expressions. You cannot cross-compile to another operating system. A library advertises <IsAotCompatible>true</IsAotCompatible>; .NET 10 adds VerifyReferenceAotCompatibility, which warns (IL3058) about dependencies that do not.
Example
// Program.cs from: dotnet new console
// A type that is only ever named by a string is invisible to the
// trimmer, so trimming can delete it and this code still compiles.
string[] parts = ["Plug", "in"];
// Built at run time, so no tool can follow the name. Publishing
// with PublishTrimmed reports warning IL2057 here.
string typeName = string.Concat(parts);
Type? found = Type.GetType(typeName);
Console.WriteLine($"resolved {typeName}: {found is not null}");
object? instance = found is null ? null : Activator.CreateInstance(found);
Console.WriteLine($"instance: {instance?.ToString() ?? "(none)"}");
class Plugin
{
public override string ToString() => "Plugin v1";
}
// Output (published without trimming):
// resolved Plugin: True
// instance: Plugin v1
//
// Published with PublishTrimmed, nothing in the code refers to
// Plugin, so it is removed and the same program prints:
// resolved Plugin: False
// instance: (none)When to use it
- Cut a container image down when you are running hundreds of replicas of one service.
- Get a command line tool to start in milliseconds rather than tens of milliseconds.
- Diagnose a published app that throws a missing type error although it runs fine from dotnet run.
More examples
A project set up for Native AOT
PublishAot belongs in the project file rather than on the command line because it changes build and editor analysis, not just publish; set on the command line, you find out about incompatible code only at release time. Native AOT publishing writes debug information to a separate file by default (.dbg on Linux, .pdb on Windows, a .dSYM folder on macOS), and you need to keep it to read a crash dump.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<!-- Compile to native at publish, and analyse for dynamic
code use during every build and in the editor. -->
<PublishAot>true</PublishAot>
<!-- Drops the ICU culture data. Smaller, but string
comparison and formatting become culture-insensitive. -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Unix only: keep the debug symbols in the binary instead
of a separate .dbg file. Makes the binary larger. -->
<StripSymbols>false</StripSymbols>
</PropertyGroup>
</Project>Replacing reflection with a source generator
This is the standard fix for a trim warning in serialisation code. The reflection-based overload, JsonSerializer.Serialize(order), inspects the type at run time and so warns under trimming; the generated JsonSerializerContext writes that inspection out as C# at compile time. The pattern repeats elsewhere: prefer a generator over reflection whenever you plan to trim.
using System.Text.Json;
using System.Text.Json.Serialization;
Order order = new(7, "shipped");
// Serialising through generated metadata. No reflection over the
// type at run time, so trimming and AOT keep what is needed.
string json = JsonSerializer.Serialize(order, AppJson.Default.Order);
Console.WriteLine(json);
record Order(int Id, string Status);
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
partial class AppJson : JsonSerializerContext
{
}
// Output:
// {"id":7,"status":"shipped"}Marking a library as trim and AOT friendly
Setting IsAotCompatible to true turns on four analysers at once (IsTrimmable, EnableTrimAnalyzer, EnableSingleFileAnalyzer, EnableAotAnalyzer) so your library's problems surface in your build rather than in a user's publish. The IsAotCompatible assembly marker itself is new in .NET 10, so packages built earlier will not carry it even when they are compatible, which is why the verification step is opt-in.
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<!-- Only the modern target can be analysed, so condition it. -->
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<!-- Opt in: warn (IL3058) about references that are not
marked AOT compatible. Added in .NET 10. -->
<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>
</PropertyGroup>
Discussion