Last Updated: May 22, 2026
You typed dotnet run, and a line of output appeared. Between that command and the output, your .cs file traveled through a compiler, got packaged into an assembly, was loaded by a runtime, translated into native machine code one method at a time, and finally executed on your CPU. This chapter walks every step of that journey so you understand what's actually happening when you build and run a C# program.
C# uses a two-step model. First, the compiler turns your source code into a portable intermediate format called Intermediate Language. Then, when you run the program, the runtime translates that intermediate format into native machine code for your specific CPU on the fly. The same compiled output can target an Intel laptop, an ARM Mac, or a Linux server, and the runtime figures out the right native instructions at run time.
Here's the full pipeline.
The first three boxes happen at build time, on your machine. Everything from "CLR loads assembly" onward happens every time the program runs. The dotted arrow from execution back to the optimizing JIT is the key trick: code starts running quickly through a fast first-pass compilation, and methods that get called often get recompiled more aggressively later.
We'll trace each stage with a tiny program you'll meet again throughout this section.
That output is the end of the pipeline. Let's start at the beginning.
When you run dotnet build (or dotnet run, which builds first), the C# compiler reads every .cs file in your project and translates them into a binary format called Common Intermediate Language. You'll see it called three different things in documentation and tooling: CIL, IL, and MSIL (an older Microsoft-specific name). They all refer to the same thing. We'll use IL from here on.
The compiler is called Roslyn. It's the open-source compiler platform Microsoft built for both C# and Visual Basic, and it's what powers everything from the command-line dotnet build to the live red squiggles in your editor as you type.
Roslyn does the usual compiler work:
string can't be assigned to an int, and Roslyn catches that here with a compile error like CS0029.That assembly is what you ship, not your .cs files. Depending on the project type, it's either a .dll (a library or framework-dependent app) or a .exe (a standalone executable on Windows). For .NET Core and later, even applications often produce a .dll plus a small native launcher.
An assembly is more than just compiled code. It's a self-describing package that bundles four things:
.pdb file next to the .dll. These map IL instructions back to source line numbers so debuggers and stack traces can show you where things happened.That self-describing property is what makes C# different from older compiled languages. A .dll knows what's in it without needing a separate header file or import library, which is why you can drop a NuGet package into a project and have everything just resolve.
Here's what each file in a typical build output is for.
| File | What it contains | Required to run? |
|---|---|---|
Program.cs | Your source code | No |
App.dll | IL bytecode plus type metadata | Yes |
App.exe | Small native launcher that hosts the runtime (on Windows / when published as executable) | Yes for direct execution |
App.pdb | Debug symbols mapping IL to source lines | No (only for debugging and good stack traces) |
App.deps.json | Lists assemblies the app depends on | Yes |
App.runtimeconfig.json | Tells the runtime which .NET version to use | Yes |
You don't usually need to read IL, but seeing it once makes the rest of the pipeline feel less abstract. A few tools let you inspect what's inside a .dll:
.dll and it dumps the IL as readable text.Run any of these on our tiny program above and you'd find IL that looks roughly like this for the part that calls Console.WriteLine:
You don't need to read IL fluently. ldstr loads a string literal, stloc.0 stores it into a local variable slot, and call invokes a method by its fully qualified name. These are IL instructions, not x86 or ARM instructions. Your CPU has no idea what ldstr means. Turning it into something the CPU can execute is the runtime's job, which is the next stage.
When you launch a .NET program (whether by running dotnet run, executing a published .exe, or invoking dotnet App.dll), a runtime called the Common Language Runtime starts up. The CLR is the engine that hosts your code: it loads assemblies, JIT-compiles IL, enforces type safety, and manages memory.
The CLR loads assemblies on demand into a logical container called an AssemblyLoadContext (older docs and .NET Framework call this an AppDomain). It doesn't eagerly load every referenced assembly at startup. It loads your application's main assembly first, then loads other assemblies the first time the running code actually needs a type from them. The Base Class Library, NuGet packages, and your own libraries all get loaded this way.
For our small example, the CLR loads the app's assembly, then pulls in System.Console the first time the code touches Console.WriteLine. After loading, the IL is in memory but still not directly runnable, because the CPU can't execute IL. That handoff is the next step.
The CLR doesn't translate the whole assembly into native code up front. Instead, it uses a Just-In-Time compiler (the JIT) that compiles each method to native code the first time that method is called. The native code is cached in memory, so the second call onward runs the cached version directly without going back through the JIT.
The JIT exists because the same IL needs to run on x64, ARM64, and any future CPU architecture .NET targets. Compiling to native instructions ahead of time would lock the assembly to one CPU. Deferring that step until the program is running lets the runtime emit instructions for whatever CPU it finds itself on.
Since .NET Core 3.0, the JIT runs in two tiers, and this is on by default in every modern .NET version.
The point of tiering is to get the best of both worlds. Cold code that runs once never pays the cost of expensive optimization. Hot code on the steady-state path gets optimized eventually, paying that cost only where it matters.
The first few calls to any method run unoptimized Tier 0 code. For a short script that exits in 50 milliseconds, the JIT may never reach Tier 1 at all. For a long-running server, the warmup cost is paid once and then steady-state throughput is what matters.
Once a method has native code (either Tier 0 or Tier 1), the CPU runs it directly. While your code is running, the CLR is still doing work behind the scenes:
free or delete.finally blocks, and either finds a matching catch or terminates the process with a stack trace.That's the whole runtime model: load IL, JIT to native code, execute, enforce safety, collect garbage, repeat.
The standard JIT model is great for long-running services, but it has two costs that hurt some workloads: a startup delay while the JIT does its first pass, and a memory footprint for the JIT itself. Ahead-of-Time compilation moves the IL-to-native step out of run time and into build time, eliminating both costs at the price of less runtime flexibility.
.NET supports two flavors of AOT.
ReadyToRun is partial AOT. When you publish a project with R2R enabled, the build pre-compiles much of the IL to native code for a specific target platform, and the JIT can still kick in for whatever wasn't pre-compiled (or for cases where the pre-compiled code is invalidated). Startup is faster because most methods are already native by the time the program starts, but you keep the JIT around as a fallback. The Base Class Library itself ships with R2R images on most platforms, which is part of why .NET startup has gotten so much faster in recent years.
Native AOT is full AOT. Introduced as a preview in .NET 7 and production-ready in .NET 8, it compiles your entire program straight to a native executable with no JIT at runtime. The result is a single binary that:
The trade-off is that Native AOT can't compile code it can't see at build time. That breaks anything that depends on reflection emitting new code at runtime, dynamic assembly loading, or some forms of serialization that generate code on the fly. Most modern libraries have AOT-friendly alternatives, but you have to check, and the build will warn you when something won't survive the trim.
Here's the same pipeline compared side by side.
The left model defers translation to run time. The right model does all the work at build time. Both produce running C# code; they just split the work differently.
Here's how the three options compare.
| Option | Startup | Steady-state perf | Binary size | Reflection support |
|---|---|---|---|---|
| Standard JIT | Slowest (cold JIT) | Excellent (Tier 1 + profile data) | Small assembly, large runtime install | Full |
| ReadyToRun | Faster (most IL pre-JIT'd) | Excellent (JIT can still optimize) | Larger assemblies, same runtime | Full |
| Native AOT | Fastest (no JIT) | Very good (no profile-guided re-JIT) | Single small binary | Limited |
dotnet build vs dotnet run vs dotnet publishYou'll use three dotnet commands constantly, and it helps to know what each one actually does to the pipeline.
| Command | What it does | Output |
|---|---|---|
dotnet build | Runs Roslyn over your project, produces IL assemblies in bin/Debug/net8.0/ | .dll plus .pdb, no run |
dotnet run | Builds if needed, then executes the assembly through the CLR | Program output to terminal |
dotnet publish | Builds and packages the app for deployment, including dependencies and runtime config | Folder ready to copy to another machine |
dotnet publish is where you choose your deployment model:
dotnet publish (default): framework-dependent. The target machine must have a matching .NET runtime installed.dotnet publish --self-contained -r linux-x64: self-contained. The publish folder includes the runtime itself, so the app runs on a machine with no .NET installed. Much larger.dotnet publish -r linux-x64 /p:PublishAot=true: Native AOT. A single native executable, no JIT, smallest deployment.The runtime identifier (-r linux-x64, win-arm64, osx-x64, and so on) tells the publish step which platform to target. JIT-only publishes can sometimes omit this and stay portable; AOT and self-contained publishes always need it because they're baking platform-specific bits into the output.
Let's trace one E-Commerce program through every stage. Save this as Program.cs.
Build it.
The output looks like this.
What just happened: Roslyn read Program.cs, parsed it, type-checked it, and emitted bin/Debug/net8.0/App.dll. That .dll contains IL for the four lines you wrote. The IL for the multiplication and the call to Console.WriteLine looks roughly like this (simplified):
Now run it.
Here's what happened behind that single line of output:
App.dll into an AssemblyLoadContext.decimal values, called the multiplication helper, formatted the string, and called Console.WriteLine.Console.WriteLine itself needed JIT-compiling on first call. Once the JIT produced native code for it, that native code was cached.If you wrapped this same logic in a loop that ran 100,000 times, you'd see Tier 1 promotion. The first iteration would call the Tier 0 native code. Somewhere around the few-thousand-call mark, the JIT would recompile the loop body with full optimizations in the background, swap the pointer, and the remaining iterations would run noticeably faster.