Top-level statements (C# 9) let a single file declare a program's entry point without writing class Program or static void Main. The compiler still emits a Program class with a Main method behind the scenes, so the runtime sees the same shape as before. This lesson digs into what the compiler generates, how args, return codes, and await work at the top level, and how to test the generated entry point.
When a file uses top-level statements, the compiler creates a hidden class called Program and puts the statements inside a synthesized Main method. Nothing about that is exposed to the source code, but you can see it in tools like SharpLab or by decompiling the produced assembly with ILSpy.
A minimal launcher for a product catalog written with top-level statements:
The classic form the compiler produces is roughly:
A few details in that synthesized output:
internal, not public. Nothing else in your assembly should reference it as Program.Something from outside the source file.<Main>$, with the angle brackets that the C# compiler uses for compiler-generated members. The runtime treats it as the entry point because of metadata, not because of the literal name Main.partial. That detail matters for testing, and we'll come back to it.You won't write any of that yourself. Understanding what's there matters so the rest of the lesson lines up with what the compiler is actually doing.
args ParameterThe synthesized Main always takes a string[] args parameter, and the compiler exposes that parameter to your top-level code under the name args. You don't declare it. You just use it.
Sample run:
args is a regular string[], so anything you can do with an array works: indexing, Length, Contains, LINQ. The name is fixed. Trying to use Args or arguments won't compile because the compiler only injects args.
If you never pass anything on the command line, args is an empty array, not null. That's a deliberate choice that lets you write args.Length without a null check.
args.Contains("--verbose") walks the array each call. For a handful of arguments, this is fine. For longer flag lists, parse once into a HashSet<string> or use a library like System.CommandLine.
By default, the synthesized Main returns void. To return an exit code, end the top-level code with a return statement that yields an int. The compiler picks up on that and emits static int Main(...) instead of static void Main(...).
Sample run with no arguments:
Sample run with a product id:
The pattern lets shell scripts and CI pipelines react to failures. Convention is 0 for success and any non-zero value for a specific failure case. Pick a small set of distinct codes and document them, the same way git or dotnet do.
You don't need a return 0; at the very bottom for success. If control reaches the end of the program without a return, the synthesized method falls off the end, and the runtime treats that as exit code 0. The explicit return 0 is only useful when the same code path can also return a non-zero value earlier.
You can use await directly in top-level statements. When the compiler sees an await, it generates static async Task Main(...) (or static async Task<int> Main(...) if you also return an int) instead of the synchronous form. The async state machine is built for you, exactly as if you had written the async Main yourself.
A cart summary that fetches a price from an in-memory async service:
Behind the scenes, the compiler produced this signature for the entry point:
The runtime knows how to start a Task-returning entry point: it kicks off the task and waits for it to complete before the process exits. Without top-level statements, you'd write static async Task Main(string[] args) yourself, which the runtime has supported since C# 7.1. Top-level statements just spare you the typing.
One thing to remember about implicit usings (.NET 6 and later, controlled by <ImplicitUsings>enable</ImplicitUsings> in the .csproj): namespaces like System, System.IO, System.Linq, System.Threading.Tasks, and System.Collections.Generic are already imported. That's why Task.Delay and decimal work above with no using directive in sight.
Top-level statements are statements first. Anything you can write inside a method body, you can write at the top of the file: variable declarations, if, for, foreach, try/catch, and local functions. The async snippet above used a static local function for FetchPriceAsync, which is exactly how you'd write it inside any other method.
What changes is where types and namespaces fit. The rule is: top-level statements come first in the file, then type and namespace declarations. The compiler stops accepting statements as soon as it sees a class, record, struct, enum, interface, or namespace declaration.
This is legal:
The record CartItem sits after the top-level statements. Anything you declare here goes into the assembly's default namespace, the same as if you'd written it inside any other file.
This is not legal, and the compiler rejects it:
The same rule covers namespaces. You can declare a namespace block at the bottom of the file, but not before the top-level code. Most projects skip namespaces in the entry-point file and rely on the file-scoped namespaces declared in their other files.
Program Class and TestsThe class the compiler generates is partial, and that's the lever you use to test or extend the entry point. You can write another partial class Program in the same project to add members.
A common reason to do this is to expose the assembly's entry point to a test project. The default access of the synthesized class is internal, so test projects need the InternalsVisibleTo attribute on the production project to see it, or you can declare your own partial that adds public helpers.
Production project's Program.cs (top-level statements):
To make CartTotal reachable from a test, move it onto a partial Program:
Two changes are worth pointing out. First, the partial Program is declared public, which overrides the default internal visibility of the compiler-generated half. The two halves merge into one class at compile time. Second, the local function CartTotal became Program.CartTotal. Now the test project can reference it directly:
For testing the entry point itself, there's a related convention. The Microsoft test templates use a parameterless static Task<int> Main(string[] args) entry point that delegates to a RunAsync helper, then the test calls RunAsync directly. The same idea applies here: keep Program.cs thin and put the logic in a method or class you can call from tests.
The flow looks like this:
The two partial halves merge into one type, which both the production runtime and a test project can reference.
Top-level statements come with a small list of rules. None of them are tricky once you know they exist.
| Rule | What it means |
|---|---|
| One file per project | Only one source file in a project may use top-level statements. A second one is a compile error. |
| Statements come first | Top-level statements must appear before any namespace or type declarations in the file. |
| Namespaces wrap, not split | You can declare namespaces below the statements, but the statements themselves are not inside a namespace. |
await upgrades the return | Using await makes the synthesized Main async Task or async Task<int>. |
return of int upgrades the return | Returning an int makes the synthesized Main return int (or Task<int> when combined with await). |
| Locals are scoped to the entry point | Variables declared at the top level are local to the synthesized Main. They are not accessible from other files. |
Trying to mix two top-level files looks like this:
You'll hit that error if you accidentally leave a second Program.cs in a folder when scaffolding a new sample. The fix is to pick one entry-point file and convert the other to a regular type.
Globally accessible names are also worth covering. Anything you declare at the top level (a local variable, a local function) lives inside the synthesized Main. Other files in the project can't see those names. To share state or helpers between files, put them on a real type, not in the entry-point file.
This compiles:
This does not, because greeting is a local variable inside the synthesized Main:
If Helper.Build needs the value, pass it in as a parameter, or move it to a static field on a shared class.
Main to Top-LevelMost production codebases still have files that look like this:
To convert it to top-level statements, you do four things:
class Program wrapper and the Main signature.namespace OrdersCli; line. The synthesized Program class lives in the project's default namespace, and the entry-point file rarely needs an explicit namespace.Main to the top of the file. Keep the return statements as-is.using System; if <ImplicitUsings> is on. Leave it if the project still targets a framework version without implicit usings.The result:
Sample run:
If the original Main declared async, the same migration works. The compiler picks up await in the top-level code and emits an async entry point automatically.
The conversion isn't free in every case. If you have other code that already references Program.Main directly (rare, but it happens in tooling), you'll need to keep a Program.Main shape. The fix is to write your own partial class Program with a static Main that calls into the top-level logic, but at that point you've recreated the classic structure with extra steps. New projects should start with top-level statements. Existing projects only need to migrate if the team values the conciseness.
9 quizzes