.NET Console Applications

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A .NET console program is one of the most approachable ways to write a command-line tool for Windows, macOS and Linux from one code base: the base class library hides most operating-system differences, the SDK can produce anything from a script run straight from source to a single native executable, and System.CommandLine, the Generic Host and Spectre.Console cover argument parsing, application plumbing and rich output. The price of that convenience is that the abstractions leak at the edges: Console behaves differently when its streams are redirected, signals exist on Unix but not on Windows, and Native AOT turns reflection-based conveniences into build warnings. This page covers the parts of .NET that matter for a program that runs in a terminal, and links rather than repeats the design rules that are not specific to .NET.

It builds on the cross-language material of this section: Windows Console & Processes (code pages, Ctrl+C events, CreateProcess), POSIX Processes, Signals & I/O (signals, descriptors, buffering) and Cross-Platform Portability (paths, line endings, config directories). The first steps with System.CommandLine, Spectre.Console and dotnet tool packaging are in Frameworks: Swift & .NET and are not repeated; the SDK commands themselves are documented in Publish and Deployment Models, Run and File-Based Apps and .NET Tools. Full-screen interfaces are in .NET TUIs with Spectre.Console & Terminal.Gui.

The page was written against .NET 10 LTS (runtime 10.0.13) with C# 14, System.CommandLine 2.0.13 (the stable 2.0 line, released in November 2025) and the documentation current in October 2026. .NET 11 is still at release candidate stage, so features that the documentation marks as ".NET 11 and later" are mentioned only to say they are not used here. Behaviour is described for Windows 10/11, macOS and Linux (glibc and musl).

Which snippets were compiled or run. No .NET SDK was available on the machine this page was written on, so none of the C#, project-file or shell snippets below were compiled or executed. They are assembled from the signatures, properties and samples in the Microsoft documentation linked after each example. The outputs shown are the outputs the code is written to produce, not captured runs. Treat every snippet as a starting point to build and test on your own machine before relying on it.

The running example is the Bookshelf shelf tool of this section, which lists books, adds them, loans and returns them, and syncs a small catalogue.

The Console Class

System.Console is the lowest-level, always-available way to talk to a terminal. It exposes three streams, a set of terminal-control members and a few events, and almost every higher-level library (System.CommandLine’s help output, the console logger, Spectre.Console) is built on it.

Streams and redirection

When a console application starts, the operating system associates standard input, standard output and standard error with it, and .NET presents them as Console.In, Console.Out and Console.Error. The documentation states the defaults plainly: In is a TextReader for the keyboard, and Out and Error are TextWriter objects for the console window, but each can be replaced with Console.SetIn, Console.SetOut and Console.SetError. Three details matter for a command-line tool:

  • Console streams are synchronized. The documentation notes that methods that are normally asynchronous, such as TextReader.ReadLineAsync, execute synchronously when the object represents a console stream, so multiple threads can read and write safely but await Console.In.ReadLineAsync() does not free the thread while it waits.

  • Console.WriteLine writes to standard output. There is no Console.Error.WriteLine shortcut on Console itself; diagnostics go through Console.Error. The line terminator comes from Environment.NewLine, which follows the platform (CRLF on Windows, LF elsewhere); to change it for a stream, set Console.Out.NewLine or Console.Error.NewLine.

  • Some members throw when a stream is redirected. Members that work on a real console can throw IOException when the underlying stream is a file or a pipe, and ReadKey throws InvalidOperationException when Console.In is redirected. The documented guards are Console.IsInputRedirected, Console.IsOutputRedirected and Console.IsErrorRedirected.

Those three properties are .NET’s isatty: they are how a tool decides between "a human is watching" and "another program is reading". The decision rules themselves (when to colour, when to prompt, when to emit JSON) are in TTY, Colour & Interactivity and Streams & Exit Codes; this page only shows the .NET calls that implement them.

The Console documentation also warns that Console.Write and Console.WriteLine have no effect in GUI applications and should not be used for output in unattended server applications, which is why services use logging instead (see Long-Running Processes & Services).

The members that matter for a CLI

Member What it does Platform notes

Console.Out, Console.Error, Console.In

The three standard streams as TextWriter / TextReader.

Same on all platforms; replaceable with SetOut, SetError, SetIn.

Console.OpenStandardOutput() and related

Raw Stream over the standard handle, for binary output.

Use it for non-text payloads; Console.Out applies the output encoding.

Console.IsOutputRedirected, IsErrorRedirected, IsInputRedirected

Whether a stream is redirected away from the console.

The portable "is this a terminal" test.

Console.ReadKey(bool intercept)

Reads one key press as a ConsoleKeyInfo (Key, KeyChar, Modifiers); intercept: true does not echo it.

Throws InvalidOperationException if input is redirected. Marked unsupported on Android, iOS, tvOS and browser.

Console.KeyAvailable

Whether a key press is waiting.

Check IsInputRedirected first.

Console.TreatControlCAsInput

true delivers Ctrl+C as an ordinary key instead of an interrupt handled by the operating system.

Can throw IOException if the console input mode cannot be changed.

Console.CancelKeyPress

Event raised for Ctrl+C and Ctrl+Break; ConsoleCancelEventArgs.Cancel = true keeps the process alive.

SpecialKey is ControlC or ControlBreak.

Console.WindowWidth, WindowHeight, BufferWidth, BufferHeight

Size of the console window and of its buffer, in character cells.

The getters work on Windows and Unix; the documentation says the setters for window size are supported only on Windows and throw PlatformNotSupportedException elsewhere.

Console.CursorLeft, CursorTop, SetCursorPosition, CursorVisible, CursorSize

Cursor placement, visibility and size.

Check the platform attributes in the reference for each member before using it on Unix.

Console.ForegroundColor, BackgroundColor, ResetColor()

The sixteen ConsoleColor values.

Portable, but limited to 16 colours; use escape sequences for more (see below).

Console.OutputEncoding, InputEncoding

Encoding used for text written to / read from the console.

The default code page comes from the system locale (documented). Set it explicitly when it matters.

Console.Clear(), MoveBufferArea, Beep, Title

Screen and window helpers.

Some are tied to a Windows console; check the UnsupportedOSPlatform / SupportedOSPlatform attributes in the reference.

Encoding

On Windows the console writes through a code page: the default is determined by the system locale, so characters outside that code page show up as question marks. The documentation shows the fix: set Console.OutputEncoding to a UTF8Encoding (or UnicodeEncoding) before writing, and make sure the console font has the glyphs. Setting it can throw IOException, so guard it when the program may run without a console, and Console.OutputEncoding can return a cached value if something else (for example a call to the Win32 SetConsoleOutputCP) changed the code page behind .NET’s back. The reference pages consulted do not state the default encoding on Linux and macOS; set it explicitly if your output must be UTF-8 regardless of locale. The documentation does not discuss byte order marks either: Encoding.UTF8 carries a preamble, so, as a precaution when output may be piped, pass new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) to avoid placing a BOM at the start of a stream another program will parse. The Windows-specific side (the activeCodePage manifest setting, wmain, WriteConsoleW) is covered in Windows Console & Processes.

Colour, cursor movement and escape sequences

ForegroundColor and ResetColor are the portable, documented way to colour text, and they are fine for a handful of highlights. For anything richer (256-colour or true-colour, hyperlinks, the alternate screen) a program writes ANSI escape sequences itself or lets a library such as Spectre.Console do it; the sequences and the capability detection (TERM, COLORTERM, NO_COLOR) are in Escape Sequences & Terminal Capabilities. The sizes reported by Console.WindowWidth are in character cells, not display columns, so wide East Asian characters and emoji need the width rules described on that page when you align tables.

Example: a shelf list that behaves in a terminal and in a pipe

The first example applies the stream rules: data on standard output, diagnostics on standard error, colour only for a human at a terminal, tab-separated plain text for a pipe, and a defensive terminal width.

Program.cs — shelf list (not compiled)
using System.Text;

try
{
    // No byte order mark: the output may be piped into another program.
    Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
catch (IOException)
{
    // The encoding could not be applied (for example there is no console); keep the defaults.
}

bool human = !Console.IsOutputRedirected;
bool colour = human && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NO_COLOR"));
int width = human ? TerminalWidth() : 100;

Book[] books =
[
    new("978-0-261-10235-4", "The Hobbit", "J. R. R. Tolkien", OnLoan: false),
    new("978-0-14-143951-8", "Pride and Prejudice", "Jane Austen", OnLoan: true),
    new("978-0-7432-7356-5", "The Great Gatsby", "F. Scott Fitzgerald", OnLoan: false),
];

int titleWidth = Math.Max(10, width - 30);
foreach (Book book in books)
{
    string status = book.OnLoan ? "on loan" : "on shelf";
    if (!human)
    {
        // Stable, parseable form for pipes and files.
        Console.Out.WriteLine($"{book.Isbn}\t{book.Title}\t{status}");
        continue;
    }

    string title = book.Title.Length > titleWidth ? book.Title[..(titleWidth - 1)] + "…" : book.Title;
    Console.Out.Write($"{book.Isbn}  {title.PadRight(titleWidth)}  ");
    if (colour && book.OnLoan)
    {
        Console.ForegroundColor = ConsoleColor.Yellow;
    }
    try
    {
        Console.Out.WriteLine(status);
    }
    finally
    {
        if (colour)
        {
            Console.ResetColor();
        }
    }
}

// Status for the human goes to stderr, so `shelf list > books.txt` still shows it.
if (human)
{
    Console.Error.WriteLine($"shelf: {books.Length} books, {books.Count(b => b.OnLoan)} on loan");
}

return 0;

static int TerminalWidth()
{
    try
    {
        int w = Console.WindowWidth;
        return w > 0 ? w : 80;
    }
    catch (IOException)
    {
        return 80;
    }
}

record Book(string Isbn, string Title, string Author, bool OnLoan);

Expected behaviour (the program is written to print this; it was not run):

$ shelf list                      # terminal: aligned columns, "on loan" in yellow, summary on stderr
$ shelf list | head -n 2          # pipe: tab-separated, no colour, no summary
978-0-261-10235-4	The Hobbit	on shelf
978-0-14-143951-8	Pride and Prejudice	on loan
$ NO_COLOR=1 shelf list           # terminal, but no colour

Example: reading single keys safely

Console.ReadKey turns a terminal into an event source, which is enough for a small picker without a full TUI library. The pitfalls are all in what happens around it: redirected input, Ctrl+C, and a cursor that is left hidden.

Picker.cs — choose a book with the arrow keys (not compiled)
static class Picker
{
    // Returns null when the user cancels, or when there is no terminal to draw on.
    public static Book? Pick(IReadOnlyList<Book> books)
    {
        if (Console.IsInputRedirected || Console.IsOutputRedirected)
        {
            return null; // the caller falls back to a plain "isbn on stdin" path
        }

        bool oldTreat = Console.TreatControlCAsInput;
        Console.TreatControlCAsInput = true;      // Ctrl+C arrives as a key, so the finally block always runs
        Console.CursorVisible = false;
        try
        {
            for (int i = 0; i < books.Count; i++)
            {
                Console.WriteLine();               // reserve the lines first, so the screen scrolls now
            }
            int top = Console.CursorTop - books.Count;
            int selected = 0;

            while (true)
            {
                Console.SetCursorPosition(0, top);
                for (int i = 0; i < books.Count; i++)
                {
                    string line = $"{(i == selected ? ">" : " ")} {books[i].Title}";
                    Console.WriteLine(line.PadRight(Math.Max(line.Length, Console.WindowWidth - 1)));
                }

                ConsoleKeyInfo key = Console.ReadKey(intercept: true);
                if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control))
                {
                    return null;
                }
                switch (key.Key)
                {
                    case ConsoleKey.UpArrow:   selected = Math.Max(0, selected - 1); break;
                    case ConsoleKey.DownArrow: selected = Math.Min(books.Count - 1, selected + 1); break;
                    case ConsoleKey.Enter:     return books[selected];
                    case ConsoleKey.Escape:    return null;
                }
            }
        }
        finally
        {
            Console.CursorVisible = true;
            Console.TreatControlCAsInput = oldTreat;
        }
    }
}

This is deliberately small. It does not handle terminal resizing, wide characters or a list longer than the window, and on Unix a terminal that is closed while the program waits delivers a hangup rather than a key. The libraries in .NET TUIs with Spectre.Console & Terminal.Gui exist for those cases, and the line-editing alternative (history, completion) is in Interactive Prompts & Line Editing.

Pitfalls

  • Testing for a terminal on only one stream. shelf list > out.txt redirects output but not input; echo x | shelf add redirects input but not output. Check the stream you are about to use.

  • Printing status to standard output. A "3 books found" line on stdout corrupts shelf list | jq and shelf list > file. Use Console.Error.

  • Reading window size in a pipe. Console.WindowWidth can throw or return zero when there is no console; always keep a fallback.

  • Assuming Windows-only members exist everywhere. The Console.WindowWidth setter and several other members are Windows-specific; the platform analyzer raises CA1416 warnings for them, so treat that warning as a portability bug.

  • Leaving the terminal in a strange state. Hiding the cursor or changing input mode and then dying on Ctrl+C leaves the user’s shell broken. Restore in finally, and also handle the signals in the next section.

Handling Ctrl+C and Termination Signals

A command-line program is stopped far more often than it finishes the way it was designed to: the user presses Ctrl+C, the terminal window closes, a container orchestrator sends SIGTERM, a CI system cancels the job. A well-behaved tool turns every one of those into the same thing: cancel the work in flight, clean up, and exit with a status that says why. .NET offers three layers for this, and the platform differences decide which to use.

Console.CancelKeyPress

Console.CancelKeyPress is raised for Ctrl+C and Ctrl+Break. The handler receives a ConsoleCancelEventArgs: SpecialKey is ConsoleSpecialKey.ControlC or ControlBreak, and Cancel decides what happens next. Its default is false, which terminates the process when the handler returns; setting it to true keeps the process running so you can finish cleanup and return from Main normally. The event is documented as unsupported on Android, iOS, tvOS and browser. Console.TreatControlCAsInput = true is the alternative when you want Ctrl+C delivered as a key (as in the picker above); then no event fires.

PosixSignalRegistration

PosixSignalRegistration.Create(PosixSignal, Action<PosixSignalContext>) registers a handler for a signal and returns an IDisposable that unregisters it. PosixSignal names SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGCHLD, SIGCONT, SIGWINCH, SIGTTIN, SIGTTOU and SIGTSTP, and on Unix raw signal numbers can be passed by casting them to PosixSignal. The context’s Signal says what arrived and Cancel suppresses the default handling. The remarks on the Create method state the platform rules exactly:

Signal Unix (Linux, macOS) Windows

SIGINT

Handler runs; Cancel = true suppresses the default termination.

Maps to Ctrl+C; can be cancelled.

SIGQUIT

Handler runs; can be cancelled.

Maps to Ctrl+Break; can be cancelled.

SIGTERM

Handler runs; can be cancelled.

A handler can be registered, but the documentation says SIGTERM can only be cancelled on Unix, so the process still ends.

SIGHUP

Handler runs on terminal hangup.

The reference page does not tabulate which console event maps to it.

SIGCHLD, SIGCONT

Terminal configuration can be cancelled.

Not applicable.

Since .NET 10 the handlers for a signal run in reverse order of registration, so the last registered handler runs first. If your tool and a library both register for the same signal, remember that the library’s handler, registered first, runs last. For the POSIX mechanics behind these signals (sigaction, async-signal safety, job control) see POSIX Processes, Signals & I/O; for the Windows console control events they map to, Windows Console & Processes.

Choosing between them

  • Use CancelKeyPress alone for a simple interactive tool: it covers Ctrl+C everywhere.

  • Add PosixSignalRegistration for SIGTERM and SIGHUP for anything that can be run by a supervisor, in a container or over SSH. Do not register SIGINT in addition to CancelKeyPress, or the same keystroke cancels twice.

  • Use System.CommandLine’s ProcessTerminationTimeout (below) when the command line library already drives your actions: it turns Ctrl+C, SIGINT and SIGTERM into a CancellationToken and enforces a deadline.

  • Use the Generic Host’s ConsoleLifetime for a long-running service; it listens for SIGINT, SIGQUIT and SIGTERM and stops the host gracefully.

Do not stack all four on one program without testing; each installs handlers and they interact.

Exit codes after a signal

POSIX shells report a process killed by signal N as exit status 128 plus N, so 130 means SIGINT and 143 means SIGTERM, and many tools return the same numbers after handling the signal themselves so scripts can tell "cancelled" from "failed". Windows has no such convention. Pick one policy, document it, and keep it identical on every platform; the conventions for exit codes in general are in Streams & Exit Codes.

Example: shelf sync with graceful cancellation

Program.cs — shelf sync (not compiled)
using System.Runtime.InteropServices;

Book[] books = [new("978-0-261-10235-4", "The Hobbit", "J. R. R. Tolkien", false),
                new("978-0-14-143951-8", "Pride and Prejudice", "Jane Austen", true)];

using CancellationTokenSource cts = new();
int signalExit = 0;

// Ctrl+C and Ctrl+Break on every platform.
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;                                   // keep running so cleanup can happen
    Interlocked.CompareExchange(ref signalExit, 130, 0);
    cts.Cancel();
};

// Termination requests from a supervisor, a container runtime or a closing terminal.
using PosixSignalRegistration? term = Register(PosixSignal.SIGTERM, 143);
using PosixSignalRegistration? hup = Register(PosixSignal.SIGHUP, 129);

PosixSignalRegistration? Register(PosixSignal signal, int exitCode)
{
    try
    {
        return PosixSignalRegistration.Create(signal, ctx =>
        {
            ctx.Cancel = true;      // honoured for SIGTERM on Unix only; Windows ends the process anyway
            Interlocked.CompareExchange(ref signalExit, exitCode, 0);
            cts.Cancel();
        });
    }
    catch (PlatformNotSupportedException)
    {
        return null;                // this platform has no such signal
    }
}

try
{
    foreach (Book book in books)
    {
        cts.Token.ThrowIfCancellationRequested();
        Console.Error.WriteLine($"shelf: syncing {book.Title}");
        await Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token);   // stands in for a network call
    }
    return 0;
}
catch (OperationCanceledException)
{
    Console.Error.WriteLine("shelf: interrupted, leaving the catalogue unchanged");
    return Volatile.Read(ref signalExit);
}

record Book(string Isbn, string Title, string Author, bool OnLoan);

Create throws PlatformNotSupportedException for a signal the platform does not support, which is why the example wraps it. After a SIGHUP the terminal is usually gone, so writing to standard error can fail; keep cleanup short and quiet in that case, and never assume the handler can prompt the user.

The Generic Host in a Command-Line Program

The .NET Generic Host (the Microsoft.Extensions.Hosting package) is the object that the Worker Service and ASP.NET Core templates build on. The documentation describes it as encapsulating an app’s resources and lifetime: dependency injection, logging, configuration, graceful shutdown and IHostedService implementations, and notes explicitly that it can be used with other application types "such as Console apps". For a CLI it is attractive when the tool has several commands that share services (a catalogue repository, an HTTP client, a logger) and a layered configuration (defaults, a config file, environment variables, flags). It is overkill for a ten-line script, and its defaults are tuned for servers, so a few of them need changing.

What Host.CreateApplicationBuilder sets up

Both host builders (Host.CreateApplicationBuilder, the recommended style for new projects, and the older Host.CreateDefaultBuilder) apply the same defaults, which the documentation lists:

  • the content root is the current working directory (Directory.GetCurrentDirectory());

  • host configuration comes from DOTNET_-prefixed environment variables and the command line;

  • app configuration comes from appsettings.json, appsettings.{Environment}.json, user secrets (in the Development environment), environment variables and the command line;

  • logging providers are Console, Debug, EventSource and, on Windows, EventLog;

  • IHostApplicationLifetime, IHostLifetime (by default ConsoleLifetime) and IHostEnvironment are registered.

For a command-line tool four of these defaults are traps:

Default Why it hurts a CLI, and the remedy

Content root = current directory

appsettings.json is looked up relative to wherever the user runs shelf, not next to the executable. Set ContentRootPath = AppContext.BaseDirectory in HostApplicationBuilderSettings, or do not rely on appsettings.json at all and load a per-user config file (see Configuration & Auth).

The command line is configuration

The host turns --key value arguments into configuration keys, which collides with System.CommandLine’s own parsing. Leave Args unset (it defaults to none) and let the parser own the command line.

Console logger writes to standard output

The Console provider sends messages to stdout unless ConsoleLoggerOptions.LogToStandardErrorThreshold is set. Set it to LogLevel.Trace so every log line goes to stderr and stdout stays clean for data.

Lifetime messages

When the host is started, ConsoleLifetime logs messages such as "Application started" at Information under the category Microsoft.Hosting.Lifetime. Raise that category to Warning for a one-shot command.

Using the host without running it

host.Run() starts every IHostedService and then blocks until shutdown, which is right for a daemon (see Long-Running Processes & Services) and wrong for a one-shot command. For a command, use the host as a container: build it, resolve the service you need from host.Services, run the work, and dispose the host. That way ConsoleLifetime never installs its own signal handlers, and cancellation stays under the control of System.CommandLine or your own CancellationTokenSource.

When the command needs the host’s own lifecycle (hosted services, IHostApplicationLifetime.StopApplication()), call await host.StartAsync() and await host.StopAsync() explicitly, and remember that ConsoleLifetime then handles SIGINT, SIGQUIT and SIGTERM (documented as Ctrl+C, Ctrl+Break on Windows or Ctrl+\ on Unix, and the signal sent by docker stop). Since .NET 6 it uses the POSIX-signal support and no longer interferes with Environment.Exit.

Example: shelf sync with configuration, logging and DI

Program.cs — a command that uses the host as a container (not compiled)
using System.CommandLine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

Option<bool> verbose = new("--verbose", "-v") { Description = "Log debug output to stderr.", Recursive = true };
Command sync = new("sync", "Synchronise the catalogue.");
RootCommand root = new("shelf -- the Bookshelf tool.") { verbose, sync };

sync.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
    HostApplicationBuilderSettings settings = new()
    {
        ContentRootPath = AppContext.BaseDirectory,   // not the current directory
    };
    HostApplicationBuilder builder = Host.CreateApplicationBuilder(settings);

    builder.Configuration.AddEnvironmentVariables(prefix: "SHELF_");

    builder.Logging.ClearProviders();
    builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // all logs to stderr
    builder.Logging.SetMinimumLevel(parseResult.GetValue(verbose) ? LogLevel.Debug : LogLevel.Information);
    builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning);

    builder.Services.AddSingleton<ICatalogue, FileCatalogue>();
    builder.Services.AddTransient<SyncService>();

    using IHost host = builder.Build();
    SyncService service = host.Services.GetRequiredService<SyncService>();
    return await service.RunAsync(ct);
});

return await root.Parse(args).InvokeAsync();

interface ICatalogue { Task<int> CountAsync(CancellationToken ct); }

sealed class FileCatalogue(IConfiguration configuration) : ICatalogue
{
    public Task<int> CountAsync(CancellationToken ct) =>
        Task.FromResult(configuration["Catalogue:Path"] is { Length: > 0 } ? 3 : 0);
}

sealed class SyncService(ICatalogue catalogue, ILogger<SyncService> logger)
{
    public async Task<int> RunAsync(CancellationToken ct)
    {
        logger.LogInformation("Starting sync");
        int count = await catalogue.CountAsync(ct);
        logger.LogDebug("Catalogue has {Count} books", count);
        Console.Out.WriteLine($"synced {count} books");   // the result, on stdout
        return 0;
    }
}
$ SHELF_Catalogue__Path=/srv/shelf.json shelf sync --verbose
info: SyncService[0]
      Starting sync
dbug: SyncService[0]
      Catalogue has 3 books
synced 3 books
$ SHELF_Catalogue__Path=/srv/shelf.json shelf sync 2>/dev/null
synced 3 books

The double underscore in the environment variable name is how the configuration system maps Catalogue:Path to a variable on every platform (a colon is not valid in variable names on all shells). The info: and dbug: lines are the console logger’s default simple format and go to standard error because of the threshold set above.

Logging formats

The console logger ships three formatters (Simple, Systemd and Json), selectable with AddSimpleConsole, AddSystemdConsole and AddJsonConsole or through the Logging:Console:FormatterName configuration key. For a tool whose logs are consumed by another program, AddJsonConsole gives one JSON object per line; the documentation warns not to pass already-serialized JSON as the message, because the logger serializes it again. Colour in the Simple formatter is a property of the formatter options, and, like any colour, should be off when standard error is not a terminal.

Native AOT and trimming with the host

The reflection-based configuration binder is not AOT-friendly. Starting with .NET 8 a configuration binding source generator replaces it with generated code; enable it by setting EnableConfigurationBindingGenerator to true in the project file. Dependency-injection registrations that rely on open generics or reflection can also raise trim and AOT warnings, so publish with the analyzers on and treat every IL2xxx / IL3xxx warning as a runtime failure in waiting (see the publishing section below). For a small tool, skipping the host and wiring two or three services by hand is often the better trade.

System.CommandLine 2.0 Beyond the Basics

The basics (RootCommand, Option<T>, Argument<T>, SetAction, Parse(args).Invoke(), the exit-code contract) are in Frameworks: Swift & .NET. This section covers the parts that come up once a tool grows: separating parsing from invocation, custom parsing and validation, response files, directives, cross-cutting behaviour now that middleware is gone, and tests. The API described is the stable 2.0 line; the library documentation is explicit that 2.0 differs from the long-running beta releases that older articles and blog posts describe (see the migration guide listed in the references).

Parsing and invocation are separate

Command.Parse(args) returns a ParseResult that contains the parsed values, Errors and UnmatchedTokens and does not run anything. ParseResult.Invoke(InvocationConfiguration) (or InvokeAsync(InvocationConfiguration, CancellationToken)) runs the action selected by the parse: the command’s SetAction delegate, or a built-in action such as help, version or a directive. Each step has its own configuration object:

Configuration Properties and meaning

ParserConfiguration (passed to Parse)

EnablePosixBundling (single-character options such as -vq; on by default), ResponseFileTokenReplacer (the @file feature; set it to null to disable it, or supply your own implementation).

InvocationConfiguration (passed to Invoke / InvokeAsync)

Output and Error (TextWriter objects used by built-in output and available to actions; set them to StringWriter in tests), ProcessTerminationTimeout (2 seconds by default, null disables it), EnableDefaultExceptionHandler (on by default; set false to handle exceptions yourself). The class is not sealed.

The behaviours worth knowing:

  • Exit codes. An action returns an int (or Task<int>); an action with no return value yields 0. When parsing fails, Invoke prints the errors to standard error and returns a non-zero exit code (1 by default). A tool that has its own contract, for example 2 for usage errors, remaps the code after Invoke or handles ParseResult.Errors before it.

  • Synchronous and asynchronous actions are not mixed. If any action is asynchronous, make them all asynchronous, use InvokeAsync, and pass the action’s CancellationToken to every cancellable call.

  • Termination. ProcessTerminationTimeout links Ctrl+C, SIGINT and SIGTERM to the token given to asynchronous actions; if the action has not finished when the timeout expires, the process is terminated. Use it instead of hand-written CancelKeyPress code when the library drives your actions.

  • Unmatched tokens. ParseResult.UnmatchedTokens lists tokens no symbol claimed. For a wrapper command that forwards the rest of the line to another program (like shelf exec -- git status), set Command.TreatUnmatchedTokensAsErrors = false and forward the list.

Custom parsing and validation

The built-in parsers handle the common types: bool, the integer and floating-point types, decimal, DateTime, DateTimeOffset, DateOnly, TimeOnly, Guid, FileInfo, DirectoryInfo, enums, and arrays and lists of those. Anything else needs a custom parser, and any value can be validated. The documentation distinguishes the tools:

Tool Use it for

Option<T>.DefaultValueFactory

A default computed when the option is absent (replaces the beta-era SetDefaultValue). With a default, a Required option no longer has to be given.

Option<T>.CustomParser (a Func<ArgumentResult, T?>)

Parsing a type with no built-in parser, parsing a supported type differently (for example a comma-separated string into an int[]), and parsing and validating in one step. Report problems with result.AddError. ArgumentResult.OnlyTake(int) allows dynamic arity when two arguments are string arrays.

Validators on an option, argument or command

Checks run after parsing. The delegate receives the symbol’s result and calls AddError. A command-level validator can compare several options.

AcceptOnlyFromAmong, AcceptExistingOnly, AcceptLegalFileNamesOnly

Built-in validators for a fixed set of values, existing files or directories, and legal file names. AcceptOnlyFromAmong also feeds tab completion and the help text.

Required, Arity, Recursive, Hidden, HelpName

Required options (an option is optional unless Required = true), the allowed number of values, options inherited by every subcommand, hiding from help and completion, and the placeholder name in help.

The 2.0 constructors take a mandatory name and optional aliases (new Option<int>("--delay", "-d")); a description is set through the Description property, not the constructor, which is the one breaking change from the betas that the compiler does not catch.

Response files

A token that begins with @ names a response file whose contents are spliced into the command line. A response file is a list of tokens separated by whitespace, can span lines, supports quotes to keep a token together, treats # to the end of the line as a comment, and may include other response files. They solve two problems: command lines longer than the terminal or operating system accepts, and re-running the same long invocation. Because the library expects all response files to come from trustworthy agents, and response files can open other files, a tool that accepts arbitrary command lines from untrusted input (for example one invoked by a web service) should disable them with ParserConfiguration.ResponseFileTokenReplacer = null.

Directives

A directive is a bracketed token placed after the program name and before any subcommand, such as shelf [suggest] ad. It cannot contain spaces, may carry an argument after a colon, and an unrecognised directive is ignored rather than causing an error. They give users cross-cutting features that do not belong to any one command. [suggest] is enabled by default on RootCommand. DiagramDirective ([diagram], which prints how the input was parsed and marks parse errors with ! and unmatched input with ???-->) and EnvironmentVariablesDirective (which lets users supply environment variables for the invocation) are public types you add with root.Directives.Add. You can write your own by deriving from Directive or constructing one with new Directive("name") and setting its action. Directives are an intentional debugging surface: ship [diagram] only if you are comfortable with users seeing the parse tree.

No middleware: what replaced it

Versions before 2.0 let you add middleware to the invocation pipeline (AddMiddleware, UseExceptionHandler, UseTypoCorrections, UseParseErrorReporting, UseHelpBuilder, …​). The migration guide says middleware was removed because it slowed application startup and the features can be expressed without it; the library is documented as trim-friendly and suited to AOT-compiled tools. The replacements are concrete: exception handling is the default and is controlled by EnableDefaultExceptionHandler; CancelOnProcessTermination became ProcessTerminationTimeout; parse-error reporting is the default and is tuned through the ParseErrorAction found in ParseResult.Action (ShowTypoCorrections, ShowHelp); help and version are the public HelpOption and VersionOption types on RootCommand; localisation moved into the library; and for anything else the answer is plain code around Parse and Invoke, as in the example below.

Example: shelf with custom parsing, validation and a wrapper around Invoke

Program.cs — shelf (not compiled)
using System.CommandLine;
using System.CommandLine.Parsing;

// --isbn: custom parser that also validates the ISBN-13 check digit.
Option<Isbn?> isbn = new("--isbn")
{
    Description = "ISBN-13, with or without hyphens.",
    Required = true,
    CustomParser = result =>
    {
        if (result.Tokens.Count != 1)
        {
            result.AddError("--isbn takes exactly one value.");
            return null;
        }
        string text = result.Tokens[0].Value;
        if (Isbn.TryParse(text, out Isbn? value))
        {
            return value;
        }
        result.AddError($"'{text}' is not a valid ISBN-13.");
        return null;
    },
};

Option<bool> verbose = new("--verbose", "-v") { Description = "Log progress to stderr.", Recursive = true };

Option<string> status = new("--status")
{
    Description = "Which books to show.",
    DefaultValueFactory = _ => "all",
};
status.AcceptOnlyFromAmong("all", "shelf", "loan");

Option<FileInfo> source = new("--from") { Description = "Text file with one title per line.", Required = true };
source.AcceptExistingOnly();

Option<DateOnly> until = new("--until") { Description = "Due date." };
Option<int> days = new("--days") { Description = "Loan length in days." };
days.Validators.Add(result =>
{
    if (result.GetValue(days) < 1)
    {
        result.AddError("--days must be at least 1.");
    }
});

Argument<string> title = new("title") { Description = "Book title." };

Command list = new("list", "List the books.") { status };
list.SetAction(parseResult =>
{
    parseResult.InvocationConfiguration.Output.WriteLine($"listing: {parseResult.GetValue(status)}");
    return 0;
});

Command add = new("add", "Add a book.") { title, isbn };
add.SetAction(parseResult =>
{
    parseResult.InvocationConfiguration.Output.WriteLine(
        $"added {parseResult.GetValue(title)} ({parseResult.GetValue(isbn)!.Value})");
    return 0;
});

Command import = new("import", "Add many books from a file.") { source };
import.SetAction(parseResult =>
{
    parseResult.InvocationConfiguration.Output.WriteLine($"importing {parseResult.GetValue(source)!.Name}");
    return 0;
});

Command loan = new("loan", "Lend a book.") { isbn, until, days };
loan.Validators.Add(result =>
{
    bool hasUntil = result.GetResult(until) is not null;
    bool hasDays = result.GetResult(days) is not null;
    if (hasUntil == hasDays)
    {
        result.AddError("Give exactly one of --until and --days.");
    }
});
loan.SetAction(parseResult =>
{
    parseResult.InvocationConfiguration.Output.WriteLine($"loaned {parseResult.GetValue(isbn)!.Value}");
    return 0;
});

RootCommand root = new("shelf -- the Bookshelf tool.") { verbose, list, add, import, loan };
root.Directives.Add(new DiagramDirective());
root.Directives.Add(new EnvironmentVariablesDirective());

// Cross-cutting behaviour lives around Parse/Invoke, not in middleware.
InvocationConfiguration invocation = new()
{
    EnableDefaultExceptionHandler = false,
    ProcessTerminationTimeout = TimeSpan.FromSeconds(5),
};

ParseResult parsed = root.Parse(args);
try
{
    int code = parsed.Invoke(invocation);
    // Invoke has already printed the parse errors (and help) to stderr; map them to the usage code 2.
    return code != 0 && parsed.Errors.Count > 0 ? 2 : code;
}
catch (IOException ex)
{
    Console.Error.WriteLine($"shelf: {ex.Message}");
    return 4;
}

sealed record Isbn(string Value)
{
    public static bool TryParse(string text, out Isbn? isbn)
    {
        isbn = null;
        string digits = text.Replace("-", "");
        if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
        {
            return false;
        }
        int sum = 0;
        for (int i = 0; i < 13; i++)
        {
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        }
        if (sum % 10 != 0)
        {
            return false;
        }
        isbn = new Isbn(digits);
        return true;
    }
}
$ shelf add "The Hobbit" --isbn 978-0-261-10235-4
added The Hobbit (9780261102354)
$ shelf add "The Hobbit" --isbn 978-0-261-10235-5
'978-0-261-10235-5' is not a valid ISBN-13.
$ shelf loan --isbn 978-0-261-10235-4 --days 14 --until 2026-12-01
Give exactly one of --until and --days.
$ shelf list --status misplaced
Argument 'misplaced' not recognized. Must be one of:
	'all'
	'shelf'
	'loan'
$ shelf [diagram] loan --isbn 978-0-261-10235-4 --days 14     # prints the parse tree instead of running loan
$ cat borrow.rsp
loan
--isbn 978-0-261-10235-4
--days 14
$ shelf @borrow.rsp
loaned 9780261102354

The exact wording of the library’s error messages (such as the misplaced message above) comes from the library and its localisation, so it may differ between releases; treat the text shown as illustrative.

Testing commands without a terminal

Because InvocationConfiguration exposes Output and Error, a test can run the whole command in process and assert on the text and the exit code. The actions must write through parseResult.InvocationConfiguration.Output (as the example does) rather than Console.Out, or the capture sees nothing.

ShelfTests.cs — capturing output (not compiled)
using System.CommandLine;

StringWriter stdout = new();
StringWriter stderr = new();

int code = root.Parse("add \"The Hobbit\" --isbn 978-0-261-10235-4")
               .Invoke(new InvocationConfiguration { Output = stdout, Error = stderr });

Debug.Assert(code == 0);
Debug.Assert(stdout.ToString().Contains("9780261102354"));
Debug.Assert(stderr.ToString().Length == 0);

Process-level tests (a real pseudo-terminal, exit codes from the built binary) are in Testing Terminal Apps & TUIs.

Help and completion

Help is generated from Name, Description, HelpName and Hidden, and RootCommand answers -h, --help, -?, /h and /?; --version is provided too. Options restricted with AcceptOnlyFromAmong or typed as enums list their allowed values in help automatically. Hiding a symbol removes it from help and completions but not from parsing.

Shell Completion

Two different mechanisms exist, and they should not be confused.

The dotnet CLI itself. The .NET 10 SDK can generate native completion scripts with dotnet completions script [SHELL] for bash, fish, nushell, PowerShell and zsh. For example, in PowerShell one line in $PROFILE enables them:

dotnet completions script pwsh | Out-String | Invoke-Expression
# bash: add to ~/.bashrc
eval "$(dotnet completions script bash)"

This completes the dotnet command, not your own tool.

Your own System.CommandLine tool. The library provides completions for static values (enums, AcceptOnlyFromAmong) and for dynamic ones through CompletionSources. To make them available in a shell, the end user installs the dotnet-suggest global tool, adds a shim script for their shell (bash, zsh or PowerShell; the shim files live in the dotnet/command-line-api repository) to the profile, and registers each tool with dotnet-suggest register --command-path <path-to-executable>. The library no longer registers itself on startup (RegisterWithDotnetSuggest was removed because it did expensive work at launch), so the registration step is manual. The documentation states that cmd.exe has no pluggable tab-completion mechanism, so no shim exists for it, and that other shells are tracked as GitHub issues.

Dynamic completion for shelf loan --isbn (not compiled)
using System.CommandLine.Completions;

isbn.CompletionSources.Add(context =>
{
    // Offer ISBNs of books that are on the shelf; keep this fast, it runs on every Tab press.
    return [new CompletionItem("978-0-261-10235-4"), new CompletionItem("978-0-7432-7356-5")];
});

Because the dotnet-suggest route needs per-user setup and a .NET process start for every Tab press, many tools also ship static completion scripts generated at build time; the shells' own mechanisms and how to install scripts are in Shells, Completions & Shell Integration. The SDK also accepts --cli-schema on every command, which prints a JSON description of the command tree, handy for generating completions or documentation for the dotnet CLI itself.

Running Other Programs with System.Diagnostics.Process

A CLI that wraps other tools (git, a compiler, a converter) uses Process and ProcessStartInfo. Most of the portability problems come from how arguments are passed and how streams are read.

API Notes

ProcessStartInfo.ArgumentList

A collection of arguments that need no prior escaping; .NET builds the platform’s command line from it. Prefer it over the single Arguments string, whose quoting you would have to get right for both the Windows rules and POSIX (see Windows Console & Processes for why they differ).

UseShellExecute

false starts the program directly and lets you redirect streams and inherit the parent’s streams; true goes through the shell/file association and cannot redirect. For a CLI, use false.

RedirectStandardOutput, RedirectStandardError, RedirectStandardInput

Redirect a stream so you can read or write it. Streams not redirected are inherited from the parent when UseShellExecute is false.

StandardOutput.ReadToEndAsync(), WaitForExitAsync(ct)

Read both output streams concurrently (start both reads before awaiting either), then wait for exit. Reading one to the end while the other fills its pipe buffer can deadlock the child.

Process.Kill(entireProcessTree: true)

Kills the process and its descendants. Kill is asynchronous: call WaitForExit afterwards. With the tree option, WaitForExit and HasExited report completion when the given process exits even if descendants are still running.

ProcessStartInfo.CreateNewProcessGroup, CreateNoWindow, WindowStyle

Process-group and window controls; relevant mostly on Windows (see the job-object discussion in the Windows page).

Platform notes: on Windows UseShellExecute = false calls CreateProcess, so shell built-ins such as dir are not programs and need cmd.exe /c, and a batch file is run through cmd.exe too, where its escaping rules apply on top of .NET’s. On Unix, the program is found through PATH, and a script without an executable bit or a shebang fails with a permission error. When the user presses Ctrl+C in a terminal, the signal goes to the whole foreground process group, so a child you start without changing its group receives SIGINT as well; the POSIX details are in POSIX Processes, Signals & I/O.

Run.cs — run git and capture its output (not compiled)
using System.Diagnostics;

static async Task<(int ExitCode, string Output, string Error)> RunAsync(
    string fileName, IEnumerable<string> arguments, CancellationToken ct)
{
    ProcessStartInfo info = new(fileName)
    {
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
    };
    foreach (string argument in arguments)
    {
        info.ArgumentList.Add(argument);          // no manual quoting
    }

    using Process process = Process.Start(info)
        ?? throw new InvalidOperationException($"Could not start {fileName}.");

    Task<string> output = process.StandardOutput.ReadToEndAsync(ct);
    Task<string> error = process.StandardError.ReadToEndAsync(ct);
    try
    {
        await process.WaitForExitAsync(ct);
    }
    catch (OperationCanceledException)
    {
        process.Kill(entireProcessTree: true);
        throw;
    }
    return (process.ExitCode, await output, await error);
}

(int code, string stdout, string stderr) = await RunAsync(
    "git", ["-C", "/srv/shelf", "status", "--porcelain"], CancellationToken.None);
Console.Out.Write(stdout);
return code;

Security notes: never build a command line by concatenating user input into Arguments (use ArgumentList); do not set UseShellExecute = true for input you did not write; resolve the executable by an explicit path when the tool runs with elevated rights, since the search path can be controlled by the caller.

File-Based Apps

The .NET 10 SDK can build, run and publish a single .cs file with no project file. The documentation presents it as a lightweight alternative for scripts, utilities and small apps; the SDK generates a virtual project from #: directives at the top of the file.

Directive Meaning

#:package Name@version

A NuGet package reference. Without a version it works only with central package management (Directory.Packages.props); Name@* takes the latest version.

#:property Name=Value

An MSBuild property, for example #:property PublishAot=false; values may use MSBuild property functions and environment variables.

#:sdk Name@version

The SDK to use (default Microsoft.NET.Sdk).

#:project path

A reference to another project.

#:include file

Include another file. Available in .NET 11 preview / SDK 10.0.300 and later per the documentation; not used here.

The commands: dotnet run file.cs (also dotnet file.cs), arguments after --, dotnet build file.cs, dotnet clean file.cs, dotnet publish file.cs, dotnet pack file.cs (file-based apps default to PackAsTool=true) and dotnet project convert file.cs (creates a normal project from the file). Code can be piped to dotnet run - on standard input.

Native AOT is the default for dotnet publish file.cs, so a file-based app gets a self-contained native executable in an artifacts directory next to the source unless you add #:property PublishAot=false. That also means a publish needs the Native AOT prerequisites described below.

Running a script directly with a shebang

On Linux and macOS a file-based app can be an executable script. The documented shebang uses env -S so that -- can be passed to dotnet, which stops it from swallowing arguments such as --help:

shelf-overdue.cs — overdue loans (not compiled)
#!/usr/bin/env -S dotnet --
#:package System.CommandLine@2.0.13

using System.CommandLine;

Option<int> within = new("--within-days") { Description = "Only loans due within this many days.", DefaultValueFactory = _ => 7 };
RootCommand root = new("List overdue Bookshelf loans.") { within };
root.SetAction(parseResult =>
{
    int limit = parseResult.GetValue(within);
    parseResult.InvocationConfiguration.Output.WriteLine($"no loans are overdue by more than {limit} days");
    return 0;
});
return root.Parse(args).Invoke();
$ chmod +x shelf-overdue.cs
$ ./shelf-overdue.cs --within-days 3
no loans are overdue by more than 3 days
$ dotnet run shelf-overdue.cs -- --within-days 3      # works on Windows too

Platform and pitfall notes drawn from the documentation:

  • The shebang file must use LF line endings and no byte order mark. If env -S is not supported on your system, use #!/usr/bin/env dotnet, which may let dotnet consume arguments that match its own parameters.

  • An extensionless file with a shebang (for example copied to a directory on PATH as shelf-overdue) can be run by name; the documentation shows this for ~/utils/hello. On Windows the shebang is a Unix concept: invoke dotnet file.cs.

  • If a .csproj exists in the current directory, dotnet run file.cs runs that project and passes file.cs as an argument to it, to preserve backward compatibility; use dotnet run --file file.cs to be explicit.

  • Implicit build files apply. Directory.Build.props, Directory.Build.targets, Directory.Packages.props, nuget.config and global.json in the file’s directory or any parent directory affect the build, and the targets in Directory.Build.targets execute. Running dotnet shelf.cs in a directory tree you did not write therefore runs build logic from that tree, and #:package downloads and runs NuGet content on first use. Keep scripts in an isolated directory, and do not run untrusted .cs files.

  • Build output is cached under the system temp directory; concurrent runs of the same file can fight over output files, so build once and run with --no-build. dotnet clean file-based-apps clears the cache.

  • A user-secrets ID is derived from the file’s full path; dotnet user-secrets list prints the secrets, so keep it out of logs and CI output.

Publishing Console Applications

dotnet publish produces the files you ship. A console application has more options than most project types because the audience can be developers with the SDK installed or end users with nothing. The SDK’s general documentation is linked at the top of this page (and the Windows-desktop view of the same options is .NET Publishing Options); this section focuses on the choices for a CLI.

The deployment models

Model How to ask for it What you ship Trade-offs

Framework-dependent (the default)

dotnet publish -c Release

The app, its dependencies and a platform-specific launcher ("apphost"), or a portable app.dll run with dotnet app.dll.

Smallest output. Needs a compatible .NET runtime on the target and rolls forward to the newest installed patch.

Self-contained

dotnet publish -c Release -r <RID> --self-contained true

The app plus the .NET runtime and libraries, but not the OS-level native dependencies of .NET.

No runtime needed; larger; you own runtime servicing. Trimming and globalisation invariant mode reduce size.

Single-file

PublishSingleFile

One file; managed assemblies and configuration bundled. Works framework-dependent or self-contained.

OS and architecture specific. Native runtime libraries stay separate unless IncludeNativeLibrariesForSelfExtract is set, which extracts them at startup.

ReadyToRun

-p:PublishReadyToRun=true

Ahead-of-time compiled code alongside IL, to improve startup.

Larger output. Works framework-dependent or self-contained, and combines with single-file and trimming.

Trimmed

PublishTrimmed

Self-contained app with unused framework and library code removed.

Self-contained only. Reflection-heavy code can fail at run time; fix the trim warnings.

Native AOT

PublishAot with -r <RID>

One native executable with a stripped-down runtime and no JIT.

Fast startup, small footprint, no runtime needed; several .NET features unavailable; must be built on the target OS.

.NET tool

PackAsTool and dotnet pack

A NuGet package installed with dotnet tool install.

Runs on the user’s runtime (unless platform-specific); a distribution channel rather than a binary format.

Choosing: publish options decision flow

The flow below condenses the trade-offs for the shelf tool. It is a starting point, not a rule: a tool can ship through more than one channel (a .NET tool for developers and a native binary for everyone else).

flowchart TD A["Start: how will shelf reach its users?"] --> B{"Is it a single C# file for yourself or your team?"} B -->|"yes"| F["File-based app: dotnet run shelf.cs, shebang on Unix. dotnet publish shelf.cs gives Native AOT by default"] B -->|"no"| C{"Can you require the .NET runtime or SDK on the target?"} C -->|"yes, developers and CI"| T["Framework-dependent: dotnet tool (global, local or dnx) or a published folder. Smallest download, rolls forward with runtime patches"] C -->|"no, end users"| D{"Free of Assembly.LoadFile, Reflection.Emit and unbounded reflection?"} D -->|"no or unsure"| S["Self-contained, optionally single-file and ReadyToRun. Larger, but any RID can be published from one machine"] D -->|"yes"| E{"Can you build on each target OS or a matching container?"} E -->|"yes"| N["Native AOT: PublishAot with -r RID. Small, fast start, no runtime needed"] E -->|"no"| TR["Self-contained trimmed single-file: PublishTrimmed, fix every trim warning. Any RID from one machine"]

Runtime identifiers

Every self-contained, single-file, ReadyToRun or AOT publish targets a runtime identifier (RID) such as win-x64, win-arm64, linux-x64, linux-arm64, linux-musl-x64 (Alpine and other musl distributions) or osx-arm64. dotnet publish -r <RID> selects one; the catalogue and how RIDs relate to target frameworks are in Target Frameworks and Runtime Identifiers. Single-file and self-contained output is always specific to an OS and architecture: you publish once per RID.

Native AOT in detail

Native AOT compiles IL to native code at publish time. The result is self-contained, has no JIT at run time, starts quickly and uses less memory; the documentation says the benefit is most significant for workloads with many deployed instances, and a short-lived CLI invoked many times is exactly that case. Everything it implies for a console app:

  • Enable it in the project file (<PublishAot>true</PublishAot>), not only on the command line, because the property also turns on the AOT, trim and single-file analyzers during build and editing.

  • Prerequisites on the build machine. Windows: Visual Studio 2022 or later with the "Desktop development with C++" workload. macOS: the Xcode Command Line Tools. Linux: clang and zlib development files (for example sudo apt-get install clang zlib1g-dev on Ubuntu, sudo apk add clang build-base zlib-dev on Alpine, sudo dnf install clang zlib-devel plus the zlib-ng packages on Fedora and RHEL).

  • Supported targets (.NET 9 and later). Windows x64, Arm64 and x86; Linux x64, Arm64 and Arm; macOS x64 and Arm64 (iOS, tvOS, Mac Catalyst and Android have their own, partly experimental, support).

  • Limitations the documentation lists: no dynamic loading (Assembly.LoadFile), no runtime code generation (System.Reflection.Emit), no C++/CLI, on Windows no built-in COM, trimming is required and has its own limits, System.Linq.Expressions always uses its slower interpreted form, every struct generic instantiation is pre-generated (which can grow the binary), not all runtime libraries are fully annotated for AOT, and diagnostics and profiling are limited. Publish reports a warning for each limitation it can find; treat each as a bug.

  • Cross-compilation. The documentation is explicit: Native AOT does not support cross-OS compilation (no standard way to get the macOS, Windows or Linux SDKs on another OS) and has only limited cross-architecture support (x64 and Arm64 on Windows, macOS and Linux) when the native toolchain for the target is installed. On Linux that means a cross-linker, the C-runtime and zlib objects for the target, and a target-compatible objcopy or strip. A Linux binary built on one distribution works on the same or a newer version of that distribution family (a binary built on Ubuntu 20.04 runs on 20.04 and later, not on 18.04), and glibc and musl are different targets. In practice: build each OS on its own runner or in a container.

  • Debug information is placed in a separate file: .dbg on Linux, .pdb on Windows, a .dSYM folder on macOS. On Unix set StripSymbols to false to keep it inside the binary (larger file).

  • Size versus speed. <OptimizationPreference>Size</OptimizationPreference> favours a smaller executable; Speed favours throughput; the default is a blend. More size reductions come from trimming options such as disabling globalisation.

Single-file and trimming details

  • Single-file extraction. Managed DLLs are bundled and loaded from memory; the native runtime libraries are separate files unless IncludeNativeLibrariesForSelfExtract is true, in which case they are extracted before the app starts. The extraction directory is taken from DOTNET_BUNDLE_EXTRACT_BASE_DIR if set, otherwise $HOME/.net on Linux and macOS and %TEMP%/.net on Windows. The documentation’s security advice: the directory should not be writable by users or services with different privileges, and /tmp or /var/tmp should not be used. Under systemd $HOME may be undefined, so set the variable in the unit (set DOTNET_BUNDLE_EXTRACT_BASE_DIR to %h/.net with an Environment= line in the [Service] section; systemd expands %h to the home directory).

  • Incompatible APIs. Code that locates files next to the assembly by Assembly.Location breaks in a single file; use AppContext.BaseDirectory and Environment.ProcessPath instead.

  • Trimming is only supported for self-contained apps, turns off trim-incompatible features, and shows warnings during build when PublishTrimmed is set in the project file. Unbounded reflection (legacy serializers, COM) is the usual source of failures.

  • Globalisation invariant mode (InvariantGlobalization) removes ICU dependence and size at the cost of culture-aware behaviour; the effect on sorting and formatting needs a test before you turn it on for a tool that prints dates or sorts titles.

Example: a Native AOT shelf project

shelf.csproj (not compiled)
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <AssemblyName>shelf</AssemblyName>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <PublishAot>true</PublishAot>
    <OptimizationPreference>Size</OptimizationPreference>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine" Version="2.0.13" />
  </ItemGroup>

</Project>
# On a Linux x64 machine with clang and zlib1g-dev installed
$ dotnet publish -c Release -r linux-x64
$ ls bin/Release/net10.0/linux-x64/publish/
shelf  shelf.dbg
$ ./bin/Release/net10.0/linux-x64/publish/shelf --version
# On Windows, from a Developer PowerShell with the C++ workload installed
dotnet publish -c Release -r win-x64
Get-ChildItem .\bin\Release\net10.0\win-x64\publish\

Building for macOS (-r osx-arm64) needs a Mac, and building for Windows needs Windows or a Windows virtual machine; a repeatable matrix over operating-system runners is described in Native Binaries & Cross-Compilation.

Example: a framework-dependent single-file and a self-contained single-file build

# Framework-dependent single file: small, needs the .NET 10 runtime on the target
dotnet publish -c Release -r linux-x64 --self-contained false -p:PublishSingleFile=true

# Self-contained single file, trimmed, ready-to-run: no runtime needed, any RID from one machine
dotnet publish -c Release -r win-x64 --self-contained true \
    -p:PublishSingleFile=true -p:PublishTrimmed=true -p:PublishReadyToRun=true

Setting the properties in the project file is preferred, because it also enables the analyzers during build; the command-line form is for experiments. Do not build framework-dependent and then publish with --no-build as self-contained (or the reverse): the documentation says to use the same options for both steps.

.NET tools versus native binaries

A .NET tool is a NuGet package containing a console application. It installs as a global tool (dotnet tool install -g shelf, into $HOME/.dotnet/tools or %USERPROFILE%\.dotnet\tools, added to the user’s PATH and visible only to that user), a tool-path tool (--tool-path, not added to PATH), or a local tool recorded in .config/dotnet-tools.json so every contributor to a repository gets the same version (dotnet new tool-manifest, dotnet tool install, then dotnet tool restore after cloning). .NET 10 added three things relevant to CLI authors:

  • Platform-specific tools. A single package can carry binaries for several RIDs; the CLI picks the matching one. The documented variations are framework-dependent platform-agnostic, framework-dependent platform-specific, self-contained platform-specific, trimmed platform-specific and AOT-compiled platform-specific. Adding the RID any to RuntimeIdentifiers keeps a portable fallback for platforms you did not list.

  • One-shot execution. dotnet tool exec <package> [args] and its short form dnx <package> [args] download and run a tool without installing it, prompting for confirmation unless the tool is already available; a local manifest pins the version. This suits CI jobs.

  • --cli-schema on all SDK commands, as noted above.

shelf tool packaging properties (not compiled)
<PropertyGroup>
  <PackAsTool>true</PackAsTool>
  <ToolCommandName>shelf</ToolCommandName>
  <PackageId>Bookshelf.Shelf</PackageId>
  <RuntimeIdentifiers>linux-x64;linux-arm64;osx-arm64;win-x64;win-arm64;any</RuntimeIdentifiers>
</PropertyGroup>

The documentation’s mechanics for packing and publishing a platform-specific AOT tool are described only briefly in the SDK release notes; check the current "create a .NET tool" guide before building a release pipeline on this.

Security: .NET tools run in full trust, so install only tools whose author you trust; a global tool is on the user’s PATH and could shadow other commands. The documentation warns that a tool manifest must be stored in a controlled location, because dotnet tool run executes whatever the manifest lists, and that tools may not work when .NET is installed via Snap.

.NET tool Native binary (AOT or self-contained)

Audience

Developers and CI that already have .NET

Anyone, including end users on Windows

Install

dotnet tool install, dotnet tool exec / dnx

Archive, package manager or installer (see Native Packages & Installers)

Updates

dotnet tool update, runtime patches roll forward

Replace the file; you own runtime security fixes if self-contained

Trust

NuGet package, full trust

Code signing and notarization (see Signing, Notarization & OS Trust)

Startup

JIT unless ReadyToRun / AOT

Fast with Native AOT

Console apps can also be packaged as container images with dotnet publish /t:PublishContainer; since .NET 10 this works for console projects without extra project settings, and ContainerImageFormat selects Docker or OCI. See Container Images for the details.

Platform Differences at a Glance

Concern Windows macOS Linux

Ctrl+C

CancelKeyPress (Ctrl+C, Ctrl+Break); maps to SIGINT / SIGQUIT in PosixSignalRegistration

CancelKeyPress, SIGINT; Ctrl+\ is SIGQUIT

Same as macOS

SIGTERM

Handler can run but the process cannot be kept alive by cancelling

Cancellable

Cancellable (containers send it on stop)

Default output encoding

System-locale code page; set Console.OutputEncoding for UTF-8

Not stated in the reference pages

Not stated in the reference pages

Window size setters

Supported

PlatformNotSupportedException

PlatformNotSupportedException

Shell completion for your tool

dotnet-suggest with a PowerShell shim; none for cmd.exe

dotnet-suggest with a zsh or bash shim

dotnet-suggest with a bash, zsh or PowerShell shim

Shebang scripts

Not applicable; run dotnet file.cs

#!/usr/bin/env -S dotnet --

Same as macOS

Native AOT build host

Windows with Visual Studio C++ workload

macOS with Xcode Command Line Tools

Linux with clang and zlib

Global tools directory

%USERPROFILE%\.dotnet\tools

$HOME/.dotnet/tools

$HOME/.dotnet/tools

Single-file extraction base

%TEMP%/.net

$HOME/.net

$HOME/.net (set DOTNET_BUNDLE_EXTRACT_BASE_DIR under systemd)

Pitfalls and Security Notes

  • Colour and prompts without a terminal check. Always branch on Console.IsOutputRedirected and Console.IsInputRedirected.

  • Logging on stdout. The console logger writes to standard output by default; set LogToStandardErrorThreshold.

  • Host defaults tuned for servers. Content root, command-line configuration and lifetime messages need adjusting for a CLI.

  • Double cancellation handlers. Pick one of CancelKeyPress, PosixSignalRegistration, ProcessTerminationTimeout or ConsoleLifetime per signal; since .NET 10 their order is reverse registration.

  • Reflection under AOT. Treat trim and AOT warnings as errors in CI for any tool you plan to publish with PublishAot or PublishTrimmed.

  • Cross-OS Native AOT. It does not exist; plan one build runner per OS.

  • Untrusted inputs. Response files, .cs scripts, tool manifests, single-file extraction directories and NuGet tools are all code-execution paths; the notes above say which to restrict.

  • Secrets. Do not print user secrets or tokens through dotnet user-secrets list or verbose log levels; see Security for the general rules.

Gaps in the Official Documentation

  • The reference pages for Console, PosixSignalRegistration and PosixSignal do not map each signal to a Windows console event beyond Ctrl+C / Ctrl+Break and the SIGTERM cancellation rule, and do not state the default console encoding on Linux and macOS. The tables above say "not stated" rather than guessing.

  • There is no official guidance for screen readers or accessibility of terminal output in .NET; see Accessibility & Internationalization for the general position.

  • The documentation for Native AOT states that the benefits are most significant at scale and lists limitations, but gives no startup-time or size numbers for command-line tools; measure your own binary rather than quoting figures.

  • The dotnet-suggest registration flow is documented as manual, with shim scripts living in the GitHub repository; there is no supported equivalent for cmd.exe.