Self-Contained, Single-File and Trimmed Publishing

Published on 2026-10-15ยทv1.0

Objective

dotnet publish is not one operation but a set of independent switches: who provides the runtime (the machine or your folder), how many files you ship (a folder or one executable), how much of the framework you keep (all of it or only what static analysis says you use), and whether code is precompiled (ReadyToRun). Each switch changes size, startup and what can break. This concept publishes the same apps in every combination and reports real sizes, startup numbers and trim warnings, so the choice is made with data instead of habit.

Environment for every number below: SDK 10.0.100, runtime 10.0.0, Release, osx-arm64, Apple M4 Max, macOS 27. The machine was shared with other builds, so wall-clock times are medians of repeated runs and only differences that held across rounds are reported.

Use Cases

  • Shipping a command-line tool to machines where you cannot install .NET.
  • Choosing the publish mode for a container image or a serverless function, where image size and cold start are billed.
  • Distributing one executable instead of a folder with 190 files.
  • Understanding why a trimmed build passes the tests in Debug and fails in production with a null from reflection.

Deep Dive

Framework-dependent or self-contained

A framework-dependent publish ships your assemblies and expects a matching shared runtime on the target; it is the default. By default it also writes an apphost (a small native launcher named after the app) next to the portable DLL, so ./Hello and dotnet Hello.dll both work; -p:UseAppHost=false drops it. A self-contained publish copies the runtime and the whole framework into the folder, and must target a runtime identifier (RID) such as linux-x64, win-arm64 or osx-arm64: the runtime is native code, so one publish runs on one OS and architecture.

plaintext
dotnet publish -c Release # framework-dependent dotnet publish -c Release -r osx-arm64 --self-contained # self-contained

The trade is servicing. A framework-dependent app rolls forward to the newest patch of its major runtime installed on the machine, so apt upgrade fixes a CVE for every app. A self-contained app keeps the runtime it was published with until you publish again.

A console app that prints its runtime and RID, published both ways:

Mode Files Size
framework-dependent 5 0.14 MB (apphost 124 KB, Hello.dll 5.5 KB)
self-contained 191 82.8 MB

Single file: one executable, with exceptions

-p:PublishSingleFile=true appends the managed assemblies, deps.json and runtimeconfig.json to the apphost. It works in both modes:

Mode Files Size
framework-dependent, single file 2 (Hello, Hello.pdb) 0.13 MB
self-contained, single file 2 76.4 MB

Managed assemblies are loaded from inside the bundle, without extraction. That breaks code that assumes assemblies live on disk, and the analyzer says so at build time:

text
Program.cs(6,33): warning IL3000: 'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

The run confirmed it: Location: '', while AppContext.BaseDirectory printed the publish folder.

Native libraries are the exception. An app using Microsoft.Data.Sqlite published as a single file still produced three files: Pkg, Pkg.pdb and libe_sqlite3.dylib (1.5 MB) next to it. Adding -p:IncludeNativeLibrariesForSelfExtract=true puts the library inside the bundle, but the host must extract it to disk before loading it. Running with DOTNET_BUNDLE_EXTRACT_BASE_DIR pointing to a scratch folder showed extract/Pkg/0UcDQXFeJ4aL/libe_sqlite3.dylib. Without that variable the documentation says the files go under $HOME/.net on Linux and macOS and %TEMP%\.net on Windows, which fails under systemd units without $HOME. (The runtime's own native code was already inside the bundle on macOS; the documentation describes separate runtime files on Windows.)

-p:EnableCompressionInSingleFile=true compresses the embedded assemblies: the self-contained Hello went from 76.4 MB to 34.8 MB, and its startup from about 40 ms to about 75 ms, because everything is decompressed into memory at launch (peak RSS 37 MB to 50 MB).

Trimming: static analysis deletes the unused framework

-p:PublishTrimmed=true runs ILLink over the self-contained output. It starts from Main, follows every static reference and removes assemblies, types and members that are never reached. Trimming exists only for self-contained apps (the shared framework of a framework-dependent app cannot be cut per app).

Mode Files Size
self-contained, trimmed 29 20.2 MB
self-contained, single file, trimmed 2 13.0 MB

The analysis cannot see reflection driven by data. This program has all three classic patterns:

csharp
using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; // 1. A type chosen at run time from a string var name = args.Length > 0 ? args[0] : "Plugin"; Console.WriteLine($"GetType(\"{name}\"): {Type.GetType(name)?.Name ?? "null"}"); // 2. Reflecting over a Type parameter, without and with annotation Console.WriteLine($"Plain: {string.Join(", ", Methods(typeof(InvoiceA)))}"); Console.WriteLine($"Annotated: {string.Join(", ", AnnotatedMethods(typeof(InvoiceB)))}"); // 3. Reflection-based JSON try { Console.WriteLine(JsonSerializer.Serialize(new InvoiceA())); } catch (Exception ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); } static IEnumerable<string> Methods(Type t) => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Select(m => m.Name); static IEnumerable<string> AnnotatedMethods( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Select(m => m.Name); class Plugin { } class Exporter { } class InvoiceA { public decimal Total { get; set; } = 10m; public void Print() { } } class InvoiceB { public decimal Total { get; set; } = 10m; public void Print() { } }

The trimmed publish printed each warning twice: once from the Roslyn analyzer (warning IL2057) and once from ILLink (Trim analysis warning IL2057):

text
Program.cs(7,43): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type. Program.cs(18,5): warning IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods' in call to 'System.Type.GetMethods(BindingFlags)'. Program.cs(14,25): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(...)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.

Untrimmed versus trimmed, with Exporter as the argument:

text
// dotnet run -c Release -- Exporter GetType("Exporter"): Exporter Plain: get_Total, set_Total, Print Annotated: get_Total, set_Total, Print {"Total":10} // ./out/trim/Trim Exporter GetType("Exporter"): null Plain: Annotated: get_Total, set_Total, Print InvalidOperationException: Reflection-based serialization has been disabled for this application. ...

Every warning became a real failure. Exporter was never referenced, so it was deleted (the default Plugin survived, apparently because ILLink could see that literal). InvoiceA lost all its methods. The annotated parameter is the fix: [DynamicallyAccessedMembers] tells ILLink that whatever Type flows into it needs its public methods kept, and the analyzer then checks every caller. [RequiresUnreferencedCode] is the other tool: it marks an API as incompatible, so the warning moves to whoever calls it, as JsonSerializer does. JSON was not trimmed away; it was switched off by a feature switch.

Feature switches: trimming changes behavior, not only size

PublishTrimmed writes switches into runtimeconfig.json. Some, from the trimmed app above:

plaintext
"System.Net.SocketsHttpHandler.Http3Support": false, "System.StartupHookProvider.IsSupported": false, "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false

Http3Support is new in .NET 10: with PublishTrimmed or PublishAot, HttpClient no longer carries HTTP/3 unless you set <Http3Support>true</Http3Support>. StartupHookProvider off means DOTNET_STARTUP_HOOKS is ignored, which is how some APM agents attach. Each switch also has an MSBuild property (EventSourceSupport, InvariantGlobalization, UseSystemResourceKeys) for cutting further.

Packages get a different treatment. With Newtonsoft.Json 13.0.4 the calls in your code are reported individually, but the warnings inside the package collapse into one:

text
Newtonsoft.Json.dll : warning IL2104: Assembly 'Newtonsoft.Json' produced trim warnings.

<TrimmerSingleWarn>false</TrimmerSingleWarn> expands them. That app still worked trimmed, by luck: the record's constructor and getters were referenced by its own code.

Trimming and ReadyToRun: the startup surprise

ReadyToRun (R2R) stores native code next to the IL so the JIT has less to do at startup (see the JIT concept). The framework already ships as R2R. The same SQLite and Newtonsoft app, with a Stopwatch around Main (median of 11 runs) and the count of JIT-compiled methods (DOTNET_JitStdOutFile=/dev/stdout DOTNET_JitDisasmSummary=1):

Publish Size JIT'd methods Time in Main Peak RSS
framework-dependent 0.96 MB of DLLs 693 46 ms 72.5 MB
framework-dependent, R2R 2.7 MB of DLLs 56 27 ms 74.8 MB
self-contained single file 77.3 MB 693 46 ms 68.7 MB
single file, trimmed 19.3 MB 3,021 101 ms 29.1 MB
single file, trimmed, R2R 44.0 MB 153 19 ms 53.0 MB

Newtonsoft.Json.dll grew from 723 KB to 2.0 MB with R2R (2.9 times). The trimmed row is the trap: the JIT compiled 3,021 methods instead of 693, a sign that the trimmed copies of the framework assemblies no longer carried their precompiled code, and the app spent twice as long in Main. Adding PublishReadyToRun recompiles the trimmed set and wins on every axis except size.

Trade-offs

  • Framework-dependent is small and patched by the OS; self-contained is pinned. A self-contained app keeps its runtime's CVEs until you republish.
    plaintext
    dotnet publish -r linux-x64 --self-contained # rebuild on every runtime patch
  • Single file simplifies copying, not path logic. Assembly.Location is empty, and native package libraries either sit next to the file or get extracted to a directory that must be writable and trusted.
  • Compression halves the file and doubles startup. Measure before enabling it; it fits rarely-launched tools, not CLIs called in loops.
  • Trimming cut 82.8 MB to 20.2 MB but turns reflection bugs into production bugs. Treat IL2xxx as errors and test the published binary, not dotnet run. With the line below, the publish of the program above stopped with error IL2026, error IL2057 and error IL2070.
    plaintext
    <PublishTrimmed>true</PublishTrimmed> <WarningsAsErrors>$(WarningsAsErrors);IL2026;IL2057;IL2070;IL2104</WarningsAsErrors>
  • Trimmed without R2R can start slower than untrimmed. Here, 101 ms against 46 ms in Main; trimmed plus R2R was the fastest at 19 ms.
  • R2R trades disk for startup. 2 to 3 times larger assemblies; little gain for an app whose own code is small, since the framework is already R2R.
  • Feature switches change runtime behavior. HTTP/3, startup hooks and reflection JSON are off in trimmed .NET 10 apps unless re-enabled.

Documentation Links