A modern C# console app is one line of code and a three-line project file. Since C# 9 and the .NET 6 templates, top-level statements let you skip the Program class and the Main method. The compiler generates them for you. This guide walks through creating the project, reading every file the SDK produces, and structuring the code so it scales past “Hello, World!”
Quick Takeaways
- Run
dotnet new consoleanddotnet runto get a working C# program. The template uses top-level statements by default. - The compiler wraps your statements in a generated
Programclass with aMainmethod. args,await, andreturnall work. - Only one file per project may contain top-level statements. Put types and namespaces after the statements.
- The
.csprojfile controls the target framework, ImplicitUsings, and Nullable. Keep it small and explicit.
| Approach | Boilerplate | Best for |
|---|---|---|
| Top-level statements | None | Scripts, tools, learning, small services |
Classic Program.Main |
Class + method | Teams that want an explicit entry point |
File-based app (dotnet run file.cs) |
No project file | One-off scripts on .NET 10+ |
Prerequisites and Setup
Install the .NET SDK. This guide targets .NET 10 (LTS) and C# 14. The commands also work on later SDKs. Verify your install:
dotnet --version
The output is the SDK version, for example 10.0.100. If the command is not found, install the SDK from Microsoft and restart your terminal.
Create the Project
Create a folder and scaffold a console app:
dotnet new console -n HelloCSharp
cd HelloCSharp
dotnet run
The dotnet new console template generates three things: Program.cs, HelloCSharp.csproj, and an obj/ folder created on first build. Running dotnet run restores dependencies, compiles, and executes in one step. The terminal prints Hello, World!.
Anatomy of Program.cs
Open Program.cs. The entire file is:
// Program.cs
// Top-level statement: no class, no Main method.
Console.WriteLine("Hello, World!");
There is no using System; because ImplicitUsings is enabled in the project file. Execution starts at the first statement and ends at the last.
What the Compiler Generates
The compiler turns your file into roughly this:
// Conceptual equivalent of what the compiler emits.
using System;
internal class Program
{
private static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
The real generated method is named <Main>$. You cannot call it by that name, but the entry point is real. Your statements run inside a static method, so every variable you declare is a local variable.
Anatomy of the .csproj File
The project file is plain XML:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Produces an executable instead of a library -->
<OutputType>Exe</OutputType>
<!-- Target framework moniker -->
<TargetFramework>net10.0</TargetFramework>
<!-- Auto-imports common namespaces globally -->
<ImplicitUsings>enable</ImplicitUsings>
<!-- Turns on nullable reference type analysis -->
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
| Property | Purpose | Recommended |
|---|---|---|
| OutputType | Exe builds a runnable app; Library builds a DLL |
Exe |
| TargetFramework | Selects the runtime and API surface | Latest LTS |
| ImplicitUsings | Adds global usings for common namespaces | enable |
| Nullable | Flags possible null dereferences at compile time |
enable |
For Microsoft.NET.Sdk, implicit usings cover System, System.IO, System.Linq, System.Collections.Generic, System.Net.Http, System.Threading, and System.Threading.Tasks. The SDK writes them to a generated *.GlobalUsings.g.cs file under obj/.
Write a Real First Program
“Hello, World!” teaches nothing about the language. This version reads input, uses a local function, and returns an exit code.
Using the Built-in args Array
// Program.cs
// 'args' is available automatically in top-level code.
string name = args.Length > 0 ? args[0] : "developer";
Console.WriteLine(Greet(name));
// Local function: declared after use, scoped to the generated Main.
static string Greet(string who) => $"Hello, {who}!";
Run it with an argument:
dotnet run -- Ada
The output is Hello, Ada!. The -- separates arguments for dotnet run from arguments for your program. Without an argument, the output is Hello, developer!.
Returning an Exit Code
// Program.cs
if (args.Length == 0)
{
Console.Error.WriteLine("Usage: HelloCSharp <name>");
return 1; // Non-zero signals failure to the shell or CI pipeline.
}
Console.WriteLine($"Hello, {args[0]}!");
return 0;
A top-level return with an integer changes the generated Main to return int. Check the result in Bash with echo $?. It prints 1 when you omit the argument.
Using await at the Top Level
// Program.cs
using var http = new HttpClient();
// 'await' works directly: the generated Main becomes async Task.
string html = await http.GetStringAsync("https://example.com");
Console.WriteLine($"Downloaded {html.Length} characters.");
The compiler produces async Task Main when it sees await. The output is the character count of the page. No .Result or .Wait() is needed.
Top-Level Statements Rules and Edge Cases
These rules trigger the most compiler errors for beginners.
One File Per Project
Only one file may contain top-level statements. A second file produces error CS8802. Use that file as your entry point and put everything else in regular classes.
Statements Come Before Type Declarations
// Program.cs
Console.WriteLine(new Counter().Next());
// Types must come AFTER all top-level statements.
class Counter
{
private int _value;
public int Next() => ++_value;
}
Placing a statement after a type declaration produces error CS8803. Using directives must come first of all.
Namespaces and the Generated Program Class
Types declared in the top-level file live in the global namespace unless you wrap them in a namespace. The generated Program class is internal. Integration tests with WebApplicationFactory need access to it, so add this to a separate file:
// ProgramMarker.cs
// Exposes the generated Program class to test projects.
public partial class Program { }
Variables Are Locals, Not Fields
Top-level variables cannot be used inside other classes. They are locals of the generated method, though local functions and lambdas can capture them. Move shared state into a class.
Top-Level Statements vs. Classic Main
| Feature | Top-level statements | Classic Main |
|---|---|---|
| Lines for Hello World | 1 | 7+ |
Access to args |
Implicit | Explicit parameter |
| Async entry point | await anywhere |
async Task Main |
| Runtime performance | Identical | Identical |
| Explicit class control | Limited | Full |
| Multiple entry points | Not allowed | Not allowed (use StartupObject) |
Both compile to the same IL shape. Choose top-level statements for readability. Choose a classic Main if your team style guide requires an explicit Program class. Scaffold the classic form with dotnet new console --use-program-main.
Modern Project Structure
A single project is fine for learning. Real work needs a layout that separates source from tests.
MyApp/
├── MyApp.slnx
├── global.json
├── Directory.Build.props
├── .gitignore
├── src/
│ └── MyApp.Console/
│ ├── MyApp.Console.csproj
│ └── Program.cs
└── tests/
└── MyApp.Tests/
├── MyApp.Tests.csproj
└── CounterTests.cs
Build it with the CLI:
dotnet new sln -n MyApp
dotnet new console -n MyApp.Console -o src/MyApp.Console
dotnet new xunit -n MyApp.Tests -o tests/MyApp.Tests
dotnet sln add src/MyApp.Console tests/MyApp.Tests
dotnet new gitignore
dotnet build
On .NET 10, dotnet new sln creates a .slnx file, the XML-based solution format. Older SDKs create the classic .sln. Both work with dotnet sln add.
| File | Role |
|---|---|
| .slnx / .sln | Groups projects so one command builds all of them |
| global.json | Pins the SDK version for every developer and CI run |
| Directory.Build.props | Shares properties (Nullable, LangVersion) across all projects |
| bin/ and obj/ | Build output and intermediate files; never commit them |
A minimal Directory.Build.props removes duplicated settings:
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Every project under that folder inherits these values. Remove the duplicates from each .csproj.
Essential dotnet CLI Commands
| Command | What it does |
|---|---|
dotnet new console |
Scaffolds a console project |
dotnet build |
Compiles and reports errors |
dotnet run |
Builds and runs the project |
dotnet run --project src/MyApp.Console |
Runs a specific project from the solution root |
dotnet test |
Runs all test projects |
dotnet publish -c Release |
Produces deployable output |
dotnet watch |
Rebuilds and reruns on file changes |
Skip the Project: File-Based Apps
.NET 10 added file-based apps. Save this as hello.cs:
// hello.cs
#:package Humanizer@2.14.1
using Humanizer;
Console.WriteLine(TimeSpan.FromMinutes(90).Humanize());
Run it with dotnet run hello.cs. The #:package directive pulls a NuGet package without a .csproj. The output is 1 hour. Convert the file to a full project later with dotnet project convert hello.cs.
Best Practices and Refactoring
Top-level statements make it easy to dump everything into one file. Resist that.
Anti-Pattern: Everything in Program.cs
// BAD: logic, parsing, and formatting tangled together.
var items = new List<string>();
foreach (var line in File.ReadAllLines(args[0]))
{
var parts = line.Split(',');
if (parts.Length > 1 && int.TryParse(parts[1], out var qty) && qty > 0)
items.Add(parts[0] + " x" + qty);
}
foreach (var i in items) Console.WriteLine(i);
This code crashes when args is empty. It cannot be unit tested, and the parsing rules are buried in a loop.
Refactored: Thin Entry Point, Testable Types
// Program.cs (GOOD): orchestration only.
if (args.Length != 1)
{
Console.Error.WriteLine("Usage: inventory <file.csv>");
return 1;
}
var parser = new OrderParser();
foreach (string line in parser.Parse(await File.ReadAllLinesAsync(args[0])))
{
Console.WriteLine(line);
}
return 0;
// OrderParser.cs: pure logic, easy to unit test.
public sealed class OrderParser
{
public IEnumerable<string> Parse(IEnumerable<string> lines)
{
foreach (string line in lines)
{
string[] parts = line.Split(',');
if (parts.Length > 1 && int.TryParse(parts[1], out int qty) && qty > 0)
{
yield return $"{parts[0]} x{qty}";
}
}
}
}
The entry point validates input and delegates. OrderParser takes lines and returns strings, so a test needs no file system.
Quick Rules
| Anti-pattern | Fix |
|---|---|
Business logic in Program.cs |
Move it into classes in separate files |
.Result or .Wait() on tasks |
Use top-level await |
| Hard-coded paths and values | Read from args or configuration |
| Ignoring exit codes | Return 0 for success, non-zero for failure |
| Disabled nullable analysis | Keep <Nullable>enable</Nullable> |
Common Build Errors
| Error | Cause | Fix |
|---|---|---|
| CS8802 | Top-level statements in more than one file | Keep them in a single file |
| CS8803 | Statement placed after a type declaration | Move statements above all types |
| CS0103 | Name does not exist, often a missing using |
Add the namespace or enable implicit usings |
| NETSDK1045 | Project targets a newer framework than the installed SDK | Install the matching SDK or lower TargetFramework |
FAQ
What are top-level statements in C#?
Top-level statements are executable code written directly in a file, without a surrounding class or Main method. The compiler generates a Program class and entry point from them. They arrived in C# 9 and are the default console template since .NET 6.
Can I still use the Main method in C# 14?
Yes. Classic Main is fully supported and compiles to the same IL shape. Run dotnet new console --use-program-main to generate it. Use it if you need explicit control over the entry-point class.
Where do I put classes when using top-level statements?
Put them in separate files, one type per file. If you must keep a type in the entry file, place it after all top-level statements. Types in that file belong to the global namespace unless you declare a namespace.
How do I run a C# program from the command line?
Run dotnet run inside the project folder. From a solution root, use dotnet run --project path/to/Project.csproj. On .NET 10 or later, run a single file with dotnet run file.cs.




