AlgoMaster Logo

How C# Works (Compilation & Execution)

Last Updated: May 22, 2026

Medium Priority
10 min read

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.

The Journey: Source to Native Code

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.

Step 1: The C# Compiler (Roslyn)

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:

  1. Parses your source into a tree of language constructs.
  2. Type-checks every expression. A string can't be assigned to an int, and Roslyn catches that here with a compile error like CS0029.
  3. Emits IL plus metadata into an output file called an assembly.

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.

What's Inside an Assembly

An assembly is more than just compiled code. It's a self-describing package that bundles four things:

  • IL bytecode for every method your code defines.
  • Type metadata describing every class, struct, interface, field, property, and method signature in the assembly. The runtime uses this for reflection, dependency resolution, and type checking.
  • References to other assemblies your code depends on, like the Base Class Library or a NuGet package.
  • Optional debug symbols stored separately in a .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.

FileWhat it containsRequired to run?
Program.csYour source codeNo
App.dllIL bytecode plus type metadataYes
App.exeSmall native launcher that hosts the runtime (on Windows / when published as executable)Yes for direct execution
App.pdbDebug symbols mapping IL to source linesNo (only for debugging and good stack traces)
App.deps.jsonLists assemblies the app depends onYes
App.runtimeconfig.jsonTells the runtime which .NET version to useYes

Peeking at IL

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:

  • `ildasm` ships with the .NET SDK. Run it on a .dll and it dumps the IL as readable text.
  • dnSpy and ILSpy are GUI decompilers that show IL alongside reconstructed C# source.
  • Visual Studio's "View IL" option shows the IL for the file you're editing.

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.

Step 2: The CLR Loads the Assembly

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.

Step 3: Just-In-Time Compilation

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.

Tiered Compilation

Since .NET Core 3.0, the JIT runs in two tiers, and this is on by default in every modern .NET version.

  • Tier 0 is fast and unoptimized. When a method is called for the first time, the JIT produces minimally optimized native code as quickly as it can. This gets the program running with low startup latency.
  • Tier 1 is slow and optimized. While Tier 0 code runs, the runtime counts how often each method is called. When a method crosses a "hot" threshold, the JIT recompiles it with full optimizations (inlining, register allocation, loop unrolling, dead code elimination) and swaps the native code pointer so future calls run the optimized 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.

Step 4: Execution

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:

  • Type safety enforcement. The CLR verifies IL on load and inserts runtime checks (array bounds, null reference, invalid casts) that throw exceptions instead of corrupting memory.
  • Memory management. Reference-type objects (classes, strings, arrays) live on the managed heap. A background component called the garbage collector tracks which objects are still reachable and reclaims the ones that aren't. You never call free or delete.
  • Exception handling. When code throws an exception, the CLR walks the call stack, runs finally blocks, and either finds a matching catch or terminates the process with a stack trace.
  • JIT promotion. As we just saw, methods can be re-JIT'd from Tier 0 to Tier 1 in the background while the program runs.

That's the whole runtime model: load IL, JIT to native code, execute, enforce safety, collect garbage, repeat.

AOT: Compiling Ahead of Time

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 (R2R)

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

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:

  • Starts almost instantly. No JIT warmup at all.
  • Has a smaller memory footprint. No JIT compiler or large runtime metadata.
  • Produces a much smaller deployment (often under 10 MB compared to 60+ MB for a self-contained framework-dependent app).

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.

OptionStartupSteady-state perfBinary sizeReflection support
Standard JITSlowest (cold JIT)Excellent (Tier 1 + profile data)Small assembly, large runtime installFull
ReadyToRunFaster (most IL pre-JIT'd)Excellent (JIT can still optimize)Larger assemblies, same runtimeFull
Native AOTFastest (no JIT)Very good (no profile-guided re-JIT)Single small binaryLimited

dotnet build vs dotnet run vs dotnet publish

You'll use three dotnet commands constantly, and it helps to know what each one actually does to the pipeline.

CommandWhat it doesOutput
dotnet buildRuns Roslyn over your project, produces IL assemblies in bin/Debug/net8.0/.dll plus .pdb, no run
dotnet runBuilds if needed, then executes the assembly through the CLRProgram output to terminal
dotnet publishBuilds and packages the app for deployment, including dependencies and runtime configFolder 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.

A Tiny Program Through the Pipeline

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:

  1. The CLR started up and loaded App.dll into an AssemblyLoadContext.
  2. The runtime asked the JIT for native code for the entry point. The JIT compiled it as Tier 0 native code, fast and unoptimized.
  3. The Tier 0 native code ran. It allocated the decimal values, called the multiplication helper, formatted the string, and called Console.WriteLine.
  4. Console.WriteLine itself needed JIT-compiling on first call. Once the JIT produced native code for it, that native code was cached.
  5. The program ended before the method ran enough times to qualify as "hot," so Tier 1 never got involved. For a short program like this, that's fine.

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.