Zeeshan Adil
← All writing

Rust Guide for C#/.NET Engineers: A Deep Dive

by Zeeshan Adil·in Systems Programming6.5 hr read

A book-length guide to Rust for engineers who already know C#. Fifteen chapters on ownership, borrowing, lifetimes, traits, generics, errors, concurrency, async and unsafe, each one anchored on the .NET model you already carry in your head.

  • #rust
  • #csharp
  • #dotnet
  • #systems
  • #memory-safety
  • #ownership
  • #borrow-checker
Share

Your first real Rust error will probably be borrow of moved value. You wrote something that would be fine in C#, the compiler refused it, and the honest reaction is that the language is being difficult for no reason.

It is not. It is asking you a question that the CLR answers for you at run time, and it wants the answer before the program runs.

That single sentence is most of what separates the two languages. Everything else follows from it: why there is no null, why strings come in two flavours, why the concurrency story is different, why async is harder than you expect, and why the compiler needs to know how long a reference lives.

So this is not a syntax tour. You already know how to write a loop. This is an attempt to port your mental model. You have one for .NET, built over years, that tells you where objects live, who cleans them up, what happens when two threads touch the same list, and roughly what the JIT will do with your generics. Most of that model is still useful. Some of it is actively wrong in Rust, and the wrong parts are what make people bounce off the language in week two.

What this assumes about you

You know value types and reference types, the stack and the heap, boxing, generics and constraints, the CLR and the JIT, roughly what the garbage collector does, IDisposable and using, threads and the task parallel library, async and await, LINQ, interfaces and extension methods. You may have used Span<T> without wanting to think about why it has so many restrictions.

None of that gets taught here. Where a C# detail matters to a comparison I will remind you of it in a sentence or two and move on.

How to read it

It is long, and it is meant to be. Each chapter stands on its own and each one assumes the ones before it, so the order is not decorative. Once you are past the foundations the chapters get more independent, and you can reasonably jump to whichever matches what you are building.

The book is in four parts, and each part says what level it is pitched at, so you can tell at a glance whether a chapter is groundwork or a detour into the machinery.

Part I, Foundations. These six chapters are load-bearing and the rest of the book assumes them.

  1. The trade Rust is actually making
  2. Reading Rust when you already know C#
  3. Where your data actually lives
  4. Ownership, and the rule that replaced the collector
  5. Borrowing, and why the compiler counts your references
  6. Lifetimes are not object lifetime

Part II, Working knowledge. How Rust does abstraction, and the two types you will touch in every function you write.

  1. Traits are interfaces that do not own your types
  2. Generics that compile to what you would have written by hand
  3. Errors are values, and you have to look at them
  4. The null that is not there

Part III, Concurrency and systems. Where ownership starts paying for itself, and where async gets harder than it is in C#.

  1. Interior mutability, or breaking the rules on purpose
  2. Fearless concurrency is a claim the type system makes
  3. Async without a runtime in the box

Part IV, Under the hood. Advanced, and skippable on a first pass. Come back when you want the machinery.

  1. Zero-cost abstractions, and where the cost actually lands
  2. Unsafe is a smaller word than you think

Then a chapter on what I would actually do in a first month, the sources worth reading next, and the questions people ask.

If you only read part of it, read Part I. Ownership, borrowing and lifetimes are not three features, they are one idea seen from three angles, and almost every later chapter is downstream of them.

The trade Rust is actually making

Part I, Foundations.

Start with the thing both languages are trying to prevent.

In C or C++, you can free memory and then use it. You can keep a pointer to a stack frame that has returned. You can hand the same buffer to two threads and let them write to it. These are not exotic mistakes. They are the ordinary result of a large codebase and a bad afternoon, and they produce crashes, silent corruption, and a large share of security vulnerabilities.

The size of that share is unusually well documented, because three organizations with very different codebases went and counted.

Matt Miller of Microsoft's Security Response Center presented the numbers at BlueHat IL in 2019. Slide 11 of the deck, which Microsoft publishes on GitHub, says it plainly: roughly 70% of the vulnerabilities Microsoft addresses through a security update each year are memory safety issues, and the chart behind it shows that proportion holding roughly flat from 2006 to 2018.

The Chromium security team reached the same number independently. Around 70% of their serious security bugs are memory unsafety, half of those are use-after-free specifically, and the analysis covers 912 high or critical severity bugs since 2015 in shipped Stable channel code. Same figure, different codebase, different engineering culture.

The Android data is the one that actually moves the argument, because it is a before and after rather than a snapshot. Android shifted new native development toward memory-safe languages, mostly Rust and Java or Kotlin, and left the old C++ alone except for bug fixes. In December 2022 the Android team reported that the annual number of memory safety vulnerabilities had fallen from 223 in 2019 to 85 in 2022, and from 76% of Android's total vulnerabilities down to 35%. Two years later the follow-up post put the figure at 24% across six years, and made the mechanism explicit: vulnerabilities decay exponentially, the density of memory safety bugs is highest in new and recently modified code, and so the returns come from where new code is written rather than from rewriting old code.

The Android team says correlation is not causation, and they are right to say it. But three independent measurements pointing the same direction is about as good as this kind of evidence gets.

C# solved this a long time ago, and it solved it the way most modern managed languages did. References are checked, arrays are bounds-checked, and a garbage collector decides when memory is no longer reachable and reclaims it. You stopped thinking about it because you were allowed to stop thinking about it.

What a tracing collector actually buys

Be precise about what the CLR does for you, because Rust's pitch is not "you get memory safety". You already have that. The pitch is that you can keep it without the machinery, and to judge that you have to know what the machinery is doing.

A tracing collector buys four separate things, and they are usually discussed as one.

Allocation gets cheap. The runtime reserves a contiguous region and keeps a pointer to the next free address. Allocating is a pointer bump, which Microsoft's own fundamentals doc describes as almost as fast as allocating from the stack. Nothing in C or Rust that goes through a general-purpose allocator gets close on the fast path.

Lifetime becomes somebody else's global problem. This is the big one, and it is easy to under-appreciate because you have never had to state it. When you write return list; in C#, you make no claim about how long that list lives, who else holds it, or when it dies. The question is answered later, globally, by tracing from roots. You never answer it locally, which is exactly why you can pass an object graph around a service without designing its lifetimes.

Cycles are free. A doubly linked list, a parent pointer, a cache holding a reference back to its owner. Reference counting chokes on all of these. Tracing does not, because reachability from roots does not care about the shape of the graph.

Fragmentation stops being your problem. The collector can compact survivors together and fix up the references, which keeps allocation on the fast path and keeps related objects near each other in memory. Not always, though. The plan phase decides per collection whether compacting is worth it and otherwise sweeps, and the large object heap is not compacted at all unless you ask for it. Fragmentation is managed for you rather than eliminated.

You pay for those four in three currencies. A runtime ships with your program. Collection traces the live object graph, which costs CPU and memory bandwidth proportional to how much live data you keep. And collection happens at times you do not choose.

Generations, and where the pause comes from

The .NET collector is generational because most objects die young. Gen0 holds new objects, survivors get promoted to gen1 and then gen2, and gen0 and gen1 together are the ephemeral generations. Objects of 85,000 bytes or more skip all of that and go on the large object heap, which the docs describe as a physical generation 3 that is logically collected along with gen2.

Two mechanisms in that design are the ones Rust deletes, so carry them in your head.

The first is the allocation budget. The CLR GC design document in dotnet/runtime describes it as a size limit per generation that triggers a collection for that generation when exceeded, and says the budget is set mostly from the survival rate of that generation: high survival means a larger budget, on the theory that the next collection will then find a better ratio of dead to live. So how often you collect is your allocation rate divided by a threshold the runtime keeps retuning underneath you.

The second is the card table. A gen0 collection wants to avoid scanning gen2, but a gen2 object can hold a reference to a gen0 object, and that reference is a root. So the JIT emits a write barrier on reference-field assignments: the same document says that when the JIT helper sees an object in the ephemeral range, it sets the byte containing the card that represents the source location. During ephemeral collections the collector scans set cards instead of the whole old heap. That barrier is a few instructions on every reference store in your program, forever, whether or not a collection ever happens.

Now the part that matters for latency. The document lists the flow directly. A user thread runs out of allocation budget and triggers a collection, the collector calls SuspendEE to suspend managed threads, then mark, then plan, then relocate and compact or else sweep, then RestartEE. With server GC the work is spread across dedicated collector threads, typically one per logical processor, but the suspension is the same suspension. With background GC, which is the default, the gen2 marking and sweeping mostly run alongside your threads, and the flow calls SuspendEE twice rather than once: at the start to decide, and again at the end to finish marking. Ephemeral collections that fire while a background collection is running are foreground collections, and those are fully blocking.

That is why "concurrent collector" is misleading if you read it as "no pauses". Background GC makes the long gen2 pause mostly go away. It does not make suspension go away, and gen0 and gen1 collections are always blocking because they are supposed to be short.

Allocation rate against pause behavior

The fundamentals doc puts the relationship in one line: the intrusiveness of garbage collections, meaning both frequency and duration, is the result of the volume of allocations and the amount of survived memory on the managed heap. Two independent knobs, pushing on different things.

Allocation rate drives frequency. Survivor volume drives duration.

That split is why the cost lands in the tail rather than in the average. Here is the arithmetic on a deliberately simple model, and then what is wrong with the model.

Take a service at 5,000 requests per second with a mean service time of 2 ms. By Little's law there are 5000 times 0.002, so about 10 requests in flight at any instant. Say it triggers 20 gen0 collections per second at 0.4 ms each. I am inventing those two numbers so the arithmetic has something to work on. Measure your own with dotnet-counters.

Total collector time is 20 times 0.4 ms, which is 8 ms per second, or 0.8% of wall clock. On a throughput graph that is invisible. It sits inside the noise of a deploy.

Now count affected requests. Each pause catches the roughly 10 requests in flight, so 20 times 10 is about 200 requests per second paying an extra 0.4 ms. Out of 5,000, that is 4%. Four percent of requests get slower, which means the effect starts appearing around p96 and is fully visible at p99. Your median does not move at all.

Then take a blocking gen2 compaction, one every 60 seconds at 30 ms. That window holds 300,000 requests. This time the in-flight count is not what dominates, because the pause is fifteen times longer than a request: arrivals during the pause queue behind it, and that is 5000 times 0.03, so 150 requests, plus the 10 already running. About 160 out of 300,000 is 0.053%, which lands in p99.9 and shows up on a dashboard as the spike nobody can reproduce.

That model is too clean in four ways, and each one makes the real behavior worse rather than better.

Pause length is not a constant. It tracks survivors, and survivors track load, so pauses get longer exactly when you have the least headroom. Queueing is not additive either. A 30 ms suspension does not simply add 30 ms to 160 requests, it creates a convoy that takes several service times to drain, so requests behind the pause are slow too. The heap configuration moves underneath you: DATAS, which adapts heap count to application size, is enabled by default starting in .NET 9, and the same page notes that when a workload ramps from lighter to heavier, allocating threads can wait, because it takes a few collections before the heap count increases. And the allocator's own slow path blocks: a thread that needs a fresh allocation context and cannot get one waits, which is not a collection pause and will not show up in your pause metrics.

None of that is an argument against the CLR. It is an argument that the cost is real, it is structural, and it is invisible in exactly the metric most teams watch.

For an ASP.NET service talking to a database, the trade is almost always correct. I would not rewrite one in Rust to save allocations, and an ASP.NET Core project on .NET 9 already ships with System.GC.Server set to true in its generated runtimeconfig.json, which is the right default for that shape of work. The interesting question is what happens when the trade stops being correct.

A proxy or sidecar in front of every request, where p99.9 is a number in a contract and every service behind you inherits your pauses. An agent installed on 50,000 hosts, where the runtime's baseline resident memory gets multiplied by 50,000 and lands on a finance review. A library that has to be callable from Python, Node and Go, where shipping a second garbage collector inside somebody else's process is a non-starter. A binary that has to start in single-digit milliseconds because something forks it per event. Firmware with no room for a runtime at all.

The compile-time alternative, in principle

Deleting the collector without going back to malloc and free has a known price. State it before Rust's rules arrive, because the rules are the price.

You would need to know, at compile time, for every value, where its last use is and who is responsible for it at that point. Then the compiler could insert the deallocation itself, at a place you could set a breakpoint on. No tracing, no write barrier, no suspension.

That is a static analysis problem, and in full generality it is undecidable. So Rust does what engineers always do with an undecidable problem: restrict the language until the analysis becomes decidable, and reject the programs it cannot prove. Every value has exactly one owner. References never outlive what they point at. You never hold a mutable reference at the same time as any other reference to the same place. Those three rules are the restriction, they are the subject of the ownership and borrowing chapters, and everything else in Rust is downstream of them.

The shape of the payoff is something you already know, because C# has it in a weaker form. using and IDisposable are deterministic cleanup at a scope boundary. Rust's Drop is the same idea, except it is not opt-in. You cannot leak a connection by forgetting a using, because there is no using to forget. Skipping cleanup is something you have to go out of your way to do, and the callout below names the function that does it.

So the trade is legible. C# spends run time and memory to buy you freedom from thinking about lifetime. Rust spends your attention at compile time to buy the same safety with no runtime cost. Neither is free. The compiler arguing with you is the bill arriving early.

There is a second thing you get, and it surprises people more than the performance. Because the compiler already tracks who owns what and who is allowed to mutate it, it can also tell whether sharing something across threads is safe. Data races become type errors. I will come back to that properly, but keep it in mind: it is not a separate feature bolted on. It falls out of ownership.

What Rust costs, honestly

Four things, and the enthusiasts undersell all four.

Compile times. They are worse than dotnet build, and the reason is this chapter's thesis running in reverse. C# emits IL and hands the optimization work to the JIT at run time. Rust hands it to LLVM at build time, after monomorphizing every generic instantiation into its own copy of the code, so a heavily generic crate produces far more machine code than you wrote. Work moved to compile time is still work. You can watch the same bill arrive in .NET the moment you publish with Native AOT, which also does whole-program codegen ahead of time and also takes a long time about it. On top of that, the crate is the unit of compilation, so a small edit can trigger a lot of recompilation, and the Cargo profiles reference notes that incremental compilation applies only to workspace members and path dependencies, which means bumping a dependency version rebuilds that dependency from scratch. There are levers, and Nicholas Nethercote's chapter on compile times in the Rust Performance Book is the place to start, but expect the edit-build-test loop to be slower than you are used to.

Ecosystem depth. Raw counts are closer than the reputation suggests. In September 2026 the crates.io summary API reports 329,413 crates and the NuGet search API reports 484,504 packages. Both numbers move daily, and neither counts quality. Breadth is not the gap. Depth in the specific places .NET is deep is the gap: no first-party Microsoft-maintained SQL Server driver, no equivalent of EF Core with a decade of migrations tooling behind it, nothing like the ASP.NET Core identity stack, no WCF or SOAP story worth the name, no Office interop, and nothing resembling the ecosystem of dependency injection containers most enterprise .NET architecture assumes. Where Rust is deep is network services, parsers, CLI tooling, cryptography, embedded, and WebAssembly. If your work sits in the first list, take the gap seriously. It is not a matter of waiting a year.

Async. It is genuinely harder, and the Rust project says so out loud. When async fn in traits stabilized in Rust 1.75, the async working group's announcement opened by saying that important features many users need were still missing, and noted that traits using async fn are not object-safe, which means they lack support for dynamic dispatch. The Reference has since renamed that property to dyn compatibility, so you will meet both terms depending on how old the page is. Nothing in your Task-based instincts prepares you for a trait method you cannot put behind a dyn. There is a chapter on it later and it is long for a reason.

A slower first month. Not slower at typing. Slower at deciding. In C# you model data and the runtime handles the rest. In Rust you model data and then answer a second question for every field and every parameter: who owns this, and for how long. That is real design work, you have never had to do it explicitly, and for the first few weeks you will get it wrong and restructure. Anyone who tells you the learning curve is a myth has forgotten what week one felt like.

There is a fifth cost that is not technical at all. C# engineers are easy to hire and Rust engineers are not. The Visual Studio and Rider debugging experience is still better than anything Rust has. And a large ASP.NET codebase written by a mixed-ability team will be fine in a way a large Rust codebase written by that same team will not. That is a legitimate input to the decision, and pretending otherwise is how migrations fail.

Reading Rust when you already know C#

Most of Rust's syntax is a renaming exercise and you can skim it. A handful of places are not, because the syntax is carrying a semantic difference C# has no way to express. Slow down at those.

Expressions, and why almost everything is one

A function is fn, types come after the name, and the return type comes after ->.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

Note the missing return and the missing semicolon. Rust is expression-oriented: the last expression in a block is the block's value. The Reference's chapter on statements covers the other half, which is that adding a semicolon turns an expression into a statement whose value is discarded.

That rule is why if, match, loop and plain blocks all produce values.

let category = if bytes > 1_000_000 { "large" } else { "small" };

let timeout_secs = match tier {
    Tier::Free => 5,
    Tier::Pro => 30,
    Tier::Enterprise => 120,
};

C# has been walking toward this for years. Expression-bodied members with =>, the conditional operator, switch expressions in C# 8: all of it exists because assigning once beats declaring then assigning in branches. Rust started there, so the pattern is universal rather than available in particular syntactic positions.

The practical consequence is that you almost never write the C# shape of declare-uninitialized-then-fill-in-branches. You write a let bound to a match. The practical hazard is a stray semicolon, and it is worth seeing once, because the message is not obviously about semicolons.

fn add(a: i32, b: i32) -> i32 {
    a + b;
}
error[E0308]: mismatched types
 --> src/main.rs:1:27
  |
1 | fn add(a: i32, b: i32) -> i32 {
  |    ---                    ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail expression
  |
help: remove this semicolon to return this value
  |
2 -     a + b;
2 +     a + b
  |

The help line at the bottom is the whole diagnostic. () is the unit type, Rust's void, and a block ending in a semicolon evaluates to it. Every stray-semicolon bug you will ever write produces some version of this.

let, mut, and shadowing

Variables are let, and they are immutable unless you say otherwise.

let count = 5;          // cannot be reassigned
let mut total = 0;      // can be

readonly in C# is opt-in and lands on fields, struct declarations and struct members. Rust flips the default and attaches it to the binding rather than the type. That distinction matters: mut is not part of i32, it is a property of this particular name, so the same value can be immutable in one function and mutable in the next.

For ordinary locals C# gives you const, which only covers compile-time constants of a handful of types, and nothing else. There is no way to say "this HttpClient local never gets reassigned", and after a while in Rust you notice you were relying on that guarantee informally the entire time.

Reassigning without mut gets you E0384, cannot assign twice to immutable variable, and the compiler points at the original binding and suggests adding mut.

Then there is shadowing, which looks like a mistake and is idiomatic.

let port = "5432";
let port: u16 = port.parse().expect("port must be a number");

The second let creates a new binding with the same name and a different type. The first is gone. The Rust Book's section on shadowing makes the case: you avoid inventing portStr and portParsed, and because it is a new binding rather than an assignment, immutability survives. C# forbids this, and forbids it for a good reason, which is that in C# a shadowed name usually means somebody made a typo. In Rust, parse-and-rebind is a named pattern with a purpose.

Functions, and three things C# has that Rust does not

fn takes parameters with declared types and returns one value. That is the whole story, and the missing pieces will annoy you.

There is no overloading. There are no default parameter values. There are no named arguments. Coming from a language where Log(string), Log(string, Exception) and Log(string, Exception, LogLevel) are one method name, this feels like a step backward, and in pure API ergonomics it is one.

What replaces them: distinct constructor names by convention, so Vec::new and Vec::with_capacity rather than an overload; Option<T> parameters for genuinely optional values; generic parameters like impl Into<String> so one function accepts several input types; and the builder pattern for anything with more than three or four knobs. The result is more verbose at the definition and often clearer at the call site, because with_capacity(1024) says what new(1024) does not.

Structs, impl blocks, and the three forms of self

Structs hold data. impl blocks hold behavior. They are separate declarations, and a type may have several impl blocks.

struct Server {
    hostname: String,
    port: u16,
}

impl Server {
    fn new(hostname: String, port: u16) -> Self {
        Server { hostname, port }
    }

    fn address(&self) -> String {
        format!("{}:{}", self.hostname, self.port)
    }

    fn set_port(&mut self, port: u16) {
        self.port = port;
    }

    fn into_hostname(self) -> String {
        self.hostname
    }
}

Self with a capital S is the type. self lowercase is the instance, and it appears explicitly as the first parameter, which is closer to Python than to C#. new is a convention, not a language feature. Rust has no constructors, and nothing stops you calling your factory function connect or open instead.

The three forms of self are the part to memorize, because they put information in a signature that C# cannot put there.

&self borrows the instance to read. The caller keeps it, and no code can reach the fields through that reference to write them. The closest C# analogue is an instance method you have promised will not change state, except here the promise is checked. The escape hatch is interior mutability. Cell, RefCell, Mutex and the atomics all let you write through a shared reference: Cell by only ever copying values in and out so no reference escapes, RefCell and Mutex by moving the exclusivity check to run time and panicking or blocking when it fails. So &self means "no direct field writes", not "nothing changes".

&mut self borrows the instance exclusively. The caller keeps it, you may mutate, and while your method runs no other reference to that instance exists anywhere in the program.

self consumes the instance. The caller hands it over and cannot use it afterward. There is no C# equivalent, and this is the one that pays for itself.

Consider a type wrapping something you must close exactly once. In C# the standard shape is IDisposable, a _disposed flag, and ObjectDisposedException for anyone who uses it afterward.

sealed class Channel : IDisposable
{
    private bool _disposed;

    public void Send(string message)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(Channel));
        // ...
    }

    public void Dispose() => _disposed = true;
}

Every one of those guards is a run-time branch, written by hand, resting on a discipline nothing enforces, and the failure arrives as a log line at 3am. In Rust you write fn close(self) and the problem stops existing. After channel.close() the name channel is no longer usable, so a second close, or a later send, is a compile error. You deleted the flag, the branch and the exception type together.

That is the shape of most Rust design decisions. Push the invariant into the signature and stop checking it at run time.

Enums, and the exhaustiveness that follows

Enums are the piece that looks familiar and is not. A C# enum is a named integer. A Rust enum is a tagged union: each variant is its own shape carrying its own data, and the set of variants is the type.

Here is a complete program. Save it as src/main.rs in a fresh cargo new project and run cargo run.

#[derive(Debug)]
enum ConnectionState {
    Idle,
    Connecting { attempt: u32 },
    Active { peer: String },
    Failed(std::io::Error),
}

fn describe(state: &ConnectionState) -> String {
    match state {
        ConnectionState::Idle => "idle".to_string(),
        ConnectionState::Connecting { attempt } => format!("connecting, attempt {attempt}"),
        ConnectionState::Active { peer } => format!("active with {peer}"),
        ConnectionState::Failed(err) => format!("failed: {err}"),
    }
}

fn main() {
    let states = vec![
        ConnectionState::Idle,
        ConnectionState::Connecting { attempt: 3 },
        ConnectionState::Active { peer: String::from("db-1:5432") },
        ConnectionState::Failed(std::io::Error::new(
            std::io::ErrorKind::TimedOut,
            "handshake timed out",
        )),
    ];

    for state in &states {
        println!("{}", describe(state));
    }
}
idle
connecting, attempt 3
active with db-1:5432
failed: handshake timed out

If you have used F# discriminated unions this is that, and if you have followed the discriminated unions proposal in dotnet/csharplang you know C# wants it too. Option and Result, which you will use in every file you write, are declared in the standard library as ordinary enums rather than built into the language.

match takes them apart, and match is exhaustive. Now add a variant, the way you would in a real system the week somebody asks for graceful shutdown.

#[derive(Debug)]
enum ConnectionState {
    Idle,
    Connecting { attempt: u32 },
    Active { peer: String },
    Failed(std::io::Error),
    Draining(u32),
}

Change nothing else, and the build stops.

error[E0004]: non-exhaustive patterns: `&ConnectionState::Draining(_)` not covered
  --> src/main.rs:11:11
   |
11 |     match state {
   |           ^^^^^ pattern `&ConnectionState::Draining(_)` not covered
   |
note: `ConnectionState` defined here
  --> src/main.rs:2:6
   |
2  | enum ConnectionState {
   |      ^^^^^^^^^^^^^^^
...
7  |     Draining(u32),
   |     -------- not covered
   = note: the matched value is of type `&ConnectionState`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
15 ~         ConnectionState::Failed(err) => format!("failed: {err}"),
16 +         &ConnectionState::Draining(_) => todo!()
   |

For more information about this error, try `rustc --explain E0004`.

Exact wording drifts a little between compiler versions, but the structure is stable: the uncovered pattern, the enum definition with the offending variant underlined, and a suggested arm. In a real codebase this fires once per incomplete match, and the build output is a work list with file names and line numbers. Every place that needs to think about draining is in front of you before the code runs.

Why C# cannot do this, and it is not for lack of trying

C# has pattern matching and switch expressions, so the shape looks the same. The difference is enforcement, and the reason is structural rather than a missing feature.

Write the closest C# equivalent, a closed-looking hierarchy of records.

abstract record ConnectionState;
sealed record Idle : ConnectionState;
sealed record Connecting(uint Attempt) : ConnectionState;
sealed record Active(string Peer) : ConnectionState;
sealed record Failed(Exception Error) : ConnectionState;
sealed record Draining(uint Remaining) : ConnectionState;

static string Describe(ConnectionState state) => state switch
{
    Idle => "idle",
    Connecting c => $"connecting, attempt {c.Attempt}",
    Active a => $"active with {a.Peer}",
    Failed f => $"failed: {f.Error.Message}",
};

On .NET 9.0.306 that compiles, with a warning.

warning CS8509: The switch expression does not handle all possible values of its
input type (it is not exhaustive). For example, the pattern '_' is not covered.

Build succeeded.
    1 Warning(s)
    0 Error(s)

And then it ships, and then:

Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException:
Non-exhaustive switch expression failed to match its input.
Unmatched value was Draining { Remaining = 3 }.

A warning at build, an exception in production. Both are reproducible with dotnet new console on that SDK, and both messages are stable text you can look up: CS8509 in the compiler messages and SwitchExpressionException in the BCL.

Notice that all five records are in one file and every leaf is sealed. It does not help. C# has no closed-hierarchy exhaustiveness check, and the reason is that the type system is open in two ways at once. Mark ConnectionState public and any assembly referencing yours can derive from it, so the compiler cannot enumerate the subtypes even in principle. And null is a legal value of every reference type, so a switch over reference patterns always carries one more case than you wrote. Roslyn does not special-case the situation where you happen to have closed the set by hand.

The same thing happens with plain enums, and there it is sharper, because you cannot fix it by being careful. A C# enum is a named integer, so a value outside the named set is still a legal value of the type.

enum Level { Debug, Info, Warn }

static string Name(Level l) => l switch
{
    Level.Debug => "debug",
    Level.Info => "info",
    Level.Warn => "warn",
};

Console.WriteLine(Name((Level)99));
warning CS8524: The switch expression does not handle some values of its input type
(it is not exhaustive) involving an unnamed enum value. For example, the pattern
'(Level)3' is not covered.

You covered every member of the enum and the compiler still cannot promise you anything, because (Level)99 exists. At run time it throws the same SwitchExpressionException. A Rust enum is closed: the variant list is the type, ConnectionState has no representation outside its declared variants, and the compiler can therefore reason about the whole set.

The trap here is specifically a C# one. When a match will not compile, the fastest way out is _ => {}, and the habit transfers directly from default: in a switch. Resist it on your own domain types. A wildcard arm is a promise that every future variant can be handled identically, which is almost never true, and the moment you write it you have converted the compile error you wanted into the run-time surprise you were trying to leave behind. Wildcards belong on genuinely open sets: integers, strings, foreign #[non_exhaustive] enums.

Traits, modules, and where things live

Traits are where interfaces go. impl Display for Server reads as "implement Display for Server", and unlike C# you can do this for a type someone else defined. There is a limit, called the orphan rule: one of the trait or the type has to be yours, so you cannot implement serde::Serialize for chrono::DateTime from your own crate. The traits chapter is about why that matters more than it sounds.

Modules replace namespaces, use replaces using, and pub replaces public, but the underlying model differs in a way that catches people.

A C# namespace is a naming device. It has nothing to do with files, nothing to do with assemblies, and nothing to do with accessibility. Your access modifiers are public, internal, private and friends, and internal means assembly-wide.

A Rust module is a tree, and that tree is the privacy boundary. The Reference states the rule: everything is private by default, and a private item is reachable from the module where it is declared and from all of that module's descendants. pub makes an item visible to whoever can see the module. pub(crate) is the honest translation of internal. pub(super) exposes an item to the parent module only, and pub(in path) targets a named ancestor module.

Two things escape the default, and both save you typing you would otherwise expect: associated items in a pub trait are public, and the variants of a pub enum are public. You never write pub on a trait method or an enum variant.

The mistake to expect: writing pub on everything, because in a C# application assembly you write public on everything and the internal distinction rarely bites. In Rust, pub on an item in a library crate is a public API commitment that constrains your next semver bump. pub(crate) is what you meant almost every time, and it costs you nothing.

On disk, against a dotnet solution

The two layouts encode different assumptions, and comparing them is the fastest way to see what Cargo takes over.

A .NET solution, stripped of noise:

MyService.sln
Directory.Build.props
Directory.Packages.props
src/
  MyService.Api/
    MyService.Api.csproj
    Program.cs
    bin/  obj/
  MyService.Core/
    MyService.Core.csproj
    bin/  obj/
tests/
  MyService.Tests/
    MyService.Tests.csproj
    bin/  obj/

Each project is an assembly, each has its own bin and obj, package versions are per-project unless you opt into central package management, tests live in a separate project referencing the code under test, and formatting and linting are third-party tools you install and then argue about.

The Cargo equivalent, following the package layout Cargo assumes by convention:

Cargo.toml           # workspace manifest
Cargo.lock           # one lock file for the whole workspace
target/              # one build directory for the whole workspace
crates/
  api/
    Cargo.toml
    src/
      main.rs        # binary crate root
      routes.rs
  core/
    Cargo.toml
    src/
      lib.rs         # library crate root
      parser.rs
    tests/
      integration.rs # each file here is its own test binary
    benches/
    examples/

The differences are not cosmetic.

Package is not crate. A package is one Cargo.toml. A crate is one compilation unit, roughly one assembly. A package can produce a library crate plus any number of binary crates, which is why src/main.rs and src/lib.rs can coexist and every file under src/bin/ becomes its own executable.

A workspace shares more than a solution does. One Cargo.lock and one target/ for the whole workspace, so members resolve against a single dependency graph instead of each project picking for itself. That is stricter than a .sln, which shares nothing until you opt into central package management.

Be careful about what that actually guarantees. Cargo unifies requirements that are semver compatible, so two members asking for serde 1.0.190 and 1.0.210 get one copy. It does not unify across incompatible majors: rand 0.8 and rand 0.9 will both be in your build, linked side by side, and a value of one is not a value of the other. On .NET one assembly identity wins per app and the mismatch surfaces at run time instead. Rust turns the diamond into a compile error and a bigger binary. Different failure mode, not an absent one.

Unit tests live in the file they test. A #[cfg(test)] mod tests block at the bottom of the source file is idiomatic, it compiles only under cargo test, and because it is a child module it can see private items. Integration tests go in tests/, compile as separate crates, and see only your public API. That split is a design tool: if a test in tests/ cannot reach something, the compiler is telling you about your API surface.

The toolchain has no seams. cargo build, cargo test, cargo fmt, cargo clippy, cargo doc, cargo add, cargo publish. One tool where .NET has dotnet, NuGet, and whichever analyzers your team settled on.

Cargo.toml itself is short enough to read in one breath, and [dev-dependencies] is what you want where you would have added a test-only PackageReference:

[package]
name = "core"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
proptest = "1"

The rest of the decoder ring

C#RustWorth knowing
class (heap, reference)struct plus Box when you want the heapNo class and struct split. Heap placement is a choice at the use site
struct (value)structThe default in Rust
interfacetraitImplementable for foreign types, within the orphan rule
enum (named integer)enum (tagged union)Closed set, carries data, matched exhaustively
abstract classtrait plus compositionNo inheritance of state anywhere in the language
IEnumerable<T>IteratorLazy in both, but no allocation per chain step in Rust
List<T>Vec<T>Same growable-array idea, same amortized push
Dictionary<K,V>HashMap<K,V>Rust defaults to SipHash: DoS-resistant, slower, and swappable per map
stringString and &strOwned and borrowed. Next chapter
nullOption<T>A type, not a value
exceptionsResult<T, E>Errors are return values
using / IDisposableDropRuns on scope exit. Not opt-in, so there is no keyword to forget
lockMutex<T>The lock owns the data
Task<T>FutureRust futures do nothing until polled
Span<T>&[T]A slice is an ordinary type. No ref struct rules to work around
internalpub(crate)Privacy is per-module, not per-assembly
assemblycrateCompilation unit in both
.csproj and .slnCargo.toml and workspaceOne lock file and one target dir per workspace
nuget.orgcrates.iocargo add rather than dotnet add package
unsafeunsafeNarrower than you think, and covered later

That is enough to read anything below. Everything else I will explain where it appears.

Where C# stays ahead in this chapter's material

Two places, and neither is a close call.

Function-level API ergonomics. Overloading, default parameter values and named arguments make a C# API pleasant in a way Rust currently cannot match, and the builder pattern is a workaround rather than a win. If you are designing a surface consumed by application developers rather than systems programmers, C# hands you better tools.

Discoverability of the visibility model. internal in a single-assembly application is one keyword with one meaning and takes about ten seconds to teach. Rust's module tree is more precise and more expressive, and it takes an afternoon and a diagram. Precision is not free.

Checking yourself

Why does a garbage collector's cost show up in p99 rather than in throughput? Collector work is a small fraction of total wall clock, so throughput barely moves, but each pause suspends every managed thread and delays only the requests in flight or arriving during it. A small percentage of requests take a large addition, which is exactly what a tail percentile measures and an average hides.

What sets how often the .NET collector runs, and what sets how long each collection takes? Frequency comes from your allocation rate against the per-generation allocation budget. Duration comes from how much data survives, because the collector's work is proportional to live objects rather than to garbage. The two are independent, which is why cutting allocations in a service with a large live set helps less than you expect.

Rust rejects a function you are certain is correct. What has the compiler actually claimed? That it cannot prove the function is safe under its rules, not that the function is unsafe. Rust's analysis is sound and incomplete on purpose, so false rejections exist. Usually the fix is to restructure until the proof goes through. The last resort is raw pointers inside unsafe, and note what that does and does not do: unsafe does not switch the borrow checker off, it lets you use a pointer type the checker has nothing to say about, and the obligation moves to you.

You add a variant to a shared enum. What happens in each language, and why? In Rust every incomplete match becomes an E0004 compile error with a file and a line, because the variant list is the whole type. In C# a switch expression gives you CS8509 or CS8524, a warning, and then throws SwitchExpressionException at run time, because the type system is open: anyone can derive from your base class, null is always another case, and an enum value outside the named set is still a legal value.

Where your data actually lives

In C# the location of a value is decided by how its type was declared. class means the instance lives on the managed heap and your variable holds a reference to it. struct means the value is stored inline, on the stack if it is a local, inside the containing object if it is a field. Box a struct and it moves to the heap and grows an object header.

You did not choose that per use. The type author chose it for you, once, for everyone.

Rust removes the distinction. There is one kind of struct, it is always a value, and it is stored wherever you put it. If you want the heap, you say so at the point of use:

let on_stack = Server { hostname: String::from("db-1"), port: 5432 };
let on_heap = Box::new(Server { hostname: String::from("db-1"), port: 5432 });

That is the whole idea, and most of this chapter is consequences of it.

One kind of struct, and what that costs

When you write class in C#, you are asking for several things at once and you probably do not separate them in your head, because you never had to.

You are asking for heap allocation. You are asking for reference semantics, so that assigning the variable copies an address and two variables see one object. You are asking for reference identity, so ReferenceEquals means something. You are asking for nullability, because a reference can be absent. You are asking for the ability to derive from it and to be dispatched virtually through it. And you are asking the GC to track it.

Rust unbundles those. A struct is a value with a size and a layout, and nothing else. Then:

  • heap allocation is Box::new
  • shared reference semantics are &T, or Rc<T> when the sharing has to outlive a single scope
  • absence is Option<T>
  • virtual dispatch is dyn Trait
  • identity is an address you can compare when you have a reason to, with Rc::ptr_eq or std::ptr::eq
  • nothing traces any of it, and a value is destroyed when its owner goes out of scope
  • there is no inheritance, and the answer is composition plus traits

The practical effect is that the same type can live in different places in different functions. A Reading can sit inline in a Vec in the ingest path, be passed by reference to a validator, and be boxed once when it goes into a heterogeneous queue. You do not need three types for that. You need one type and three use sites.

The cost is that you now think about indirection every time you write a signature. In C# you write Customer in a parameter list and the language has already decided what that means. In Rust you write Customer, &Customer, &mut Customer, or Box<Customer>, and each is a different contract. That is four options where the language used to hand you one, and for the first month it is genuinely slower.

Box, and the struct that contains itself

Box<T> is the simplest owning pointer. One allocation, one owner, freed when the box goes out of scope. It is documented as the most straightforward form of heap allocation in the language, and that is exactly what it is: new without a collector attached.

You reach for it in four situations, and only four.

The first is a type that contains itself. In C#, a class holding a field of its own type is unremarkable, because the field is a reference and every reference is one word:

class Node
{
    public int Value;
    public Node Next;   // one word, may be null
}

Write the direct translation in Rust and the compiler stops you:

struct Node {
    value: i32,
    next: Option<Node>,
}
error[E0072]: recursive type `Node` has infinite size
 --> src/main.rs:1:1
  |
1 | struct Node {
  | ^^^^^^^^^^^
2 |     value: i32,
3 |     next: Option<Node>,
  |                  ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
3 |     next: Option<Box<Node>>,
  |                  ++++     +

The error is not a rule the designers made up. It is arithmetic that has no solution. The compiler computes the size of Node as the size of value plus the size of next, and the size of next depends on the size of Node. There is no fixed point. Any layout you propose is four bytes too small.

C# never hits this because the field is not a Node, it is a reference to a Node, and a reference is eight bytes regardless of what it points at. The indirection was always there. You just did not type it. Box is you typing it.

With the fix, Node measures 16 bytes: eight for the pointer, four for the i32, four of padding to keep the whole thing a multiple of its eight-byte alignment. The error code page covers the general form.

The second situation is a value whose size is not known at compile time. dyn Trait and [T] are unsized, so they cannot be stored in a local or a field directly. Box<dyn Error> is the workhorse example, and unsized types get their own reference chapter because the rules are subtle.

The third is a value large enough that moving it around costs real bytes. Moving a Box copies eight bytes. Moving the value copies the value. If a struct is a few hundred bytes and you pass it up through five call frames, boxing it once is a fair trade, although the optimizer often removes the copies anyway.

The fourth is when you need a stable address, which comes up in intrusive data structures and in FFI.

That is the list. Notice what is not on it: "because I would have used a class in C#." Boxing every struct out of habit is a week-one reflex, and it produces Rust that allocates more than the C# it replaced.

The first model was too simple

The model so far: a value lives where you put it, a local is on the stack, Box is the heap. Good enough to write code with, and not quite what happens.

What is actually true is narrower. size_of tells you the size a value occupies when the compiler has to give it a place: inside a struct, inside an array, or when it crosses a function boundary that was not inlined. It does not promise the value is ever written to memory at all. A Reading that you construct, read one field from, and drop may spend its entire life in registers, with no stack slot allocated for it. LLVM decides that, not the type system.

The same applies to moves. When you hand a String to a function, the model says twenty-four bytes are copied from one stack slot to another. In a release build with inlining, frequently nothing is copied because there was only ever one slot. The move is a fact about ownership, which the next chapter is about, and only sometimes a fact about memcpy.

So read the sizes below as the ABI-level truth: what the layout is when it exists. Do not read them as a promise about instructions. Those are different claims, and conflating them is how people end up micro-optimizing a struct that the optimizer had already deleted.

Two strings, and why

The part that trips people up first is that Rust has two string types you will meet in the first hour.

String is an owned, growable, heap-allocated UTF-8 buffer. It is List<byte> shaped, with a UTF-8 guarantee the type enforces on every path in. &str is a borrowed view into UTF-8 bytes that somebody else owns. A string literal in your source is a &'static str, pointing into the read-only data of the binary itself.

let owned: String = String::from("db-1.internal");
let borrowed: &str = &owned;          // a view, no allocation
let literal: &str = "db-1.internal";  // points into the binary

The closest thing in your existing model is string versus ReadOnlySpan<char>. String owns and can grow. &str is a pointer and a length, cannot grow, and cannot outlive what it points at.

Once that lands, the convention makes sense: take &str in function parameters, return String when you produce something new. Taking &str means callers with either type can call you without allocating.

fn is_internal(host: &str) -> bool {
    host.ends_with(".internal")
}

Now the encoding, which is the real difference from .NET and the one that changes how you write code.

A .NET string is a sequence of UTF-16 code units. Length counts code units, s[i] returns a char, and a char is sixteen bits. That design dates from a time when Unicode fit in sixteen bits and the encoding looked fixed-width. It stopped being fixed-width when Unicode went past U+FFFF, and the repair was surrogate pairs: two char values that together mean one character. Microsoft's encoding introduction is direct about this, and System.Text.Rune exists because char alone cannot represent every scalar value.

Rust made the other choice. String and str are guaranteed to hold valid UTF-8, always, enforced by the type. A Rust char is a Unicode scalar value and is four bytes, so it can hold anything, and there are no surrogate pairs to reassemble.

The reason is boundaries. Almost everything a service touches is UTF-8 already: HTTP bodies, JSON, config files, database wire protocols, filenames on Linux. If your in-memory representation is UTF-16, every read transcodes in and every write transcodes out. .NET absorbs that cost, and for most applications it is invisible. For a proxy moving bytes, it is not invisible, and it is the kind of cost that shows up as CPU you cannot explain.

You pay for that guarantee in a different currency, and the bill arrives the first time you try to index a string.

Why you cannot index a string by position

The direct translation of the most ordinary line in C# does not compile:

string host = "db-1.internal";
char first = host[0];         // fine, O(1)
let host = String::from("db-1.internal");
let first = host[0];
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:22
  |
3 |     let first = host[0];
  |                      ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
note: required for `String` to implement `Index<{integer}>`

The mechanism is worth having straight, because the same reasoning turns up again in get, in the slicing rules, and in why iteration is the default way to walk text.

In UTF-8, characters are one to four bytes. Byte number seven is not character number seven. So host[7] has two possible meanings, and both are bad. If it returns a byte, the type is u8 and you have handed the caller a fragment of a character with no warning. That is the same bug UTF-16 indexing produces in .NET, with a different trigger: an accented Latin letter is a single UTF-16 code unit and behaves fine, so the failure waits for something above U+FFFF, an emoji or a rarer CJK character, where s[i] hands you a lone surrogate half. If it returns a char, the operation is O(n), because finding the seventh character means walking from the start, and Rust will not give an O(n) operation the syntax of an O(1) one.

So it refuses, and the note tells you which one you meant. The Book's chapter on strings works through the same argument at length.

Range slicing does work, because a range is a pair of byte offsets and you are explicitly saying you know where the boundaries are. If you are wrong, it panics rather than producing mojibake:

let s = "naïve";
println!("{}", &s[0..3]);
thread 'main' panicked at src/main.rs:3:22:
end byte index 3 is not a char boundary; it is inside 'ï' (bytes 2..4 of string)

That panic is a design choice you should notice. Slicing a string at a byte offset you got from somewhere trustworthy is fast and common. Slicing at an offset you made up is a bug, and Rust would rather crash the request than corrupt the data.

What you use instead, depending on what you actually meant:

You wantedRust
the nth characters.chars().nth(n), and know it is O(n)
the nth bytes.as_bytes()[n]
character positions with byte offsetss.char_indices()
a substring by known byte range&s[start..end]
a substring, falliblys.get(start..end), returns an Option
find and cuts.find('.') then slice at the returned offset

The comparison worth sitting with. First the C#, which compiles and is wrong:

string s = "a\U0001F980b";      // a, crab, b
Console.WriteLine(s.Length);     // 4, because the crab is two code units
Console.WriteLine(s[1]);         // a lone high surrogate, prints as garbage

And the Rust, which is uglier and correct:

let s = "a🦀b";
println!("{}", s.len());                    // 6, bytes
println!("{}", s.chars().count());          // 3, characters
println!("{:?}", s.chars().nth(1));         // Some('🦀')

Neither language makes the right thing free. C# makes the wrong thing free.

The owned and borrowed pairs

The String and &str split is not a special case for text. It is a pattern the whole standard library follows, and once you see it the library stops looking arbitrary.

Owns the bufferBorrows a viewNotes
String&strUTF-8 guaranteed on both
Vec<T>&[T]the array case
PathBuf&Pathplatform-native path bytes
OsString&OsStrOS strings, not guaranteed UTF-8
CString&CStrnul-terminated, for FFI
Box<T>&Tthe general case

The owned type is a value you can move, grow, and drop. The borrowed type is a pointer with no ownership and a lifetime attached, fat when the thing it points at is unsized and thin when it is not. Functions take the borrowed form, constructors and builders return the owned form.

The reason &owned works where &str is expected, and &my_vec works where &[T] is expected, is deref coercion: the compiler will insert a Deref call to turn &String into &str at a call site. It does not go the other way, which is why the most common early error is this one:

error[E0308]: mismatched types
 --> src/main.rs:7:32
  |
7 |     println!("{}", is_internal(owned));
  |                    ----------- ^^^^^ expected `&str`, found `String`
  |                    |
  |                    arguments to this function are incorrect
  |
note: function defined here
 --> src/main.rs:1:4
  |
1 | fn is_internal(host: &str) -> bool {
  |    ^^^^^^^^^^^ ----------
help: consider borrowing here
  |
7 |     println!("{}", is_internal(&owned));
  |                                +

The fix is one ampersand, and the compiler tells you exactly where to put it. Add the ampersand. Do not add .clone().

Slices deserve one more paragraph because they are the piece with the closest .NET analogue. A &[T] is an address plus a length, and it can point at a Vec, at a fixed-size array, or at part of either. The slice primitive has the whole surface. A function taking &[f64] accepts all three callers without allocating:

fn mean(samples: &[f64]) -> f64 {
    if samples.is_empty() { return 0.0; }
    samples.iter().sum::<f64>() / samples.len() as f64
}

fn main() {
    let owned: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
    let fixed: [f64; 3] = [10.0, 20.0, 30.0];

    assert_eq!(mean(&owned), 2.5);          // Vec
    assert_eq!(mean(&fixed), 20.0);         // fixed-size array
    assert_eq!(mean(&owned[1..3]), 2.5);    // part of a Vec
}

Span<T> is the same shape and exists for the same reason. The difference is that Span<T> had to be a ref struct with a list of restrictions bolted on, because C# had no way to express "this must not outlive the thing it points at" in the general type system. In Rust, &[T] is an ordinary type. The lifetime rule is not a special case for slices, it is how every reference works, so slices needed no new machinery.

What things actually cost

std::mem::size_of reports the size of a type in bytes, align_of reports its alignment, and offset_of!, stable since Rust 1.77, reports where a field landed. Reading a few of these is the fastest way to build intuition. Here is a complete program you can paste into a file and run.

#![allow(dead_code)]

use std::mem::{align_of, offset_of, size_of};
use std::num::NonZeroU32;

struct Reading {
    timestamp: i64,
    value: f64,
    sensor_id: i32,
    valid: bool,
}

// Declared worst-first on purpose.
struct Ragged {
    flag: bool,
    id: u64,
    kind: u8,
}

// Same fields, C rules, no reordering allowed.
#[repr(C)]
struct RaggedC {
    flag: bool,
    id: u64,
    kind: u8,
}

struct Node {
    value: i32,
    next: Option<Box<Node>>,
}

fn row(name: &str, size: usize, align: usize) {
    println!("{:<20} {:>3}   {:>2}", name, size, align);
}

fn main() {
    println!("{:<20} {:>3}   {:>2}", "type", "size", "align");
    row("i32", size_of::<i32>(), align_of::<i32>());
    row("f64", size_of::<f64>(), align_of::<f64>());
    row("bool", size_of::<bool>(), align_of::<bool>());
    row("char", size_of::<char>(), align_of::<char>());
    row("()", size_of::<()>(), align_of::<()>());
    row("&i32", size_of::<&i32>(), align_of::<&i32>());
    row("Box<i32>", size_of::<Box<i32>>(), align_of::<Box<i32>>());
    row("Option<Box<i32>>", size_of::<Option<Box<i32>>>(), align_of::<Option<Box<i32>>>());
    row("Option<&i32>", size_of::<Option<&i32>>(), align_of::<Option<&i32>>());
    row("Option<i32>", size_of::<Option<i32>>(), align_of::<Option<i32>>());
    row("Option<NonZeroU32>", size_of::<Option<NonZeroU32>>(), align_of::<Option<NonZeroU32>>());
    row("Option<bool>", size_of::<Option<bool>>(), align_of::<Option<bool>>());
    row("&str", size_of::<&str>(), align_of::<&str>());
    row("&[u8]", size_of::<&[u8]>(), align_of::<&[u8]>());
    row("String", size_of::<String>(), align_of::<String>());
    row("Vec<u8>", size_of::<Vec<u8>>(), align_of::<Vec<u8>>());
    row("Reading", size_of::<Reading>(), align_of::<Reading>());
    row("Node", size_of::<Node>(), align_of::<Node>());

    println!();
    println!("Ragged  size {:>2}  flag@{}  id@{}  kind@{}",
        size_of::<Ragged>(),
        offset_of!(Ragged, flag), offset_of!(Ragged, id), offset_of!(Ragged, kind));
    println!("RaggedC size {:>2}  flag@{}  id@{}  kind@{}",
        size_of::<RaggedC>(),
        offset_of!(RaggedC, flag), offset_of!(RaggedC, id), offset_of!(RaggedC, kind));

    println!();
    let host = String::from("db-1.internal");
    println!("\"{}\"  len {}  chars {}", host, host.len(), host.chars().count());
    let emoji = "a🦀b";
    println!("\"{}\"  len {}  chars {}", emoji, emoji.len(), emoji.chars().count());
    println!("byte offsets: {:?}", emoji.char_indices().collect::<Vec<_>>());
}

On stable Rust 1.98, x86_64, that prints:

type                 size   align
i32                    4    4
f64                    8    8
bool                   1    1
char                   4    4
()                     0    1
&i32                   8    8
Box<i32>               8    8
Option<Box<i32>>       8    8
Option<&i32>           8    8
Option<i32>            8    4
Option<NonZeroU32>     4    4
Option<bool>           1    1
&str                  16    8
&[u8]                 16    8
String                24    8
Vec<u8>               24    8
Reading               24    8
Node                  16    8

Ragged  size 16  flag@9  id@0  kind@8
RaggedC size 24  flag@0  id@8  kind@16

"db-1.internal"  len 13  chars 13
"a🦀b"  len 6  chars 3
byte offsets: [(0, 'a'), (1, '🦀'), (5, 'b')]

Go through the interesting rows.

char is four bytes, not one and not two. It holds a scalar value, so it needs twenty-one bits and gets thirty-two. A .NET char is two bytes and cannot hold the crab on its own.

() is zero bytes. The unit type is Rust's void with the difference that it is a real type you can put in a generic parameter, which is why Result<(), E> is the signature of a function that can fail and returns nothing useful. HashSet<T> is a HashMap with zero-sized values, and the map genuinely stores nothing for them.

String and Vec<u8> are both twenty-four bytes: pointer, length, capacity. A .NET List<T> tracks roughly the same thing, an array reference and a count, with capacity implied by the array's own length. This is where the layouts diverge. In Rust those twenty-four bytes are the value. They sit in your stack frame, or inline inside the containing struct, and there is one heap allocation, for the elements. In .NET you have a reference on the stack, a List<T> object on the heap with a header and a method table pointer, and a separate array object on the heap with its own header. One more allocation, and one more pointer hop on the way to any element.

&str and &[u8] are sixteen: address plus length.

Reading is twenty-four. Eight for the i64, eight for the f64, four for the i32, one for the bool, three of padding to reach a multiple of the eight-byte alignment.

Alignment, padding, and why the compiler moves your fields

Alignment is a hardware constraint that leaks into the type system. A type with alignment eight must start at an address divisible by eight, because that is what the load and store instructions want. Structs inherit the largest alignment of their fields, and the compiler inserts padding bytes to keep every field aligned and to make the struct's own size a multiple of its alignment, so that consecutive elements in an array stay aligned.

Padding is why field order matters. Ragged declares a bool, then a u64, then a u8. Laid out in declaration order: one byte, seven of padding, eight, one, seven more of padding. Twenty-four bytes for ten bytes of data.

Rust does not lay it out in declaration order. The default representation makes no guarantee about field order, and rustc sorts fields to minimize padding. The output shows id at offset 0, kind at 8, flag at 9, and six bytes of tail padding. Sixteen bytes. A third smaller, for free, because you were not promised an order in the first place. The Rustonomicon's chapter on repr(Rust) walks through the same example.

Write #[repr(C)] and you opt into the C rules, which do promise declaration order, and you get twenty-four bytes back. You do that at an FFI boundary or when a struct maps onto a wire format, and nowhere else. The reference section on the C representation is the spec.

Now the C# side, which does not go the way most people assume. The same struct, plus two variations worth having in the same output:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

struct A { public bool Flag; public ulong Id; public byte Kind; }   // declared badly
struct B { public ulong Id; public bool Flag; public byte Kind; }   // declared well

[StructLayout(LayoutKind.Auto)]
struct AutoA { public bool Flag; public ulong Id; public byte Kind; }

class ClassA { public bool Flag; public ulong Id; public byte Kind; }
class ClassB { public ulong Id; public bool Flag; public byte Kind; }

class LayoutProbe
{
    static unsafe void Main()
    {
        Console.WriteLine($"A     = {Unsafe.SizeOf<A>()}   (default struct layout)");
        Console.WriteLine($"B     = {Unsafe.SizeOf<B>()}   (default struct layout)");
        Console.WriteLine($"AutoA = {Unsafe.SizeOf<AutoA>()}   (LayoutKind.Auto)");
        Console.WriteLine($"A is sequential: {typeof(A).IsLayoutSequential}");

        A a = default;
        Console.WriteLine($"A offsets: Flag {(byte*)&a.Flag - (byte*)&a}, " +
                          $"Id {(byte*)&a.Id - (byte*)&a}, " +
                          $"Kind {(byte*)&a.Kind - (byte*)&a}");

        long m0 = GC.GetAllocatedBytesForCurrentThread();
        var ca = new ClassA();
        long m1 = GC.GetAllocatedBytesForCurrentThread();
        var cb = new ClassB();
        long m2 = GC.GetAllocatedBytesForCurrentThread();
        GC.KeepAlive(ca); GC.KeepAlive(cb);
        Console.WriteLine($"ClassA = {m1 - m0}, ClassB = {m2 - m1}   (auto layout)");
    }
}

That needs AllowUnsafeBlocks. On .NET 9.0.10, x64, it prints:

A     = 24   (default struct layout)
B     = 16   (default struct layout)
AutoA = 16   (LayoutKind.Auto)
A is sequential: True
A offsets: Flag 0, Id 8, Kind 16
ClassA = 32, ClassB = 32   (auto layout)

The reason A stayed at twenty-four is not that the runtime looked at it and declined to help. It is that the field order was never up for negotiation. The C# compiler stamps LayoutKind.Sequential on structs by default, which the StructLayoutAttribute documentation states plainly: C#, Visual Basic and C++ apply sequential layout to structures by default, and for classes you have to ask for it explicitly. Sequential means declaration order, so declaring the fields badly cost eight bytes per instance and declaring them well got the eight bytes back.

Ask for auto layout on the struct and the packing arrives: AutoA has the bad declaration order and comes out at sixteen. Classes get auto layout without asking, which is why ClassA and ClassB are both thirty-two bytes, sixteen of object overhead plus ten bytes of fields rounded up to the allocation granularity, with the declaration order making no difference at all.

So the defaults are inverted relative to where the pain is. In C# the type most likely to be held by the million, the struct in an array, is the one that keeps your declaration order and your padding. Rust reorders by default and asks you to opt out with #[repr(C)]. In a struct with a dozen fields of mixed width, nobody keeps the ordering optimal by hand across three years of maintenance, and only one of these two languages makes that a non-issue by default.

Niche optimization, or why an optional Box is one word

Option<Box<i32>> is eight bytes. So is Option<&i32>. Option<bool> is one. Option<NonZeroU32> is four. But Option<i32> is eight, when the payload is only four.

The mechanism is called niche optimization. A niche is a bit pattern that the payload type can never hold. If the compiler can find one, it stores the None case there instead of adding a separate discriminant field.

Box and & can never be null, so the null address is a niche, and None is the null pointer. That is more than an optimization the compiler happens to do. It is a documented guarantee: Option<Box<U>> has the same size, alignment, and calling convention as Box<U> when U is sized.

bool uses two bit patterns out of two hundred and fifty six, so there are 254 spare ones and None gets one of them. NonZeroU32 cannot be zero, so zero is free. Only the pointer cases are promised in writing; the rest is the compiler taking an opportunity it is allowed to take.

i32 uses every one of its four billion patterns for a real value. There is no niche. So the compiler adds a discriminant byte, giving five bytes, then rounds up to the four-byte alignment, giving eight. The output above shows exactly that: size 8, align 4.

This is why "Rust has no null" costs you nothing at the representation level. The type is checked, you cannot dereference an Option without handling None, and the machine code is a nullable pointer. You get the compile-time guarantee for free.

The comparison in C# is instructive because C# does this twice, differently. Nullable<T> for value types is a struct with a bool HasValue and a T Value, so int? costs eight bytes for the same reason Option<i32> does. Nullable reference types, added in C# 8, work the other way: the annotations are recorded in metadata so other assemblies can read them, but they have no runtime representation, the checking is compiler flow analysis that produces warnings, and a caller from an assembly that never opted in can hand you a null anyway. Rust's Option is one mechanism that covers both cases, is enforced rather than warned about, and is free where a niche exists.

The general layout rules are in the type layout reference, which is the document to reach for when a size surprises you.

A million of them

Take a record you would actually hold a lot of.

class Reading
{
    public long Timestamp;
    public double Value;
    public int SensorId;
    public bool Valid;
}

On x64, a CoreCLR object has an eight-byte header word ahead of the reference and an eight-byte method table pointer at it. The runtime source spells this out: on 64-bit targets the object header is a four-byte sync block value plus four bytes of alignment padding, the object itself starts with the method table pointer, and the minimum object size works out to twenty-four bytes.

So the arithmetic for one Reading is sixteen bytes of overhead plus eight plus eight plus four plus one, which is thirty-seven, rounded up to forty. A Reading[] of a million is the array object itself, twenty-four bytes of header plus a million eight-byte references, plus a million forty-byte objects.

array:    24 + 8,000,000  =  8,000,024
objects:  1,000,000 x 40  = 40,000,000
total                     = 48,000,024   (about 48 MB)

You can check that instead of trusting it. GC.GetAllocatedBytesForCurrentThread reports allocation exactly, and reading it either side of a single new Reading() on .NET 9.0.10, x64, gives forty bytes. Reading it either side of the array and the million objects gives 48,000,056, which is the arithmetic plus a few dozen bytes of noise from the loop itself.

The Rust equivalent is a Vec<Reading> where Reading is twenty-four bytes. One allocation. Twenty-four million bytes. Half the memory, and the elements are contiguous, so a scan touches half as many cache lines and the prefetcher can see what you are doing.

The part that does not show up in the byte count is the GC. Those million objects are a million entries the collector has to mark. Rust has zero objects there, because a Vec<Reading> is one allocation as far as the allocator is concerned, and nothing traces it.

Now the honest half. Change the C# to a struct:

struct Reading { public long Timestamp; public double Value; public int SensorId; public bool Valid; }

A Reading[] of a million is then 24,000,024 bytes on the same counter, twenty-four bytes of array header plus a million twenty-four-byte values. Identical to Rust, one allocation, same cache behavior, and the collector has one object to look at.

So the gap is not between the languages. It is between reference layout and value layout, and C# gives you both. What C# does not give you is the ability to pick per use site. Once Reading is a struct, every place it flows through an object, an interface, or a non-generic API boxes it, and you are back to forty bytes plus a copy. Once it is a class, the array is forty megabytes everywhere, including the one hot path where you needed it not to be. Rust's Reading is twenty-four bytes in the Vec and eight bytes in the Box<Reading> in the queue, and it is the same type in both places.

At a hundred readings this is a rounding error and you should not think about it. At a million per host, on a fleet where resident memory is the budget you are actually measured against, it is the difference between one instance size and the next one up.

The mistakes that come from C# habits

Boxing everything. The class reflex says heap. Write Box<Server> in a field and you have added an allocation and a pointer hop for nothing. Symptom: Box in a struct definition where the inner type has a known size and is not recursive. Fix: delete the Box. If you needed sharing rather than heap placement, you wanted &, or Rc if the sharing outlives a scope.

String in every parameter. Coming from string, you write fn handle(host: String), and now every caller has to give up ownership or call .to_string(). Symptom: .to_string() or .clone() at call sites, on values the callee only reads. Fix: take &str. The rule of thumb is take the borrowed form, return the owned form, and only take the owned form when you genuinely keep the value.

Reaching for .clone() when the compiler complains about String versus &str. The E0308 above is a type mismatch whose fix is a borrow, and the compiler's suggestion is a single ampersand. Cloning silences it and allocates. Symptom: .clone() appearing right after you added a function. Fix: read the help line, which tells you where the ampersand goes.

Treating .len() as a character count. It is bytes. "café".len() is five. Symptom: off-by-a-few bugs that only appear for non-ASCII input, which in practice means a support ticket from a customer with an accent in their name. Fix: .chars().count() if you meant characters, and be aware it is O(n) and still not grapheme clusters.

Slicing at an offset you computed with character arithmetic. &s[..10] for a truncation panics on the wrong input. Symptom: a panic saying "is not a char boundary". Fix: get offsets from char_indices, find, or the byte positions the parser gave you, or use s.get(..10) and handle the None.

Reaching for #[repr(C)] because it sounds faster. It buys no speed. It disables the reordering that made your struct smaller, so it usually costs you bytes. Symptom: #[repr(C)] on a type that never crosses an FFI or wire boundary. Fix: remove it unless the bytes have to match something outside the process.

Assuming field order in a normal struct. It is not defined, and transmuting or reading raw bytes on that assumption is undefined behavior. Symptom: unsafe code doing pointer arithmetic on field offsets. Fix: offset_of!, or #[repr(C)] if you truly need a stable layout.

Where the C# model wins

Three places, and they are not small.

You do not have to make this decision at all. In C# you write class, the language decides everything downstream, and for a service where the database round trip dominates, that is correct and the mental overhead saved is real. Most of the code most people write does not need per-use-site layout control, and paying attention to it is a cost with no return.

UTF-16 indexing is O(1) and the data model is simpler when your input is ASCII. Parsing a fixed-width protocol, walking a header, scanning for a delimiter, all of these are more direct with s[i] than with char_indices. Rust gives you as_bytes for that, but you have left the string type to get it. .NET also gives you string interning and StringBuilder, and its string immutability makes reasoning about shared state easier than Rust's owned-mutable String does in some designs.

And .NET's Span<T> plus ArrayPool<T> plus stackalloc cover a large part of the same territory Rust's slices cover, with a shorter path from working code to fast code. If you already have a C# service and the layout is your problem, you can usually fix it in C# without leaving C#. That should be said plainly, because the answer to "my .NET service allocates too much" is very often "use structs and pooling", not "rewrite it in Rust".

Checking yourself

Why does Option<Box<T>> fit in eight bytes when Option<i32> needs eight for a four-byte payload? Box can never be null, so the null bit pattern is a niche the compiler uses for None. i32 uses every bit pattern for a real value, so there is no niche, a discriminant byte gets added, and alignment rounds five up to eight.

A struct has a bool, a u64, and a u8, declared in that order. What is its size, and what would it be with #[repr(C)]? Sixteen with the default representation, because rustc reorders to put the u64 first and pack the two single-byte fields together. Twenty-four with #[repr(C)], because the C rules force declaration order: seven bytes of padding after the bool to align the u64, then seven more at the end to keep the size a multiple of eight.

Your function needs to return the first three characters of a user-supplied name. Why is &name[..3] wrong, and what do you write instead? Byte three may land inside a multi-byte character, and slicing there panics. Use name.char_indices().nth(3) to find the byte offset of the fourth character and slice at that, or take name.chars().take(3).collect::<String>() if you are producing a new owned string.

You are holding two million small records in a Rust Vec and the same two million in a C# List<T> of a class. Where does the memory difference come from, and how would you close it in C# without changing languages? From the sixteen bytes of object header per instance plus the eight-byte reference in the backing array, plus the per-object rounding. Change the class to a struct and the C# array matches the Rust Vec almost exactly, at the cost of boxing anywhere the record flows through object or a non-generic interface.

Ownership, and the rule that replaced the collector

The whole model is three rules. The Rust Book states them as:

  1. Each value in Rust has an owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value will be dropped.

That is it. Everything the borrow checker does is enforcement of those three sentences, and every error message you will fight for the next month is one of them being violated.

fn main() {
    let config = String::from("retries=3");   // config owns the buffer
    println!("{}", config);
}                                             // scope ends, buffer freed here

No collector ran. The compiler knew, while compiling, that the buffer became unreachable at the closing brace, so it emitted the deallocation there. Deterministic, and free at run time.

Write the same thing in C#, with a string built at run time rather than an interned literal, and the reference goes out of scope at the closing brace. Nothing is freed at that point. The object becomes eligible for collection, and it will be reclaimed at some later moment chosen by the runtime based on allocation pressure, generation budgets and which latency mode the process is in. The word "eligible" is doing all the work in that sentence. Rust replaced eligible with freed.

Now the part that generates the error message from the first paragraph of this guide:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

s2 takes ownership. s1 is dead from that line on, and reading it is the error.

In C# this is fine, because s1 and s2 are two references to one heap object and the collector will deal with it when both are unreachable. In Rust, allowing it would mean two owners, which would mean two deallocations of one buffer. A double free. So the compiler moves ownership and marks s1 as no longer usable.

The diagnostic is worth reading line by line, because rustc puts more into it than you expect and most people skim it.

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

Three spans and a help line, four separate facts. The first span, on the declaration, tells you the reason: String is not Copy, which is a property of the type, not of this line. The second span tells you where the value left. The third span, the one with the carets, is where you tried to use it, and the verb is precise: borrowed, not used, because println! takes its arguments by reference. The help line offers the escape hatch and is honest about it being a cost rather than a fix.

Learn to read the first span first. Usually the question you actually have is "why is this type not Copy", and that line answers it.

What a move actually compiles to

Nothing happened at run time. The String header, three words wide, so 24 bytes on a 64-bit target, was copied from one stack slot to another, and the heap buffer never moved. The whole event was bookkeeping in the compiler.

That is the model you should carry, and it is the model the language documentation encourages: the Book says outright that any automatic copying can be assumed to be inexpensive, because Rust never deep-copies for you.

The mechanism underneath is a dataflow analysis on MIR, the compiler's mid-level intermediate representation. rustc tracks moves and initialization as a set of initialized places: assigning to a previously uninitialized local adds it to the set, and moving out of a local removes it. The move is an edit to that set, not a runtime operation. There is no flag written and no header touched, and nothing like the write barrier the CLR emits on a reference store so the collector can later find old-to-young references.

The honest version has an asterisk. At the MIR level a move of a non-Copy value is still a copy of its bytes, and LLVM is usually but not always able to delete that copy. Issue 13707, open since 2014, is exactly the request to stop emitting memcpys for moves where the compiler could avoid it. The Copy trait docs say the same thing more gently: both a copy and a move can result in bits being copied in memory, although this is sometimes optimized away.

For a String at 24 bytes, this is noise. For a 4 KB struct moved through five layers of builder methods, it is not, and the fix is the same one you would reach for in C# with a large struct: put it behind an indirection, which in Rust means Box, and move the pointer instead.

Moves in and out of functions

Passing a value to a function moves it, for exactly the same reason assignment does. The parameter is a new binding, and there can only be one owner.

fn consume(s: String) { /* s dropped at the end of this function */ }

fn main() {
    let name = String::from("db-1");
    consume(name);
    println!("{}", name);     // error[E0382]: borrow of moved value: `name`
}

This is where the model first bites. In C#, handing an object to a method copies a reference and settles nothing about who is responsible for it afterwards. In Rust the signature is the contract, and s: String means "give me this, I will destroy it".

Returning a value moves it the other way, out of the function and into the caller's binding. That direction is free of surprises and is how constructors work: String::from builds a value and hands you ownership of it.

If you want to keep something you are passing, there are three options, and choosing between them is most of what writing Rust feels like early on. Borrow it, which is the next chapter and is almost always the answer. Clone it, which allocates a copy and is sometimes exactly right. Or have the function give it back by returning it, which is clumsy and occasionally the clearest thing, particularly for builder chains where fn with_port(mut self, p: u16) -> Self reads better than any alternative.

There is a subtlety here that catches people. Reassigning an initialized variable drops the old value first, immediately, at the point of assignment:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");    // the "hello" buffer is freed right here
    println!("{s}");
}

The Reference states this as a rule: assignment runs the destructor of its left-hand operand, if it is initialized. In C#, reassigning a reference makes the old object unreachable and nothing else. If that old object held a socket, the socket stays open until a finalizer runs, or forever if there is no finalizer. Same line of code, completely different consequence.

Copy, move, clone

Three words that sound like synonyms and are not.

Copy is for types where a bitwise duplicate is a complete, independent value: integers, floats, bool, char, shared references, and tuples or arrays of those. They implement the Copy marker trait, and assignment duplicates rather than moves. This is why let x = 5; let y = x; leaves x perfectly usable and you never think about it.

Move is the default for everything else. The bits are copied and the source binding is invalidated at compile time. Cheap, because it is only the value itself, never the heap data it points at.

Clone is explicit and possibly expensive. a.clone() on a String allocates a new buffer and copies the bytes. Clone::clone may run arbitrary code, and it is deliberately something you have to type.

The rule for whether a type can be Copy is worth internalizing, because the compiler will quote it at you. From the Copy documentation: a type can implement Copy if all of its components implement Copy. The example in those docs is a struct PointList with one field, points: Vec<Point>. Derive Copy on it and you get E0204:

the trait `Copy` cannot be implemented for this type; field `points` does not implement `Copy`

Two consequences fall out of that rule, and both explain things you will hit.

String cannot be Copy because duplicating its bits would duplicate responsibility for one heap buffer, and both copies would try to free it. Same for Vec<T>, Box<T>, File, and anything holding a handle.

Any type implementing Drop cannot be Copy, and the compiler enforces it with E0184. The Drop docs give the reasoning plainly: types that are Copy get implicitly duplicated by the compiler, making it very hard to predict when, and how often, destructors will be executed. A destructor that might run an unknowable number of times has stopped being a destructor.

#[derive(Clone)] generates the obvious thing: an implementation that calls clone on each field. For a generic type it also adds a bound, so #[derive(Clone)] struct Reading<T> { frequency: T } produces an impl that requires T: Clone. That is usually what you want and occasionally not. The Clone docs work through the case where it is wrong: a struct holding a function pointer fn() -> T is cloneable regardless of whether T is, but the derived impl will demand T: Clone anyway. Writing the impl by hand is the fix, and it is three lines.

One more thing about Clone that a C# reader will get wrong on the first read. Clone is not automatically deep. It calls clone on each field, and if a field is an Arc<Mutex<T>>, cloning that field bumps a reference count and gives you a second handle to the same mutable state. The std docs flag this explicitly. So #[derive(Clone)] on a struct full of Arc fields is closer to C#'s MemberwiseClone than to a deep copy, and that is a real source of bugs in shared-state code.

Your C# intuition maps closely if you line them up:

SituationC#Rust
Assign an intcopies the valueCopy, copies the value
Assign a structcopies all fieldsCopy if all fields are, else a move
Assign a class referencecopies the reference, two names for one objectmove, one name for one value
Deep copywrite it yourself, or serialize and deserialize.clone(), if every field clones deeply
Two ownersnormal, the collector sorts it outneeds Rc or Arc. Chapter eleven

That third row is the real change. In C#, var b = a; on a class gives you two names for one object, and that is so normal that you stopped seeing it. Every aliasing bug you have ever debugged, every time a caller mutated a List<T> you thought you owned, lives in that row. Rust's default is the opposite, and the cost is that you have to say what you mean.

Taking a struct apart

Ownership is tracked per field, not only per variable, which is more useful than it sounds and produces error messages nobody expects.

struct Server {
    hostname: String,
    port: u16,
}

fn takes_server(_s: Server) {}

fn main() {
    let server = Server { hostname: String::from("db-1.internal"), port: 5432 };

    let host = server.hostname;
    println!("port is {}", server.port);
    println!("host is {}", host);

    takes_server(server);
}

Line by line: server.hostname moves out, and from that point server is partially moved. Reading server.port still works, because that field was never moved and u16 is Copy anyway. Using server as a whole does not, because there is no longer a whole Server there.

error[E0382]: use of partially moved value: `server`
  --> src/main.rs:15:18
   |
11 |     let host = server.hostname;
   |                --------------- value partially moved here
...
15 |     takes_server(server);
   |                  ^^^^^^ value used here after partial move
   |
   = note: partial move occurs because `server.hostname` has type `String`, which does not implement the `Copy` trait
help: consider cloning the value if the performance cost is acceptable
   |
11 |     let host = server.hostname.clone();
   |                               ++++++++

The word partially in both the header and the label is the compiler telling you it tracked the field, not just the binding. C# has no analogue at all. A field read is a field read, the object stays intact, and if you wanted to null the field out you would have to do it yourself.

The fix, nine times out of ten, is to destructure everything at once instead of one field at a time:

let Server { hostname, port } = server;   // moves both fields, consumes `server`

That form is common enough in real Rust that you should reach for it by reflex.

Now the version that has no easy fix, and teaches the mechanism:

struct Handle(u32);

struct Connection {
    handle: Handle,
    id: u32,
}

impl Drop for Connection {
    fn drop(&mut self) {
        println!("closing connection {}", self.id);
    }
}

fn steal(conn: Connection) -> Handle {
    conn.handle
}
error[E0509]: cannot move out of type `Connection`, which implements the `Drop` trait
  --> src/main.rs:16:5
   |
16 |     conn.handle
   |     ^^^^^^^^^^^
   |     |
   |     cannot move out of here
   |     move occurs because `conn.handle` has type `Handle`, which does not implement the `Copy` trait
   |
help: consider borrowing here
   |
16 |     &conn.handle
   |     +

The reason is worth spelling out because it is the clearest example of the compiler protecting an invariant you wrote yourself. Connection has a destructor. That destructor takes &mut self and will run when the value goes out of scope. If you were allowed to move handle out, drop would later run on a Connection whose handle field is gone, and drop has no way to know. The E0509 documentation states the rule plainly: you have to think of all values whose type implements Drop as single units whose fields cannot be moved.

The safe way out is to make the field optional and take it:

struct Connection {
    handle: Option<Handle>,
    id: u32,
}

fn steal(mut conn: Connection) -> Option<Handle> {
    conn.handle.take()        // leaves None behind, `conn` stays whole
}

Option::take replaces the field with None and hands you the contents, which keeps Connection fully initialized so its destructor stays valid. std::mem::take and std::mem::replace do the same job for non-optional fields. This is the standard shape for any type that owns a resource and has a close or into_inner method, and once you have seen it you will see it everywhere in the standard library.

Drop is using that you cannot forget

When a value goes out of scope, Rust runs its destructor. That destructor is two things: a call to Drop::drop if the type implements the Drop trait, and then the automatically generated code that recursively destroys every field.

Here is a complete program you can paste into a file and run. It exists to show drop order, which is defined precisely and which you will need to reason about the first time two of your values depend on each other.

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Noisy,
    second: Noisy,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn consume(n: Noisy) {
    println!("  consume got {}", n.0);
}

fn main() {
    let a = Noisy("a");
    let b = Noisy("b");

    {
        let _inner = Noisy("inner");
        println!("in block");
    }

    println!("value is {}", Noisy("temp").0);

    consume(Noisy("arg"));

    let moved = Noisy("moved");
    consume(moved);

    let _pair = Pair {
        first: Noisy("pair.first"),
        second: Noisy("pair.second"),
    };

    println!("end of main");
}

Output:

in block
drop inner
value is temp
drop temp
  consume got arg
drop arg
  consume got moved
drop moved
end of main
drop Pair
drop pair.first
drop pair.second
drop b
drop a

Five rules are visible in that output, and all five are written down in the Reference rather than being accidents of the current implementation.

The inner block drops _inner at its closing brace, not at the end of main. The temporary Noisy("temp") lives to the end of the statement that created it and no longer. A value moved into consume is dropped by consume, at the end of that function, because parameters live in the scope of the whole function body. The binding moved is not dropped again at the end of main, because the compiler knows it was moved out. And at the end of main, locals drop in reverse declaration order, while the fields of Pair drop in declaration order, after Pair's own drop has run.

Reverse order for locals is not arbitrary. It means a value can safely hold a reference to something declared before it, which is the entire basis of the guard pattern.

Now the same shape in C#, so the comparison is concrete rather than asserted:

sealed class Noisy : IDisposable
{
    private readonly string _name;
    public Noisy(string name) => _name = name;
    public string Name => _name;
    public void Dispose() => Console.WriteLine($"dispose {_name}");
}

sealed class Pair : IDisposable
{
    private readonly Noisy _first = new("pair.first");
    private readonly Noisy _second = new("pair.second");

    public void Dispose()
    {
        Console.WriteLine("dispose Pair");
        _first.Dispose();     // you wrote this line, and the order, by hand
        _second.Dispose();
    }
}

static class Program
{
    static void Consume(Noisy n) => Console.WriteLine($"  consume got {n.Name}");

    static void Main()
    {
        using var a = new Noisy("a");
        using var b = new Noisy("b");

        Consume(new Noisy("arg"));   // never disposed. nothing warns you.

        using var pair = new Pair();
        Console.WriteLine("end of main");
    }
}

Two differences, in order of how much they cost you.

The Noisy("arg") passed to Consume is never disposed. It becomes garbage, its Dispose never runs, and if it held a socket the socket stays open until the process notices. The C# compiler says nothing, because Consume taking a Noisy communicates nothing about ownership. In Rust, fn consume(n: Noisy) is a statement that consume will destroy the value, and the compiler enforces both halves.

Pair.Dispose is hand-written, and so is the order of its field disposal. Forget a line and you leak a field. Rust's drop glue does that for you and cannot be forgotten.

The parts that are the same deserve credit. A local declared with using is disposed at the end of the scope it is declared in, several resources declared in one using statement are disposed in reverse order of declaration, and a return in the middle of a using block still disposes because the compiler rewrites the block into try/finally. If you consistently use using, C# gives you deterministic cleanup too. The difference is the word "if".

The fallback when nobody writes the using is a finalizer, and finalizers are a genuinely worse tool. A class that has one gets an entry in the finalize queue for the collector to process, you get no control over when it runs, and on .NET 5 and later finalizers are not run as part of application termination at all. Which is why the dispose pattern has you call GC.SuppressFinalize(this) the moment you have cleaned up properly: the finalizer is the path you want to avoid taking. Rust has no queue to get onto. The deallocation is a call at a known instruction.

Once destruction is guaranteed, a lot of API designs that would be reckless in C# become safe. A MutexGuard unlocks on drop, so you cannot forget to release a lock and you cannot release it twice. A transaction handle rolls back on drop unless you called commit, which consumed it. A tracing span closes on drop. A connection checked out of a pool returns itself on drop. Each of those is a type whose only job is to exist for a scope, and each of them is a category of bug that stops being possible.

Panics run drops too. A panic unwinds the stack by default and runs the destructor of every value on the way out, roughly what finally gives you in C# but applied to every value rather than to the blocks you remembered to write. Neither is absolute: an unhandled exception in .NET can tear the process down without running your finally blocks, and Rust has its own escape hatch, a build configured with panic = "abort", which terminates without unwinding and therefore without dropping. That is a deliberate trade for smaller binaries and it is the right choice for some embedded targets, so it is worth knowing which one your profile uses.

The model above was simplified

The claim a few pages back was that the compiler inserts the deallocation at the closing brace. True for the straight-line case, false as soon as control flow gets involved.

fn some_condition() -> bool { true }

fn main() {
    let mut y = Vec::new();
    {
        let x = vec![1, 2, 3];
        if some_condition() {
            y = x;          // x moves into y, sometimes
        }
    }                       // should x be dropped here?
    println!("{y:?}");
}

The compiler cannot answer that at compile time, because the answer depends on a runtime value. What actually happens is a pass called drop elaboration. rustc runs two dataflow analyses, MaybeInitializedPlaces and MaybeUninitializedPlaces, over the MIR. When a place is definitely initialized at a drop point, the drop is emitted unconditionally. When it is definitely uninitialized, the drop is deleted. When the answer is genuinely conditional, the compiler allocates a hidden boolean on the stack, a drop flag, sets and clears it as the value is initialized and moved from, and wraps the drop in a check of that flag.

So there is a run-time cost after all: a boolean on the stack and a branch, in the specific cases where the compiler could not prove the answer. Compare that honestly to a tracing collector, which walks the object graph, and it is still a rounding error. But "zero cost" is not literally true, and it is better to know where the cost lives than to repeat a slogan.

The other simplification was calling the move free. At MIR level it is a byte copy, and the optimizer usually removes it. Usually.

Connections, files, and the resources you must not leak

The place this stops being a language-tour curiosity and starts being an architectural difference is anything that holds a limited, external resource: database connections, file descriptors, sockets, GPU buffers, locks.

In a .NET service, the failure mode is familiar. A SqlConnection is not disposed on some error path. Microsoft's own guidance is blunt about what follows: connections that are not explicitly closed might not be added or returned to the pool. Once the pool is at maximum size with no usable connection available, further requests queue, and if the pooler cannot satisfy one before the connection times out, it throws. Under load that presents as intermittent timeouts on a service that looks healthy, which is what makes it hard to diagnose. .NET has good answers to this. SafeHandle exists so that even a badly written wrapper releases its OS handle when the collector eventually finalizes it, and analyzers flag undisposed locals. They are all mitigations for the fact that Dispose is a method somebody has to call.

In Rust the pooled connection is a struct whose Drop returns it to the pool. There is no path out of the function that skips it. Early return runs it. The ? operator running on an error runs it. A panic unwinding through the frame runs it. The set of code paths that leak a connection is empty, not small.

The second-order effect is on API design, and it is bigger than the leak itself. Because you can trust destruction, you can build types whose only purpose is to guarantee a paired operation. Lock acquired and released. Transaction begun and rolled back. Metric timer started and recorded. Buffer checked out and returned. In C# you can write those as IDisposable and they work, right up until a caller in a different team's code forgets the using, at which point the failure is silent and surfaces as a slow leak in production weeks later. The .NET team clearly thinks the guard shape is right. The System.Threading.Lock type added in .NET 9 hands you a disposable scope from EnterScope(), and locking such an object is defined as compiling to exactly using (x.EnterScope()). Same design. One guarantee weaker, because the moment you call EnterScope yourself instead of using the lock statement, writing the using is back to being your job.

The mistakes that come from the C# habit

Cloning to make the error go away. This is the big one, and the compiler even suggests it. A .clone() compiles, the error disappears, and you move on. Grep your own diff for it: a clone() inside a loop, or a cluster of them in one function, usually means you are paying allocations to avoid learning the borrow rules. The fix is usually to change a parameter from String to &str or from Vec<T> to &[T], which is the next chapter. Clone deliberately, when you genuinely need a second independent value, and it is fine.

Expecting assignment to alias. You write let b = a; intending two handles to one thing, then get confused when a is dead. What you wanted was let b = &a; for read-only sharing, or Rc/Arc for genuine shared ownership. The tell is that you are surprised by the error rather than by the design.

Writing C# getters. fn hostname(&self) -> String { self.hostname } is the reflex and it does not compile: E0507, cannot move out of self.hostname which is behind a shared reference. The compiler suggests .clone(). Take the suggestion and you have a getter that allocates on every call. The right signature is fn hostname(&self) -> &str, which is free.

Building an object graph before you have read chapter eleven. Parent pointers, observer lists, a cache whose entries know their owner. All trivial in C#, all a long fight in Rust if you attack them head-on. Recognize it early: if you are hours into lifetime errors on a data structure that is fundamentally a graph, stop and reach for Rc<RefCell<T>>, or for an arena with integer indices, which is what a lot of production Rust does.

Assuming Drop always runs. It does not run on std::process::exit, it does not run under panic = "abort", and it does not run for a leaked value. Do not put anything load-bearing in a destructor that must happen for correctness rather than for tidiness.

Where C# still wins

Three places, and they are not small.

Graphs and shared mutable state. A tracing garbage collector is genuinely better at object graphs with cycles and back-references than ownership is. A doubly linked list, a scene graph with parent pointers, an observer registry, a domain model where entities reference each other freely: in C# these are ordinary code. In Rust they cost you Rc<RefCell<T>>, which moves borrow checking to run time and can panic, or an arena of indices, which trades compiler-checked references for integers the compiler cannot check at all. If your domain is fundamentally a graph, C# is the better tool and saying otherwise is dishonest.

Cleanup that can fail. Drop::drop takes &mut self, returns nothing, and cannot be async. Closing a file can fail, and std::fs::File says outright that errors detected on closing are ignored by its Drop implementation, so you must call sync_all yourself if you care. C#'s Dispose can throw, which is its own problem, but at least the error can reach you.

Async cleanup. This one is a clear win for .NET today. C# has IAsyncDisposable and await using, so a resource whose teardown involves I/O can await it. Rust has no stable async Drop. The tracking issue for async drop codegen is still open and the feature is nightly-only, so today the workaround is an explicit async fn close(self) that callers must remember to call, which is exactly the IDisposable problem you came to Rust to escape. If your system is full of async resources that need graceful shutdown, this will annoy you.

And a fourth thing that is not a win for either side, just a note on proportion. For the large middle of application code, ownership is invisible. Integers, bool, small Copy structs, values that are created and consumed in one function: none of it triggers a single ownership decision. The rules only start costing you attention at the boundaries where data is shared, stored, or outlives the function that made it. That is a much smaller fraction of a codebase than week one suggests.

Checking yourself

Why can a type not implement both Copy and Drop? Copy means the compiler may duplicate the value implicitly, at any assignment, as often as it likes. A destructor that ran an unpredictable number of times on unpredictable duplicates would be meaningless, so the compiler rejects the combination with E0184.

Given a struct with a Drop impl, why can you not move one field out of it? Because the destructor is guaranteed to run later and takes &mut self, so it would observe a partially initialized value with no way to know which fields are gone. Rust's answer is to keep the struct whole and swap the field's contents out instead, with Option::take or mem::replace.

In the worked example, why is moved not dropped at the end of main even though it was declared there? Because it was moved into consume, which took ownership and dropped it at the end of its own body. The compiler's initialization analysis knows the binding is dead, so it emits no drop for it. If the move had been conditional, the compiler would have emitted a drop flag and a branch instead.

You get error[E0382]: borrow of moved value on a variable used inside a move closure and again afterwards. What are your three options, and which is usually right? Clone before the closure captures it, restructure so the closure borrows rather than moves, or wrap the value in Arc and clone the handle. For a value shared with a spawned thread, Arc is usually right. For a value the closure merely reads once, borrowing is usually right, and cloning is the answer only when you actually want two independent values.

Borrowing, and why the compiler counts your references

Moving ownership into every function you call would be unbearable, so Rust lets you lend a value instead. A borrow is a reference, written with an ampersand, and it does not take ownership.

fn length_of(s: &String) -> usize {
    s.len()
}

fn main() {
    let name = String::from("db-1");
    let n = length_of(&name);
    println!("{} is {} chars", name, n);   // name is still ours
}

Nothing surprising. It reads like passing an object to a method in C#. If borrowing stopped there this would be two paragraphs. The rule attached to borrows is what makes it a chapter.

One aside before the rule, since a reviewer will raise it. That signature should really be &str, not &String, and clippy's ptr_arg lint will tell you so, because &String forces every caller to own a String first. &String is written here to keep the borrow visible next to a type you already recognize.

What the rule actually says

There are two kinds.

  • &T is a shared borrow. You read through it. You can have as many as you like at once, and they may overlap freely.
  • &mut T is an exclusive borrow. You read and write through it. While it is live, no other reference to that value may exist. Not another &mut, and not even a plain &.

The Rust Book states it as: at any given time you can have either one mutable reference or any number of immutable references, and references must always be valid (references and borrowing). Memorize that, then attach two footnotes to it, because each one costs a newcomer an afternoon.

The rule is about places, not names. self.left and self.right are two places. You can hold &mut self.left and &self.right simultaneously and the compiler is fine with it, because it tracks the path, not the variable at the root of the path. That gets important further down this chapter, and it is also where the checker's limits show.

"At any given time" is not "in the same block". A borrow is live from where it is created until its last use, which is usually much shorter than the enclosing scope. That was not always true, and the older model is still what most people carry in their heads, so I will come back to it.

One more thing, and it is the single most useful reframing I can offer. Rust calls &mut a mutable reference, so people read the rule as being about writing. It is not. It is about exclusivity. Mutation is what you are permitted to do because you have exclusivity, not the reason for the restriction. Read &mut T as "the only reference to this place right now" and you will predict the compiler correctly far more often than if you read it as "the writable one".

Why aliasing plus mutation costs you speed

Safety is the argument everyone leads with. The optimization argument gets less airtime and is easier to demonstrate than to describe.

Here is a function in C#. Nothing clever in it.

static void Compute(ref int input, ref int output)
{
    if (input > 10) output = 1;
    if (input > 5)  output *= 2;
}

Call it with two different variables and you get 2. Call it with the same variable twice and you get 1. Outputs below are from .NET 9.0.10:

int a = 12;
Compute(ref a, ref a);
Console.WriteLine(a);          // prints 1

int inp = 12, outp = 0;
Compute(ref inp, ref outp);
Console.WriteLine(outp);       // prints 2

Now stand where the JIT stands. It would like to load input once into a register and keep it there, because the second if reads it again and a register beats a memory load. It is not allowed to. The write to output might be a write to input, since both are addresses and nothing in the signature rules it out, so the language semantics require the second read to see the write. The first call proves that is not paranoia. The value genuinely changed between the two reads.

This is not a C# weakness. It is the default in every language with unrestricted references. C offers restrict so you can promise the compiler a pointer is unaliased, but it is opt-in, unchecked, and rare outside numeric kernels. Get it wrong and you have undefined behavior with no diagnostic. C++ never standardized it at all; the major compilers ship __restrict as an extension and most codebases never type it.

Rust gets the promise for free, because the types already made it. Write the same function with &u32 and &mut u32 and the aliased call does not exist:

fn compute(input: &u32, output: &mut u32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
}

fn main() {
    let mut a: u32 = 12;
    compute(&a, &mut a);
    println!("{a}");
}
error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable
  --> src/main.rs:12:17
   |
12 |     compute(&a, &mut a);
   |     ------- --  ^^^^^^ mutable borrow occurs here
   |     |       |
   |     |       immutable borrow occurs here
   |     immutable borrow later used by call

For more information about this error, try `rustc --explain E0502`.

Read the bottom annotation, because it is doing the real work. The shared borrow of a is not merely created before the exclusive one. It is still needed at the moment of the call. That overlap is the violation. Every diagnostic in this chapter has a line like that, and it is the line that tells you which code to move.

Because the case cannot be written, rustc can tell the backend so. Mutable reference parameters are emitted carrying LLVM's noalias attribute, which is deliberately modeled on C's restrict: memory reached through pointers based on that argument is not reached through pointers not based on it, for any location that gets modified during the call (LLVM language reference). Once LLVM believes that, it can hold values in registers across intervening writes, delete redundant loads, drop writes nothing reads, reorder memory operations, and vectorize loops it would otherwise have to treat as opaque. The Rustonomicon's chapter on aliasing has the clearest short list, and it uses a version of the same compute function to make the point.

The bug this is really about

You have hit this in C#, and the compiler let you write it:

var servers = new List<string> { "db-1", "db-2" };
foreach (var s in servers)
{
    if (s == "db-1") servers.Add("db-3");
}
System.InvalidOperationException: Collection was modified; enumeration operation may not execute.

The mechanism deserves precision, because it explains both what the check costs and what it misses. List<T> keeps an internal _version counter that every Add, Insert, Remove and Clear increments. The enumerator snapshots the counter when it is created and compares it at the top of every MoveNext, throwing when they differ (List.cs in dotnet/runtime). So you pay a field load and a branch on every iteration of every loop over every list you ever write, whether or not anything mutates. And because it fires at run time, it finds the bug only on the path that actually executed.

Arrays get no protection at all. Same runtime, same foreach, no counter:

var arr = new[] { 1, 2, 3 };
foreach (var x in arr)
{
    if (x == 1) arr[2] = 99;
    Console.Write($"{x} ");
}
// prints: 1 2 99

No exception. The loop reads the value you wrote mid-iteration. That is defensible behavior, and it is also exactly the surprise the version counter exists to prevent on List<T>, which tells you the protection is a per-type decision made by a library author, not a property of the language.

Rust's version of the list case never reaches run time.

fn main() {
    let mut servers = vec!["db-1", "db-2"];
    for s in &servers {
        if *s == "db-1" {
            servers.push("db-3");
        }
    }
}
error[E0502]: cannot borrow `servers` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:13
  |
3 |     for s in &servers {
  |              --------
  |              |
  |              immutable borrow occurs here
  |              immutable borrow later used here
4 |         if *s == "db-1" {
5 |             servers.push("db-3");
  |             ^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

For more information about this error, try `rustc --explain E0502`.

for s in &servers creates a shared borrow that the loop holds until it finishes. push takes &mut self. Those cannot overlap. No counter, no per-iteration branch, no exception type, no test needed to discover it.

The fix is the one you would have written in C# anyway, minus the hope:

let mut to_add = Vec::new();
for s in &servers {
    if *s == "db-1" {
        to_add.push("db-3");
    }
}
servers.extend(to_add);

Now sit with why this matters beyond tidiness. push may reallocate the vector's buffer and copy the elements to a new address, which would leave anything pointing into the old buffer aimed at freed memory. C# never gets there. List<T>.Enumerator holds the list and reads _items through it on every MoveNext, so a resize just means it follows the new array, and even if something did still hold the old one the collector would keep it alive. The worst outcome is a confusing exception. In a language with no collector, the identical code is a use-after-free. Rust does not have to pick between the exception and the crash. It deleted the third option by making the situation unexpressible.

Where you meet it first

The pattern that catches every newcomer is smaller than a loop:

fn main() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];
    data.push(4);
    println!("{first}");
}
error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &data[0];
  |                  ---- immutable borrow occurs here
4 |     data.push(4);
  |     ^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |                ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.

Move the println! above the push and it compiles. Nothing else changes.

That fix exposes the footnote I promised. I said a borrow is live from creation until its last use. The simpler model most people start with is that a borrow lives until the end of the enclosing block, and that simpler model used to be the truth. Before the 2018 edition, borrow regions were lexical. The reordered version above would still have failed, because first counted as borrowed all the way to the closing brace whether you touched it again or not.

What replaced it is called non-lexical lifetimes. The borrow checker now runs over the control flow graph of a function instead of its block structure, and a borrow's region is the set of program points where it might still be used. The RFC is readable if you want the reasoning.

The rollout took years, which is worth knowing if you read old code or old advice. NLL shipped with the 2018 edition in Rust 1.31. Code on the 2015 edition ran in a migration mode that executed both checkers and compared them, downgrading the new checker's complaints to warnings. That mode was narrowed release by release until Rust 1.63 in August 2022, when the old borrow checker was removed and all Rust code became NLL-checked. It is why Rust written before 2018 contains more artificial { } blocks than anybody enjoyed typing.

The practical habit that falls out: when the compiler rejects something, look for where the borrow is last used, not where it was created. Every diagnostic above carries an annotation saying the borrow is used later, either "later used here" pointing at a line, or "later used by call" when the use is the call you are standing in. That line is the constraint. The line with the carets is only the victim.

Methods borrow without asking

Something quietly confuses people for weeks here, and it is not in the rule at all.

let mut v = vec![1, 2, 3];
let n = v.len();
v.push(4);

You never wrote an ampersand. But len is fn len(&self) -> usize and push is fn push(&mut self, value: T), so the compiler inserted &v for the first call and &mut v for the second. That insertion is called autoref, and it happens during method resolution. Most method calls you write are therefore borrows you did not type, and which kind of borrow comes from a signature at the other end that you did not read. The exception is a receiver declared as plain self, which consumes the value instead, and that is exactly why into_iter and friends are named the way they are.

In C# you have no equivalent habit to draw on. Every instance method call on a class gets the same this, and the difference between a getter and a mutator is naming convention. Structs do a little better, since a readonly member is checked, but that check is about copies, not about who else holds the object. In Rust the difference is load bearing. conn.stats() and conn.reset_stats() lock different amounts of the program, and neither call site shows it.

Which sets up an oddity. By the strict rule, this should be illegal:

let mut v = vec![1, 2, 3];
v.push(v.len() as i32);

The &mut v for push is created before the arguments are evaluated, and v.len() needs a &v. Two overlapping borrows, one exclusive. It compiles anyway, and it compiles because of a special case called two-phase borrows. A compiler-inserted &mut starts life reserved, behaving like a shared borrow, and only activates into a real exclusive borrow at the point of the call. Between reservation and activation, other shared borrows are allowed.

The part worth remembering is the scope of the exemption. Two-phase borrows apply only to borrows the compiler inserted for you: the autoref on a method receiver, mutable reborrows in argument position, and overloaded compound assignment operators. Write the &mut yourself and you get the strict rule back. That asymmetry is why v.push(v.len() as i32) works and a hand-rolled equivalent sometimes does not, and knowing the reason saves you from concluding the checker is arbitrary.

Reborrowing, or why &mut works at all

&mut T does not implement Copy. Moving it is a real move. So this should fail:

fn takes(v: &mut Vec<i32>) { v.push(0); }

fn main() {
    let mut v = vec![1, 2, 3];
    let r = &mut v;
    takes(r);
    takes(r);      // r was moved into the first call, surely
    println!("{r:?}");
}

It compiles and prints [1, 2, 3, 0, 0]. What happens is a reborrow. When you pass a &mut T where a &mut T is expected, the compiler does not move your reference. It rewrites the argument as &mut *r, creating a fresh, shorter exclusive borrow through r. That new borrow lives for the duration of the call, and r is frozen while it exists. When the call returns, the reborrow ends and r is usable again.

You can see the mechanism by removing the condition that triggers it. Pass the reference somewhere the expected type is a generic parameter rather than a reference type, and the coercion has nothing to hook onto:

fn consume<T>(x: T) -> T { x }

fn main() {
    let mut v = vec![1, 2, 3];
    let r = &mut v;
    consume(r);
    consume(r);
}
error[E0382]: use of moved value: `r`
 --> src/main.rs:7:13
  |
5 |     let r = &mut v;
  |         - move occurs because `r` has type `&mut Vec<i32>`, which does not implement the `Copy` trait
6 |     consume(r);
  |             - value moved here
7 |     consume(r);
  |             ^ value used here after move
  |
help: consider creating a fresh reborrow of `r` here
  |
6 |     consume(&mut *r);
  |             ++++++

For more information about this error, try `rustc --explain E0382`.

The help line is the whole lesson in one suggestion. Write the reborrow by hand and it works. Reborrowing is not a loophole in the rule, it is the rule applied recursively: at any instant there is still exactly one usable exclusive reference to that vector, it is just that which one it is changes as calls nest and return.

C# has no equivalent question to answer. Passing a ref int onward twice is just two calls, because a ref argument is not a value that can be consumed. C# does have ref locals, ref reassignment, and since C# 11 ref fields inside a ref struct, so references do get stored. What C# never does is track how long any of them are live in the way this section describes. Its ref safety rules ask a narrower question, whether a reference can outlive its storage, and they stop there.

Splitting a borrow, and the three fixes

This is where a C# engineer loses a real afternoon, so it gets a full example.

struct Connection {
    id: u32,
    bytes_sent: u64,
}

struct Pool {
    connections: Vec<Connection>,
    log: Vec<String>,
}

impl Pool {
    fn record(&mut self, message: String) {
        self.log.push(message);
    }

    fn report(&mut self) {
        for conn in &self.connections {
            self.record(format!("conn {} sent {}", conn.id, conn.bytes_sent));
        }
    }
}

Every line of that is the obvious thing to write, and it does not compile.

error[E0502]: cannot borrow `*self` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:13
   |
17 |         for conn in &self.connections {
   |                     -----------------
   |                     |
   |                     immutable borrow occurs here
   |                     immutable borrow later used here
18 |             self.record(format!("conn {} sent {}", conn.id, conn.bytes_sent));
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

For more information about this error, try `rustc --explain E0502`.

Note what it says: *self, not self.log. The loop holds a shared borrow of self.connections, and self.record(..) needs an exclusive borrow of the entire self, because record's signature says &mut self and the checker will not look inside the body to see that it only touches self.log. Borrow checking is function-local by design. Signatures are the contract; bodies are not.

This is worth stating flatly because it is the deepest structural difference between the two languages' error messages. A C# method taking this is opaque and always was, so nobody thinks about it. A Rust method taking &mut self is opaque and the opacity is enforced, so the coarseness of your signatures becomes an ergonomics problem in a way it never was in C#.

Three fixes, all ordinary refactors. Here is a complete program with all three, which compiles and runs:

use std::mem;

struct Connection {
    id: u32,
    bytes_sent: u64,
}

struct Pool {
    connections: Vec<Connection>,
    log: Vec<String>,
}

impl Pool {
    // Fix 1: touch the two fields directly. They are disjoint places,
    // so the compiler tracks them separately.
    fn report_inline(&mut self) {
        for conn in &self.connections {
            self.log
                .push(format!("conn {} sent {}", conn.id, conn.bytes_sent));
        }
    }

    // Fix 2: the helper takes only what it needs, not all of self.
    fn record(log: &mut Vec<String>, message: String) {
        log.push(message);
    }

    fn report_split(&mut self) {
        for conn in &self.connections {
            Pool::record(
                &mut self.log,
                format!("conn {} sent {}", conn.id, conn.bytes_sent),
            );
        }
    }

    fn record_method(&mut self, message: String) {
        self.log.push(message);
    }

    // Fix 3: move the field out, work on it, put it back.
    fn report_take(&mut self) {
        let conns = mem::take(&mut self.connections);
        for conn in &conns {
            self.record_method(format!("conn {} sent {}", conn.id, conn.bytes_sent));
        }
        self.connections = conns;
    }
}

fn main() {
    let mut pool = Pool {
        connections: vec![
            Connection { id: 1, bytes_sent: 4096 },
            Connection { id: 2, bytes_sent: 128 },
        ],
        log: Vec::new(),
    };

    pool.report_inline();
    pool.report_split();
    pool.report_take();

    for line in &pool.log {
        println!("{line}");
    }
    println!("{} connections still here", pool.connections.len());
}

Output:

conn 1 sent 4096
conn 2 sent 128
conn 1 sent 4096
conn 2 sent 128
conn 1 sent 4096
conn 2 sent 128
2 connections still here

Fix 1 is usually correct and usually the least popular, because it means inlining a helper you liked. Fix 2 is the one that improves the design: a free or associated function taking &mut Vec<String> states its dependencies honestly, and you can now test it without building a Pool. Fix 3 is the escape hatch. mem::take swaps the field for its Default value, which for a Vec is an empty vector that allocates nothing, so the cost is a few pointer writes. Use it when the loop genuinely needs the whole self, and be aware that if the body panics between the take and the restore, the field stays empty in whatever catches the unwind.

There is a fourth option in the standard library worth knowing, for the case where the two halves are inside one collection rather than in two fields. split_at_mut hands you two exclusive slices of one buffer:

let mut buf = [1u8, 2, 3, 4, 5, 6];
let (left, right) = buf.split_at_mut(3);
left[0] = 9;
right[0] = 8;
println!("{buf:?}");   // [9, 2, 3, 8, 5, 6]

Two live &mut into the same array, which the rule says is impossible. It is sound because the ranges are provably disjoint, and the proof lives inside an unsafe block in the standard library rather than in the type system. That pattern, a small audited unsafe core exposing a safe API, is how most of the interesting data structures in Rust are built.

ref, out, in, and the ref struct family

The surface resemblance to C#'s reference parameters is real and shallow.

C#Nearest RustWhere the analogy breaks
ref T x&mut TC# makes no exclusivity promise. Compute(ref a, ref a) compiles.
in T x&Tin forbids the callee writing, not others writing.
out T xreturn the valueRust returns tuples and Option, so out has no reason to exist.
ref structany type holding a &C# restricts one family of types; Rust checks every reference.

The ref row is the one to internalize. ref in C# gives a callee the ability to write to your variable, which is what &mut gives too. What it does not give is any statement about who else can see that storage. You can pass ref to a field while three other objects hold references to the containing instance, and nothing checks it. That is the freedom that forced the reload in the Compute example.

out is more interesting than it looks, because its existence is a symptom. C# grew out and then the TryParse convention because returning two values is awkward in a language without cheap sum types. Rust returns Option<T> or Result<T, E> and a tuple when it needs more, so the pattern never formed. If you find yourself reaching for an out parameter in Rust, you want a tuple or an enum.

The closest C# has come to Rust's model is the ref struct family. Span<T> cannot be a field of a class, cannot be an array element, cannot be boxed, and cannot be captured in a lambda. Those restrictions exist for exactly the reason Rust's borrows exist: a Span<T> may point at a stack frame or into the interior of an object, and the compiler has to be certain it cannot outlive what it points at.

The rules there are version dependent and have loosened. Before C# 13 a ref struct could not appear in an async method at all, could not be used in iterators, could not implement interfaces, and could not be a type argument. C# 13 relaxed all four with conditions: a ref struct variable may not be used in the same block as an await expression, may not sit in a code segment containing yield return, may implement interfaces but never be converted to one, and may be a type argument only where the parameter declares allows ref struct. Note what the compiler is checking in all of that. It is checking lifetime, whether a reference can outlive its storage. It never asks whether two live references overlap. The rest, when a span is safe to hand around and when it is stale, is prose in Microsoft's usage guidelines that a human is expected to follow.

If you have ever been annoyed that you cannot stash a Span<T> somewhere convenient, you have already met the borrow checker. You met it in one corner of C# instead of everywhere, and you probably concluded the restriction was about Span<T> rather than about references in general.

At scale

None of this bites hard in a 300 line program. It bites in the shape of the systems you end up building.

The pressure shows up first as signature design. In a service where request handlers reach into a shared state object, every &mut self method on that object becomes a lock on the whole object for the duration of the call. Coarse methods that were harmless in C# become a source of daily friction, and the codebase drifts toward small structs with narrow methods and free functions that take the two or three fields they need. That drift is a genuine design improvement, and it is also a real cost you pay in refactoring time before you get there.

The second pressure is the pull toward indices. When a data structure has internal cross-references, the honest Rust answer is often to stop using references and store integer keys into a Vec or a slot map, which restores the aliasing you wanted while keeping the checker satisfied, at the price of the compiler no longer proving that a key is valid. That is a real tradeoff, not a trick: you swapped a compile-time guarantee for a runtime one. The Rust compiler itself is built this way, leaning on arenas and index types rather than pointer graphs (memory management in rustc), and so is essentially every entity-component system in the ecosystem.

The third shows up when concurrency arrives, and it is the one that makes the earlier work pay. Because the compiler already tracks who may mutate what, sharing across threads is checked by the same machinery. A List<T> handed to two threads in C# is a code review problem. The equivalent in Rust is a type error.

The mistakes that are specific to you

Cloning to make the error go away. Every borrow error can be silenced with .clone(), and for a small String in a cold path that is a perfectly good answer. The mistake is doing it reflexively, discovering three months later that a hot loop clones a Vec per iteration, and concluding Rust is slow. Rule of thumb: clone deliberately, in cold code, with a comment saying why. If you clone the same value in a loop, that is the signal to restructure.

Reading &mut as "writable". The root of more confusion than anything else here. When you cannot see why a &mut was rejected, re-read it as "the only reference right now" and the answer usually appears.

Expecting the compiler to look inside a function body. You know your &mut self method only touches one field. The checker does not, and will not, because it checks each function against signatures alone. Fix the signature, not the body.

Holding a reference across a mutation because C# let you. The first = &data[0] then push shape. C# usually hides the problem, because you copied the element rather than pointing at it, and because the collector will not free anything you can still reach. The one place C# lets you make the same mistake is instructive. Take a span over a list's buffer with CollectionsMarshal.AsSpan, add an element that forces a resize, then write through the span. On .NET 9.0.10 the list keeps its original contents, the write lands in the orphaned old array, and nothing throws:

using System.Runtime.InteropServices;

var list = new List<int>(3) { 1, 2, 3 };
var span = CollectionsMarshal.AsSpan(list);
list.Add(4);        // capacity 3 to 6, new backing array
span[0] = 99;       // writes into the old one
Console.WriteLine($"{list[0]} {span[0]}");   // prints: 1 99

Memory safe, silently wrong, and no diagnostic anywhere. That is the shape the borrow checker exists to reject. Retrain on the reallocation: any method that can grow a collection can move its buffer.

Assuming a getter is free. The aside above. If a struct is fighting you, count how many of its accessors return references to fields, and consider whether those fields could simply be visible within the module.

Where the rule stops helping

Any structure with genuine shared mutable state is harder in Rust, and sometimes meaningfully harder. A cyclic object graph, an observer list where the observers hold back-references to the subject, a cache that several subsystems mutate. In C# you write it and move on. In Rust you reach for reference counting plus interior mutability, and Rc<RefCell<T>> moves the one-writer-or-many-readers check to run time, where a violation is a panic rather than a compile error. You have re-created the C# situation with worse ergonomics and a sharper failure mode. If that is the shape of your problem, C# is the better tool and saying so is not disloyalty.

The checker is also conservative. It rejects programs that are perfectly sound, and you will meet a few: some self-referential structures, some loops where a human can see the borrow ends but the analysis cannot, and the well-known one where a function conditionally returns a reference derived from an argument. That last shape is documented as a limitation by the compiler team, complete with a worked last_or_push example, and fixing it is the stated goal of the polonius work (NLL fully stable). Until then the workaround is a redundant let, which is annoying and harmless.

The standard library papers over the common cases with audited unsafe code, split_at_mut being the visible example, and that option is open to you too. Reaching for it is the intended design rather than a defeat: a small verified core, a large safe surface.

And the guarantee has a boundary. The borrow checker reasons about safe Rust. Cross an FFI boundary into a C library and nobody is checking that the pointer you handed over is not stashed somewhere. Rust does not make that safe. It makes it visible, by requiring the word unsafe at every point where you took responsibility, so review has somewhere to look.

Checking yourself

Why does &mut exclude even shared borrows, when two readers and one writer sounds survivable? Because the guarantee the compiler sells to the optimizer is that memory reached through this reference is not reached any other way. One reader is enough to break it, and it is also enough to observe a half-finished mutation or to be left dangling by a reallocation.

A method call has no ampersand in it. What did it borrow? Whatever its receiver signature says. &self takes a shared borrow of the whole receiver, &mut self an exclusive one, self consumes it. The call site does not show which, which is why coarse &mut self methods cause borrow errors in code that looks innocent.

Why does pool.connections behave differently from pool.connections()? Direct field access borrows one place, so a disjoint field stays free. A getter borrows all of *self, because borrow checking is function-local and the signature is the only thing it reads. Same output, different constraint.

You need to iterate a field while calling a &mut self method. What are your options? Inline the field accesses so the borrows stay disjoint, narrow the helper to take only the field it needs, or mem::take the field out and put it back. The middle one usually leaves the code better than you found it.

Where would you not use this model? Anywhere the domain really is a mutable shared graph. Rc<RefCell<T>> gets you there, at the cost of moving the check to run time and turning a compile error into a panic. That is a fair trade sometimes and a bad one often, and C# doing it natively is a genuine advantage.

Lifetimes are not object lifetime

This is the chapter where the mental model has to change most, because the word is misleading.

In C#, lifetime means how long an object stays alive on the heap, and that is decided at run time by reachability. A local goes out of scope, the collector eventually notices that nothing points at the object, and the memory comes back. The duration is a property of the running program. You cannot read it off the source.

In Rust, a lifetime is a region of code. It is a compile-time label for a set of points in your program over which a reference must stay valid. It is not a duration, it is not measured in anything, and there is nothing at run time that corresponds to it.

That last part is worth demonstrating rather than asserting:

use std::mem::size_of;

struct Borrowed<'a> { s: &'a str }
struct Owned { s: String }

fn main() {
    println!("&str          {} bytes", size_of::<&str>());
    println!("Borrowed<'a>  {} bytes", size_of::<Borrowed>());
    println!("String        {} bytes", size_of::<String>());
    println!("Owned         {} bytes", size_of::<Owned>());
}
&str          16 bytes
Borrowed<'a>  16 bytes
String        24 bytes
Owned         24 bytes

A &str is a pointer and a length, sixteen bytes on a 64-bit target. Adding a lifetime parameter to a struct adds nothing. Regions are erased once borrow checking is finished, so nothing downstream of the borrow checker knows they were ever there. No header word, no handle table, no bookkeeping. The annotation exists to be checked and then thrown away.

The simple model, and then the real one

Start with a deliberately simplified picture: a lifetime is a pair of curly braces. A value declared inside a block is valid from its declaration to the closing brace, and any reference to it must not escape that block.

That model is good enough for the next few sections, and it is the model the language actually used until non-lexical lifetimes shipped in Rust 1.31 alongside the 2018 edition. It is also wrong in a specific direction, which the previous chapter touched on: a borrow ends at its last use, not at the closing brace. So the real model is a set of points in the control flow graph, computed by solving constraints, not a lexical scope.

The distinction matters when you start reading errors. When rustc says a value "does not live long enough", it has computed a region for your reference, computed a region for the data, and found the first does not fit inside the second. It then reports the three points that pin the failure down: where the data was declared, where it was dropped, and where the borrow was still being used. If you have read the NLL RFC, that is the machinery. Niko Matsakis's The Borrow Checker Within is the best short piece on why the model is shaped this way.

The signature is a contract, checked at the boundary

One design decision produces every lifetime annotation you will ever write.

The compiler checks a function body against its signature. It checks every call site against the same signature. It never lets one inform the other. Your caller's borrow checking does not read your function body, and your function body's borrow checking does not read your callers.

So this fails:

fn longer(a: &str, b: &str) -> &str {
    if a.len() > b.len() { a } else { b }
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:32
  |
1 | fn longer(a: &str, b: &str) -> &str {
  |              ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the
    signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
  |          ++++     ++          ++          ++

The compiler is not confused about your body. It can see the body. It is refusing to publish an answer that the body happens to imply, because the caller only gets the signature, and a signature that silently depended on the body would break every time you refactored the body.

You have lived with exactly this decision in C#, and you probably never noticed. C# ref safety is also computed from declarations, never from bodies. This compiles cleanly:

static ref int Longer(ref int a, ref int b)
    => ref (a > b ? ref a : ref b);

C# accepts it without annotation because its rule is coarser. A ref parameter may be returned by ref unless you mark it scoped, so the compiler assumes the result can come from either argument and asks no further questions here. The cost of that coarseness shows up at the call site instead:

int outer = 1;
ref int result = ref outer;
{
    int inner = 2;
    result = ref Longer(ref outer, ref inner);
}
Console.WriteLine(result);
error CS8374: Cannot ref-assign 'Longer(ref outer, ref inner)' to 'result'
because 'Longer(ref outer, ref inner)' has a narrower escape scope than 'result'.

Same idea, same enforcement point, different vocabulary. The ref safety design that C# 11 formalized gives every reference one of a small fixed set of escape scopes: the declaration block, the enclosing method, return-only, or the caller's context. Rust does the same job with an open set of named regions that you can also relate to one another.

Working the annotated function properly

Now the annotated Rust version:

fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

Read it as a claim, not an instruction. "There exists some region 'a. Both arguments stay valid for at least all of 'a, and the returned reference stays valid for all of 'a too." You did not make anything live longer. Annotations never change behavior. They describe relationships that already exist, and the Rust Book puts it plainly: lifetime annotations do not change how long any of the references live.

The interesting part is what happens per call site, because 'a is solved fresh each time.

fn main() {
    let primary = String::from("primary-endpoint");

    // Call site one: both inputs live to the end of main.
    let fallback = String::from("fallback");
    println!("{}", longer(&primary, &fallback));

    // Call site two: one input dies early, but so does the result.
    {
        let temporary = String::from("tmp");
        println!("{}", longer(&primary, &temporary));
    }

    // Call site three: a literal is 'static, so 'a shrinks to primary's region.
    println!("{}", longer(&primary, "a-very-long-static-literal"));
}
primary-endpoint
primary-endpoint
a-very-long-static-literal

Three calls, three different regions substituted for 'a. At call site two, 'a becomes something like "the inner block", because temporary cannot support anything larger. That is fine, because the result is only used inside that block. At call site three, one argument is a string literal baked into the binary, valid for the whole program, and the other is not, so 'a shrinks to fit primary.

Now break it:

fn main() {
    let primary = String::from("primary-endpoint");
    let winner;
    {
        let fallback = String::from("fallback");
        winner = longer(&primary, &fallback);
    }
    println!("{}", winner);
}
error[E0597]: `fallback` does not live long enough
  --> src/main.rs:10:35
   |
 9 |         let fallback = String::from("fallback");
   |             -------- binding `fallback` declared here
10 |         winner = longer(&primary, &fallback);
   |                                   ^^^^^^^^^ borrowed value does not live long enough
11 |     }
   |     - `fallback` dropped here while still borrowed
12 |     println!("{}", winner);
   |                    ------ borrow later used here

Two constraints, unsatisfiable together. The println! forces 'a to include line 12. The signature forces fallback to outlive all of 'a, and fallback dies on line 11. No region satisfies both.

A human can see that primary is the longer string and the returned reference will really point at it. The compiler will not use that reasoning, because it is checking against the signature, and the signature says the result may come from either. If you actually know it always comes from the first, say so:

fn longer<'a>(a: &'a str, b: &str) -> &'a str {
    a
}

Now b gets its own independent region and the broken program above compiles. That narrowing is the direct analogue of C#'s scoped modifier:

// `scoped` says: this reference cannot be returned or stored past the call.
static ref int First(ref int a, scoped ref int b) => ref a;

Swap First in for Longer at the call site above and it compiles and prints 1. Different syntax, identical purpose: tell the boundary which input the result can come from. The expressiveness gap is that scoped is one bit per parameter, while Rust lets you name as many regions as you need and say how they relate.

The three elision rules

You will write hundreds of functions before you type an apostrophe, because the compiler applies three rules first. From the Reference:

  1. Each elided lifetime in the parameters becomes a distinct lifetime parameter.
  2. If there is exactly one lifetime used in the parameters (elided or not), that lifetime is assigned to all elided output lifetimes.
  3. If the receiver is &self or &mut self, that reference's lifetime is assigned to all elided output lifetimes.

One example of each, in a program that compiles:

struct Limits {
    max: usize,
}

// Rule one only: no output reference, so nothing else to decide.
// Expands to: fn record<'a, 'b>(level: &'a str, message: &'b str)
fn record(level: &str, message: &str) {
    println!("[{}] {}", level, message);
}

// Rule two: exactly one input lifetime, so the output gets it.
// Expands to: fn clamp<'a>(name: &'a str, limits: Limits) -> &'a str
fn clamp(name: &str, limits: Limits) -> &str {
    &name[..name.len().min(limits.max)]
}

struct Router {
    prefix: String,
}

impl Router {
    // Rule three: &self wins, so the output is tied to the router.
    // Expands to: fn prefix<'a>(&'a self) -> &'a str
    fn prefix(&self) -> &str {
        &self.prefix
    }
}

fn main() {
    record("INFO", "started");
    println!("{}", clamp("db-primary", Limits { max: 2 }));
    println!("{}", Router { prefix: "/api/v1".into() }.prefix());
}
[INFO] started
db
/api/v1

Note clamp. It has two parameters and one input lifetime, because Limits is an owned value and contributes no region. Rule two counts lifetimes, not parameters, which is why it fires more often than people expect.

Elision does not apply when the rules run out. The longer function is the standard case: two input lifetimes, no receiver, so after rule one the compiler has 'a and 'b and no basis for choosing. The other case has no inputs at all:

fn make_name() -> &str {
    let name = String::from("db-1");
    &name
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:19
  |
1 | fn make_name() -> &str {
  |                   ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is
    no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless
      you're returning a borrowed value from a `const` or a `static`
  |
1 | fn make_name() -> &'static str {
  |                    +++++++
help: instead, you are more likely to want to return an owned value
  |
1 - fn make_name() -> &str {
1 + fn make_name() -> String {
  |

Take the first suggestion literally and you get the real error, one layer down:

error[E0515]: cannot return reference to local variable `name`
 --> src/main.rs:3:5
  |
3 |     &name
  |     ^^^^^ returns a reference to data owned by the current function

This is the C++ dangling reference bug, refused at compile time, in a language where you were not thinking about pointers. The C# version of this function cannot be written wrong, because reachability decides and the collector is still holding the string. Rust has no collector to lean on, so it makes the case unrepresentable instead. The second suggestion is the right one, and it is why the compiler now prints it: return the String and let ownership move to the caller.

Elision is a default, not inference

This is the part most tutorials skip, and it is the one that will cost you an afternoon.

Elision does not look at your body and work out the right answer. It applies a fixed rule and produces a signature. If that signature is wrong for what you meant, the body fails to typecheck, and the error is confusing until you know what happened.

struct Router { prefix: String }

impl Router {
    fn strip(&self, path: &str) -> &str {
        path.strip_prefix(self.prefix.as_str()).unwrap_or(path)
    }
}

Rule three fires. The output is tied to &self. But the value being returned is a slice of path, not of self:

error: lifetime may not live long enough
 --> src/main.rs:5:9
  |
4 |     fn strip(&self, path: &str) -> &str {
  |              -            - let's call the lifetime of this reference `'1`
  |              |
  |              let's call the lifetime of this reference `'2`
5 |         path.strip_prefix(self.prefix.as_str()).unwrap_or(path)
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |         method was supposed to return data with lifetime `'2`
  |         but it is returning data with lifetime `'1`
  |
help: consider introducing a named lifetime parameter and update trait if needed
  |
4 |     fn strip<'a>(&self, path: &'a str) -> &'a str {
  |             ++++               ++          ++

Take the suggestion and it compiles, and the caller can now keep the result after the router is gone. Without it, the router becomes an anchor: every string it hands you is pinned to the router's own lifetime for no reason. In a routing table shared across a request pipeline, that is the difference between a design that composes and one where a lifetime parameter creeps into every type that touches the router.

Structs that hold references

A struct with a reference field has to declare the relationship, because the struct must not outlive what it borrows:

struct Parser<'a> {
    input: &'a str,
    position: usize,
}

Break it and the error is the same shape as before:

error[E0597]: `text` does not live long enough
  --> src/main.rs:10:34
   |
 9 |         let text = String::from("GET /health");
   |             ---- binding `text` declared here
10 |         parser = Parser { input: &text, position: 0 };
   |                                  ^^^^^ borrowed value does not live long enough
11 |     }
   |     - `text` dropped here while still borrowed
12 |     println!("{} at {}", parser.input, parser.position);
   |                          ------------ borrow later used here

Here is a complete one you can run. It parses a log buffer into borrowed views, copies nothing, and shows both elision behaviors in one place:

struct LogLine<'a> {
    level: &'a str,
    target: &'a str,
    message: &'a str,
}

impl<'a> LogLine<'a> {
    fn parse(line: &'a str) -> Option<LogLine<'a>> {
        let mut parts = line.splitn(3, ' ');
        let level = parts.next()?;
        let target = parts.next()?.trim_end_matches(':');
        let message = parts.next()?;
        Some(LogLine { level, target, message })
    }

    // Tied to the buffer, not to self.
    fn message(&self) -> &'a str {
        self.message
    }

    // Elided: rule three ties this to &self.
    fn target(&self) -> &str {
        self.target
    }
}

fn main() {
    let buffer = String::from(
        "INFO http: request completed\n\
         ERROR db: connection reset by peer\n\
         WARN cache: eviction storm",
    );

    let mut worst: Option<&str> = None;

    for line in buffer.lines() {
        let entry = match LogLine::parse(line) {
            Some(e) => e,
            None => continue,
        };
        println!("{:>5} {:<6} {}", entry.level, entry.target(), entry.message);
        if entry.level == "ERROR" {
            worst = Some(entry.message());
        }
    }

    println!("worst: {:?}", worst);
    println!("buffer bytes: {}", buffer.len());
}
 INFO http   request completed
ERROR db     connection reset by peer
 WARN cache  eviction storm
worst: Some("connection reset by peer")
buffer bytes: 90

Ninety bytes allocated, once. Every &str in every LogLine points into that one buffer. Nothing was copied and nothing was reference-counted.

Now drop the 'a from message's return type, so it reads fn message(&self) -> &str. The program stops compiling:

error[E0597]: `entry` does not live long enough
  --> src/main.rs:43:26
   |
37 |         let entry = match LogLine::parse(line) {
   |             ----- binding `entry` declared here
...
43 |             worst = Some(entry.message());
   |                          ^^^^^ borrowed value does not live long enough
44 |         }
45 |     }
   |     - `entry` dropped here while still borrowed
46 |
47 |     println!("worst: {:?}", worst);
   |                             ----- borrow later used here

The data is still perfectly alive. buffer outlives everything. What died is the LogLine itself, 48 bytes of pointer and length pairs that the compiler was told the result depended on. Elision picked &self, and &self is the loop variable.

That distinction, -> &str versus -> &'a str on a borrowed struct's accessor, is the single most useful thing in this chapter if you ever write a zero-copy parser.

The same shape in C#

You have written this before, with different words:

using System;

ref struct LogLine
{
    public ReadOnlySpan<char> Level;
    public ReadOnlySpan<char> Target;
    public ReadOnlySpan<char> Message;

    public static bool TryParse(ReadOnlySpan<char> line, out LogLine result)
    {
        result = default;
        int a = line.IndexOf(' ');
        if (a < 0) return false;
        int b = line.Slice(a + 1).IndexOf(' ');
        if (b < 0) return false;

        result.Level = line.Slice(0, a);
        result.Target = line.Slice(a + 1, b).TrimEnd(':');
        result.Message = line.Slice(a + 1 + b + 1);
        return true;
    }
}

class Program
{
    static void Main()
    {
        string buffer =
            "INFO http: request completed\n" +
            "ERROR db: connection reset by peer\n" +
            "WARN cache: eviction storm";

        foreach (var range in buffer.AsSpan().Split('\n'))
        {
            var line = buffer.AsSpan()[range];
            if (!LogLine.TryParse(line, out var entry)) continue;
            Console.WriteLine($"{entry.Level,5} {entry.Target,-6} {entry.Message}");
        }
    }
}

The three log lines come out identical to the Rust version's. The Split overload that yields ranges over a span needs .NET 9. The ref struct itself is much older, C# 7.2, though a genuine ref field rather than a span-typed one only became legal in C# 11, which is why Span<T> had to be special-cased by the runtime before that.

The interesting difference is what you cannot do next. In Rust, collecting borrowed slices into a Vec is ordinary code:

fn errors_in<'a>(buffer: &'a str) -> Vec<&'a str> {
    buffer.lines().filter(|l| l.starts_with("ERROR")).collect()
}

The C# equivalent does not exist. Put a span in a class field and you get one error, put it in a List and you get another:

class Errors { public ReadOnlySpan<char> First; }
var errors = new List<ReadOnlySpan<char>>();
error CS8345: Field or auto-implemented property cannot be of type
'ReadOnlySpan<char>' unless it is an instance member of a ref struct.

error CS9244: The type 'ReadOnlySpan<char>' may not be a ref struct or a type
parameter allowing ref structs in order to use it as parameter 'T' in the
generic type or method 'List<T>'

C# handles this by restricting where the type may appear: not a class field, not a generic argument unless the parameter declares allows ref struct (C# 13), not captured, not across an await. The rules are per-type and coarse. Rust handles it by tracking, per value, which region it must fit inside, so a Vec of references is fine as long as the Vec itself does not outlive the buffer.

That is a real expressiveness gap, and it favors Rust. It is also a real complexity gap, and it favors C#. Microsoft's span usage guidelines are a page of rules a person follows. Rust's are a solver you argue with.

Where borrowed structs scale, and where they stop

The pattern scales in one specific way and fails in another, and knowing which is which is most of the skill.

It scales when there is a natural owner with a clear boundary. Read a request into one buffer, parse headers into slices of it, route on those slices, respond, drop the buffer. Read a log batch, parse ten thousand lines into borrowed views, aggregate, drop. Deserialize a JSON document with borrowed string fields instead of owned ones. In all three the lifetime parameter stays inside one module, because the buffer's region is obvious and everything downstream is shorter.

It fails the moment a borrowed value has to outlive the buffer. Push a LogLine onto a queue for a background worker, and the queue must not hold references into a buffer the request thread is about to drop. Cache a parsed value across requests, same problem. Send it to another thread, same problem with a louder error. At that boundary you copy: to_string(), or an Arc<str> if the same text is shared widely, or a reference-counted byte buffer type if you are moving a lot of bytes.

The failure mode people actually hit is not a crash. It is propagation. A lifetime parameter is viral. Once Parser<'a> exists, anything holding a Parser needs 'a, and anything holding that needs 'a, and three modules later you are threading a region through code that does not care about it. That churn is the real cost, and it is why the advice for month one is to own your data by default, take the allocation, and introduce borrowed types only where a profiler says the copies matter.

There is also a wall you will hit early. A struct cannot own a buffer and borrow from it:

struct Document<'a> {
    text: String,
    title: &'a str,   // wants to point into self.text
}
error[E0505]: cannot move out of `text` because it is borrowed
 --> src/main.rs:9:26
  |
7 |     let text = String::from("Postmortem\nthe cache fell over");
  |         ---- binding `text` declared here
8 |     let title = text.lines().next().unwrap();
  |                 ---- borrow of `text` occurs here
9 |     let doc = Document { text, title };
  |                          ^^^^  ----- borrow later used here
  |                          |
  |                          move out of `text` occurs here

Self-referential structs are not expressible with ordinary references, and no amount of annotation fixes it. Moving the String into the struct would move the buffer, invalidating the pointer. The boring fix is to store indices instead of references, which costs a bounds check on access and nothing else:

use std::ops::Range;

struct Document {
    text: String,
    title: Range<usize>,   // an index, not a pointer
}

impl Document {
    fn new(text: String) -> Self {
        let end = text.find('\n').unwrap_or(text.len());
        Document { text, title: 0..end }
    }

    fn title(&self) -> &str {
        &self.text[self.title.clone()]
    }
}

In C# this is a non-problem, because an object holding a string and an offset into it is just an object holding two things, and the collector keeps the string alive. Recognizing that you have wandered into the self-referential case, and reaching for indices immediately instead of spending two hours on annotations, is worth more than any amount of lifetime theory.

The two meanings of 'static

Same token, two unrelated jobs.

As a reference lifetime, &'static str means the data is valid for the entire program. String literals qualify, because they are baked into the binary. So do references to items declared static. This is the meaning the compiler suggests when it cannot find a region, and it is almost always the wrong fix for a returned local.

As a bound on a generic, T: 'static means the type contains no references with a region shorter than the whole program. That is a statement about the type, not about how long a particular value lives. An owned String satisfies it. So does u32. So does a Vec of owned data. None of them live forever, and none of them need to:

use std::any::type_name;

fn needs_static<T: 'static>(_value: T) {
    println!("{} satisfies T: 'static", type_name::<T>());
}

fn main() {
    let owned = String::from("not a global, not a literal");
    needs_static(owned);          // owned data: fine
    needs_static(42u32);          // no references at all: fine
    needs_static("a literal");    // &'static str: fine
}
alloc::string::String satisfies T: 'static
u32 satisfies T: 'static
&str satisfies T: 'static

The String is dropped at the end of needs_static. It satisfied the bound anyway, because the bound asks what the type could contain, not how long the value survives. Add a borrow of a local and it breaks, and the diagnostic names the mechanism directly:

error[E0597]: `local` does not live long enough
  --> src/main.rs:14:26
   |
13 |     let local = String::from("stack-bound");
   |         ----- binding `local` declared here
14 |     let borrowed: &str = &local;
   |                          ^^^^^^ borrowed value does not live long enough
15 |     needs_static(borrowed);
   |     ---------------------- argument requires that `local` is borrowed for `'static`
16 | }
   | - `local` dropped here while still borrowed
   |
note: requirement that the value outlives `'static` introduced here
  --> src/main.rs:3:20
   |
 3 | fn needs_static<T: 'static>(_value: T) {
   |                    ^^^^^^^

You will meet this bound on std::thread::spawn and on task spawners in async runtimes, and the reason is mechanical: the spawned work may still be running after the calling frame returns, so it must not hold a reference into that frame. It is not saying "give me a global". It is saying "do not lend me your stack".

use std::thread;

fn main() {
    let name = String::from("worker-1");
    let handle = thread::spawn(|| {
        println!("hello from {}", name);
    });
    handle.join().unwrap();
}
error[E0373]: closure may outlive the current function, but it borrows `name`,
which is owned by the current function
 --> src/main.rs:5:32
  |
5 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `name`
6 |         println!("hello from {}", name);
  |                                   ---- `name` is borrowed here
  |
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `name` (and any other
      referenced variables), use the `move` keyword
  |
5 |     let handle = thread::spawn(move || {
  |                                ++++

Adding move transfers ownership into the closure, the closure now contains an owned String, and T: 'static is satisfied.

The mistakes C# engineers make here

Reading an annotation as a command. Writing 'static to "make it live longer" is the reflex, and it never works, because annotations constrain and never extend. You recognize it when adding a lifetime turns one error into a worse one further down. The fix is to ask what region the data actually has and make the signature honest about it.

Taking the compiler's first suggestion. On fn make_name() -> &str, the 'static help is listed first and is almost never right. Modern rustc now prints the owned-return suggestion too, and that is nearly always the fix.

Assuming elision inferred something. It applied a rule. When a method returns a slice of an argument rather than of self, rule three gives you the wrong contract and the body stops typechecking. Recognize the phrase "method was supposed to return data with lifetime '2" and reach straight for a named parameter on the argument.

Reaching for a borrowed struct too early. A String costs 24 bytes of struct plus one allocation. A lifetime parameter costs zero bytes and propagates through every type that touches it. In month one, the second is far more expensive.

Trying to build a self-referential type. Owning a buffer and borrowing from it produces E0505 and cannot be annotated away. Switch to indices the moment you see that error on a constructor.

Reading T: 'static as "must be a global". It means "contains no short-lived references". If adding move to a closure fixes your error, this was the confusion.

Where the C# approach genuinely wins

Anything with cycles or back-pointers. A parent pointer in a tree, an observer list, a cache whose entries reference each other, a doubly linked list. In C# you write the obvious thing and the collector sorts it out. In Rust you reach for indices into a slab, or Rc with Weak back-edges, or an arena, and every one of those is more code and more thought for a data structure that was not the interesting part of your problem.

Closures over locals, as above. Long-lived object graphs with no clear single owner, which is most of what a business application is. And the plain fact that for a service where every object is heap-allocated and lives for a request, the entire lifetime apparatus buys you a guarantee you were already getting from the runtime, at the cost of a category of compile error you did not have.

C#'s ref safety rules are also easier to learn, precisely because they are coarse. A short fixed ladder of escape scopes, a handful of restrictions on where a ref struct may appear, one scoped modifier. You can hold all of it in your head. Rust's model is more expressive and correspondingly larger, and the honest summary is that you pay for the expressiveness whether or not you need it.

Checking yourself

Why does fn longer(a: &str, b: &str) -> &str fail to compile even though the body clearly returns one of the two arguments? Because the compiler checks call sites against the signature alone, never against the body. Elision rule one gives the two parameters distinct regions and no rule picks one for the output, so the signature would be ambiguous to every caller.

A method returns &str and the caller gets "does not live long enough" even though the underlying buffer is still alive. What happened? Elision rule three tied the returned reference to &self, so the compiler thinks the result depends on the receiver rather than on the buffer. Annotate the struct's lifetime explicitly on the return type.

Does T: 'static mean the value must live for the whole program? No. It means the type contains no references with a shorter region. An owned String satisfies it and is dropped normally. The bound exists to stop you lending a stack frame to something that may outlive it.

You are writing a struct that holds a String and a slice of that same String. What should you do? Not that. It is self-referential and produces E0505 regardless of annotations. Store a Range of byte indices and expose an accessor that slices on demand.

Traits are interfaces that do not own your types

Part II, Working knowledge.

A trait is a set of method signatures a type can implement. So far, an interface.

trait HealthCheck {
    fn is_healthy(&self) -> bool;

    fn status_line(&self) -> String {
        if self.is_healthy() { "OK".to_string() } else { "DEGRADED".to_string() }
    }
}

The default method body is C# 8 default interface methods, and it works roughly the same way: implementors get it for free and may override it.

The difference is where the implementation goes.

struct Database { connected: bool }

impl HealthCheck for Database {
    fn is_healthy(&self) -> bool { self.connected }
}

The impl block is separate from the type declaration, and it does not have to be in the same file, the same module, or written by the same person. That is the change that matters, and it is bigger than it looks. You can implement your trait for a type you did not write.

The thing extension methods cannot do

In C#, a type's interface list lives in the type's own declaration. Spread it across partial files if you like, but it is fixed by the assembly that defines the type, and it is baked into the metadata the compiler emits for it. Anyone downstream who wants that type to satisfy a new interface has to wrap it.

Rust has no such list. An impl is a free-standing item, and when the compiler checks whether a type satisfies a bound it considers every impl in the crate graph, wherever it was written. So this is legal:

impl HealthCheck for i32 {
    fn is_healthy(&self) -> bool { *self == 200 }
}

That is an implementation of your interface for a primitive, with dispatch, with generic bounds, with everything an interface gives you.

Extension methods are the reflex you will reach for, and they do not reach. An extension method is a static method plus call-site sugar. It gives you the dot, and nothing else. Watch what happens when the dot is not the point:

using System;
using System.Collections.Generic;

public interface IHealthCheck
{
    bool IsHealthy();
}

public static class IntExtensions
{
    public static bool IsHealthy(this int status) => status == 200;
}

public static class Program
{
    static void ReportAll<T>(IEnumerable<T> items) where T : IHealthCheck
    {
        foreach (var i in items) Console.WriteLine(i.IsHealthy());
    }

    public static void Main() => ReportAll(new[] { 200, 500 });
}

The call in Main does not compile, and the compiler says exactly why:

error CS0315: The type 'int' cannot be used as type parameter 'T' in the generic
type or method 'Program.ReportAll<T>(IEnumerable<T>)'. There is no boxing
conversion from 'int' to 'IHealthCheck'.

The extension method exists. 200.IsHealthy() compiles fine on its own. It just is not part of what int is, so it cannot satisfy a constraint, cannot be found through a T, and cannot be dispatched through an interface reference. In Rust the equivalent generic function takes i32 without comment, because impl HealthCheck for i32 did change what i32 is, from the compiler's point of view, for the whole crate graph.

This is why C# codebases grow adapter classes in places where Rust writes four lines. It is also why C# needs IEqualityComparer<T> and IComparer<T> as separate objects you pass around: when you cannot change how a foreign type compares, you have to smuggle the comparison in through a parameter. Rust has that escape hatch too, in the form of sort_by_key and friends, but it needs it far less often.

Trait methods exist only if the trait is in scope

There is a catch that trips up everyone arriving from C#, and it is the same catch extension methods have.

mod health {
    pub trait HealthCheck {
        fn is_healthy(&self) -> bool;
    }

    impl HealthCheck for i32 {
        fn is_healthy(&self) -> bool { *self == 200 }
    }
}

fn main() {
    let status: i32 = 200;
    println!("{}", status.is_healthy());
}
error[E0599]: no method named `is_healthy` found for type `i32` in the current scope
  --> src/main.rs:13:27
   |
3  |         fn is_healthy(&self) -> bool;
   |            ---------- the method is available for `i32` here
...
13 |     println!("{}", status.is_healthy());
   |                           ^^^^^^^^^^ method not found in `i32`
   |
   = help: items from traits can only be used if the trait is in scope
help: trait `HealthCheck` which provides `is_healthy` is implemented but not in scope; perhaps you want to import it
   |
1  + use crate::health::HealthCheck;
   |

The impl exists. The compiler can see it. It refuses anyway, because method resolution starts from the set of traits you have imported, and you imported none. Add use crate::health::HealthCheck; and it compiles.

The reason is the same one that makes the whole feature work. If a method call searched every trait in the dependency graph, then any crate anywhere could change the meaning of your code by adding an impl. Scoping the search to your imports means adding an impl in a library cannot silently hijack a call in a consumer.

Two related details, since you will hit both. Inherent methods, the ones in a plain impl Type block, win over trait methods with the same name, with no ambiguity error. And when two traits both provide a name method, you disambiguate with fully qualified syntax:

trait Pilot  { fn name(&self) -> String; }
trait Wizard { fn name(&self) -> String; }

struct Human;

impl Human {
    fn name(&self) -> String { "just a person".to_string() }
}

impl Pilot for Human {
    fn name(&self) -> String { "Captain".to_string() }
}

impl Wizard for Human {
    fn name(&self) -> String { "Gandalf".to_string() }
}

fn main() {
    let h = Human;
    println!("{}", h.name());                    // just a person, inherent wins
    println!("{}", Pilot::name(&h));             // Captain
    println!("{}", <Human as Wizard>::name(&h)); // Gandalf
}

That is C#'s explicit interface implementation, solved from the call site instead of the declaration.

The orphan rule, and why it exists

You cannot implement someone else's trait for someone else's type. Try to format a Vec of String the way you want:

use std::fmt;

impl fmt::Display for Vec<String> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.join(", "))
    }
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
 --> src/main.rs:3:1
  |
3 | impl fmt::Display for Vec<String> {
  | ^^^^^^^^^^^^^^^^^^^^^^-----------
  | |                     |
  | |                     `Vec` is not defined in the current crate
  | impl doesn't use only types from inside the current crate
  |
  = note: define and implement a trait or new type instead

Niko Matsakis wrote the post that explains the design, and the case it protects is two "cousin" crates: neither depends on the other, both depend on std, and both want impl Display for Vec<String>. Each compiles alone. Link them and you have two answers to the same question. The rule as later refined in RFC 2451 and stated in the Reference is stricter than the folk version, but the folk version gets you most of the way: either the trait or the self type must be yours.

The folk version is a simplification, though. Watch this compile:

use std::ops::Add;

#[derive(Debug, Clone, Copy)]
struct Bytes(u64);

impl Add<Bytes> for u64 {
    type Output = Bytes;
    fn add(self, rhs: Bytes) -> Bytes { Bytes(self + rhs.0) }
}

fn main() {
    println!("{:?}", 64u64 + Bytes(1024)); // Bytes(1088)
}

Add is foreign. u64 is foreign. It is still allowed, because the real rule looks at every type in the impl header, not only the self type. RFC 2451 phrases it as an ordered list: the self type first, then the trait's type arguments left to right. Somewhere in that list there must be a local type, and no uncovered generic parameter may appear before it. Here the list is u64, then Bytes, there are no generic parameters at all, and Bytes is local, so the impl is accepted. That is why you can teach u64 to add itself to your type without owning u64.

Newtype, and what it costs

When the orphan rule does block you, the standard move is to wrap the foreign type in a one-field struct. The community calls this a newtype, and it is the same idea as a C# adapter class, except mandatory and cheap.

use std::fmt;

struct Csv(Vec<String>);

impl fmt::Display for Csv {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.0.join(","))
    }
}

fn main() {
    let cols = Csv(vec!["region".to_string(), "p99".to_string(), "errors".to_string()]);
    println!("{cols}");           // region,p99,errors
    println!("{}", cols.0.len()); // 3
}

A newtype costs nothing at run time. In practice rustc gives a one-field struct the same size and alignment as the field, and if you need that in writing rather than in practice, #[repr(transparent)] makes it a guarantee about layout and ABI. The default representation deliberately promises less, so do not build unsafe code on the assumption without the attribute.

What a newtype is not free of is API surface. Csv has none of Vec's methods. You either reach through .0, forward the methods you need by hand, or implement Deref to the inner type, which the book suggests and which I would use sparingly, because Deref on a newtype leaks every inner method back out and undoes most of the reason you wrapped it.

Default methods carry more weight here than in C#

Two differences from default interface methods, and the second one is the interesting one.

First, a Rust default method is callable on the concrete type. A C# default interface method is not. This surprises people:

public interface IHealthCheck
{
    bool IsHealthy();
    string StatusLine() => IsHealthy() ? "OK" : "DEGRADED";   // default implementation
}

public class Database : IHealthCheck
{
    public int OpenConns { get; init; }
    public int MaxConns { get; init; }
    public bool IsHealthy() => OpenConns < MaxConns;
}

public static class Program
{
    public static void Main()
    {
        var db = new Database { OpenConns = 95, MaxConns = 100 };
        Console.WriteLine(db.StatusLine());
    }
}
error CS1061: 'Database' does not contain a definition for 'StatusLine' and no
accessible extension method 'StatusLine' accepting a first argument of type
'Database' could be found

You have to cast to the interface, or reach it through a constrained type parameter. The reason is C#'s member lookup rules: a default implementation is a member of the interface, not of the class, so it never enters the class's member set and never becomes a candidate for db.StatusLine(). In Rust, trait methods are found through the trait rather than through a member list, so db.status_line() resolves as long as HealthCheck is in scope.

Second, and much more consequential: because a default method sits on the trait, an implementor can override it, and every generic caller picks up the override statically.

Iterator is the example. The standard library trait has one required associated type, Item, one required method, next, and more than seventy provided methods with default bodies. Write next and you have map, filter, zip, take_while, fold, sum, collect, all of it:

struct Countdown { remaining: u32 }

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<u32> {
        if self.remaining == 0 {
            None
        } else {
            self.remaining -= 1;
            Some(self.remaining + 1)
        }
    }
}

fn main() {
    let total: u32 = Countdown { remaining: 5 }
        .filter(|n| n % 2 == 1)
        .map(|n| n * 10)
        .sum();
    println!("{total}");          // 90
}

C# gets the same adapters on IEnumerable<T>, but through LINQ's extension methods, which cannot be overridden by an implementor. So when Enumerable.Count wants to avoid walking a sequence it already knows the length of, it has nowhere to look except the object itself. The actual source does a chain of runtime type tests: is it an ICollection<TSource>, is it an internal Iterator<TSource>, is it a non-generic ICollection, and only then does it loop.

In Rust that fast path is a method override. Implement size_hint accurately and collect into a Vec reserves from the lower bound instead of growing. Override count or nth and every caller in every crate picks it up, resolved at compile time, with no type test. Small design decision, long tail, and one of the few places where Rust's model is straightforwardly better rather than differently balanced.

The cost shows up in versioning. Adding a defaulted method to a published trait can break downstream code, because a consumer who implemented both your trait and a local trait with a method of the same name now has an ambiguous call. The Cargo SemVer reference documents this as a minor-but-possibly-breaking change. C# has the same hazard with default interface methods, which is part of why the feature arrived with such careful rules.

Supertraits are constraints, not base classes

Traits can require other traits, which reads like inheritance and is not:

use std::time::Duration;

trait Monitored: HealthCheck {
    fn interval(&self) -> Duration;

    fn schedule_line(&self) -> String {
        format!("{}  (every {:?})", self.status_line(), self.interval())
    }
}

That says any type implementing Monitored must also implement HealthCheck. Read it as the bound it desugars to, where Self: HealthCheck. It buys Monitored's default bodies the right to call HealthCheck methods on self, and it buys callers the right to treat any Monitored as a HealthCheck. It is a constraint on implementors, not a base type. Nothing is inherited: no fields, no constructor, no base call, no slot layout.

If you write impl Monitored for Database without an impl HealthCheck for Database somewhere, the compiler tells you the bound is unsatisfied. That is the whole feature.

There is no inheritance

Rust has no class inheritance at all. No base classes, no virtual and override, no protected members.

For a lot of C# code this is a non-event, because the useful part of the hierarchy was the interface anyway. Where it bites is the places you leaned on a base class for something other than a contract, and each of those has a different answer.

Shared state in an abstract base. Composition. Put the shared struct in a field, and if callers need to reach it generically, add a trait method that hands out a reference to it.

struct Common { name: String, last_checked: u64 }

trait HasCommon {
    fn common(&self) -> &Common;
    fn name(&self) -> &str { &self.common().name }
}

Verbose compared to a protected field. Also explicit about who owns what, which the rest of the language is going to force on you regardless.

The template method pattern. Rust does this with less ceremony and no loss. An abstract base with a non-virtual public method calling protected abstract hooks is, exactly, a trait with a defaulted method calling required methods. The status_line above is that pattern.

A polymorphic collection of subclasses. If the set of types is closed and you own it, an enum is usually the better answer, and gives you exhaustive match on top. If the set is open, you want a trait object, which the next section covers.

Reuse with no polymorphism. If the base class existed only so three types could share an implementation, write a free function and call it from three places.

The case with no good answer is anything that discovers implementations at run time. A DI container scanning an assembly for every IHandler, a plugin loaded from a directory. Rust has no reflection, so each of those becomes a hand-populated registry or a build-time code generation step. More on that at the end of the section.

Associated types versus generic parameters

Iterator could have been written two ways. The standard library chose this:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

The alternative would have been trait Iterator<T> with fn next(&mut self) -> Option<T>. The book walks through why not, and the difference is exactly one thing: how many times a type may implement the trait.

A generic trait can be implemented many times for one type, once per set of type arguments. An associated type can be chosen only once per type, because there can be only one impl Iterator for Countdown.

You already know what the other choice looks like, because C# made it. IEnumerable<T> is generic, so one class can implement it more than once:

public class Both : IEnumerable<int>, IEnumerable<string>
{
    IEnumerator<int> IEnumerable<int>.GetEnumerator()    { yield return 1; }
    IEnumerator<string> IEnumerable<string>.GetEnumerator() { yield return "one"; }
    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator();
}

That compiles and runs. The cost lands on every call site. foreach (var x in b) is CS1640, because the type implements multiple instantiations of IEnumerable<T>, and you have to write foreach (var x in (IEnumerable<int>)b) to say which one you meant.

Rust's version of that class does not exist. Two impl Iterator for Countdown blocks give you:

error[E0119]: conflicting implementations of trait `Iterator` for type `Countdown`
  --> src/main.rs:18:1
   |
5  | impl Iterator for Countdown {
   | --------------------------- first implementation here
...
18 | impl Iterator for Countdown {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `Countdown`

In exchange, countdown.next() never needs an annotation, and neither does anything downstream of it. Every adapter in the chain can name Self::Item and the compiler knows what it is.

So the choice, when you are designing a trait. If the implementor decides the type, use an associated type. If the caller decides, use a generic parameter. An iterator's element type is a property of the iterator, so Item is associated. A conversion's source type is a property of the call, so From<T> is generic, and one type happily implements From<u32>, From<String> and From<io::Error> all at once.

Add shows both in one trait, which is a useful thing to have seen:

pub trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

Rhs is a generic parameter with a default, so you may implement Add for your type more than once, once per right-hand side. Output is associated, because once Self and Rhs are fixed, the result type is not a free choice.

The cost of picking wrong is asymmetric. Turning a generic parameter into an associated type breaks anyone who wrote two impls, and there may be nobody in that position. Turning an associated type into a generic parameter breaks every bound spelled Trait<Item = X> in every downstream crate, and unless you give the new parameter a default it breaks every bare mention of the trait as well. When in doubt, associated type, and take the pain later if it comes.

Operator overloading

Operators are traits in std::ops. + is Add, [] is Index, * on a smart pointer is Deref. Arithmetic on the primitives is still compiler-level, but for every other type the operator is a trait impl and nothing more.

use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
struct Bytes(u64);

impl Add for Bytes {
    type Output = Bytes;
    fn add(self, rhs: Bytes) -> Bytes { Bytes(self.0 + rhs.0) }
}

impl Add<u64> for Bytes {
    type Output = Bytes;
    fn add(self, rhs: u64) -> Bytes { Bytes(self.0 + rhs) }
}

impl Add<Bytes> for u64 {
    type Output = Bytes;
    fn add(self, rhs: Bytes) -> Bytes { Bytes(self + rhs.0) }
}

fn main() {
    println!("{:?}", Bytes(1024) + Bytes(512)); // Bytes(1536)
    println!("{:?}", Bytes(1024) + 64u64);      // Bytes(1088)
    println!("{:?}", 64u64 + Bytes(1024));      // Bytes(1088)

    let mut v = vec![Bytes(3), Bytes(1), Bytes(2)];
    v.sort();
    println!("{v:?}");                          // [Bytes(1), Bytes(2), Bytes(3)]
}

C# is closer here than people expect. An operator is a public static member declared inside one of its operand types, and at least one operand must be that type, so public static Bytes operator +(ulong left, Bytes right) declared inside Bytes is legal and always has been. 64UL + myBytes compiles. The requirement is the same shape as coherence: own one side of the expression.

Two things do differ. The first is that a C# operator has to live inside a type declaration, so you cannot add one to a type after the fact, at least not before the C# 14 extension operators mentioned above. The second is the one that matters more. Add is an ordinary trait, so a generic function can bound on it and get + for any type that implements it, including through your own impls. C# only got the equivalent with generic math in .NET 7, built on static abstract interface members, and an extension operator does not make its receiver satisfy IAdditionOperators, so generic math cannot see it.

The sort() call in that example is worth a second look. It works because #[derive(Ord)] gave Bytes a total order, and the signature is fn sort(&mut self) where T: Ord. Drop Ord from the derive list and the program stops compiling.

The C# equivalent checks later. List<T>.Sort() has no constraint on T at all. It reaches for Comparer<T>.Default, and if T implements neither IComparable<T> nor IComparable you get an InvalidOperationException on the line that sorts, not on the line that defined the type. Same requirement, checked at a different time.

Deriving the boring ones

Most C# codebases have hand-written Equals, GetHashCode and ToString overrides, and at least one of them is subtly wrong. Rust generates them:

#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct ServerId {
    region: String,
    index: u32,
}

Six trait implementations from one line, structurally derived from the fields. The appendix in the book is the normative list, and it is worth knowing precisely what each one writes:

  • Debug gives you {:?} formatting, which is what assert_eq! prints when a test fails, and what you want in logs.
  • PartialEq compares every field; two structs are equal when all fields are. On an enum, each variant equals only itself. Eq adds no methods at all. It is a marker saying the relation is reflexive, which is why f64 implements PartialEq but not Eq: NaN != NaN.
  • PartialOrd compares fields in declaration order, first field first. On enums, variants declared earlier sort lower. Ord promises a total order. Field order is load-bearing here, and reordering fields for tidiness silently changes your sort.
  • Hash combines the hashes of every field. Because Eq and Hash are derived from the same fields, they cannot disagree, which removes an entire category of dictionary bug.
  • Clone calls clone on each field. Copy is a marker meaning the value can be duplicated by copying its bits, with no code run, and it requires Clone.
  • Default calls Default::default on each field. On an enum you have to mark one variant with #[default].

Display is deliberately not derivable. How a type should look to a user is a decision the compiler cannot make.

Now the part where the simple model breaks. I said derive writes the implementation you would have written. It does not quite. It writes the implementation with a bound on every generic parameter:

use std::rc::Rc;

#[derive(Clone)]
struct Cached<T> { inner: Rc<T> }

struct Connection;   // deliberately not Clone

fn main() {
    let c = Cached { inner: Rc::new(Connection) };
    let _copy = c.clone();
}
error[E0599]: the method `clone` exists for struct `Cached<Connection>`, but its trait bounds were not satisfied
  --> src/main.rs:10:19
   |
4  | struct Cached<T> { inner: Rc<T> }
   | ---------------- method `clone` not found for this struct because it doesn't satisfy `Cached<Connection>: Clone`
...
6  | struct Connection;   // deliberately not Clone
   | ----------------- doesn't satisfy `Connection: Clone`
...
10 |     let _copy = c.clone();
   |                   ^^^^^ method cannot be called on `Cached<Connection>` due to unsatisfied trait bounds
   |
note: the following trait bounds were not satisfied:
      `Connection: Clone`
help: consider annotating `Connection` with `#[derive(Clone)]`

Cached<T> holds an Rc<T>, and cloning an Rc bumps a refcount. It never touches the T. But #[derive(Clone)] emitted impl<T: Clone> Clone for Cached<T> because it is a syntactic macro that sees a type parameter and adds the obvious bound. It cannot reason about what the fields actually need.

Take the compiler's suggestion here and you have made Connection cloneable to satisfy a bound nothing uses. The real fix is to drop the derive and write the impl you meant:

impl<T> Clone for Cached<T> {
    fn clone(&self) -> Self {
        Cached { inner: Rc::clone(&self.inner) }
    }
}

C# is not without an answer here. A record generates value equality, GetHashCode, ToString, a copy constructor for with expressions, and, when it is declared positionally, Deconstruct. It does all of that well. The difference is granularity: record is a package deal attached to a kind of type, while derive is per-trait and opt-in, so you can have structural equality without ordering, or ordering without hashing, and the choices are visible on the line above the struct.

The traits worth learning first

You do not need the whole standard library. These carry most of the weight:

TraitWhat it gives youNearest C# thing
Debug{:?} formatting for logs and testsToString for debugging
Display{} formatting for users, and .to_string() freeToString
Cloneexplicit duplication, as deep as the type choosesICloneable, done properly
Copyimplicit bitwise copy, no code runvalue-type assignment
PartialEq / Eq==, and map key eligibilityIEquatable<T>
PartialOrd / Ord<, sort, BTreeMap keysIComparable<T>
HashHashMap and HashSet keysGetHashCode
Defaulta zero value, and ..Default::default()parameterless constructor
From / Intoinfallible conversionimplicit conversion operators
TryFrom / TryIntofallible conversionTryParse, with a real return type
Iteratorlazy sequences, plus every adapter for freeIEnumerable<T> plus LINQ
IntoIteratorwhat for loops actually callGetEnumerator
Dropdeterministic cleanup at scope exitIDisposable
Derefsmart pointer transparencyno real equivalent
AsRef / Borrowcheap reference conversion in APIsoverloads taking string and ReadOnlySpan<char>
Add, Index, and friendsoperatorsoperator +, this[int]
Send / Syncthread-safety, checked by the compilernothing, you read the docs and hope

From deserves a note because it pulls its weight in error handling later. Implement From<A> for B and you get Into<B> for A free.

Blanket implementations

That last sentence is doing something specific.

This is how ToString works. You never implement it. You implement Display, and .to_string() appears. Try to implement it directly and the compiler shows you the blanket impl by name:

use std::fmt;

struct Bytes(u64);

impl fmt::Display for Bytes {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} B", self.0) }
}

impl ToString for Bytes {
    fn to_string(&self) -> String { format!("{} bytes", self.0) }
}
error[E0119]: conflicting implementations of trait `ToString` for type `Bytes`
  --> src/main.rs:9:1
   |
9  | impl ToString for Bytes {
   | ^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: conflicting implementation in crate `alloc`:
           - impl<T> ToString for T
             where T: std::fmt::Display, T: ?Sized;

You can write your own. Extension traits are the common use: define a trait, blanket-implement it for everything satisfying some bound, and every one of those types gains the methods.

The cost is that a blanket impl is close to a one-way door. Ship impl<T: HealthCheck> Reportable for T in a library, and you have permanently forbidden anyone, including yourself, from writing a specific impl Reportable for MyThing. Specialization, which would let a more specific impl win, is a long-running unstable feature and you cannot rely on it. Blanket impls are a fine tool and a poor default.

Serialization, where the orphan rule actually bites

The place this stops being theory is serialization. serde::Serialize is a foreign trait. A timestamp type from some other crate is a foreign type. Coherence says you cannot connect them, and the moment your service has forty types crossing an API boundary, several of them will be exactly that combination.

serde's answer is worth studying because it is what a mature ecosystem does when it hits this wall. There is #[serde(with = "module")] to point a field at conversion functions, remote derive to generate an impl for a foreign type through a local mirror struct, and a whole crate, serde_with, that exists mostly to paper over the gap. None of that is elegant. All of it is the tax coherence charges, and it is charged at exactly the moment your dependency graph gets wide.

The other thing that changes at scale is that a trait becomes a public contract in a way an interface is not. In C#, adding a member to a widely implemented interface breaks implementors, which everyone knows. In Rust the same is true, plus the ambiguity hazard from defaulted methods, plus the fact that adding a blanket impl can break a consumer who wrote a specific one, plus the fact that changing an associated type to a generic parameter or back is a major version bump. The SemVer reference is long for a reason. If you are designing a trait that other teams will implement, treat the associated-type-versus-generic-parameter decision as an architectural one, because it is the hardest to reverse.

The mistakes to expect

Forgetting the use. You will lose time to no method named ... on a method you can see in the source. Read the help line. The compiler tells you which import is missing.

Trying to build a class hierarchy out of supertraits. trait B: A looks like class B : A, so people stack four of them and then look for the shared state. There is none. When you catch yourself writing a supertrait chain to share fields, stop and put a struct in a field instead.

Implementing ToString instead of Display. The E0119 above. Also PartialOrd by hand when Ord is what callers need, and Ord by hand in a way that disagrees with the derived PartialOrd, which is a silent bug rather than a compile error.

Deriving Copy reflexively. Copy means assignment duplicates the value silently instead of moving it. On a small struct of numbers that is right. On anything you expect to be tracked by the ownership rules, it quietly removes the compiler's ability to tell you about a stale copy. Derive Clone first, add Copy when you have a reason.

Reaching for Box<dyn Trait> because an interface always meant run-time polymorphism in C#. Most of the time a generic parameter is what you want, and the next section is about why.

Where C# comes out ahead

Two places, honestly.

Discoverability is the first. In C#, class Database : IHealthCheck, IDisposable tells you what the type does in one line, and Go-to-Definition lands on the whole story. In Rust the impls are scattered across a file, a crate, or somebody else's crate, and answering "what does this type implement" means asking rustdoc or rust-analyzer. Tooling handles it well. Reading the source does not.

Run-time discovery is the second, and it is the bigger one. An architecture that finds its implementations by scanning at run time, which describes a lot of enterprise .NET, has no direct translation. Rust's answer is a registry you fill in by hand or a macro that builds one at compile time. Both work. Neither is as convenient as a container that just finds your handlers, and if your design leans on that, price the rewrite in before you port anything.

Checking yourself

Why can you write impl HealthCheck for i32 but not impl Display for Vec<String>? The first has a local trait, so coherence is safe: no other crate can define your trait. The second has a foreign trait and a foreign type, so a cousin crate could write the identical impl and linking both would give two answers to one question.

A colleague says a supertrait is Rust's version of a base class. What is the one-sentence correction? trait B: A desugars to a where Self: A bound, so it constrains who may implement B and lets B's default bodies call A's methods, but nothing is inherited: no fields, no constructor, no overridable slot.

You are designing trait Repository. Should the entity type be an associated type or a generic parameter? Associated, if each repository handles exactly one entity, which is the usual case, and it saves every caller from annotating. Generic, only if one type genuinely needs to be a repository for several entities at once, the way From<T> is implemented many times over.

You implemented Display for your type and .to_string() works, but you never wrote it. Where did it come from, and what does that prevent you from doing? From the blanket impl<T: Display + ?Sized> ToString for T in the standard library. Because coherence permits one impl, it prevents you from writing your own ToString for that type, which is why the compiler answers with E0119 if you try.

Generics that compile to what you would have written by hand

Generics look almost identical in the two languages, and compile to something quite different.

fn largest<T: PartialOrd>(items: &[T]) -> &T {
    let mut best = &items[0];
    for item in items {
        if item > best { best = item; }
    }
    best
}

T: PartialOrd is where T : IComparable<T> with different punctuation, and with one difference worth knowing about. PartialOrd is the partial order, which is why f64 implements it and NaN compares to nothing. The total order is a separate trait, Ord, and f64 does not implement it. C# collapses both into IComparable<T> and leaves you to remember which one you are getting. Longer bound lists move to a where clause exactly as in C#, and for the same reason: the signature stops fitting on one line.

Both languages check the body of the generic function against the declared bounds, at the definition, before anyone calls it. Use > inside largest without declaring PartialOrd and you get an error in largest, not at some distant call site. That is the same contract C# gives you, and it is worth saying out loud, because the third member of this family does not offer it. C++ templates are checked once the arguments are known, which is where the famous walls of instantiation errors come from. Rust and C# agree on the front half of the design. They disagree completely on the back half.

Where clauses reach further than C# constraints

C# constraints come from a fixed grammar. A base class or interface list, plus class, struct, notnull, unmanaged, default, new(), and the allows ref struct anti-constraint, in a documented order, and every one of them constrains a type parameter you declared.

Rust has one kind of constraint, the trait bound, and it applies to any type at all, not only to a parameter name.

use std::fmt::Display;

fn summarize<I>(items: I) -> String
where
    I: IntoIterator,
    I::Item: Display,
    Vec<I::Item>: Default,
{
    let mut out = String::new();
    for item in items {
        out.push_str(&format!("{item} "));
    }
    out
}

I::Item: Display bounds an associated type of a parameter. C# cannot say that: there is no way to write "T is a sequence of something printable" without introducing a second type parameter and forcing every caller to spell it out. Vec<I::Item>: Default bounds a concrete type built out of a parameter, which C# also has no syntax for. And Rust has higher-ranked bounds, where F: for<'a> Fn(&'a str) -> &'a str, which say the bound holds for every lifetime rather than for one the caller picked.

Going the other way, some C# constraints have no Rust spelling because Rust has nothing for them to constrain. new() has no equivalent, because Rust has no constructors; you write T: Default and call T::default(), which is an ordinary trait bound. The class versus struct split does not exist, because Rust has no such split to make. And allows ref struct exists because C# needs a way to tell a generic method it must obey ref safety rules for T, which in Rust is not opt-in and not a constraint, it is what the borrow checker does to every type.

There is one constraint in Rust you did not write and it is on almost everything. Every type parameter carries an implicit Sized bound. You opt out with ?Sized, which is why that appears all through the standard library. Forget it and E0277 tells you the size cannot be known at compile time, which is usually the moment a C# engineer discovers that dyn Trait is not something you can store by value.

The compiler writes the copies for you

The real difference is what the compiler does with largest.

The strongest evidence that a generic function has no run-time existence in Rust is that you cannot give one a stable exported name. Ask for one and the compiler tells you the request is meaningless.

#[unsafe(no_mangle)]
pub fn foo<T>() {}
warning: functions generic over types or consts must be mangled
 --> src/lib.rs:2:1
  |
1 | #[unsafe(no_mangle)]
  | -------------------- help: remove this attribute
2 | pub fn foo<T>() {}
  | ^^^^^^^^^^^^^^^
  |
  = note: `#[warn(no_mangle_generic_items)]` on by default

That is the no_mangle_generic_items lint, warn by default rather than a hard error, and its explanation is blunt: the attribute has no effect here and should be removed. In editions before 2024 the attribute is spelled #[no_mangle] without the unsafe wrapper, and the lint fires the same way.

There is no symbol called foo to export, because there is no function called foo in the object file. There are only foo::<u8>, foo::<String>, and whatever else you instantiated, each with the type arguments baked into its mangled name.

Compare that with C#, where typeof(T) inside a generic method returns a real Type because the runtime genuinely has one. Rust's nearest equivalent, std::any::type_name, is resolved at compile time and its documentation is explicit that the output carries no stability guarantee and is meant for diagnostics only. That is the philosophical gap in one API.

The model above is too simple

One copy per type argument is the right first model, and it is wrong in four ways that matter once your build gets slow.

It is one copy per reachable instantiation, not per type. Before codegen, rustc runs a monomorphization collector that walks outward from roots and builds the list of concrete items that actually need machine code. A generic function nobody calls costs nothing. A generic function called from three places at the same type costs one copy.

Lifetimes are erased entirely. Type parameters produce copies. Lifetime parameters do not, and never have. A function generic over 'a compiles once. This is easy to miss, and it is why lifetime-heavy code is not, by itself, a compile-time problem.

Copies get shared, but only in some builds. rustc has a share_generics mode that lets a crate reuse instantiations already emitted upstream instead of stamping out its own. In the compiler's own configuration code the default is on at opt-level 0, 1, s, and z, and off at opt-level 2 and 3. In plain terms: cargo build shares, cargo build --release does not. The trade is that a local copy can be optimized against its call site and a shared one cannot, which matters more the harder you are optimizing. It is one of several reasons a release build has more code to chew through than a debug build.

The cost lands in your crate, not the library's. The dev guide's codegen unit partitioning table is blunt about it: for a generic function in a dependency, regardless of inlining, all monomorphized instances appear in a single codegen unit of the crate that used them. A library with a wide generic API surface does not pay for that. You do, every time you build.

What .NET does instead

.NET sits between erasure and monomorphization, and it picks per type argument.

For value type arguments the runtime specializes. Microsoft's documentation of generics at run time says the runtime creates a specialized generic type with the value type substituted into the CIL, once per unique value type, which is why List<int> really does store unboxed integers.

For reference type arguments it shares. The type loader chapter of the Book of the Runtime names the mechanism: every reference-type argument is replaced by an internal type called System.__Canon, and the resulting canonical instantiation stands in for all of them. List<string> and List<object> are distinct types with their own method tables, and they share one EEClass and one set of compiled method bodies, because all references are the same size and the layouts are identical. Constraint checking has to be special-cased to ignore the fact that __Canon satisfies nothing.

Sharing is not free, and the shared generics design document shows what it costs. Where a specialized body would hardcode a type handle:

mov rcx, type handle of List<string>
call ToString()
ret

the shared body has to go and find it:

mov rcx, generic context
mov rcx, [rcx + offset of InstantiatedMethodDesc::m_pPerInstInfo]
mov rcx, [rcx + dictionary slot containing type handle of List<T>]
call ToString()
ret

Two extra dependent loads through a per-instantiation generic dictionary, on every operation that needs to know what T is. Rust never pays this, because Rust has nothing to look up. What .NET buys with it is the ability to construct a closed generic type nobody wrote down at compile time, which Rust cannot do at any price.

So the bill:

Rust.NET
Value type argumentsspecialized copyspecialized copy
Reference type argumentsspecialized copyone shared canonical body
Boxing at the boundaryimpossible, there is no shared shapenone inside a specialized instantiation, but a struct entering an interface-typed slot boxes
typeof(T) at run timeno such thingworks
Cost of knowing Tzerodictionary indirection in shared code
New instantiation at run timeimpossibleroutine
Where the cost landsyour binary and your buildJIT time and working set

What Rust buys by specializing everything at compile time: no boxing, ever, because there is no shared representation to box into. Full inlining across the generic boundary. Bounds resolved statically, so a comparison in a generic function costs what a comparison in a concrete function costs.

What you pay: binary size, since ten instantiations mean ten copies, and compile time, since every one of those copies has to be optimized and emitted. How much depends entirely on how many instantiations your crate graph reaches, and cargo llvm-lines, further down, is how you find that out rather than guess at it.

When you want the vtable instead

Sometimes you genuinely need a heterogeneous collection, which monomorphization cannot give you because the concrete types differ at run time. That is what dyn is for. Here is a complete program that uses both strategies on the same trait, which you can paste into main.rs and run:

use std::mem::size_of;

struct Request {
    path: String,
    tenant: u32,
    bytes: usize,
}

trait Policy {
    fn name(&self) -> &'static str;
    fn allows(&self, req: &Request) -> bool;
}

struct MaxBody { limit: usize }
struct BlockedPaths { paths: Vec<&'static str> }
struct TenantQuota { tenant: u32, max_bytes: usize }

impl Policy for MaxBody {
    fn name(&self) -> &'static str { "max_body" }
    fn allows(&self, req: &Request) -> bool { req.bytes <= self.limit }
}

impl Policy for BlockedPaths {
    fn name(&self) -> &'static str { "blocked_paths" }
    fn allows(&self, req: &Request) -> bool {
        !self.paths.iter().any(|p| *p == req.path.as_str())
    }
}

impl Policy for TenantQuota {
    fn name(&self) -> &'static str { "tenant_quota" }
    fn allows(&self, req: &Request) -> bool {
        req.tenant != self.tenant || req.bytes <= self.max_bytes
    }
}

// Static. One machine-code copy per concrete P. The call to `allows` is
// direct, and the optimizer may inline it.
fn enforce<P: Policy>(policy: &P, req: &Request) -> bool {
    if policy.allows(req) {
        return true;
    }
    println!("rejected by {}", policy.name());
    false
}

// Dynamic. One machine-code copy total. The call to `allows` is an
// indirect call through a vtable.
fn enforce_all(policies: &[Box<dyn Policy>], req: &Request) -> bool {
    let mut ok = true;
    for policy in policies {
        if !policy.allows(req) {
            println!("rejected by {}", policy.name());
            ok = false;
        }
    }
    ok
}

fn main() {
    let req = Request { path: "/v1/scrape".to_string(), tenant: 7, bytes: 4096 };

    println!("static:  {}", enforce(&MaxBody { limit: 1024 }, &req));

    let chain: Vec<Box<dyn Policy>> = vec![
        Box::new(MaxBody { limit: 8192 }),
        Box::new(BlockedPaths { paths: vec!["/admin"] }),
        Box::new(TenantQuota { tenant: 7, max_bytes: 2048 }),
    ];
    println!("dynamic: {}", enforce_all(&chain, &req));

    println!("&MaxBody        {} bytes", size_of::<&MaxBody>());
    println!("&dyn Policy     {} bytes", size_of::<&dyn Policy>());
    println!("Box<dyn Policy> {} bytes", size_of::<Box<dyn Policy>>());
}

The last three lines are the point. On a 64-bit target you get 8, 16, and 16. A reference to a concrete type is one machine word. A reference to a trait object is two.

The vtable holds more than function pointers. The standard library's DynMetadata, the nightly API for reaching one directly, documents the contents as the type's size, its alignment, a pointer to its drop_in_place, and then pointers to the trait's methods. Size and alignment are in there because dropping and deallocating a trait object requires knowing how big the thing behind the pointer actually is, and nothing else in the program knows.

One vtable exists per trait implementation, per the E0038 explanation. Not per type. MaxBody as dyn Policy and MaxBody as dyn Debug are two different vtables, and the pairing lives in the pointer, not in the value.

Because the vtable is chosen when the fat pointer is built, you cannot generally convert a &dyn A into a &dyn B. Supertrait upcasting has worked since Rust 1.86, so &dyn Trait coerces to &dyn Supertrait when Supertrait is actually a declared supertrait. Write trait MyTrait: Any and you can upcast to dyn Any and downcast from there, which the release notes call out as the useful case. That is the whole story, and it is a small story next to what a C# interface reference can do.

CLR interface dispatch is a different animal

It is tempting to say a dyn call is an interface call. The shapes are close and the mechanisms are not.

A C# object reference is one word. The object it points at begins with a pointer to its method table, so a List<IPolicy> is an array of single-word references and every element carries its own type with it. That is why is, as, and pattern matching on interfaces cost so little structurally: the object already knows everything.

Interface calls do not go through a classic vtable. CoreCLR uses virtual stub dispatch, and the design document is clear that although the mechanism could handle both, it is currently used only for interface dispatch, with plain virtual calls still using a real vtable. A call site starts on a lookup stub, which asks the resolver and back-patches itself. If the site looks monomorphic it gets a dispatch stub, which compares the receiver's method table against one cached value and jumps straight to the cached target. If that comparison keeps failing, the site is demoted to a resolve stub, which hashes the token and type into a global cache.

So a hot, monomorphic interface call in .NET is a compare, a well-predicted branch, and a jump to the cached target. A dyn call in Rust is a load and an indirect call with no guard at all. Neither is obviously cheaper, and if the site is polymorphic in .NET, the hash lookup makes Rust's unconditional indirect call look good.

Then the JIT does something Rust structurally cannot. It devirtualizes. The guarded devirtualization design records that with static analysis alone the JIT could devirtualize around 15% of virtual call sites and around 5% of interface call sites, so it added the ability to guess a type, emit a type test, and inline the guessed target behind it. Dynamic profile-guided optimization feeds those guesses with real observed types, and Microsoft says it is enabled by default from .NET 8, worth about 15% on average across a suite of roughly 4600 benchmarks, with 23% of them improving by 20% or more.

Rust settles this at compile time or not at all. LLVM will devirtualize a dyn call when the concrete type is visible at the site, which happens in small functions where the Box::new and the call sit in the same body, and stops happening once the pointer has crossed a boundary the optimizer cannot see through. Nothing is feeding it a profile of which Policy your production traffic actually hits.

That is a real advantage for the CLR, and it is the advantage of having a compiler running inside your process.

Being honest about what dispatch costs

The indirect call is not the expensive part.

On a modern out-of-order core, an indirect call whose target is stable is predicted, and the load of the vtable entry overlaps with everything around it. If the call happens once per HTTP request, it is invisible next to the syscall you are about to make.

The expensive part is the optimization you lose. A direct call can be inlined, and once it is inlined the compiler can constant-fold the arguments, drop bounds checks, hoist loads out of the loop, and vectorize. An indirect call is a wall. Nothing crosses it.

Which means the cost scales with two things: how small the callee is, and how many times you call it. A dyn Iterator in the inner loop of a numeric kernel is a genuine problem, because the body you failed to inline is a few instructions and the loop runs a very large number of times. A Box<dyn Policy> chain evaluated once per request sits under everything else the request does, and designing around it puts effort in the wrong place.

A practical rule: default to generics, and reach for dyn when you need a collection of mixed types, when you are crossing a plugin boundary, or when the instantiation count is getting silly and you would rather have the smaller binary. The dispatch cost is real but small, and it is rarely the reason something is slow. Do not contort a design to avoid a vtable.

Dyn compatibility, previously called object safety

Not every trait can be a trait object, and the reason is always the same. There has to be a single vtable that works for every implementor.

Add a method with a generic parameter to Policy and the compiler stops you:

trait Policy {
    fn name(&self) -> &'static str;
    fn allows(&self, req: &Request) -> bool;
    fn log<W: std::io::Write>(&self, out: &mut W);
}
error[E0038]: the trait `Policy` is not dyn compatible
  --> src/main.rs:41:32
   |
41 | fn enforce_all(policies: &[Box<dyn Policy>], req: &Request) -> bool {
   |                                ^^^^^^^^^^ `Policy` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:11:8
   |
 8 | trait Policy {
   |       ------ this trait is not dyn compatible...
...
11 |     fn log<W: std::io::Write>(&self, out: &mut W);
   |        ^^^ ...because method `log` has generic type parameters
   = help: consider moving `log` to another trait

The mechanism is the one from the first half of this section. log is monomorphized per W, so a vtable would need one slot per W anyone might ever use, and that set is unbounded and not known when the vtable is built.

The reference lists the rules, and each has the same shape of justification.

  • No Self: Sized, directly or from a supertrait. This one is subtle and the E0038 explanation spells it out. Rust guarantees that dyn Trait itself implements Trait, so that a &dyn Trait can be handed to a function taking T: Trait + ?Sized. But dyn Trait is unsized, so it can never implement a trait requiring Self: Sized. Rather than carve out an exception to that guarantee, Rust forbids the trait object.
  • No Self in a method except as the receiver. fn clone_me(&self) -> Self has no single answer through a trait object, which is why Clone cannot be used as dyn Clone.
  • No type or const parameters on a method. The one above. Lifetime parameters are fine, because they are erased and produce no extra vtable slots.
  • No associated constants, and no associated types that take generics.
  • Receivers must come from a fixed set: &self, &mut self, Box<Self>, Rc<Self>, Arc<Self>, or a Pin of one of those. A nested receiver like Rc<Box<Self>> is out.
  • No opaque return types, which is why an async fn in a trait and a method returning impl Trait both make the trait dyn incompatible. That is the root of the rough edge you will meet later with async traits.

The escape hatch is worth memorizing, because it turns most of these from a redesign into a one-line fix. Put where Self: Sized on the offending method and it becomes explicitly non-dispatchable: the trait is dyn compatible again, the method is unavailable on trait objects, and every concrete implementor keeps it.

trait Policy {
    fn name(&self) -> &'static str;
    fn allows(&self, req: &Request) -> bool;
    fn log<W: std::io::Write>(&self, out: &mut W) where Self: Sized;
}

C# has the same rule now, for the same reason. Static abstract interface members arrived in C# 11 to make generic math work, and they can only be reached through a type parameter. Call one on an interface-typed variable and you get:

error CS8926: A static virtual or abstract interface member can be accessed only on a type parameter.

No receiver means no entry in a dispatch table, in either language. The difference is which side of the line each language drew first. C# always had run-time dispatch and had to add a static-only escape. Rust always had static dispatch and had to add rules for what may become dynamic.

Underneath both sets of rules is one asymmetry. .NET's dispatch machinery can grow at run time, because the runtime is a compiler and can produce a new instantiation the first time it sees one. Rust's vtable is finished at link time. Every dyn compatibility rule follows from that.

impl Trait, in both positions

impl Trait is a way of not naming a type. It is not a third dispatch strategy sitting alongside generics and dyn.

In return position, fn checks() -> impl Iterator<Item = String> says the function returns exactly one concrete type, fixed at compile time, and the signature does not say which one. You get static dispatch and no allocation, and you keep the freedom to change the concrete type later without breaking callers.

The "exactly one" is enforced, and the diagnostic says so directly:

trait Encoder {}
impl Encoder for u8 {}
impl Encoder for u16 {}

fn pick(wide: bool) -> impl Encoder {
    if wide {
        return 0_u16;
    }
    0_u8
}
error[E0308]: mismatched types
 --> src/main.rs:9:5
  |
5 | fn pick(wide: bool) -> impl Encoder {
  |                        ------------ expected a single type implementing `Encoder` because of return type
6 |     if wide {
7 |         return 0_u16;
  |                ----- return type resolved to be `u16`
8 |     }
9 |     0_u8
  |     ^^^^ expected `u16`, found `u8`

Two return types means you wanted Box<dyn Encoder>, and now you know it for a structural reason rather than a stylistic one.

This is where the iterator story lands. In Rust, .iter().filter(f).map(g) has the concrete type Map<Filter<Iter<'_, T>, F>, G>, a real struct nested three deep, and every next is a direct call the optimizer can inline until the whole chain collapses into one loop. Nobody wants to write that type in a signature, so you write -> impl Iterator<Item = U> and the concrete type stays hidden and stays static.

C# has no equivalent. Returning IEnumerable<T> hands the caller an interface reference, and every MoveNext is an interface call the JIT may or may not devirtualize depending on what the profile saw. That is a deliberate trade in LINQ's design and it buys real things, including the ability to swap in a different pipeline at run time. It is also why impl Trait has no C# spelling: there is no way in C# to say "a concrete type I will not name."

Return-position impl Trait inside a trait desugars to an anonymous associated type, which is exactly why it makes the trait dyn incompatible. And Rust 2024 changed the default lifetime capture rules for impl Trait in return position, so if you are reading older code the edition guide chapter is the reference.

The decision table

Generic T, or impl Trait argument-> impl Traitdyn Trait
Type chosen bythe callerthe calleerun time
Dispatchdirect, inlinabledirect, inlinableindirect, through a vtable
Machine codeone copy per concrete typeone copy per concrete typeone copy total
Pointer widthone wordone wordtwo words
Heterogeneous collectionnonoyes
Allocation impliednonenoneusually, via Box
Works with generic methodsyesyesno, E0038
Nearest C#where T : IPolicy with a struct argumentnothinginterface reference, or where T : IPolicy with a class argument

Binary size, and the trick the standard library uses

Binary size is where monomorphization stops being an abstraction. The pattern that causes it is a wide, convenient generic API: every entry point taking impl AsRef<Path> or impl Into<String> for the caller's comfort, and every one of those stamping out a copy of a large function body per argument type.

The standard library solves this in a way worth copying. OpenOptions::open is generic and its body is one line:

pub fn open<P: AsRef<Path>>(&self, path: P) -> io::Result<File> {
    self._open(path.as_ref())
}

fn _open(&self, path: &Path) -> io::Result<File> {
    // the actual work, compiled exactly once
}

The generic shell exists only to convert, and it inlines to nothing. The body lives in a non-generic inner function compiled once, however many argument types callers use. If you have a generic function longer than a few lines and called with many types, this split is the first thing to try, and cargo llvm-lines will tell you which functions to try it on.

The compile-time story compounds the same way. Since a dependency's generic code is codegen'd in your crate, a graph full of generic-heavy libraries costs you build time in proportion to how many instantiations you reach, not how large the libraries are.

The .NET version of this bill exists, and you only see it when you take the JIT away. The Native AOT documentation says it plainly: generic parameters substituted with struct type arguments get specialized code per instantiation, the dynamic runtime generates many of those on demand, and Native AOT has to pre-generate all of them, which can have significant impact on the disk size of the application. That is Rust's trade-off arriving in .NET, for the same underlying reason. It is also why heavily generic, reflection-driven .NET code is the hardest kind to move to AOT: the constraints a Native AOT migration imposes and the constraints Rust imposes are close cousins.

Two dispatch habits worth unlearning

Reaching for Box<dyn Trait> because an interface always meant run-time polymorphism. This is the big one. In C# the interface is the dispatch mechanism, so the habit is to type every parameter as the interface. In Rust the default should be fn thing<P: Policy>(p: &P) or fn thing(p: impl Policy), and dyn should be a decision you made on purpose. Recognize it when every field on your struct is a Box<dyn Something> and none of them is ever a mixed collection.

Assuming a C# constraint gives you static dispatch. The mirror mistake, and the more expensive one. A method declared where TPolicy : IPolicy looks specialized, and with a class argument it is not: the shared canonical body has no way to know the type, so the call is a genuine interface dispatch. Make the type argument a struct and the picture changes completely. The C# compiler emits the call with the constrained. prefix, and the documented semantics are that when the type is a value type implementing the method, the pointer is passed unmodified to a direct call with no boxing.

Here is the same program in C#, and the only line that really matters is which of the three policies is a struct.

using System;
using System.Collections.Generic;

record Request(string Path, uint Tenant, int Bytes);

interface IPolicy
{
    string Name { get; }
    bool Allows(Request req);
}

sealed class MaxBody(int limit) : IPolicy
{
    public string Name => "max_body";
    public bool Allows(Request req) => req.Bytes <= limit;
}

sealed class BlockedPaths(params string[] paths) : IPolicy
{
    public string Name => "blocked_paths";
    public bool Allows(Request req) => Array.IndexOf(paths, req.Path) < 0;
}

readonly struct TenantQuota(uint tenant, int maxBytes) : IPolicy
{
    public string Name => "tenant_quota";
    public bool Allows(Request req) => req.Tenant != tenant || req.Bytes <= maxBytes;
}

static class Enforcer
{
    // Specialized only when TPolicy is a value type. With a class argument
    // this runs the shared __Canon body and Allows is a real interface call.
    public static bool Enforce<TPolicy>(TPolicy policy, Request req)
        where TPolicy : IPolicy
    {
        if (policy.Allows(req)) return true;
        Console.WriteLine($"rejected by {policy.Name}");
        return false;
    }

    public static bool EnforceAll(IReadOnlyList<IPolicy> policies, Request req)
    {
        var ok = true;
        foreach (var policy in policies)
        {
            if (policy.Allows(req)) continue;
            Console.WriteLine($"rejected by {policy.Name}");
            ok = false;
        }
        return ok;
    }

    public static void Main()
    {
        var req = new Request("/v1/scrape", 7, 4096);

        Console.WriteLine($"shared code:      {Enforce(new MaxBody(1024), req)}");
        Console.WriteLine($"specialized code: {Enforce(new TenantQuota(7, 2048), req)}");

        var chain = new List<IPolicy>
        {
            new MaxBody(8192),
            new BlockedPaths("/admin"),
            new TenantQuota(7, 2048),   // boxed on the way into the list
        };
        Console.WriteLine($"interface calls:  {EnforceAll(chain, req)}");
    }
}

That last comment is the C# equivalent of Box::new. Putting a struct into a List<IPolicy> boxes it, silently, and Rust makes you type the allocation out.

Writing Vec<dyn Policy> and being confused. Trait objects are unsized, so they cannot be stored by value. E0277 tells you the size cannot be known at compile time. You want Vec<Box<dyn Policy>>, or a slice of references.

Panicking at E0038 and redesigning the trait. Usually the fix is where Self: Sized on the one or two methods causing it, not a new hierarchy.

Making everything generic out of habit. In C# a spare type parameter is nearly free. In Rust it is a multiplier on codegen, it leaks into every signature downstream, and it makes the inference errors longer. If a function is only ever called with one type, take that type.

Expecting to find implementations at run time. A container scanning assemblies for IPolicy has no Rust translation, because there is no assembly to scan and the generic code that would have used those types was never emitted. You build the registry yourself, usually a Vec<Box<dyn Policy>> assembled in main, or you generate it with a macro.

What reflection gives C# that Rust cannot

Run-time type interrogation, first and most. if (policy is IDisposable d) is a normal thing to write in C#, and it works because the method table the object points at carries its full interface map. In Rust a &dyn Policy carries one vtable for one trait, and asking whether the value behind it also implements Serialize is not a question the language can answer. You get Any and downcast_ref, which recovers the one concrete type, and only for types that are 'static. If your design leans on interrogating objects, Rust will fight you the whole way.

Run-time instantiation, second. Constructing a closed generic type from a Type object, deserializing into a type named in a config file, building an expression tree and compiling it. All routine in .NET, all impossible in Rust by construction.

And profile-guided devirtualization is a real edge over anything Rust does with dyn. Rust's answer, use static dispatch on the hot path, is a good answer, but it is a design constraint where .NET gets an automatic optimization.

Where Rust wins is narrower and deeper. Nothing is boxed unless you boxed it, the inliner can see through every generic call, and the performance you get is the performance you can read off the source. That is a good trade for a service where latency tails matter. It is a bad trade for a plugin host.

Checking yourself

Why does #[no_mangle] do nothing on a generic function in Rust, and what does that tell you about typeof(T)? Because there is no single function to name, only one mangled symbol per instantiation. That same fact means Rust has no run-time T to hand you, while .NET keeps reified type arguments and can answer typeof(T) at run time.

where TPolicy : IPolicy in C# and P: Policy in Rust look identical. What happens differently at the call? In Rust the call is direct and inlinable in every instantiation. In C# it depends on the type argument: a value type gets specialized code and a direct call with no boxing, and a reference type runs the shared __Canon body where the call is a real interface dispatch, possibly devirtualized later by the JIT using profile data.

Your trait has one method with a generic parameter and you need a Vec of them. What is the smallest fix? Add where Self: Sized to that method. It becomes explicitly non-dispatchable, the trait is dyn compatible again, and every concrete implementor keeps the method.

When does the cost of dynamic dispatch actually show up in a profile? When the callee is small enough that the inlining you lost mattered more than the indirect call, and it runs often enough for that to accumulate. Inner loops of tight numeric or parsing code, not once-per-request policy checks.

Errors are values, and you have to look at them

Rust has no exceptions. A function that can fail says so in its return type:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

That is the whole mechanism. Result is an ordinary enum in the standard library, declared with the same enum keyword you would use for your own types. It has no compiler magic in it. You could write it yourself in four lines and it would work identically, right up until you wanted the ? operator, which is the one place the compiler knows the name.

fn read_config(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

The caller cannot ignore it. There is no String to use until they take it out of the Result, and doing that means dealing with the error case. Not by convention or code review. The type does not fit otherwise.

There is one softer guard on top. Result carries a #[must_use] attribute in the standard library, so throwing the whole thing away without binding it produces a warning rather than silence:

warning: unused `Result` that must be used
 --> src/main.rs:9:5
  |
9 |     read_config("app.toml");
  |     ^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
9 |     let _ = read_config("app.toml");
  |     +++++++

That wording comes straight from the annotation on the type, #[must_use = "this Resultmay be anErr variant, which should be handled"]. It is a warning, not an error, and unused_must_use is warn by default. Adding #![deny(unused_must_use)] at the crate root turns discarding a fallible call into a hard compile failure, which is closer to what people imagine Rust does out of the box. Worth doing, and worth knowing it is not the default.

What a C# signature does not tell you

Now the same function in C#:

string ReadConfig(string path)

Nothing in that signature tells you anything about failure. File.ReadAllText can throw FileNotFoundException, DirectoryNotFoundException, IOException, UnauthorizedAccessException, PathTooLongException, NotSupportedException, ArgumentException, ArgumentNullException, and System.Security.SecurityException. Some of those share a base class. Some do not. The list lives in the XML documentation, which the compiler does not check against the implementation, and it is transitively incomplete the moment ReadConfig calls anything else.

Checked exceptions were Java's attempt to fix exactly this, and C# deliberately declined to copy them. Anders Hejlsberg gave the reasoning in a 2003 interview, The Trouble with Checked Exceptions: developers escaped the requirement rather than meeting it, declaring throws Exception on everything or writing empty catch blocks they intended to fill in later and never did. He also raised a versioning problem, that adding a checked exception to an existing method breaks every caller.

Rust's version works better for two reasons that are worth separating. First, the error is a return value rather than a second control-flow channel, so it composes with everything else in the language instead of needing its own rules. Second, the propagation syntax is one character, so the ceremony that killed checked exceptions never accumulates.

The question mark, and what it actually desugars to

Writing match at every call site would be unbearable, so there is an operator for the common case:

fn load_settings(path: &str) -> Result<Settings, ConfigError> {
    let raw = std::fs::read_to_string(path)?;
    let parsed = serde_json::from_str(&raw)?;
    Ok(parsed)
}

Start with the simple model, because it is right most of the time: ? means if this is Ok, unwrap it and carry on, and if it is Err, return it from this function immediately. Early-return propagation, written once per fallible call.

That model is simplified in two ways, and both matter.

The first is the conversion. ? does not return the error unchanged. The Rust Reference spells it out: for a Result, Err(e) returns Err(From::from(e)). So std::io::Error becomes ConfigError on the way out, as long as you have said how. This is the piece that makes the whole design ergonomic instead of merely correct. Without it, every call site would need a map_err closure and the code would read worse than the exception version. With it, two unrelated error types flow into one enum and the call site stays a single character.

The second is what ? actually compiles to. Try is a lang item, and the operator expands to this:

match core::ops::Try::branch(expr) {
    core::ops::ControlFlow::Continue(val) => val,
    core::ops::ControlFlow::Break(residual) =>
        return core::ops::FromResidual::from_residual(residual),
}

That is the reference's own description, and it is the current shape of the Try v2 design. branch splits the value into "keep going with this" or "stop, here is the leftover". The leftover is called the residual, and FromResidual decides what the enclosing function should return given one. For Result, the FromResidual implementation is where the From::from call lives.

The reason this matters practically is that ? is not hardcoded to Result. It also works on Option, on ControlFlow, and on Poll<Result<T, E>> and Poll<Option<Result<T, E>>>, which is why you see it inside hand-written Future implementations. The Try trait itself is still unstable, gated behind try_trait_v2, so you cannot implement it for your own types on stable Rust as of 1.98. Its tracking issue has been open since 2021.

Get the conversion wrong and the compiler is unusually direct about it:

error[E0277]: `?` couldn't convert the error to `ConfigError`
  --> src/config.rs:12:44
   |
11 | fn load(path: &str) -> Result<ServerConfig, ConfigError> {
   |                        --------------------------------- expected `ConfigError` because of this
12 |     let raw = std::fs::read_to_string(path)?;
   |               -----------------------------^ the trait `From<std::io::Error>` is not implemented for `ConfigError`
   |               |
   |               this can't be annotated with `?` because it has type `Result<_, std::io::Error>`
   |
   = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

The note is the useful line. It tells you the fix is not at the call site, it is a missing From implementation on your error type. Once you have read that note twice you will never be confused by ? again.

A worked example, in both languages

Read a file, parse it, validate it. Three fallible steps, three different kinds of failure, one error type at the boundary. Here is the whole thing in Rust, standard library only, no dependencies. Paste it into src/main.rs and cargo run will work.

use std::error::Error;
use std::fmt;
use std::fs;
use std::num::ParseIntError;

#[derive(Debug)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    pub max_connections: u32,
}

#[derive(Debug)]
pub enum ConfigError {
    Io { path: String, source: std::io::Error },
    MalformedLine { line_no: usize, text: String },
    BadNumber { key: &'static str, source: ParseIntError },
    Missing { key: &'static str },
    OutOfRange { key: &'static str, value: u32, max: u32 },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io { path, .. } => write!(f, "could not read config file `{path}`"),
            ConfigError::MalformedLine { line_no, text } => {
                write!(f, "line {line_no} is not `key = value`: `{text}`")
            }
            ConfigError::BadNumber { key, .. } => write!(f, "`{key}` is not a number"),
            ConfigError::Missing { key } => write!(f, "required key `{key}` is missing"),
            ConfigError::OutOfRange { key, value, max } => {
                write!(f, "`{key}` is {value}, above the maximum of {max}")
            }
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::Io { source, .. } => Some(source),
            ConfigError::BadNumber { source, .. } => Some(source),
            _ => None,
        }
    }
}

fn read(path: &str) -> Result<String, ConfigError> {
    fs::read_to_string(path).map_err(|source| ConfigError::Io {
        path: path.to_string(),
        source,
    })
}

fn parse(raw: &str) -> Result<Vec<(String, String)>, ConfigError> {
    let mut pairs = Vec::new();
    for (i, line) in raw.lines().enumerate() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let (key, value) = line.split_once('=').ok_or_else(|| ConfigError::MalformedLine {
            line_no: i + 1,
            text: line.to_string(),
        })?;
        pairs.push((key.trim().to_string(), value.trim().to_string()));
    }
    Ok(pairs)
}

fn get<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
    pairs
        .iter()
        .find(|(k, _)| k.as_str() == key)
        .map(|(_, v)| v.as_str())
}

fn validate(pairs: &[(String, String)]) -> Result<ServerConfig, ConfigError> {
    let host = get(pairs, "host")
        .ok_or(ConfigError::Missing { key: "host" })?
        .to_string();

    let port: u16 = get(pairs, "port")
        .ok_or(ConfigError::Missing { key: "port" })?
        .parse()
        .map_err(|source| ConfigError::BadNumber { key: "port", source })?;

    let max_connections: u32 = match get(pairs, "max_connections") {
        Some(text) => text.parse().map_err(|source| ConfigError::BadNumber {
            key: "max_connections",
            source,
        })?,
        None => 128,
    };

    if max_connections > 10_000 {
        return Err(ConfigError::OutOfRange {
            key: "max_connections",
            value: max_connections,
            max: 10_000,
        });
    }

    Ok(ServerConfig { host, port, max_connections })
}

pub fn load(path: &str) -> Result<ServerConfig, ConfigError> {
    let raw = read(path)?;
    let pairs = parse(&raw)?;
    validate(&pairs)
}

fn main() {
    let sample = "# demo config\nhost = 10.0.0.4\nport = eighty\nmax_connections = 512\n";
    fs::write("server.conf", sample).expect("writing the sample file into cwd should succeed");

    match load("server.conf") {
        Ok(config) => println!("loaded: {config:?}"),
        Err(e) => {
            eprintln!("error: {e}");
            let mut cause = e.source();
            while let Some(inner) = cause {
                eprintln!("  caused by: {inner}");
                cause = inner.source();
            }
        }
    }
}

With port = eighty that prints:

error: `port` is not a number
  caused by: invalid digit found in string

Change it to port = 8080 and you get the success line instead:

loaded: ServerConfig { host: "10.0.0.4", port: 8080, max_connections: 512 }

The second line of the failure output comes from ParseIntError, reached through source(). That chain is the Rust equivalent of InnerException, and it is the reason it is worth storing the underlying error in your variant rather than flattening it into a string. Try port = 80800 and the cause changes to number too large to fit in target type, because u16 tops out at 65535 and ParseIntError distinguishes an invalid digit from an overflow.

The C# version, written the way a careful engineer actually writes it:

using System;
using System.Collections.Generic;
using System.IO;

public sealed record ServerConfig(string Host, ushort Port, uint MaxConnections);

public class ConfigException : Exception
{
    public ConfigException(string message, Exception? inner = null) : base(message, inner) { }
}

public sealed class ConfigIoException : ConfigException
{
    public string Path { get; }
    public ConfigIoException(string path, Exception inner)
        : base($"could not read config file `{path}`", inner) => Path = path;
}

public sealed class MalformedLineException : ConfigException
{
    public int LineNumber { get; }
    public MalformedLineException(int lineNumber, string text)
        : base($"line {lineNumber} is not `key = value`: `{text}`") => LineNumber = lineNumber;
}

public sealed class BadNumberException : ConfigException
{
    public string Key { get; }
    public BadNumberException(string key, string text)
        : base($"`{key}` is not a number", new FormatException($"`{text}` is not a valid integer"))
        => Key = key;
}

public sealed class MissingKeyException : ConfigException
{
    public string Key { get; }
    public MissingKeyException(string key)
        : base($"required key `{key}` is missing") => Key = key;
}

public sealed class ValueOutOfRangeException : ConfigException
{
    public ValueOutOfRangeException(string key, uint value, uint max)
        : base($"`{key}` is {value}, above the maximum of {max}") { }
}

public static class ConfigLoader
{
    public static ServerConfig Load(string path)
    {
        string raw = Read(path);
        var pairs = Parse(raw);
        return Validate(pairs);
    }

    private static string Read(string path)
    {
        try
        {
            return File.ReadAllText(path);
        }
        catch (IOException ex)
        {
            throw new ConfigIoException(path, ex);
        }
        catch (UnauthorizedAccessException ex)
        {
            throw new ConfigIoException(path, ex);
        }
    }

    private static List<KeyValuePair<string, string>> Parse(string raw)
    {
        var pairs = new List<KeyValuePair<string, string>>();
        string[] lines = raw.Split('\n');
        for (int i = 0; i < lines.Length; i++)
        {
            string line = lines[i].Trim();
            if (line.Length == 0 || line.StartsWith('#')) continue;

            int sep = line.IndexOf('=');
            if (sep < 0) throw new MalformedLineException(i + 1, line);

            pairs.Add(new KeyValuePair<string, string>(
                line[..sep].Trim(), line[(sep + 1)..].Trim()));
        }
        return pairs;
    }

    private static ServerConfig Validate(List<KeyValuePair<string, string>> pairs)
    {
        string? Get(string key)
        {
            foreach (var p in pairs)
                if (p.Key == key) return p.Value;
            return null;
        }

        string host = Get("host") ?? throw new MissingKeyException("host");

        string portText = Get("port") ?? throw new MissingKeyException("port");
        if (!ushort.TryParse(portText, out ushort port))
            throw new BadNumberException("port", portText);

        uint maxConnections = 128;
        string? maxText = Get("max_connections");
        if (maxText is not null && !uint.TryParse(maxText, out maxConnections))
            throw new BadNumberException("max_connections", maxText);

        if (maxConnections > 10_000)
            throw new ValueOutOfRangeException("max_connections", maxConnections, 10_000);

        return new ServerConfig(host, port, maxConnections);
    }
}

public static class Program
{
    public static void Main()
    {
        try
        {
            Console.WriteLine(ConfigLoader.Load("server.conf"));
        }
        catch (ConfigException ex)
        {
            Console.Error.WriteLine($"error: {ex.Message}");
            for (Exception? inner = ex.InnerException; inner is not null; inner = inner.InnerException)
                Console.Error.WriteLine($"  caused by: {inner.Message}");
        }
    }
}

Given the same port = eighty input, that prints the same two lines the Rust version does, InnerException standing in for source():

error: `port` is not a number
  caused by: `eighty` is not a valid integer

This is good C#. It is roughly the same length, it is arguably more readable in the Validate method because ?? and throw expressions are terse, and the exception hierarchy carries structured data the same way the Rust variants do.

Three differences are real, and none of them is about verbosity.

The signature. Result<ServerConfig, ConfigError> names both outcomes. ServerConfig Load(string path) names one. A caller reading the C# has to open the implementation, or trust a comment, to learn that ConfigException is the family it should catch.

The set is closed in one and open in the other. In Rust, ConfigError has five variants and that is all there will ever be until someone edits the enum. Matching on it exhaustively is checked. In C#, Load can also throw ArgumentException from File.ReadAllText when the path is empty. On .NET 9 that is System.ArgumentException with the message about the value not being an empty string, and it does not derive from IOException, so neither catch in Read sees it and it escapes the catch (ConfigException) at the caller. An empty string is exactly what an unset environment variable produces when it is read carelessly, so this is the ordinary path rather than an exotic one.

Adding a case is a compile error in one and silence in the other. Add a DuplicateKey variant to ConfigError and every downstream match that was exhaustive stops compiling:

error[E0004]: non-exhaustive patterns: `ConfigError::DuplicateKey { .. }` not covered
  --> src/main.rs:14:11
   |
14 |     match err {
   |           ^^^ pattern `ConfigError::DuplicateKey { .. }` not covered
   |
note: `ConfigError` defined here
  --> src/config.rs:11:10
   |
11 | pub enum ConfigError {
   |          ^^^^^^^^^^^
...
17 |     DuplicateKey { key: String },
   |     ------------ not covered
   = note: the matched value is of type `ConfigError`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown

Add a DuplicateKeyException subclass and nothing breaks, nothing warns, and the new exception quietly falls through to whatever generic handler is furthest up the stack. Which behavior you prefer depends on whether you are the library author or the person on call.

That last point cuts both ways for library design. If your enum is public, adding a variant is a breaking change, because you just broke every exhaustive match in your users' code. The escape hatch is #[non_exhaustive] on the enum, which forces external matches to include a wildcard arm and makes future variants additive. Put it on public error enums by default. Leave it off internal ones, where the compile error is exactly the reminder you want.

thiserror in a library, anyhow in a binary

The hand-written Display and Error implementations above are mechanical. Every arm restates a field list the compiler already knows about, and the two impl blocks have to be edited in lockstep every time a variant changes. Almost nobody writes them by hand. thiserror is a derive macro that generates them from attributes:

use thiserror::Error;

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
    #[error("could not read config file `{path}`")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("line {line_no} is not `key = value`: `{text}`")]
    MalformedLine { line_no: usize, text: String },

    #[error("`{key}` is not a number")]
    BadNumber {
        key: &'static str,
        #[source]
        source: std::num::ParseIntError,
    },

    #[error("required key `{key}` is missing")]
    Missing { key: &'static str },

    #[error("`{key}` is {value}, above the maximum of {max}")]
    OutOfRange { key: &'static str, value: u32, max: u32 },
}

Same type, same behavior. The two hand-written impl blocks, twenty-five lines in the version above, are gone; the enum pays one attribute line per variant to get them back. The line count barely moves in an example this small. What changes is that the message and the source chain now live next to the variant they describe, so adding a variant is one edit instead of three, and a variant can no longer drift out of sync with a match arm you forgot to update. The #[source] attribute wires up the source() chain, and a field simply named source is picked up even without it. A #[from] attribute instead would also generate the From implementation that ? needs, which is how you get automatic conversion for variants that wrap exactly one foreign error. The crate's own documentation makes the important promise: it does not appear in your public API, so switching to or from hand-written implementations is not a breaking change.

anyhow is the other half of the pair, by the same author, and it solves the opposite problem. In a binary you usually do not want to match on error variants. You want a single type that any error can convert into, and you want to attach human context on the way up:

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let path = std::env::args().nth(1).context("usage: loader <config path>")?;
    let raw = std::fs::read_to_string(&path)
        .with_context(|| format!("reading config from {path}"))?;
    let config: ServerConfig = toml::from_str(&raw)
        .with_context(|| format!("parsing {path}"))?;
    println!("{config:?}");
    Ok(())
}

anyhow::Error is a boxed trait object, so anything implementing std::error::Error converts into it for free. That is what makes ? work across four unrelated error types in one function without a single From implementation. context and with_context add a layer to the chain, and the default report prints the whole chain. Use with_context when building the message costs something, because the closure only runs on the error path.

The split is not a matter of taste. anyhow's own README draws it: use anyhow when you do not care what error type your functions return and just want it to be easy, which is common in application code, and use thiserror when you are a library designing a dedicated error type so the caller gets exactly the information you chose.

LibraryBinary
Error typeyour own enumanyhow::Error
Cratethiserroranyhow
Whycallers need to match on the reasonnobody matches, they log and exit
Public APIyes, mark #[non_exhaustive]not applicable

The failure mode is using anyhow in a library. The caller then cannot distinguish "file missing" from "invalid TOML" without string matching, which is the same trap as catching Exception and reading ex.Message.

fn main() -> Result<(), E> works because of the Termination trait: an Err return prints the error with its Debug formatting and exits with a failure code. Convenient, and slightly surprising the first time, since it is Debug and not Display.

Panic is not an exception

panic! exists, and it is for bugs. Index out of bounds panics. Integer overflow panics in debug builds. unwrap() on a None panics.

The Rustonomicon states the intended hierarchy plainly: Option when something might reasonably be absent, Result when something can go wrong and be handled, a thread panic when it cannot reasonably be handled, and an abort when something catastrophic happens. Four tiers, and the unwinding chapter is where they are written down.

The distinction the language is drawing is about invariants. Result is for things that can go wrong in the world: the file is missing, the JSON is malformed, the connection dropped. panic! is for things that should be impossible: a sorted array that is not sorted, a state machine in a state its author never wrote, a length that came back negative. In the first case the world surprised you. In the second, you were wrong about your own program, and continuing means computing on a false premise.

C# does not draw that line in the type system, which is why defensive try/catch is a common habit there and why catch (Exception) in a request handler feels reasonable. In Rust, catching a panic is not defensive. It is usually wrong, because the panic means something you believed is false, and the code that would handle it was written by the same person who was wrong.

Unwind versus abort

The default panic strategy unwinds: it walks back up the stack running every destructor on the way, then terminates the thread. You can catch that with catch_unwind, which is the closest thing to a catch block, and the standard library makes it deliberately awkward through the UnwindSafe bound so that you have to think about whether the data you are recovering is in a consistent state.

The alternative is abort. Set it per profile in Cargo.toml:

[profile.release]
panic = "abort"

That maps to the -C panic codegen flag and is documented in the Cargo book. Aborting terminates the process immediately, runs no destructors, and lets the compiler drop all the landing pad code it would otherwise emit, which makes binaries smaller. Some targets have no choice: the docs note that NVPTX does not support unwinding and always aborts. Tests, benchmarks, build scripts and proc macros ignore the setting, because the test harness currently requires unwinding to report a failing test without taking the runner down with it.

Three details that catch people out. A panic inside a destructor that is already running during an unwind aborts the process, because there is no sensible way to unwind two things at once. Since Rust 1.81, an uncaught panic escaping an extern "C" function aborts rather than being undefined behavior, closing a long-standing soundness hole; if you genuinely need to unwind across an FFI boundary, the "C-unwind" ABI is the supported way. And panic = "abort" changes observable behavior in library code that relied on destructors running, so it is a decision for the top-level binary, not something a library should assume either way.

unwrap() and expect("message") extract the value and panic on failure. They get a bad reputation they only partly deserve. They are correct in tests, in prototypes, in main where there is nowhere left to propagate, in const contexts, and anywhere you can genuinely prove the case cannot happen. expect with a sentence explaining why it cannot happen is strictly better than unwrap, and the sentence should state the invariant, not restate the operation. expect("regex is a compile-time literal and was tested") earns its place. expect("unwrap failed") does not, and reviewers should say so.

The cost, and where it actually lands

The performance story here is real, and it is smaller and more specific than the enthusiasm suggests.

On the .NET side, throwing has genuinely been expensive. Microsoft's Exceptions and Performance guidance, reprinted from the 2008 Framework Design Guidelines, says a throwing member can be orders of magnitude slower, and it is where the Try-Parse pattern is written down. int.TryParse is Result with worse ergonomics and an out parameter.

Read that page carefully though, because it is not the blanket "avoid exceptions" people quote it as. It tells you not to use error codes over performance worries, and then offers Tester-Doer and Try-Parse as the two sanctioned ways to keep the fast path exception-free. The design position is that exceptions are the correct default and you buy your way out of them at specific hot spots. Rust starts from the other end.

.NET 9 made a substantial dent in it. Stephen Toub's Performance Improvements in .NET 9 describes two changes: the exception handling implementation ported over from Native AOT was switched on by default, and a global spinlock was removed from the exception path. His benchmark runs a thousand iterations, each awaiting a recursion ten levels deep that yields and then throws, catching at the top. It moves from 123.03 ms on .NET 8 to 54.68 ms on .NET 9, a reported ratio of 0.44.

Divide by the thousand iterations and that is about 123 microseconds per iteration before and 55 after. Do not read those as the cost of a throw. Each iteration also pays for eleven async frames and a Task.Yield, so the figure bundles the async machinery in with the exception, and a shallow synchronous throw is cheaper than this by a wide margin. The ratio is the durable part: a bit over twice as fast, and still expensive enough that you would not put it in a loop.

The spinlock detail is the one worth carrying. Toub calls the failure mode an exception storm: one failure causes another, CPU spikes on the overhead, other work starves and times out and throws in turn, and the thing feeds itself. A serialized structure on the exception path is what turns a rate of failures into a throughput collapse. That is a different shape of problem from "throwing is slow", and it is the shape that takes a service down.

Rust's Result has no such machinery. The error path is a returned value and a branch, so it costs about what the success path costs, and it scales with cores the way any other return does.

Rust's panic path, on the other hand, is not cheap. The Rustonomicon is explicit that the implementation is optimized for the case where you do not unwind, so being ready to unwind costs nothing, and actually unwinding is more expensive than in a language like Java. Panicking in a loop is a worse idea in Rust than throwing in a loop is in C#. It is just that nobody does it, because the type system pushed all the expected failures into Result.

There is a cost on the Rust side that people miss, and it is a layout cost rather than a control-flow one. Result<T, E> is a value returned by value, and its size is roughly the larger of T and E plus a tag. If your error enum grows a variant holding a String plus a PathBuf plus a backtrace, every fallible function in the crate now returns something wide, and that gets copied on every return whether it succeeded or not. Clippy has a lint for exactly this, result_large_err, with a default threshold of 128 bytes. The fix is to box the error, Result<T, Box<ConfigError>>, or to box the fat variant's payload. In a hot parser this is measurable in a way that the error handling strategy itself never is.

Where C# genuinely wins

Two places, and they are not small.

A single boundary can catch everything, including bugs. ASP.NET Core middleware turns any exception, from any depth, in any library you did not write, into a 500 and a log line, and no function between the fault and the handler needs to mention it. Rust has no equivalent unless you either thread an error type through every layer or box everything into anyhow early. A panic deep in a third-party crate will take out your worker thread; the workaround is catch_unwind at the task boundary, which the async runtimes do for you but which is a deliberate act rather than a default.

Refactoring is cheaper. Adding a new failure mode three layers down in C# costs nothing at the intermediate layers. In Rust it costs an enum variant, a From implementation, and possibly a signature change in every function between. That is the price of the guarantee, and on a large codebase under time pressure it is a price you feel.

Exception habits that do not survive the port

Reaching for catch_unwind as a try/catch. It is not one. It does not catch Result errors, because those are not thrown. It is for FFI boundaries, thread supervisors, and test harnesses. If you find yourself wanting it in business logic, the thing you actually want is a Result.

Making one giant error enum for the whole crate. The C# instinct is a single exception base class per assembly. In Rust that produces an enum with thirty variants where every function returns all thirty, and callers cannot tell which four are actually possible. One error enum per module, converted upward with From, is the pattern that scales.

Stringly typed errors. Result<T, String> looks fine for a week. Then you need to distinguish retryable from fatal at a call site and you are matching on message text. Use an enum from the start, even a two-variant one.

Treating ? as a throw. It is a return. That means it runs no destructors beyond the normal scope exit, it cannot be intercepted between here and the caller, and if you are holding a lock guard when it fires, the guard drops on the way out exactly as it would at the end of the function. Usually that is what you want. Occasionally it is not, and the difference is invisible if you are picturing an exception.

Logging and returning. In C#, logging then rethrowing is a bad habit that produces duplicate entries. In Rust it is the same bad habit with a different syntax. Attach context with anyhow, or add a variant, and log once at the boundary.

The null that is not there

Option<T> is the other enum you will use constantly, and it is built from the same parts:

enum Option<T> {
    Some(T),
    None,
}

There is no null in safe Rust. A value that might be absent has a type that says so, and the compiler will not let you use it as though it were present.

fn find_server(id: u32) -> Option<Server> { /* ... */ }

match find_server(7) {
    Some(server) => println!("{}", server.hostname),
    None => println!("not found"),
}

The honest comparison with nullable reference types

C# 8 added nullable reference types, and it is a genuine improvement. It is also a different kind of thing, and the difference is worth being precise about because the marketing blurs it.

Nullable annotations are static analysis over a runtime that still has null. Microsoft's own documentation says it directly: the annotation does not change the runtime type, and string and string? are both System.String. The CLR does not check anything. The annotations live in metadata so that the compiler can read them across assemblies, which is why the feature works at all across NuGet packages, but nothing enforces them when the code runs.

The docs name two holes themselves, under "Known pitfalls", and call them limitations of the static analysis rather than bugs in your code:

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static class Pitfalls
{
    public static void Show()
    {
        Student s = default;              // no warning, FirstName and LastName are null
        string[] values = new string[3];  // no warning, every element is null

        Console.WriteLine(s.FirstName is null);   // True
        Console.WriteLine(values[0] is null);     // True
    }
}

That builds with zero warnings on .NET 9 with nullable fully enabled, and prints True twice. Two fields and three array slots, all typed non-nullable, all holding null, and the compiler had nothing to say. There is no equivalent in Rust, because there is no way to conjure a String out of zeroes: vec![String::new(); 3] needs a real value to clone, and a struct with a String field cannot be produced without one.

Three more differences that matter day to day. Warnings are warnings, so a codebase can ship with hundreds of them. The null-forgiving ! operator silences any of them with one character and no justification required. And the analysis does not trace into method bodies, so a helper that checks for null teaches the compiler nothing unless you annotate it with [NotNullWhen] or a sibling from the nullable analysis attributes.

In Rust none of that applies, because Option<T> is a type rather than an annotation. match exhaustiveness is a compile error, not a warning. There is no !. There is no unannotated third-party crate whose optionality is unknown.

Where C# is genuinely ahead: nullable reference types were retrofitted onto twenty years of existing code, incrementally, file by file, with a per-project or per-file switch. Rust could never have done that, and if it had needed to, Option would not exist. Nullable<T> for value types has been sound since C# 2.0 and needs no defense at all. And for deep member access, a?.b?.c ?? d really is nicer than the equivalent chain of and_then calls. Language-level syntax beats combinators when the shape is common enough to deserve syntax.

Working with it without drowning in match

if let handles the one-case version:

if let Some(server) = find_server(7) {
    println!("{}", server.hostname);
}

let else handles the early-return version, which is the shape most guard clauses want:

let Some(server) = find_server(7) else {
    return Err(ConfigError::Missing { key: "server" });
};
// server is a plain Server from here on

That form is newer than the rest and worth dating: let else stabilized in Rust 1.65, November 2022. Before it you wrote a match with a diverging arm and an outer let, which is why older code looks clumsier than it needed to be. The else block must diverge, meaning it has to return, break, continue, or panic; it cannot fall through to a value.

Let chains are newer still. In Rust 1.88, June 2025, you can chain let bindings with && inside an if or while condition, and use earlier bindings later in the chain:

if let Some(server) = find_server(7)
    && let Some(tls) = server.tls_config()
    && tls.is_valid()
{
    // ...
}

That is edition-gated to Rust 2024. On a 2021 crate it will not compile, which is the sort of version dependence worth knowing before you copy an example off a blog and wonder why it fails.

And there is a combinator for almost anything you would otherwise write by hand:

let port = find_server(7)
    .map(|s| s.port)             // Option<Server> to Option<u16>
    .unwrap_or(5432);            // default if None

let name = find_server(7)
    .filter(|s| s.is_healthy())  // Some only if the predicate holds
    .map(|s| s.hostname)
    .unwrap_or_else(|| expensive_default());   // closure runs only on None

let region = find_server(7)
    .and_then(|s| s.region)      // map would give Option<Option<Region>>; this does not
    .unwrap_or_default();        // Default::default() on None

The mapping to LINQ is close enough to be useful: map is Select, and_then is SelectMany, filter is Where, unwrap_or is FirstOrDefault with a supplied default. The difference is that these are ordinary inherent methods on an ordinary enum rather than extension methods over IEnumerable, so there is no sequence and no deferred execution underneath, and Result carries the same set. Prefer unwrap_or_else over unwrap_or when the default costs anything, because unwrap_or evaluates its argument eagerly. Clippy has a lint for the case where it matters, or_fun_call, which fires when you pass a function call to unwrap_or instead of deferring it.

Converting between Option and Result

The two types are the same shape with different meaning, so the conversions are cheap and you will use them constantly.

let server = find_server(7)
    .ok_or(ConfigError::Missing { key: "server" })?;   // Option to Result, then propagate

let server = find_server(7)
    .ok_or_else(|| ConfigError::Missing { key: "server" })?;  // lazy variant

let maybe: Option<Settings> = load_settings("app.toml").ok();   // Result to Option, discard why

let flipped: Result<Option<u16>, ParseIntError> =
    maybe_port_text.map(|t| t.parse::<u16>()).transpose();      // Option<Result> to Result<Option>

transpose is the one people rediscover late. It turns Option<Result<T, E>> into Result<Option<T>, E>, which is exactly what you want for an optional field that has to parse correctly when present. Without it you end up nesting a match two deep for something that should be one line.

? works on Option too, but only inside a function that itself returns Option. Use it in a Result-returning function and the compiler tells you precisely what to do:

error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s, in a function that returns `Result`
  --> src/config.rs:31:41
   |
28 | fn parse_port(raw: &str) -> Result<u16, ConfigError> {
   | -------------------------------------------------- this function returns a `Result`
...
31 |     let (_, value) = raw.split_once('=')?;
   |                                         ^ use `.ok_or(...)?` to provide an error compatible with `Result<u16, ConfigError>`

The label is the fix. This is not the generic trait error dressed up: the wording is a hand-written special case in core, attached to FromResidual with rustc_on_unimplemented, with its own message and its own suggested label for each direction of the mistake. Somebody decided this confusion was worth its own paragraph. The underlying error is the same one a C# habit produces: you reached for the propagation operator on a lookup that has no reason attached, and the type system is asking you to supply the reason.

It costs nothing, and that is mostly not a figure of speech

The obvious worry is that wrapping every optional value in an enum adds a tag byte and a branch.

The simplified model is that Option<T> is a tag plus a payload, sized to hold the larger case and aligned. That model is correct for Option<i32>: i32 has alignment 4 and every 32-bit pattern is a legitimate integer, so the tag needs its own space and alignment rounds the total to 8 bytes. Twice the size for the same number. That is the honest answer for plain integers.

For pointer-like types the model is wrong, and deliberately so. The compiler uses an invalid bit pattern inside the payload as the None marker. A Box, a &T, a NonNull and a function pointer can never be null, so null is free to mean None. This is the null pointer optimization, and the exact guarantee is written down in the standard library. Rust guarantees that Option<T> has the same size, alignment and function-call ABI as T for this list:

  • Box<U> where U: Sized, specifically only Box<U, Global>
  • &U and &mut U where U: Sized
  • fn and extern "C" fn
  • num::NonZero types
  • ptr::NonNull<U> where U: Sized
  • a #[repr(transparent)] struct wrapping any of the above

For those, transmuting a T into Option<T> is defined behavior, and for the cases the docs mark, an all-zero byte pattern is None. That is what makes Option<Box<T>> usable directly as a nullable pointer across an FFI boundary with no wrapper.

You can watch it yourself:

use std::mem::size_of;

fn main() {
    println!("i32                {}", size_of::<i32>());
    println!("Option<i32>        {}", size_of::<Option<i32>>());
    println!("Box<i32>           {}", size_of::<Box<i32>>());
    println!("Option<Box<i32>>   {}", size_of::<Option<Box<i32>>>());
    println!("&i32               {}", size_of::<&i32>());
    println!("Option<&i32>       {}", size_of::<Option<&i32>>());
    println!("String             {}", size_of::<String>());
    println!("Option<String>     {}", size_of::<Option<String>>());
}

On a 64-bit target the pointer-shaped rows come out equal on both sides, and only Option<i32> is larger than the thing it wraps. String is three words, a pointer plus a length plus a capacity, and its pointer is non-null, so Option<String> is also three words. Amos has a byte-level walkthrough with the disassembly if you want to see the generated code rather than the sizes.

The branch is the other half of the worry, and it is the same branch you were already writing. Option<Box<T>> compiles to a null check. You wrote that null check by hand in C#, or the JIT inserted it for you and then usually elided it. The difference is not the instruction. It is that the compiler knows the check happened.

Option or Result

Use Option when absence is normal and carries no explanation. A cache lookup missed. A list was empty. A configuration key was not set and has a default. Nobody needs to know why, because there is no why.

Use Result when absence is a failure with a reason worth propagating. The file did not open, and the caller should know whether that was permissions or a missing path.

A workable test: if the caller might reasonably want to log or branch on the reason, it is a Result. If the only sensible response to None is a default or a different code path, it is an Option.

Reaching for Option where the caller will immediately want to know what went wrong is the most common design mistake here. ok_or exists precisely because people notice it one layer too late, and it is a cheap fix at the boundary. The reverse mistake, a Result whose error type carries no information anyone will read, is rarer but worse. Result<T, ()> is an Option with extra typing. Clippy's result_unit_err fires on public functions that return it and tells you to define a real error type.

Twenty modules in, the shape you converge on

On a service with twenty modules the shape you converge on is layered rather than uniform. Leaf modules define narrow enums with three or four variants each. Mid-layer modules define their own enums whose variants wrap the leaf errors, with #[from] doing the conversion so that ? keeps working unchanged. The HTTP boundary maps the top-level enum to status codes in one match, which is exhaustive, which means adding a failure mode anywhere below forces someone to decide what status code it gets. That last property is the whole argument. In the C# equivalent, a new exception type three layers down maps to 500 by default and nobody finds out until the dashboard does.

The place it gets expensive is the mid-layer wrapping. Ten modules deep, you are writing conversions that only exist to move a value from one enum to another. Boxing into anyhow earlier removes the boilerplate and removes the exhaustive match at the top with it. That is a real trade and reasonable people land in different places. The defensible default: keep typed errors wherever a caller branches on the reason, and box everywhere else.

On the Option side, the scale effect is quieter and mostly about deserialization. Once every optional field in a config struct is an Option<T>, you get a struct where half the fields are optional and the code downstream is a forest of unwrap_or. The fix is the same one you would apply in C#: parse into a permissive shape once, validate into a strict shape with no optionals, and let everything after that point be total. Rust just makes the boundary between the two shapes visible in the type, which means the day someone adds a field they have to say which side of the boundary it lives on.

Checking yourself

Why does ? need a From implementation, and what does it call it on? ? on an Err returns Err(From::from(e)), converting the inner error type into the function's declared error type. Without a From implementation from the source error to your error, the conversion has nothing to call, and you get E0277, reported as the ? operator failing to convert the error to your declared type.

A function returns Option<Config> and callers keep asking why it was None. What changed, and what is the fix? Absence stopped being uninteresting and became a failure with a reason. Change the return type to Result<Config, E> with an enum naming the reasons, or convert at the boundary with ok_or if the inner function genuinely has no more information.

Your crate compiles with panic = "abort" in the release profile. Name two things that behave differently. Destructors no longer run during a panic, so anything relying on Drop for cleanup or flushing on the failure path silently stops happening, and catch_unwind can no longer recover. Binaries also get smaller because the landing pad code is not emitted.

Is Option<u64> the same size as u64, and why does the answer differ for Option<Box<u64>>? No. Every 64-bit pattern is a valid u64, so the discriminant needs its own space and alignment pushes Option<u64> to 16 bytes. Box can never be null, so the compiler uses the all-zero pointer as None, and the standard library guarantees Option<Box<U>> has the same size, alignment and ABI as Box<U>.

Interior mutability, or breaking the rules on purpose

Part III, Concurrency and systems.

One owner and one writer is a clean model, and some real designs do not fit it. A graph where nodes reference each other. An observer list where several places hold the same subject. A cache shared by every request handler. A parse tree where a child needs to ask its parent a question.

C# does all of these without a thought, because every reference is shared and every object is mutable. Rust has to be asked.

The asking is the part worth understanding. Rust does not have a back door bolted on for awkward cases. It has a single, tiny, compiler-known primitive, and every safe abstraction that lets you mutate through a shared reference is built on top of it. The Rust Reference is blunt about this: UnsafeCell<T> is the only allowed way to mutate through a shared reference, and everything else in std::cell is a wrapper around it that adds a rule the wrapper can enforce itself.

Picking one of those wrappers is picking which rule you want enforced, and where the enforcement happens.

What the compiler gives up

&T in Rust carries an address and a promise. The promise is that for the life of that reference, the pointed-at value does not change. LLVM gets told this, roughly as noalias and readonly, and it uses it: it can hoist a load out of a loop, keep a field in a register across a call, and skip a reload after a store through some unrelated pointer.

UnsafeCell<T> is the switch that turns that assertion off for one field. It is a lang item, so the compiler knows about it specifically, and the moment a UnsafeCell is in the type, the "this does not change" claim is withdrawn for the memory it covers.

That is the real cost of interior mutability, and it is easy to miss because it never shows up as a line item. You pay a borrow flag or a counter, yes, but you also pay in optimizations that quietly stop happening. In practice this is small. In a hot loop over a RefCell it is not always small.

The design goal is worth naming. The C++ answer to shared mutable state is "be careful". The C# answer is "the runtime will keep it alive and you should probably take a lock". Rust's answer is to make the escape hatch a type, so that a reader can see it in the signature, the optimizer can see it in the layout, and there is exactly one place to look when something goes wrong.

Shared ownership, and what the count actually is

Rc<T> is a reference-counted pointer. Cloning it bumps a counter instead of copying the data, and the value is dropped when the last handle goes away.

use std::rc::Rc;

let config = Rc::new(Config::load());
let a = Rc::clone(&config);   // count is 2
let b = Rc::clone(&config);   // count is 3
// value dropped when the last of config, a and b goes out of scope

This is the closest thing Rust has to what a garbage collector gives you, done eagerly and locally. Rc::clone(&x) and x.clone() do the same thing; the community convention is the explicit form, so that a reader scanning for expensive clones can tell at a glance that this one is not.

The simple model is "there is a counter". The real thing is two counters, and the difference matters later in this chapter. A new allocation in the current standard library looks like this:

struct RcInner<T: ?Sized> {
    strong: Cell<usize>,
    weak: Cell<usize>,
    value: T,
}

strong counts the Rc handles. When it hits zero, T is dropped, immediately, on whichever line released the last handle. weak counts the Weak handles plus one, because all the strong handles collectively hold a single weak reference between them; when weak hits zero the allocation itself is freed. That is why Rc::weak_count returns the internal weak count minus one, and it is why a Weak can safely check whether the value is still there without the block having been freed underneath it.

Both counters are Cell<usize>, not atomics. That is what makes Rc cheap: an increment and a decrement with no bus lock, no memory fence. It is also what makes Rc single-threaded only, enforced at compile time, which is the subject of the next chapter.

The Rc handle you hold is one pointer wide. The overhead is in the allocation: two usize of header on a 64-bit target, so 16 bytes per shared value regardless of how big the value is. For a shared config loaded once, irrelevant. For 100,000 tree nodes, 16 bytes each is 1.6 MB of pure bookkeeping before you have stored anything.

Now the part that surprises people. Rc<T> gives you shared ownership, and shared ownership only ever gives you shared access:

use std::rc::Rc;

let shared = Rc::new(vec![1, 2, 3]);
let _other = Rc::clone(&shared);
shared.push(4);
error[E0596]: cannot borrow data in an `Rc` as mutable
  = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Rc<Vec<i32>>`

Rc implements Deref and deliberately does not implement DerefMut, because it cannot know whether some other handle is reading right now. Every C# instinct you have says this should work. It does not, and the error above is the language telling you that shared ownership and mutation are two separate permissions here, and it only handed you one.

Cell, when you only ever swap the value

Cell<T> is the version with no borrowing at all. Through a &Cell<T> you can never obtain a &T to the value inside. You move values in and out, which means there is no borrow to conflict with anything, which means there is nothing to panic about.

use std::cell::Cell;

struct Metrics {
    hits: Cell<u64>,
    misses: Cell<u64>,
}

impl Metrics {
    fn record(&self, hit: bool) {          // note: &self, not &mut self
        if hit {
            self.hits.set(self.hits.get() + 1);
        } else {
            self.misses.set(self.misses.get() + 1);
        }
    }

    fn hit_rate(&self) -> f64 {
        let h = self.hits.get() as f64;
        let m = self.misses.get() as f64;
        if h + m == 0.0 { 0.0 } else { h / (h + m) }
    }
}

The C# version of this is unremarkable, which is exactly the point:

sealed class Metrics
{
    private long _hits;
    private long _misses;

    public void Record(bool hit)
    {
        if (hit) _hits++; else _misses++;
    }

    public double HitRate =>
        _hits + _misses == 0 ? 0 : (double)_hits / (_hits + _misses);
}

C# has no shared-versus-exclusive distinction on references, so there is nothing to opt out of. In Rust, Cell is what lets record take &self, which in turn is what lets Metrics sit inside an Rc, get borrowed by three call sites, and still count things. Without it you would have to thread &mut Metrics through every layer, and that is the kind of change that ripples through an entire call graph.

One correction to the shorthand you will see everywhere. Cell<T> is not restricted to Copy types. set, replace, swap and into_inner work for any T. It is get that needs T: Copy, because get copies the value out and leaves the original in place, and that is only sound for types where a bitwise copy is a valid duplicate. For a Cell<String> you use replace or take and put something back.

If you are on Rust 1.88 or newer, Cell::update collapses the get-modify-set dance into self.hits.update(|n| n + 1). On anything older, write it out.

RefCell, and the borrow check at run time

RefCell<T> keeps the borrowing rules and moves the enforcement from compile time to run time.

use std::cell::RefCell;

let counter = RefCell::new(0);
*counter.borrow_mut() += 1;              // exclusive borrow, released at end of statement
println!("{}", counter.borrow());        // shared borrow

The mechanism is a single isize sitting next to your value. Zero means unborrowed, a positive value counts live shared borrows, a negative value counts live exclusive ones. borrow() returns a Ref guard that decrements on drop; borrow_mut() returns a RefMut that does the same. If the flag says the request is not allowed, it panics.

The rule has not changed. Any number of readers, or exactly one writer. Only the moment of enforcement moved, and with it the failure mode: you traded a compile error for a possible crash, in exchange for expressing a shape the static checker could not verify.

The failure is worth seeing in all three forms, because you have already met two of them.

In C#, this compiles and throws:

var entries = new List<long> { 10, 20 };
foreach (var e in entries)
{
    if (e > 15) entries.Add(e / 2);
}
// System.InvalidOperationException:
// Collection was modified; enumeration operation may not execute.

In Rust with a plain Vec, the same shape does not compile at all. That is error[E0502], and the borrowing chapter covered it.

In Rust with a RefCell, it compiles, and then it does this:

use std::cell::RefCell;

struct Ledger {
    entries: Vec<i64>,
}

fn split_large(ledger: &RefCell<Ledger>) {
    let view = ledger.borrow();                       // shared borrow, held by `view`
    for e in view.entries.iter() {
        if *e > 15 {
            ledger.borrow_mut().entries.push(*e / 2); // wants exclusive access
        }
    }
}

fn main() {
    let ledger = RefCell::new(Ledger { entries: vec![10, 20] });
    split_large(&ledger);
    println!("{:?}", ledger.borrow().entries);
}
thread 'main' (0x37f8) panicked at src/main.rs:11:20:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Line 11 column 20 is the borrow_mut call, not a line inside the standard library, because borrow and borrow_mut are marked #[track_caller]. Without that you would get a location inside cell.rs, which tells you nothing about which of your call sites did it.

Look at what the three versions cost. C# pays a version-counter comparison inside List<T>.Enumerator.MoveNext on every iteration of every foreach, forever, in every program, so that this one class of bug throws instead of corrupting. Rust with a Vec pays nothing and rejects the program. Rust with a RefCell pays one branch on each borrow and crashes the process. You chose the middle option by writing RefCell, and the panic is the invoice.

Three habits keep RefCell panics out of your codebase, and they are all the same habit.

Never hold a borrow across a call you do not control. The panic above happens because view is alive when borrow_mut runs. Compute first, mutate second:

fn split_large(ledger: &RefCell<Ledger>) {
    let extra: Vec<i64> = ledger
        .borrow()
        .entries
        .iter()
        .filter(|e| **e > 15)
        .map(|e| e / 2)
        .collect();                              // borrow ends with this statement
    ledger.borrow_mut().entries.extend(extra);
}

Keep borrows to a single statement or an explicit small block. A Ref bound to a let lives until the end of its scope. A Ref produced as a temporary inside an expression dies at the end of that statement. The second is much harder to get wrong, and when you genuinely need a named guard, wrap it in braces so the scope is visible.

Use try_borrow_mut at re-entrancy boundaries. It returns a Result instead of panicking. If you are writing anything callback-shaped, an event bus, a plugin host, a component that invokes user code, this is the difference between degrading and dying. The panic is not a bug in your data structure, it is a re-entrancy report, and sometimes the right response is to queue the work rather than to abort.

Rc of RefCell is roughly a C# object reference

Put the two together and you get the pattern that shows up in every Rust codebase that models a graph:

use std::cell::RefCell;
use std::rc::Rc;

type Shared<T> = Rc<RefCell<T>>;

let subscribers: Shared<Vec<String>> = Rc::new(RefCell::new(Vec::new()));
let handle = Rc::clone(&subscribers);
handle.borrow_mut().push("alerts".to_string());

Rc<RefCell<T>> is, roughly, a C# object reference. Shared, mutable, reference-counted rather than traced. Say it out loud, because that is the honest description and pretending otherwise wastes people's time.

It is not free, and the arithmetic is easy. On a 64-bit target you are paying two usize for the Rc header and one isize for the borrow flag, so 24 bytes of overhead per shared value plus alignment padding, one allocation each, and a branch on every access. And you gave up something the type system was doing for you: Rc<RefCell<T>> tells a reader nothing about who can write or when, which is precisely the information Rust normally hands you for free in a signature.

When you find yourself writing it everywhere, that is worth noticing. It usually means the design is fighting the language rather than the language failing the design. The fourth chapter of Learning Rust With Entirely Too Many Linked Lists is titled "A Bad Safe Deque" and spends its length demonstrating exactly this, on the smallest possible data structure, and it is worth an hour.

Cycles, and the one place a garbage collector simply wins

Rc cannot collect cycles. This is not a Rust shortcoming so much as a property of reference counting itself, and it applies equally to C++'s shared_ptr and to Python's counting layer before its cycle collector runs.

use std::cell::RefCell;
use std::rc::Rc;

struct Session {
    id: u32,
    peer: RefCell<Option<Rc<Session>>>,
}

impl Drop for Session {
    fn drop(&mut self) {
        println!("dropping session {}", self.id);
    }
}

fn main() {
    {
        let a = Rc::new(Session { id: 1, peer: RefCell::new(None) });
        let b = Rc::new(Session { id: 2, peer: RefCell::new(None) });

        *a.peer.borrow_mut() = Some(Rc::clone(&b));
        *b.peer.borrow_mut() = Some(Rc::clone(&a));

        println!("a strong = {}", Rc::strong_count(&a));
        println!("b strong = {}", Rc::strong_count(&b));
    }
    println!("scope ended");
}
a strong = 2
b strong = 2
scope ended

No "dropping session" line, ever. At the closing brace, a and b go out of scope and each count drops from 2 to 1. Neither reaches zero, because each object is being held alive by the other. Drop never runs and the memory is never freed.

The compiler will not say a word about it. Nothing in the type system models "this graph has a loop in it". Miri does catch it, because it checks at the end of a run for allocations that are no longer reachable from a static and reports "the evaluated program leaked memory", so running your tests under Miri is a real defense here. That is a test-time tool, though, not a compile-time one, and it only sees the paths your tests take.

.NET does not have this problem, and it is not close. The collector works from roots: static fields, thread stacks, registers, GC handles. It builds the graph of everything reachable from those roots, and anything not in that graph is garbage. A cycle floating free of every root is unreachable by definition, so it is collected like anything else, and you never think about it. Reachability is a global property, and a tracing collector computes global properties. A reference count is a local one, and local counts cannot see a loop.

This is genuinely the place where the .NET model is better, full stop. Not "better if you squint", not "a different set of tradeoffs". If your data is a real graph with real cycles and it churns, a tracing collector is doing work for you that Rust is going to make you do by hand.

Two honest qualifications. First, leaking memory is safe in Rust, deliberately: mem::forget and Rc cycles are both allowed, because leaks cannot corrupt anything. Rust's safety promise was never "no leaks". Second, in practice this bites less often than it sounds, because the fix is usually a change in modeling rather than a change in machinery.

Weak, and a tree that actually works

Weak<T> is a non-owning handle. It does not contribute to the strong count, so it cannot keep a value alive, and you cannot use it directly. You call upgrade, which returns Option<Rc<T>>: Some if the value is still there, None if the last strong handle has gone.

The standard shape for a tree is: parents own children with Rc, children point back at parents with Weak. Here is the whole thing, runnable.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    name: String,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

impl Node {
    fn new(name: &str) -> Rc<Node> {
        Rc::new(Node {
            name: name.to_string(),
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(Vec::new()),
        })
    }

    fn add_child(parent: &Rc<Node>, child: Rc<Node>) {
        *child.parent.borrow_mut() = Rc::downgrade(parent);
        parent.children.borrow_mut().push(child);
    }

    fn path(&self) -> String {
        match self.parent.borrow().upgrade() {
            Some(p) => format!("{}/{}", p.path(), self.name),
            None => self.name.clone(),
        }
    }
}

fn main() {
    let root = Node::new("root");
    let services = Node::new("services");
    let db = Node::new("db");

    Node::add_child(&root, Rc::clone(&services));
    Node::add_child(&services, Rc::clone(&db));

    println!("{}", db.path());
    println!("root:     strong={} weak={}", Rc::strong_count(&root), Rc::weak_count(&root));
    println!("services: strong={} weak={}", Rc::strong_count(&services), Rc::weak_count(&services));
    println!("db:       strong={} weak={}", Rc::strong_count(&db), Rc::weak_count(&db));

    drop(root);
    println!("after dropping root, db.path() = {}", db.path());
}
root/services/db
root:     strong=1 weak=1
services: strong=2 weak=1
db:       strong=2 weak=0
after dropping root, db.path() = services/db

Read the counts. services has a strong count of 2 because the local binding and the vector inside root both own it. root has a strong count of 1 and a weak count of 1, the weak one being the back pointer from services. db has no children so nothing points weakly at it.

Then drop(root) runs, the strong count reaches zero, and the whole Node is destroyed, including its children vector, which takes services from 2 down to 1. The Weak inside services is now dangling in the logical sense, and the last line proves it handles that gracefully: upgrade returns None, path stops walking, and you get services/db instead of a crash.

The C# version does not need any of this:

sealed class Node
{
    public string Name { get; }
    public Node? Parent { get; private set; }
    public List<Node> Children { get; } = new();

    public Node(string name) => Name = name;

    public void AddChild(Node child)
    {
        child.Parent = this;
        Children.Add(child);
    }

    public string Path() => Parent is null ? Name : $"{Parent.Path()}/{Name}";
}

Count the two listings: 16 lines of C# against 27 lines of Rust for the same type, and the C# needed no design decisions to get there. That gap is real and you should not talk yourself out of noticing it.

But notice the semantic difference too, because it is not only ceremony. In C#, db holding a Parent reference keeps root alive for as long as db is alive. The whole tree is one reachability unit; drop your reference to the root and nothing is collected until every node is unreachable. In Rust, the Weak back pointer says explicitly that children do not own their parents, so dropping the root really does free the root, and children discover it. Whether that is what you want is a design decision. Rust made you make it. C# made it for you.

Arenas and indices, which is usually the better answer

Now the opinion. Reaching for Rc<RefCell<T>> at the first borrow error is almost always the wrong move, and it is the most common way a C# engineer's first Rust codebase ends up worse than the C# it replaced.

Before you reach for it, try restructuring so that ownership is a tree and everything else holds an index into a central Vec. For graphs specifically, an arena with integer ids is usually simpler, faster and much easier to debug than a web of counted pointers.

Same tree, no counting, no borrow flags, no cycles possible:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NodeId(usize);

struct Node {
    name: String,
    parent: Option<NodeId>,
    children: Vec<NodeId>,
}

#[derive(Default)]
struct Tree {
    nodes: Vec<Node>,
}

impl Tree {
    fn add(&mut self, name: &str, parent: Option<NodeId>) -> NodeId {
        let id = NodeId(self.nodes.len());
        self.nodes.push(Node {
            name: name.to_string(),
            parent,
            children: Vec::new(),
        });
        if let Some(p) = parent {
            self.nodes[p.0].children.push(id);
        }
        id
    }

    fn path(&self, id: NodeId) -> String {
        let node = &self.nodes[id.0];
        match node.parent {
            Some(p) => format!("{}/{}", self.path(p), node.name),
            None => node.name.clone(),
        }
    }

    fn rename_subtree(&mut self, id: NodeId, suffix: &str) {
        let mut stack = vec![id];
        while let Some(current) = stack.pop() {
            self.nodes[current.0].name.push_str(suffix);
            stack.extend(self.nodes[current.0].children.iter().copied());
        }
    }
}

fn main() {
    let mut tree = Tree::default();
    let root = tree.add("root", None);
    let services = tree.add("services", Some(root));
    let db = tree.add("db", Some(services));
    let cache = tree.add("cache", Some(services));

    println!("{}", tree.path(db));
    println!("{}", tree.path(cache));

    tree.rename_subtree(services, "-v2");
    println!("{}", tree.path(db));
}
root/services/db
root/services/cache
root/services-v2/db-v2

Count what is gone. No Rc, no RefCell, no Weak, no reference counts, no borrow flag, no panic path, no cycle. rename_subtree walks and mutates the tree with a plain &mut self, which the borrow checker verifies statically, because at any instant there is exactly one live borrow of self.nodes. The nodes are contiguous, so a traversal is a linear walk over one allocation instead of a pointer chase through a hundred thousand of them. NodeId is Copy, so passing ids around is free. And every id is a small integer, which means your Debug output is readable, your snapshots serialize, and you can log a node reference without formatting a whole subtree.

This is not a Rust trick. It is the same technique you already reach for in C# when a List<T> of structs and integer indices beats a graph of class instances, and for the same reasons: fewer allocations, better locality, and a gen2 collection that has far fewer objects to mark. Rust just makes the alternative annoying enough that you find this one sooner.

The honest downside is real and you should hear it before you go and rewrite something. An index is not a reference, and the compiler stops helping you. If you remove a node and reuse the slot, an old NodeId now silently addresses a different node. That is a logic bug, not a memory-safety bug, so the program will not crash, it will just be wrong, which is arguably worse. The standard answer is a generational index: store a generation counter alongside each slot and put the generation in the id, so a stale id fails to resolve instead of resolving to a stranger. slotmap does this and is the crate to reach for; petgraph builds a general graph library on the same idea, with NodeIndex and EdgeIndex acting as references into vectors. The pattern has been written up for a decade; Niko Matsakis wrote the canonical explanation in 2015, and Catherine West's RustConf 2018 keynote made the case at length for game entity systems, which is where the shape gets its worst-case workout.

There is one thing indices cost you that is easy to overlook: the arena has to be reachable everywhere you want to resolve an id. That means threading &Tree or &mut Tree through your call graph, and if your API surface makes that awkward, you will feel it. That awkwardness is usually a signal that the traversal logic belongs on the arena rather than on the node, which tends to be a better design anyway, but it is a real refactor and not a free one.

What this looks like once the system is real

At small scale none of this matters and you should not agonize over it. It starts mattering in three specific places.

Single-threaded servers and interpreters. Anything with a per-thread world, an event loop, a script host, a compiler pass, ends up with a lot of shared mutable state that never crosses a thread. Rc<RefCell<T>> is genuinely the right call here, and it is what the alternative would be in any other language too. The failure mode at scale is not memory, it is that borrow panics start appearing in production from re-entrancy paths nobody tested: a handler that fires a callback that touches the same store. That is when try_borrow_mut and a deferred work queue stop being pedantic.

Thread-local state. thread_local! plus RefCell is the canonical std pattern, and it is so prone to double-borrow bugs that the standard library grew with_borrow and with_borrow_mut in Rust 1.73 specifically to make the borrow scope impossible to get wrong. Use them. The closure boundary is the whole point.

Large object graphs. This is where the arena pulls ahead by a lot, and where the two runtimes' costs finally diverge in the same direction. A million Rc<RefCell<Node>> values means a million allocations, 24 bytes of overhead each before padding, and pointer chasing on every traversal. The equivalent .NET object graph is cheaper to write, but the collector's mark work scales with the number of live objects, so a big long-lived graph is the shape that gen2 has to keep re-walking. Be fair about the mitigation: background GC is on by default and does most of that gen2 marking on dedicated threads, so this shows up as CPU spent rather than as long stop-the-world pauses. The work is still proportional to the graph. Both runtimes reward the same fix: fewer, larger, flatter allocations.

The two habits that spread Rc through a codebase

Reaching for Rc<RefCell<T>> on the first borrow error. The symptom is a codebase where a Shared<T> alias has spread into every module and no signature tells you who writes what. The fix is almost always to make ownership a tree, pass &mut down instead of storing handles up, and use ids for anything that needs to point sideways.

Storing an Rc where a & would do. Sharing a value for the duration of a call is a borrow, not shared ownership. If the handle never outlives the caller, take &T and delete the Rc. This one is easy to spot: an Rc that is cloned into a function and dropped at the end of it was never shared ownership, it was a borrow with an allocation attached.

Assuming a borrow ends where the expression ends. let g = cell.borrow(); holds until the end of the scope, not the end of the line. C# has scope-bound cleanup too, in using and lock, but it is always announced by a keyword, so there is no habit of reading a plain local declaration as something that holds a resource open. Treat borrow() the way you treat a lock block, and give it explicit braces.

Expecting Weak to behave like WeakReference<T>. They are spelled similarly and solve different problems. .NET's WeakReference<T> exists so a cache can let the collector reclaim entries under memory pressure; it is a hint to a collector that is already handling your cycles. Rust's Weak exists to break ownership cycles that would otherwise leak, and it is load-bearing rather than advisory. Using Weak for caching in Rust mostly does not make sense, because nothing is going to come along and reclaim things for you.

Treating a borrow panic as a bug in RefCell. It is not. It is your program telling you that two code paths wanted the same value at the same time, which is information you would not have had in C#, where the second path would have quietly seen a half-updated object. The panic is the diagnosis, not the disease.

Two places the collector simply wins

Two places, and neither is a close call.

Cyclic object graphs that change shape at run time. Covered above. A tracing collector computes reachability globally and reference counting cannot. If you are modeling something genuinely graph-shaped and mutable, .NET does this correctly for free and Rust makes you design for it. Say so in a code review rather than defending the language.

Prototyping speed on anything object-shaped. The C# node class above required no decisions. The Rust version forces three: which direction ownership runs, Rc or an arena, and what happens when a parent is gone. Those are good decisions to have made by the time the thing ships, and terrible ones to be forced into on day one when the shape of the problem is still unknown. If you are exploring, explore in the language that lets you.

For most application code this whole section is a footnote, because ownership really is a tree and none of the machinery ever comes up. Interior mutability is not an advanced technique to graduate into. It is a specific tool for a specific shape, and the shape is rarer than the amount of writing about it suggests.

Checking yourself

Why does Rc<T> refuse to give you a &mut T, when it clearly knows how many handles exist?

Because knowing the count is not enough. Another handle could be reading right now, somewhere up the call stack, and Rc has no way to see that. It only implements Deref, never DerefMut. Rc::get_mut is the exception, and it works precisely because it refuses unless the count is 1 and there are no Weak handles, at which point no other reader can exist.

A RefCell panics with "RefCell already borrowed". What does that tell you about your code, and what does it not tell you?

It tells you a borrow_mut was requested while some Ref guard was still alive. Thanks to #[track_caller] you get the location of the failing call. It does not tell you where the conflicting borrow was taken, which is the piece you usually want, so look for a let binding holding a guard across the failing call, or a re-entrant callback.

Two nodes hold Rc handles to each other and go out of scope. What happens, and what would .NET do?

Both counts drop to 1 rather than 0, so neither value is dropped, Drop never runs, and the allocation leaks. .NET would collect both, because neither is reachable from a root and the collector works from reachability rather than from counts. This is the clearest case where a tracing collector is simply better.

You have a hundred thousand nodes with parent and child links. What do you build?

An arena: one Vec<Node>, ids as indices, Option<NodeId> for the parent, Vec<NodeId> for the children. Cycles become impossible, traversal is a linear walk, ids are Copy and printable, and the borrow checker verifies your mutations statically. Reach for slotmap if nodes get removed, so that stale ids fail to resolve rather than pointing at a recycled slot.

Fearless concurrency is a claim the type system makes

This is where ownership pays a dividend that is hard to appreciate until you have shipped something.

The distinction matters more than it sounds. In C#, a torn long on a 32-bit runtime and a lost update on a Dictionary are both "threading bugs" in the postmortem, but they are different animals. The torn read is the machine doing something the language never promised. The lost update is the machine faithfully executing a program whose logic was wrong. Rust draws a hard line between the two: the first category is what the compiler refuses to let you write, and the second is still entirely yours.

In C#, nothing stops you sharing a List<T> across threads. The compiler does not know or care. If you forget the lock, you find out in production, intermittently, on the busiest day. The entire System.Collections.Concurrent namespace exists because the language cannot tell you when you needed it.

Rust makes the first mistake a compile error, and it does it with two traits that have no methods at all.

Send and Sync, and why they are only marker traits

Send means a value can be moved to another thread. Sync means a reference to it can be shared with another thread. The standard library states the second one as an equation: a type T is Sync if and only if &T is Send.

Four relationships fall out of that, and they are worth memorizing, because nearly every confusing error in this area is one of them wearing a costume.

This is truewhen this is true
&T is SendT is Sync
&mut T is SendT is Send
&T and &mut T are SyncT is Sync
Arc<T> is SendT is Send and Sync

The third row surprises people. A &mut T is Sync, because a mutable reference reached through a shared reference is read-only anyway. The fourth row is the one that actually bites, and it turns up two examples from now.

That closure rule is the one doing the real work. A closure is a struct you did not write, whose fields are its captures, so when you hand a closure to thread::spawn the compiler is doing exactly the same field-by-field walk it would do on any struct. If one capture is not Send, the closure is not Send, and the call does not compile.

Rc<T> is the canonical failure, because its reference count is a plain integer. Two threads incrementing it can lose an increment, the count drops to zero while a handle is still live, and you have a use-after-free. So the standard library states it outright, with impl<T: ?Sized, A: Allocator> !Send for Rc<T, A>, a negative implementation that overrides the structural rule. That is belt and braces: Rc holds a NonNull, which carries a negative Send impl of its own, so the answer would be no either way. The comment on the matching !Sync impl says why it is spelled out anyway, which is documentation and better error messages. Try to move one across a thread boundary:

use std::rc::Rc;
use std::thread;

fn main() {
    let counts = Rc::new(vec![1, 2, 3]);

    let handle = thread::spawn(move || {
        println!("{:?}", counts);
    });

    handle.join().unwrap();
}
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
   --> src/main.rs:7:32
    |
  7 |       let handle = thread::spawn(move || {
    |                    ------------- ^------
    |                    |             |
    |  __________________|_____________within this `{closure@src/main.rs:7:32: 7:39}`
    | |                  |
    | |                  required by a bound introduced by this call
  8 | |         println!("{:?}", counts);
  9 | |     });
    | |_____^ `Rc<Vec<i32>>` cannot be sent between threads safely
    |
    = help: within `{closure@src/main.rs:7:32: 7:39}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`
note: required because it's used within this closure
note: required by a bound in `spawn`
    |
128 |     F: Send + 'static,
    |        ^^^^ required by this bound in `spawn`

Read the last three lines, because they are the whole idea. The rule is not baked into the compiler as a special case for threads. It is a where clause on an ordinary generic function that you could have written yourself: thread::spawn requires F: FnOnce() -> T, F: Send + 'static and T: Send + 'static. The standard library writes that Send bound on a line of its own, which is why the note can put a caret under exactly that clause instead of the whole where block. The compiler noticed that a type whose reference count is a plain integer failed a bound, and it stopped you. Not a lint, not a warning, not a runtime assertion. The program does not build.

The fix is Arc<T>, the atomically counted version.

Now the sequel, which is where the fourth row of that table earns its keep. Arc makes shared ownership thread safe. It does not make the thing inside thread safe. Wrap the RefCell from the previous chapter in an Arc and the compiler is not fooled:

use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

fn main() {
    let shared = Arc::new(RefCell::new(0));
    let s = Arc::clone(&shared);
    thread::spawn(move || {
        *s.borrow_mut() += 1;
    });
}
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
    |
    = help: the trait `Sync` is not implemented for `RefCell<i32>`
    = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
    = note: required for `Arc<RefCell<i32>>` to implement `Send`

RefCell tracks its borrow count with a plain integer too, so it is not Sync, so Arc<RefCell<i32>> is not Send, so the closure is not Send. Three deductions, one error, and the compiler names the replacement in the note. That is a much better afternoon than the C# version of the same story, which is a Dictionary that has quietly stopped being a valid hash table.

One more that is worth knowing early: MutexGuard is Sync but deliberately not Send. On some platforms a lock must be released by the thread that took it, so the guard is pinned to its thread. It matters far more for async than for threads, and the async chapter picks that up.

What the atomic count costs

The Rust Book says atomics come with a performance penalty and leaves it there. On x86-64 the penalty for a clone is one instruction prefix. Compile two functions that do nothing but clone, one an Rc and one an Arc, in release mode, and rustc emits this:

clone_arc:
	mov	rax, qword ptr [rdi]
	lock		inc	qword ptr [rax]
	jle	.LBB8_2
	ret

clone_rc:
	mov	rax, qword ptr [rdi]
	inc	qword ptr [rax]
	je	.LBB9_1
	ret

lock inc instead of inc. That prefix is not free, because it forces the cache line holding the count into exclusive state on the executing core, so if eight threads are cloning the same Arc in a tight loop they are all fighting over one cache line. But it is also not the disaster people imagine when they read "atomic operation."

The interesting cost is on the other end. The Rustonomicon's walkthrough of Arc explains why the clone can use Relaxed ordering: incrementing the count does not publish any data, so no happens-before relationship is needed. The drop is different. It decrements with Release, and the thread that sees the count hit zero issues an Acquire fence before running the destructor, so that every write any other thread made through its handle happens-before the deallocation. That fence sits behind the branch only the last drop takes, and on x86-64 it emits no instruction at all, because loads on that architecture already have acquire semantics. On a weaker memory model such as AArch64 it becomes a real barrier instruction. Either way the common case, a clone and a drop that is not the last one, stays cheap.

The practical advice that follows: clone an Arc once per thread, not once per item. If you find yourself writing Arc::clone inside a hot loop, you almost certainly wanted to pass &Arc<T>, or better, &T, and let deref coercion do it. And if a program ever leaks enough clones to push the strong count past isize::MAX, Arc aborts the process rather than let the count wrap, which is the standard library choosing a crash over a use-after-free.

The lock owns the data

C#'s lock protects a region of code and trusts you to only touch the shared state inside it. Here is the shape you have written a hundred times, with the mistake sitting right next to the correct version:

public sealed class LogTotals
{
    private readonly System.Threading.Lock _gate = new();
    private readonly Dictionary<int, long> _byStatus = new();
    private long _bytes;

    public void Record(int status, long bytes)
    {
        lock (_gate)
        {
            _byStatus.TryGetValue(status, out var n);
            _byStatus[status] = n + 1;
            _bytes += bytes;
        }
    }

    // Someone adds this six months later, in a hurry.
    public void RecordFast(int status, long bytes)
    {
        _byStatus.TryGetValue(status, out var n);
        _byStatus[status] = n + 1;
        _bytes += bytes;
    }

    public long Bytes => _bytes;
}

Both methods compile. On .NET 9 or later that class builds with zero warnings, and RecordFast is a Dictionary corruption waiting for enough concurrent traffic to trigger a resize. The association between _gate and the two fields it protects lives in a comment, or in the head of whoever wrote it, or nowhere.

C# has been improving the edges of this. .NET 9 added System.Threading.Lock, a dedicated type, and the C# 13 compiler recognizes it in a lock statement and lowers it to EnterScope() in a using, instead of the older Monitor.Enter and try/finally pair around an arbitrary object. The compiler will even warn you if you cast a known Lock to something else and lock on that. Those are real improvements. None of them connects the lock to the data.

Rust puts the data inside the lock, so there is no second place to put it:

use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Default)]
struct Inner {
    by_status: HashMap<u16, u64>,
    bytes: u64,
}

pub struct LogTotals {
    inner: Mutex<Inner>,
}

impl LogTotals {
    pub fn record(&self, status: u16, bytes: u64) {
        let mut guard = self.inner.lock().unwrap();
        *guard.by_status.entry(status).or_insert(0) += 1;
        guard.bytes += bytes;
    }
}

There is no way to write record_fast. The fields are not reachable from &self at all; lock is the only thing that hands out a reference to them, and it returns a MutexGuard which implements Deref and DerefMut onto the inner value. Forgetting to take the lock stopped being a discipline problem. It became a type error.

And because the guard releases when it is dropped, there is no finally to forget and no early return that leaks the lock, including when a panic unwinds through the critical section.

That last sentence is the simplified model, and it is worth replacing with the real one, because the simplification will cost you an afternoon eventually. The guard is not released "at the end of the block." It is released when the guard value is dropped, and Rust drops values at four different moments:

  • End of scope, which is the case you were thinking of.
  • An explicit drop(guard), which is how you release early without introducing a block.
  • End of statement, for a temporary. *totals.lock().unwrap() += 1; never binds the guard to anything, so the lock is held for exactly that statement. let g = totals.lock().unwrap(); on its own line holds it until the end of the enclosing block.
  • Unwind. A panic runs destructors on the way out, which is where the poisoning story starts.

The one hole is that leaking is safe in Rust. std::mem::forget(guard) compiles, is not unsafe, and leaves the mutex locked forever. Nobody does this deliberately, but it explains why the standard library can never promise "the lock is always released."

When reads dominate, RwLock<T> is the same shape with many readers or one writer. It is also the primitive people reach for too fast. The standard library documentation includes a deadlock example that is easy to write by accident: thread one takes a read lock, thread two blocks trying to take the write lock, and thread one then takes a second read lock and hangs, because on some platforms a waiting writer blocks new readers. C# names this problem out loud, which is why ReaderWriterLockSlim has an explicit LockRecursionPolicy and defaults to forbidding recursion. Rust's answer is that the policy is whatever the OS does and you should not rely on it.

My opinion, and it is only that: for a critical section measured in nanoseconds, an uncontended Mutex usually beats an RwLock, because the reader-writer bookkeeping costs more than the exclusion you saved. Reach for RwLock when readers hold the lock for a meaningful stretch, not because the word "read" appears in your method name.

Poisoning, and the honest advice

lock() returns a Result, which is the first thing that annoys people coming from C#. The reason is poisoning: if a thread panics while holding the lock, the data behind it may be halfway through an update, so the mutex records that and every later lock() returns Err.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let cache = Arc::new(Mutex::new(vec!["ready".to_string()]));

    let handle = {
        let cache = Arc::clone(&cache);
        thread::spawn(move || {
            let mut guard = cache.lock().unwrap();
            guard.push("half written".to_string());
            panic!("blew up mid update");
        })
    };

    let outcome = handle.join();
    println!("worker exited cleanly: {}", outcome.is_ok());
    println!("mutex poisoned: {}", cache.is_poisoned());

    match cache.lock() {
        Ok(guard) => println!("clean read: {:?}", *guard),
        Err(poisoned) => {
            let guard = poisoned.into_inner();
            println!("read through poison: {:?}", *guard);
        }
    }

    cache.clear_poison();
    println!("still poisoned: {}", cache.is_poisoned());
}
worker exited cleanly: false
mutex poisoned: true
read through poison: ["ready", "half written"]
still poisoned: false

That is stdout; the panic message and its backtrace hint go to stderr alongside it. Two things to take from the block. The lock was released even though the thread panicked, which is the unwind case from the list above doing its job. And the half-written value is still there, because poisoning is advisory: PoisonError::into_inner hands you the guard anyway, and clear_poison resets the flag.

Most code writes .unwrap() and accepts that a poisoned mutex should take the process down, and I think that is the right default for a service. The panic already told you an invariant broke. Continuing to serve requests from a data structure you know is inconsistent is how a crash becomes a data corruption incident. Handle poisoning deliberately when the guarded state is genuinely rebuildable, such as a cache you can clear, and not otherwise.

Scoped threads remove the 'static bound

thread::spawn requires 'static, which reads as a strange demand until you notice what it is defending against. A spawned thread can outlive the function that created it, so any reference it captured could dangle. The compiler will not take your word that you are going to join it.

use std::thread;

fn main() {
    let lines = vec!["a".to_string(), "b".to_string()];

    let handle = thread::spawn(|| {
        println!("{}", lines.len());
    });
}
error[E0373]: closure may outlive the current function, but it borrows `lines`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `lines`
7 |         println!("{}", lines.len());
  |                        ----- `lines` is borrowed here
  |
note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `lines` (and any other referenced variables), use the `move` keyword

The move keyword the help line suggests is fine when you have one thread. With four, you now need Arc around read-only data purely to satisfy a lifetime rule, which means an allocation plus an atomic increment per thread to share a Vec that was never going anywhere.

std::thread::scope, stable since Rust 1.63, removes the bound by making the guarantee structurally: every thread spawned inside the scope is joined before scope returns, so borrowing a local is provably safe. The API documentation is worth reading once for the two lifetimes involved.

This is genuinely underused. Most fan-out-and-join work in a service is scoped in shape, and if your workers only need to read the input, scoped threads let you pass &[T] and delete the Arc entirely.

Putting it together

Here is a complete program you can paste into a file and run. It shards a batch of log lines across four scoped threads, has each worker accumulate locally and merge once under a mutex, and reports per-worker summaries back over a channel. Output is deterministic because everything is sorted before printing, which is the discipline that makes concurrent code testable.

use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::Mutex;
use std::thread;

#[derive(Default)]
struct Totals {
    by_status: HashMap<u16, u64>,
    bytes: u64,
}

impl Totals {
    fn record(&mut self, status: u16, bytes: u64) {
        *self.by_status.entry(status).or_insert(0) += 1;
        self.bytes += bytes;
    }

    fn merge(&mut self, other: Totals) {
        for (status, count) in other.by_status {
            *self.by_status.entry(status).or_insert(0) += count;
        }
        self.bytes += other.bytes;
    }
}

fn parse(line: &str) -> Option<(u16, u64)> {
    let mut parts = line.split_whitespace();
    let status = parts.nth(2)?.parse().ok()?;
    let bytes = parts.next()?.parse().ok()?;
    Some((status, bytes))
}

fn main() {
    let lines: Vec<String> = (0..12)
        .map(|i| format!("GET /item/{i} {} {}", 200 + (i % 3) * 100, 128 + i * 8))
        .collect();

    let totals = Mutex::new(Totals::default());
    let (tx, rx) = mpsc::channel::<String>();

    thread::scope(|scope| {
        for (worker, chunk) in lines.chunks(3).enumerate() {
            let totals = &totals;      // a plain borrow, no Arc anywhere
            let tx = tx.clone();
            scope.spawn(move || {
                let mut local = Totals::default();
                for line in chunk {
                    if let Some((status, bytes)) = parse(line) {
                        local.record(status, bytes);
                    }
                }
                let summary = format!("worker {worker}: {} bytes", local.bytes);

                // One lock per worker, not one per line.
                let mut shared = totals.lock().unwrap();
                shared.merge(local);
                drop(shared);

                tx.send(summary).unwrap();
            });
        }
        drop(tx);   // release the original sender, or the loop below never ends

        let mut summaries: Vec<String> = rx.iter().collect();
        summaries.sort();
        for s in summaries {
            println!("{s}");
        }
    });

    let totals = totals.into_inner().unwrap();
    let mut by_status: Vec<_> = totals.by_status.into_iter().collect();
    by_status.sort();
    println!("total bytes: {}", totals.bytes);
    for (status, count) in by_status {
        println!("status {status}: {count}");
    }
}
worker 0: 408 bytes
worker 1: 480 bytes
worker 2: 552 bytes
worker 3: 624 bytes
total bytes: 2064
status 200: 4
status 300: 4
status 400: 4

Three things in there are worth naming. The workers borrow lines and totals directly, which only works because of the scope. Each worker takes the lock once, at the end, rather than once per line, which is the difference between four lock acquisitions and twelve. And into_inner() at the end consumes the mutex and hands back the value without locking, because by then the compiler knows there is exactly one owner left, so there is nobody to exclude.

Channels, and the sender you forgot to drop

The standard library ships multi-producer, single-consumer channels, and the ownership model makes them nicer than they are elsewhere: sending a value moves it, so the sender provably cannot keep using it. That is BlockingCollection<T> with the handoff checked.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for i in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send(format!("worker {i} done")).unwrap();
        });
    }
    drop(tx);                       // close the last sender so the loop can end

    let mut messages: Vec<String> = rx.iter().collect();
    messages.sort();
    for message in messages {
        println!("{message}");
    }
}

The drop(tx) line is the part everyone forgets, and the symptom is a hang rather than an error, which makes it worse. The channel disconnects when every sender is gone, and the original tx you cloned from is still alive in the enclosing scope. Your C# instinct here is correct and transfers directly: this is BlockingCollection<T>.CompleteAdding(), or ChannelWriter<T>.Complete(), except that Rust expresses "no more items" through the ownership system instead of a method call, so nothing reminds you.

Rayon and PLINQ

For data parallelism, rayon is what PLINQ is for you. Change iter() to par_iter() and the chain runs across a work-stealing thread pool. The FAQ describes the mechanism, which is the Cilk model: calling join(a, b) from a worker pushes b onto that worker's queue where other workers can steal it, and the worker gets on with a. The pool defaults to one thread per logical core.

What makes this safe is not rayon being clever. It is that rayon's method signatures name the bounds, and the compiler checks them like any other generic call. Try to use a Cell as a counter inside a parallel for_each:

error[E0277]: `Cell<usize>` cannot be shared between threads safely
    |
    = help: within `{closure@src/main.rs:11:31: 11:34}`, the trait `Sync` is not implemented for `Cell<usize>`
    = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicUsize` instead
note: required by a bound in `rayon::iter::ParallelIterator::for_each`
    |
378 |         OP: Fn(Self::Item) + Sync + Send,
    |                              ^^^^ required by this bound in `ParallelIterator::for_each`

That one bound in rayon's source is the entire safety story. A third-party crate got to reuse the same guarantee the standard library uses, because Send and Sync are ordinary traits and for_each is an ordinary generic function.

The C# equivalent of that mistake compiles. This builds clean, zero warnings, on .NET 9:

var seen = new Dictionary<int, int>();
lines.AsParallel().ForAll(l => seen[l.Length] = seen.GetValueOrDefault(l.Length) + 1);

Where the C# way is better

Having said all that, PLINQ does several things rayon does not, and pretending otherwise would be dishonest.

PLINQ is conservative by design. At runtime it inspects the query shape and, if it judges parallelism unlikely to pay, runs the thing sequentially. Rayon has no equivalent analysis. Its splitting is adaptive, in that a worker only really hands off the far half of a join when another worker steals it, so it does not shred a small job into thousands of tasks. But nothing decides on your behalf that the parallel version was a bad idea, and par_iter() over a short collection with a cheap closure loses to the plain loop. PLINQ also gives you AsOrdered for order preservation, WithDegreeOfParallelism, WithCancellation for a CancellationToken, and WithMergeOptions to control how partial results come back. Rayon has a global pool you can configure and per-call control that is coarser.

The gap is wider for concurrent collections. ConcurrentDictionary<TKey, TValue> and its GetOrAdd and AddOrUpdate are excellent, heavily tuned, and in the box. Rust's standard library has no equivalent; the answer is Mutex<HashMap<K, V>>, which serializes everything, or a crate like dashmap. Interlocked is more ergonomic than assembling the same operation from std::sync::atomic with explicit orderings. And .NET's cancellation story is unified in a way Rust's simply is not, because a CancellationToken threads through synchronous and asynchronous code alike.

If your workload is a request-scoped fan-out over a shared cache, C# will get you there faster and the result will be fine. The Rust argument gets strong when the shared state is complex, long-lived, and touched from many places, which is exactly where the C# convention of "remember to take the lock" degrades as the team grows.

What breaks at scale, and what the type system still will not do

The Rustonomicon is refreshingly blunt about the limits. Rust considers it "safe" to deadlock, to have a race condition, to leak memory, to overflow an integer, and, in its own words, to delete the production database.

So take the guarantee literally and no further. Two mutexes acquired in opposite order on two threads hang exactly as they would in C#, and the compiler says nothing, because neither thread is doing anything memory-unsafe. It just never finishes.

The lock ordering advice is the same advice it has always been, and it is worth writing down because it is the part no language solves. Define a total order over your locks, document it next to the type definitions, and always acquire in that order. If a code path needs two locks and you cannot establish an order, that is a design smell rather than a locking problem, and the fix is usually to merge the two pieces of state into one mutex or to split them so no path needs both. Never call into code you do not control while holding a lock, because you cannot know what it locks. And prefer to compute outside the critical section and hold the lock only for the update, which is exactly what the worked example does when each worker accumulates locally and merges once.

The logical races are also still yours. This compiles, is free of data races, and is wrong:

if counter.lock().unwrap().is_below_limit() {
    counter.lock().unwrap().increment();
}

Two separate lock acquisitions. Another thread can slip between them. It does not even deadlock, because the condition of an if is a temporary scope and the first guard is dropped before the block runs, so the second lock() finds the mutex free. The type system verified both accesses were synchronized, and the program still exceeds the limit. The fix is one acquisition spanning the check and the act, and no compiler is going to tell you that.

At the scale where this starts to matter, the failure mode changes character. In a service handling meaningful traffic, the interesting problem is rarely a missing lock; it is a lock held slightly too long. A guard that stays alive because it was bound to a let at the top of a function, across an allocation, a log call, and a metrics increment, will not corrupt anything. It will serialize your whole request path, and you will see it as a p99 that degrades with concurrency while CPU sits idle and every thread is parked. Rust's guard makes the extent of the critical section visible in the code, since the lock is held exactly as long as the guard is alive, but you still have to look. The same discipline that keeps lock blocks small in C# keeps guard scopes small in Rust, and the tools for finding the problem, sampling profilers and lock contention counters, are the ones you already know.

The other scale problem is one Rust makes easy to create: Arc<Mutex<T>> as a reflex. Every C# field that was mutable and shared becomes one, and now a structure that used to be a graph of objects is a graph of locks with no ordering discipline, which is the deadlock recipe above with more steps.

Mistakes a C# engineer makes here

Translating every shared field into Arc<Mutex<T>>. In C# a shared mutable object is the default, so the mechanical translation produces a lock per field. Recognize it when your struct has three or more Arc<Mutex<...>> members. The fix is usually one mutex around a struct that holds all three fields, which makes the invariant between them enforceable and removes any question of ordering.

Locking once per item. for line in lines { totals.lock().unwrap().record(line); } is correct and slow, and it is the direct translation of a lock inside a foreach body. Accumulate into a local and merge once.

Reaching for Arc to satisfy a lifetime. If the compiler complains about 'static on thread::spawn and the data is read-only and outlives the join, you wanted thread::scope.

Forgetting drop(tx). The symptom is a receive loop that never ends. Recognize it by the fact that nothing is logged after the last worker finishes.

Assuming Send plus Sync means correct. Those traits certify the absence of data races and nothing else. A program made entirely of Arc<Mutex<T>> can deadlock on its second lock and lose updates across two separate acquisitions.

Trying to use a RefCell because it worked single-threaded. The compiler will tell you, clearly, and name RwLock in the note. Take the suggestion.

Checking yourself

Why is Arc<RefCell<T>> rejected when Arc<T> is fine? Because Arc<T> is only Send when T is both Send and Sync, and RefCell is not Sync: its borrow counter is a plain integer, so two threads sharing a reference to it could race on that counter. Arc makes shared ownership thread safe, not the value inside.

A colleague says Rust prevents race conditions. What do you correct? Rust prevents data races, which are unsynchronized concurrent accesses with at least one write. Race conditions are timing-dependent logic bugs, and a check-then-act split across two lock acquisitions is still wrong in Rust, still compiles, and still runs.

When would you choose thread::scope over thread::spawn plus Arc? When the threads are joined before the borrowed data goes away, which is nearly every fan-out-and-join. The scope guarantees the join, so the 'static bound is not needed, and you can pass ordinary references instead of paying for an allocation and per-thread atomic increments.

Your service starts returning PoisonError from every request after one bad deploy. What happened, and what are your options? A thread panicked while holding that mutex, so the lock is flagged as guarding possibly inconsistent data. You can crash the process, which is usually right; recover the guard with PoisonError::into_inner and repair the state; or clear the flag with clear_poison once you know the data is sound again. What you should not do is call into_inner reflexively to make the errors go away.

Async without a runtime in the box

This is the chapter where your C# instincts help least, so it is worth being precise about what is different.

The syntax is nearly identical:

async fn fetch_config(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?;
    response.text().await
}

async fn and .await, postfix instead of prefix. Under that, two different designs, and the differences are not cosmetic. They change what a call does, where memory is allocated, how work is canceled, and which errors you will spend a Tuesday afternoon on.

Tasks are hot, futures are cold

In C#, calling an async method starts it. The body runs synchronously on the calling thread until it hits an await on something that has not completed, and the Task you get back represents work already in progress. Task.Run schedules onto the thread pool immediately.

In Rust, calling an async fn runs none of it. You get back a value implementing Future, and that value is inert. Nothing happens until something polls it, which happens when you .await it or hand it to a runtime.

let fut = fetch_config("https://example.com/config");   // nothing has happened
let body = fut.await?;                                   // now it runs

That distinction produces one very specific bug. Better to see it than to be warned about it.

// This does not fetch anything.
let pending: Vec<_> = urls.iter().map(|u| fetch_config(u)).collect();
// ... other work you expected to overlap with four in-flight requests ...
let mut bodies = Vec::new();
for f in pending {
    bodies.push(f.await?);
}

You wrote it expecting the four requests to be in flight during the middle section, the way urls.Select(FetchConfigAsync).ToList() would be in C#. They were not. The loop at the bottom runs them strictly one after another, so the wall clock is the sum of four latencies instead of the maximum. Nothing crashed. Nothing warned. The code is slower than the synchronous version, because it does the same serial work with more ceremony.

The compiler will help you when the future is discarded outright:

warning: unused implementer of `Future` that must be used
  --> src/main.rs:31:5
   |
31 |     fetch_config(url);
   |     ^^^^^^^^^^^^^^^^^
   |
   = note: futures do nothing unless you `.await` or poll them
   = note: `#[warn(unused_must_use)]` on by default

That note line is the whole design in one sentence. But bind the future to a variable, or collect it into a Vec, and the lint has nothing to complain about. You asked for a value and you got one.

The fix is to say what you meant. futures::future::join_all polls the whole collection concurrently on the current task. tokio::spawn per future hands each one to the runtime as an independent task, which is the closest thing to what C# did for you automatically.

To be fair, C# has the mirror-image trap and it bites just as often. Select is lazy, so an IEnumerable of tasks that never gets materialized starts nothing either, and passing a lazy sequence into Task.WhenAll starts the tasks at enumeration time rather than at projection time. The .ToList() in idiomatic C# is doing real work. The difference is that in C# the laziness is in LINQ and you can drop it, while in Rust the laziness is in the language and you cannot.

What a future actually is

Here is the entire trait, from the standard library, plus the enum it returns, which lives next door in std::task:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}

Read that as a question and two possible answers. Something asks the future to make progress. The future either finishes and hands back a value, or says not yet.

Compare it with what C# does. Task is a heap object with an internal continuation list. When you await something incomplete, the generated code calls AwaitUnsafeOnCompleted, which registers your state machine as a continuation on the awaited task. Completion is a push: whoever finishes the I/O walks the continuation list and schedules the resumption. The Task has identity, it can be handed around, awaited twice, stored in a dictionary.

Rust chose the opposite direction. Nobody pushes into a future. A caller pulls, repeatedly, until the future says Ready. Without Boats, who did much of the design work on this, wrote up why, and the short version is that a poll model lets a future be a plain value with no heap identity, no allocation, and no reference counting. That is the property that makes everything else in this chapter possible: because a future is just a value you own, awaiting one inside another can embed it as a field rather than allocate it separately, and destroying one is just dropping a value.

Now the deliberately simplified model, so I can correct it. If polling is a pull, the obvious implementation of a runtime is a loop: poll the future, and if it returns Pending, poll it again. That would work, and it would also spin one CPU core at full tilt while waiting for a socket. Nobody wants that.

Wakers, and why a pull model needs them

The second parameter is the fix. Context carries a Waker, and the contract is this: if a future returns Pending, it must first arrange for that Waker to be woken when the situation changes. The runtime then stops polling and goes to sleep. When the waker fires, the runtime puts the task back on its run queue and polls it again.

The wakers are registered at the leaves. A read on a tokio::net::TcpStream bottoms out in a future that talks to Tokio's reactor rather than to another future. When that leaf's poll finds the socket not readable, it clones the waker out of the Context, hands it to the reactor along with the registered file descriptor, then returns Pending. That Pending propagates up through every .await in the call chain to the top, and the whole task suspends. Later the reactor's epoll or kqueue or IOCP call reports the socket readable, the reactor calls wake(), and the scheduler polls the task again from the top.

From the top matters. The runtime does not resume in the middle of your call stack the way a thread does. It calls poll on the outermost future, which reads its state discriminant, jumps to the right resume point, and calls poll on the inner future it was waiting on, which does the same thing, down to the leaf. A deep await chain re-walks itself on every wakeup. That is cheap, because each step is a discriminant check and a direct call with no virtual dispatch, and it is the mechanism that makes cancellation free later on.

There is no scheduler in the standard library

The language defines what a Future is. It ships no runtime to execute one. No thread pool, no timer, no I/O reactor. std::future and std::task are types and traits and nothing else.

You pick a runtime, and in practice that means Tokio:

#[tokio::main]
async fn main() {
    let body = fetch_config("https://example.com/config").await.unwrap();
    println!("{}", body);
}

Coming from .NET, where the thread pool and the synchronization context are simply there, this feels like an omission. The reason is that Rust targets environments with no operating system, no threads, and no allocator. A mandatory work-stealing scheduler would exclude embedded firmware, kernel modules, and anything running on bare metal, all of which want async for exactly the reason everyone wants it, which is doing several things on one stack. There is also no single right scheduler: a work-stealing pool and a thread-per-core design have genuinely different tradeoffs, and freezing one into the standard library would have picked a winner in 2019.

The cost is real and you should not let anyone talk you out of noticing it. Because AsyncRead and AsyncWrite are not in the standard library either, runtimes define their own, and a library written against Tokio's I/O types usually needs Tokio. The traits that would have made runtimes interchangeable do not exist at the level everyone shares. This is a genuine place where .NET is ahead, not by design brilliance but by having one owner who shipped a batteries-included answer and made everybody use it.

My recommendation is not to be clever. Use Tokio unless you have a specific reason not to, and treat "this crate needs a different runtime" as a reason to look for a different crate.

A complete program, and the C# it replaces

Enough theory. Here is something you can paste into a fresh cargo new project with tokio = { version = "1", features = ["full"] } in Cargo.toml and run. It queries four shards concurrently with a per-shard deadline, and it prints its cleanup so you can see when it happens.

use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{sleep, timeout};

#[derive(Debug)]
struct Shard {
    id: u32,
    rows: u64,
}

/// Stands in for a pooled connection. The Drop impl is the point of the example.
struct ShardConn(u32);

impl Drop for ShardConn {
    fn drop(&mut self) {
        println!("  closing connection to shard {}", self.0);
    }
}

/// Pretend this is a network round trip. Note what it does not take:
/// no cancellation token, no deadline, no awareness of its caller.
async fn query_shard(id: u32) -> Shard {
    let _conn = ShardConn(id);
    let latency = match id {
        1 => 100,
        2 => 200,
        3 => 300,
        _ => 700,
    };
    sleep(Duration::from_millis(latency)).await;
    Shard { id, rows: u64::from(id) * 1_000 }
}

#[tokio::main]
async fn main() {
    let mut set = JoinSet::new();

    for id in 1..=4 {
        set.spawn(async move {
            match timeout(Duration::from_millis(450), query_shard(id)).await {
                Ok(shard) => Ok(shard),
                Err(_elapsed) => Err(id),
            }
        });
    }

    let mut total = 0u64;
    let mut ok = 0u32;
    let mut timed_out = Vec::new();

    while let Some(joined) = set.join_next().await {
        match joined.expect("shard task panicked") {
            Ok(shard) => {
                println!("shard {} returned {} rows", shard.id, shard.rows);
                total += shard.rows;
                ok += 1;
            }
            Err(id) => {
                println!("shard {} timed out", id);
                timed_out.push(id);
            }
        }
    }

    println!("{total} rows from {ok} shards, timed out: {timed_out:?}");
}

Output:

  closing connection to shard 1
shard 1 returned 1000 rows
  closing connection to shard 2
shard 2 returned 2000 rows
  closing connection to shard 3
shard 3 returned 3000 rows
  closing connection to shard 4
shard 4 timed out
6000 rows from 3 shards, timed out: [4]

The C# equivalent, in full. This needs .NET 9 or later for Task.WhenEach, and assumes implicit usings.

record Shard(int Id, long Rows);

sealed class ShardConn : IDisposable
{
    private readonly int _id;
    public ShardConn(int id) => _id = id;
    public void Dispose() => Console.WriteLine($"  closing connection to shard {_id}");
}

static class Program
{
    static async Task<Shard> QueryShardAsync(int id, CancellationToken ct)
    {
        using var conn = new ShardConn(id);
        int latency = id switch { 1 => 100, 2 => 200, 3 => 300, _ => 700 };
        await Task.Delay(latency, ct);
        return new Shard(id, id * 1000L);
    }

    static async Task<(Shard? Shard, int Id)> RunShardAsync(int id)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(450));
        try
        {
            return (await QueryShardAsync(id, cts.Token), id);
        }
        catch (OperationCanceledException)
        {
            return (null, id);
        }
    }

    static async Task Main()
    {
        var tasks = Enumerable.Range(1, 4).Select(RunShardAsync).ToList();

        long total = 0;
        int ok = 0;
        var timedOut = new List<int>();

        await foreach (var task in Task.WhenEach(tasks))
        {
            var (shard, id) = await task;
            if (shard is not null)
            {
                Console.WriteLine($"shard {shard.Id} returned {shard.Rows} rows");
                total += shard.Rows;
                ok++;
            }
            else
            {
                Console.WriteLine($"shard {id} timed out");
                timedOut.Add(id);
            }
        }

        Console.WriteLine(
            $"{total} rows from {ok} shards, timed out: [{string.Join(", ", timedOut)}]");
    }
}

Same output. Put the two side by side and the interesting difference is in the signatures, not the bodies. QueryShardAsync takes a CancellationToken and passes it to Task.Delay. query_shard takes nothing. Cancellation in Rust is not a parameter, it is the absence of further polling, so the deadline is applied by a wrapper the callee never sees.

Cancellation is drop, and that cuts both ways

Canceling a future is dropping it. No token threaded through eleven layers, no cooperative check, no OperationCanceledException. tokio::time::timeout holds the inner future as a field, and when the timer fires first it returns Err and lets the inner future fall out of scope. Dropping the state machine drops every field it held, which is why ShardConn printed its line for shard 4 without anyone writing cleanup code for the canceled path.

select! is the general form:

tokio::select! {
    result = fetch_config(url) => handle(result),
    _ = tokio::time::sleep(Duration::from_secs(2)) => timeout_path(),
}

Whichever branch finishes first wins, and the other future is dropped mid-flight. In C# you would pass a CancellationToken down through every layer and hope everyone honors it. A third-party library that ignores the token cannot be canceled at all, which is why Task.Delay without a token is such a common source of hangs during shutdown.

That is the good half. The bad half is that a future can be canceled at any await point, whether or not its author considered the possibility.

The shape of the bug is always the same:

loop {
    tokio::select! {
        msg = rx.recv() => process(msg),

        // Wrong. read_exact is not cancellation safe. Every time rx.recv()
        // wins the race, the partially filled buffer and the bytes already
        // taken off the socket go on the floor, and the next iteration
        // starts a fresh read_exact from wherever the stream now sits.
        res = socket.read_exact(&mut buf) => {
            if res.is_ok() { frames += 1; }
        }
    }
}

Borrowck gives you a small hint here, if you are paying attention: the handler cannot read buf, because the branch future holds it mutably for the whole select! block. The buffer does not really belong in this loop.

The fix is to move the non-cancellation-safe work into its own task, or to hold the future across loop iterations with tokio::pin! so it is polled to completion instead of recreated each time. Both are more code than the broken version, which is why the broken version keeps getting written.

The related problem is making a section of async work not cancelable, which C# engineers usually assume is trivial because it is. In C# you simply do not check the token, and the work runs to completion. In Rust the caller can drop you at any await, and there is no way to refuse. The only real answer is tokio::spawn, because a spawned task is owned by the runtime, and dropping its JoinHandle detaches the task rather than canceling it. If you want it stopped, you call abort() explicitly.

join is not WhenAll, exactly

Two ways to run several futures at once, and confusing them is a performance bug rather than a correctness one.

join! and join_all poll all of their futures on the current task, on one thread. They give you concurrency, meaning overlapped I/O waits, and nothing else. If the futures do real CPU work, join! is strictly serial.

tokio::spawn and JoinSet hand each future to the scheduler as an independent task, which a multi-threaded runtime can run on different worker threads. That is parallelism, and it is why the worked example above used JoinSet.

In C# the distinction does not exist in the same place, because tasks are already hot and already scheduled by the time you get them. Task.WhenAll is just a join over things that are running anyway. Coming from that model, the instinct is to reach for join_all and expect the thread pool to show up. It does not.

The cost of spawn is the Send + 'static bound, which brings us to the error you are actually going to hit.

The Send bound problem

The guard-across-await mistake is so common that rustc has a dedicated diagnostic for it. Here is code that looks obviously fine:

use std::sync::{Arc, Mutex};

struct Cache {
    entries: Mutex<Vec<String>>,
}

async fn refresh(cache: Arc<Cache>) {
    let mut entries = cache.entries.lock().unwrap();
    let fresh = fetch_names().await;
    entries.extend(fresh);
}

#[tokio::main]
async fn main() {
    let cache = Arc::new(Cache { entries: Mutex::new(Vec::new()) });
    tokio::spawn(refresh(cache)).await.unwrap();
}

And here is what you get:

error: future cannot be sent between threads safely
  --> src/main.rs:16:18
   |
16 |     tokio::spawn(refresh(cache)).await.unwrap();
   |                  ^^^^^^^^^^^^^^ future returned by `refresh` is not `Send`
   |
   = help: within `impl Future<Output = ()>`, the trait `Send` is not
           implemented for `MutexGuard<'_, Vec<String>>`
note: future is not `Send` as this value is used across an await
  --> src/main.rs:9:31
   |
8  |     let mut entries = cache.entries.lock().unwrap();
   |             ------- has type `MutexGuard<'_, Vec<String>>` which is not `Send`
9  |     let fresh = fetch_names().await;
   |                               ^^^^^ await occurs here, with `mut entries` maybe used later
note: required by a bound in `tokio::spawn`

Read the three parts. The error is reported at the spawn, because that is where the Send bound is required. The help line names the guilty type. The note names the actual line, and the phrase await occurs here, with mut entries maybe used later is the whole explanation: the guard has to be stored as a field of the future so it survives the suspension, which makes the future contain a MutexGuard, which makes the future not Send, which makes it unspawnable.

You will look for an error code and not find one. This diagnostic is printed without one. It is a special case that rustc substitutes for the generic unsatisfied-trait-bound message you would otherwise get, which does carry a code, E0277.

The mechanism is an operating system rule leaking upward. On several platforms the lock that std::sync::Mutex sits on must be released by the thread that acquired it, so MutexGuard is !Send everywhere for portability. A work-stealing runtime can resume a task on a different worker thread after an await. The type system propagates the !Send all the way out to the spawn.

Three fixes, in the order I would try them.

Restructure so the lock is not held across the await. Usually the right answer, because it also shortens the critical section.

async fn refresh(cache: Arc<Cache>) {
    let fresh = fetch_names().await;
    cache.entries.lock().unwrap().extend(fresh);
}

Scope the guard explicitly when you need the locked data before the await. An inner block makes the drop point unambiguous to the compiler and to the next reader.

async fn refresh(cache: Arc<Cache>) {
    let stale = {
        let entries = cache.entries.lock().unwrap();
        entries.iter().take(10).cloned().collect::<Vec<_>>()
    };                                   // guard dropped here
    let fresh = fetch_missing(&stale).await;
    cache.entries.lock().unwrap().extend(fresh);
}

An explicit drop(entries) before the await also works on current compilers, whose analysis tracks whether a value is still live at each suspension point. Historically it did not, which is why you will find forum threads insisting you need a block. The block still reads better.

Use tokio::sync::Mutex only if you genuinely must hold the lock across an await, for instance while writing to a socket you have exclusive access to. Its guard is Send, and it costs more than the standard one for uncontended locks. Tokio's own guidance is to prefer std::sync::Mutex in async code and reach for the async one deliberately.

The same error appears for Rc, RefCell, and anything else that is not Send, held across an await for any reason including being a temporary inside a match scrutinee. That last one is genuinely surprising the first time.

Pin, and why self-reference forces it

Back to poll, and the part I skipped. Why Pin<&mut Self> rather than &mut Self?

Look at what a compiled async fn contains:

async fn run() {
    let buf = String::from("hello world");
    let n = count_words(&buf).await;
    println!("{n}");
}

The generated future for run holds buf as a field, because buf must survive the suspension. It also holds the count_words future as a field, because that is what nesting means. And the count_words future holds &buf, a reference into a sibling field of the same struct.

That is a self-referential struct. Move it in memory and the reference points at the old location. Almost every Rust type is safe to move with a memcpy, there are no move constructors, and the whole language leans on that. The state machines async generates are the exception.

Pin is the type-level promise that a value will not move again. The key detail, and the one that makes it liveable, is that the promise only starts once polling starts. Before the first poll, a future is an ordinary value: you can move it, put it in a Vec, return it from a function, pass it to spawn. That is why the cold-future model works at all. Pin constrains only the window between the first poll and completion, and the runtime is what holds it still.

Most of the time this is invisible. You meet it in two places. First, recursion:

error[E0733]: recursion in an async fn requires boxing
  --> src/main.rs:5:1
   |
5  | async fn walk(node: &Node) {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^
6  |     walk(child).await;
   |     ----------------- recursive call here
   |
   = note: a recursive `async fn` must introduce indirection such as
           `Box::pin` to avoid an infinitely sized future

A future that embeds itself has no finite size, so the compiler stops you. The fix is Box::pin(walk(child)).await, which puts the inner future behind a pointer. C# does async recursion without thinking about it, because every Task is already a heap object and the state machines never nest. That is a real ergonomic cost on the Rust side, paid in exchange for the flat non-recursive case being allocation-free.

Second, select! in a loop over a future you want to keep across iterations. tokio::pin!(fut) pins it to the stack frame so it can be polled repeatedly without being recreated. The std::pin module docs are worth a slow read once, and then you can mostly forget them.

Both languages build a state machine, and both allocate somewhere

C# rewrites an async method into a type implementing IAsyncStateMachine, with a field per local that survives an await, a field per distinct awaiter type, and an integer for the resume point. In a release build that type is a struct, zero-initialized on the stack; in a debug build the compiler emits a class instead, which helps certain debugging scenarios. If the method completes without ever actually suspending, the state machine never leaves the stack and there is no allocation at all. Stephen Toub's walkthrough is the definitive account, and this is a heavily compressed version of it.

The old story, which you will still find repeated, is that on first suspension the state machine gets boxed and the Task is a second, separate heap object. That was true on .NET Framework, where the first suspension of a call allocated the box, a MoveNextRunner, an Action delegate, and the Task itself, on top of ExecutionContext capture.

Modern .NET does not do that. The builder allocates an AsyncStateMachineBox<TStateMachine> which derives from Task<TResult> and holds the state machine as a strongly typed field. It is the task. One allocation, on first suspension only, reused for every later suspension of that call. For methods returning ValueTask or ValueTask<T> you can go further: annotate the method with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] and the box is rented from a pool and returned on completion, giving amortized zero allocation. That pooling does not apply to Task-returning methods, because a Task can outlive the call and be awaited again.

So the honest C# number is one allocation per async method that actually suspends, not two, and zero for methods that complete synchronously.

Rust's number is different in a way that matters at depth. Because awaiting an inner future embeds it as a field, a chain of ten async fn calls compiles to one struct nested ten deep, whose total size is known at compile time. tokio::spawn allocates once to put that struct in the task cell. The nine inner levels cost nothing. Tyler Mandry, from the compiler team, wrote up how the layout works, including how variants that cannot be live at the same time get overlapped so the size is closer to the maximum than the sum.

C#Rust
Call with no suspensionno allocation in a release build, the state machine stays on the stackno allocation, the future is a value
First suspension, N levels deepone box per suspending method, so up to Nnone, the levels are fields of one struct
Handing work to the scheduleralready allocated as the Taskone allocation in spawn
Async recursionno ceremony, but one box per suspended levelrequires Box::pin, one allocation per level
Trait method returning a futureno extra cost, Task is already a type-erased heap objectno allocation with static dispatch, one Box per call with #[async_trait]

That table is the honest summary. Rust wins on deep chains of ordinary calls, which is the common case in a network service. C# wins on recursion and on dynamic dispatch, where its uniform heap-allocated Task is exactly the indirection Rust has to add by hand.

At scale, in a real system

The place this stops being academic is a gateway or proxy holding tens of thousands of connections.

One task per connection is the normal shape. Each task is one allocation containing the whole future for that connection's handler. Suppose the handler's future is 4 KB, which is not hard to reach once you have a couple of buffers and a TLS session in scope across awaits. At 50,000 connections that is 4096 times 50,000, a little over 200 MB of resident memory in futures alone. Arithmetic, not a benchmark. It is also visible and boundable, which is the part I like: std::mem::size_of_val on your future tells you before you deploy. The .NET equivalent is spread across per-connection Task objects and their state machine boxes, and is harder to see in a single number, though a dump will show it.

Future size is a real tuning knob. A big match arm that only runs on the error path still contributes its locals to the size of every instance of that future. Boxing the cold branch shrinks the hot one.

The other thing that bites at scale is blocking. A poll that does not return promptly holds its worker thread hostage, and Tokio's default worker count is the number of cores. Call a synchronous file read or a CPU-heavy compression routine inside an async function and you have removed one of, say, eight workers until it finishes. Do it on all of them and the runtime stops. tokio::task::spawn_blocking exists for exactly this, and Tokio's post on preemption explains how the runtime tries to keep long-running tasks from starving others.

.NET is more forgiving here, and this is worth saying plainly. The managed thread pool injects additional threads when work is queued and not draining, so a service that blocks in async code degrades to slow rather than to stopped. Tokio does not do that. The .NET behavior papers over a real bug, which is not a compliment exactly, but at three in the morning it is the difference between a latency alert and an outage.

Async in traits is still a rough edge

Async functions in traits have been stable since Rust 1.75, released in December 2023. That is real progress and you should use them. Two limitations remain.

First, you cannot add a Send bound to the returned future from outside the trait, so a public trait with async fn gets a warning:

warning: use of `async fn` in public traits is discouraged as auto trait bounds
         cannot be specified
 --> src/lib.rs:7:5
  |
7 |     async fn fetch(&self, url: Url) -> HtmlBody;
  |     ^^^^^
  |
  = note: you can suppress this lint if you plan to use the trait only in your
          own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future`
      and add any desired bounds such as `Send`, but these cannot be relaxed
      without a breaking API change
  |
7 -     async fn fetch(&self, url: Url) -> HtmlBody;
7 +     fn fetch(&self, url: Url) -> impl Future<Output = HtmlBody> + Send;
  |

Without that bound, any implementation of your trait is unspawnable on a multi-threaded runtime, and your users hit the future cannot be sent between threads safely error from two sections ago with no idea it was your fault. The Rust team's own recommendation is the trait_variant macro, which generates both a local and a Send version of the trait.

Second, a trait with async fn is not dyn compatible. You cannot make a dyn Handler out of it, which rules out the plugin and middleware patterns you would reach for instinctively. The workaround is the async_trait macro, which rewrites every method to return a boxed future and costs one allocation per call.

C# has none of these problems. An interface method returning Task<T> has worked since 2012, dispatches dynamically, and composes with everything. IAsyncEnumerable<T> and await foreach have been in the language since C# 8; the Rust equivalent, AsyncIterator, is still nightly-only, and the ecosystem uses the Stream trait from the futures crate instead.

The function-coloring complaint applies to both languages equally, so I will not repeat it as if it were a Rust problem. An async function can only be awaited from an async function, in C# as much as in Rust, and .Result is as bad an idea as block_on.

The mistakes you will actually make

Building a collection of futures and expecting concurrency. Recognize it by wall-clock time equal to the sum of the parts. Fix with join_all for concurrency on one task, or JoinSet for parallelism across workers.

Holding a MutexGuard or an Rc across an await. Recognize it by the error pointing at your spawn rather than at your lock. Fix by shortening the critical section, not by switching to tokio::sync::Mutex reflexively.

Putting a non-cancellation-safe operation in a select! loop. Recognize it by data that vanishes under load and never in tests, because the losing branch only loses when both branches are actually racing. Fix by reading the cancel-safety note on every method you put in a branch, before you write the loop.

Blocking inside async. Recognize it by throughput collapsing to nothing while CPU sits idle. In .NET the same mistake shows up as slowly rising latency, so your instincts will not fire in time. Fix with spawn_blocking.

Assuming dropping a JoinHandle cancels the task. It detaches it. Call abort(), or hold the handle.

Reaching for async when you do not need it. A CLI tool that makes three HTTP calls does not need a runtime. Rust's synchronous story is genuinely good, and ureq or a plain thread pool will be simpler and compile faster. The .NET habit of making everything async because everything is already async does not transfer.

Checking yourself

Why does building a Vec of futures and awaiting them in a loop take as long as doing them one at a time? Because futures are inert until polled. The Vec holds four state machines that have executed nothing; the loop then drives them to completion strictly in sequence. Concurrency requires something that polls them all, like join_all or a task per future.

Why does poll need a Context at all, when it could just be called again? Because polling in a loop would spin the CPU. The Context carries a Waker that the future must register before returning Pending, so the runtime knows when it is worth polling again and can sleep in the OS event queue until then.

A colleague holds a std::sync::MutexGuard across an await and the error points at tokio::spawn, twenty lines away. Why there? The guard must be stored as a field of the future to survive the suspension, which makes the whole future not Send. spawn is simply the first place a Send bound is required, so that is where the obligation fails.

Your service closes connections cleanly on shutdown in C# using IAsyncDisposable. What is the Rust equivalent? There is not one yet. Drop cannot await, so anything requiring network I/O to shut down needs an explicit async fn close(self) that callers must call. This is a case where the C# design is straightforwardly better today.

Zero-cost abstractions, and where the cost actually lands

Part IV, Under the hood. Advanced, and skippable on a first pass.

The phrase gets repeated so often that it stops meaning anything, so here is the actual claim: you cannot hand-write it better, and you do not pay for what you do not use. Not "free". Not "faster than C#".

Rust borrowed the wording from C++. The Rust Book quotes Bjarne Stroustrup's zero-overhead principle directly, and the useful half is the first sentence: "What you don't use, you don't pay for." The second half is the ambitious one, that what you do use, you could not hand code any better.

Both halves are testable.

Two pipelines, and what exists at run time

A complete Rust program. Paste it into src/main.rs and run it.

#[derive(Clone, Copy, PartialEq)]
enum Status {
    Ok,
    Failed,
}

struct Reading {
    status: Status,
    bytes: u64,
}

fn total_ok_bytes(readings: &[Reading]) -> u64 {
    readings
        .iter()
        .filter(|r| r.status == Status::Ok)
        .map(|r| r.bytes)
        .sum()
}

fn main() {
    let mut readings = Vec::new();
    for i in 0..1000u64 {
        readings.push(Reading {
            status: if i % 3 == 0 { Status::Failed } else { Status::Ok },
            bytes: i,
        });
    }

    let chain = readings
        .iter()
        .filter(|r| r.status == Status::Ok)
        .map(|r| r.bytes);

    println!("pipeline size: {} bytes", std::mem::size_of_val(&chain));
    println!("total: {}", total_ok_bytes(&readings));
}

On a 64-bit target it prints pipeline size: 16 bytes, then the total.

Sixteen. That is the size of a bare slice iterator and nothing more, because slice::Iter is two pointer-sized fields for a non-zero-sized element type, and the two adapters wrapped around it add zero. The whole three-stage pipeline lives in a register pair.

The C# that does the same work, also complete, also runnable.

using System;
using System.Linq;

enum Status { Ok, Failed }

readonly record struct Reading(Status Status, long Bytes);

static class Program
{
    static long TotalOkBytes(Reading[] readings) =>
        readings.Where(r => r.Status == Status.Ok).Select(r => r.Bytes).Sum();

    static long TotalLoop(Reading[] readings)
    {
        long total = 0;
        foreach (var r in readings)
            if (r.Status == Status.Ok) total += r.Bytes;
        return total;
    }

    static long Measure(Func<long> f)
    {
        long before = GC.GetAllocatedBytesForCurrentThread();
        f();
        return GC.GetAllocatedBytesForCurrentThread() - before;
    }

    static void Main()
    {
        var readings = new Reading[1000];
        for (int i = 0; i < readings.Length; i++)
            readings[i] = new Reading(i % 3 == 0 ? Status.Failed : Status.Ok, i);

        for (int i = 0; i < 20; i++) { TotalOkBytes(readings); TotalLoop(readings); }

        Console.WriteLine("chain type: " +
            readings.Where(r => r.Status == Status.Ok).Select(r => r.Bytes).GetType().Name);
        Console.WriteLine("linq bytes: " + Measure(() => TotalOkBytes(readings)));
        Console.WriteLine("loop bytes: " + Measure(() => TotalLoop(readings)));
    }
}

On x64 with .NET 9 or later, in release, that prints:

chain type: ArrayWhereSelectIterator`2
linq bytes: 112
loop bytes: 0

GC.GetAllocatedBytesForCurrentThread is exact rather than sampled, so this is a per-call byte count and not a benchmark estimate. The hand-written loop allocates nothing. The LINQ version allocates on every call, and the 112 falls out of the library source, which is worth walking through because it is not what most people guess.

The 112 bytes, itemized

Start with the good news, because LINQ is far less naive than the folklore says.

The chain type is ArrayWhereSelectIterator, which means the two stages collapsed into one object. That is not the JIT being clever. It is written into the library: Where.cs defines ArrayWhereIterator, ListWhereIterator and IEnumerableWhereIterator, and each of them overrides Select to return a fused WhereSelect iterator instead of stacking another layer. Enumerable.Where picks the array shape or the List<T> shape when the source is one of those, so the common cases skip the interface indirection entirely.

The delegates are free too. Neither lambda captures anything, so Roslyn hoists both into a static cache and reuses them forever. The C# specification explicitly allows this: conversions of semantically identical anonymous functions with the same set of captured variables to the same delegate type are permitted, though not required, to return the same delegate instance. After the first call the cached instances are reused, which is why the program above warms up before it measures.

The 112 bytes are two objects. The field lists come from Where.cs and from Iterator.cs, whose base class contributes _threadId as an int, _state as an int, and _current typed as the element.

Where allocates an ArrayWhereIterator<Reading> first. Object header and method table pointer 16, source array reference 8, predicate delegate reference 8, thread id 4, state 4, and a Reading field for the current value at 16, since a 4 byte enum followed by an 8 byte long pads to 16. That is 56 bytes, and it becomes garbage immediately, because Select reads its fields and throws it away.

Select then allocates the ArrayWhereSelectIterator<Reading, long>. Header 16, source 8, predicate 8, selector 8, thread id 4, state 4, current long 8. Also 56.

56 plus 56 is 112. Every call. These are layout details of one runtime on one architecture, not a contract, so treat the shape as the lesson and re-measure if the exact figure matters to you.

Enumerating does not add more. Iterator<TSource> is its own enumerator, and its GetEnumerator returns this when _state is still 0 and the calling thread is the one that created it, cloning only when you enumerate a second time. Sum then asks TryGetSpan for a contiguous view, does not get one from a where-select iterator, and falls back to a plain foreach, which allocates nothing.

Now make one lambda capture a local:

static long TotalCapturing(Reading[] readings, long threshold) =>
    readings.Where(r => r.Bytes > threshold).Select(r => r.Bytes).Sum();

That comes to 200 bytes per call instead of 112. The extra 88 is a display class holding the captured long, 16 bytes of header plus an 8 byte field, which is 24, plus a delegate object at 64, which is a 16 byte header over the six reference-sized fields Delegate and MulticastDelegate declare between them. The second lambda still captures nothing, so it still comes out of the static cache.

In C#, capture is the allocation, not the lambda.

The Rust side is a struct, and then it is a loop

Nothing above happens in the Rust version, and the reason is that Rust's iterator adapters are not objects that wrap other objects. They are structs that contain other structs, by value.

The definitions in the standard library are two fields each:

pub struct Filter<I, P> {
    iter: I,
    predicate: P,
}

pub struct Map<I, F> {
    iter: I,
    f: F,
}

So readings.iter().filter(p).map(f) has the type Map<Filter<slice::Iter<Reading>, P>, F>, where P and F are the anonymous types of your two closures. Neither closure captures anything, so both are zero-sized, and the whole nest is exactly the 16 bytes of the slice iterator. There is no object, no header, no allocation, and nothing for a collector to trace.

Then generic code gets stamped out per concrete type. Filter::next for your specific predicate type is a separate function from Filter::next for anyone else's, which means the compiler knows the exact closure body it is calling and can inline it. No virtual call, no delegate invoke, no guarded devirtualization needed because there was never a call to devirtualize.

There is a second mechanism worth knowing, because it is why iterator chains optimize so unusually well. sum is implemented in terms of fold, and adapters like Filter override fold rather than relying on repeated next calls. That converts the pipeline from external iteration, where the consumer pulls one item at a time through a chain of state machines, into internal iteration, where the source drives a single loop and the adapters are just closures called inside it. What LLVM sees at that point is one loop over a contiguous slice with two inlined bodies, which is the loop you would have written.

Closures are types, and that is the whole difference

In C# a lambda becomes a delegate. Func<Reading, bool> is one type, whatever the body is, and two different predicates are two instances of the same type. That uniformity is why you can put a lambda in a field, swap it at run time, and store a dictionary of them without thinking about it.

In Rust a closure expression produces a value with a unique, anonymous type that cannot be written out, approximately equivalent to a struct holding the captured values, with an impl of Fn, FnMut or FnOnce generated for it. Two closures with identical bodies are two different types.

The compiler will tell you this the first time you fight it:

fn main() {
    let mut a = 0u8;
    let foo: fn(u8) -> u8 = |v: u8| { a += v; a };
}
error[E0308]: mismatched types
 --> src/main.rs:3:29
  |
3 |     let foo: fn(u8) -> u8 = |v: u8| { a += v; a };
  |              ------------   ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found closure
  |              |
  |              expected due to this
  |
  = note: expected fn pointer `fn(u8) -> u8`
                found closure `{closure@src/main.rs:3:29: 3:36}`
note: closures can only be coerced to `fn` types if they do not capture any variables
 --> src/main.rs:3:39
  |
3 |     let foo: fn(u8) -> u8 = |v: u8| { a += v; a };
  |                                       ^ `a` captured here

Read that note carefully, because it is the whole model in one line. A closure with no captures is a function, and coerces to a plain function pointer. A closure with captures is a value carrying data, and there is no single type that can hold it alongside every other closure.

The payoff is the 16 byte pipeline above. The price is that impl Fn(...) in a signature means "one specific closure type chosen at compile time", so a function cannot return two different closures from two branches:

fn pick(flag: bool) -> impl Fn(u32) -> u32 {
    if flag {
        |x| x + 1
    } else {
        |x| x * 2
    }
}

Trimmed to the lines that matter, the diagnostic reads:

error[E0308]: `if` and `else` have incompatible types
  = note: expected closure `{closure@src/main.rs:3:9: 3:12}`
             found closure `{closure@src/main.rs:5:9: 5:12}`
  = note: no two closures, even if identical, have the same type
  = help: consider boxing your closure and/or using it as a trait object

Take the help. Box<dyn Fn(u32) -> u32> gets you back to exactly what C# had: one allocation, one indirect call, one type that any closure fits into. That is the honest framing of the whole chapter in miniature. Rust's default is the specialized version with no runtime cost, and the general version is available for one allocation when you need it. C# only ships the general version, and then spends enormous engineering effort in the JIT trying to recover the specialized one.

Bounds checks, honestly

This is the part of the zero-cost pitch that gets oversold, so take it slowly.

Rust bounds-checks v[i] and panics on failure. .NET bounds-checks array and span indexing and throws IndexOutOfRangeException. Both are on by default in release builds. Neither language lets you index past the end and keep going.

Both compilers remove the check when they can prove it is redundant. RyuJIT has a dedicated range analysis phase whose stated job is to eliminate array index range checks based on value numbers and assertions, and the canonical for (int i = 0; i < arr.Length; i++) shape is the one it handles best. Rust leans on LLVM, and the reliable pattern is the same idea from the other direction: iterate rather than index, so the bound is structural and there is nothing to check.

Both have known gaps, and they are the same kinds of gaps: the analysis is pattern matching on shapes it recognizes, and anything outside those shapes keeps its check. The dotnet issue tracker carries a steady stream of them, such as checks that survive when a loop touches several different indices and a redundant check that is not folded away after an earlier one already proved the range. Rust has the mirror image: an index expression carries a check unless LLVM can see the bound, so for i in 0..v.len() { v[i] } is usually fine and anything that indexes a second slice with the same variable usually is not, which is why zip and iter().enumerate() are the idiomatic shapes.

Both give you an escape hatch. Rust has get_unchecked, which is unsafe. C# has Unsafe.Add and MemoryMarshal.GetReference, which are the same thing with a different spelling.

Nobody wins this one. If you are moving to Rust expecting free indexing, you are moving for the wrong reason.

Where the claim actually fails

"You could not hand code it any better" is an aspiration, not a theorem, and it is worth naming the cases where it is false.

The clearest one is a place where C# is straightforwardly better. Enumerable.Sum over an int[] in modern .NET does not do what you think. It calls TryGetSpan, and if the source is contiguous it takes a vectorized path that accumulates four Vector<T> per iteration and tracks overflow by watching sign bits, then checks once at the end instead of branching per element. The gate is real and the source says so: it needs Vector<T>.Count above 2 and a span of at least four vectors, so on a 128 bit machine long sums stay scalar. Everything that misses the gate falls into a checked scalar loop. Either way you get a guaranteed OverflowException, and often SIMD, from a naive nums.Sum().

Rust's Iterator::sum on a primitive integer panics on overflow only when overflow checks are enabled, which by default means debug builds. In release it wraps. So the same program can be correct in debug and silently wrong in release, and the language considers that fine, because wrapping is defined behavior rather than undefined behavior. If you want the C# semantics you give up sum and write iter.try_fold(0u64, u64::checked_add), then handle the None, which is more code and is not what the ergonomic path hands you.

That is a real point against Rust, and it is worth internalizing early: Rust removed the tax on abstraction, not the tax on arithmetic you did not think about.

There are softer failures too. format! allocates. Trait objects cost an indirect call. Deeply nested iterator chains occasionally defeat LLVM's inliner, and when that happens the abstraction is not free and the only way to know is to look at the generated code. The claim holds far more often than it fails, which is why it is worth making, but it is a claim about a compiler doing its job rather than a guarantee written into the language.

Where you do pay

Compile time. This is the tax you feel every day, and monomorphization is the mechanism. A generic function is not machine code until someone picks concrete types for it, so the compiler stamps out a separate copy per instantiation. Vec<u64> and Vec<String> produce two copies of the generated code, and the work scales with the number of instantiations rather than the number of source lines.

The part that surprises people arriving from .NET is where that work happens. A generic function from a dependency is not machine code in the dependency's rlib. It is stored as MIR, and it gets collected and codegenned inside your crate, because only your crate knows which types you instantiated it with. Pull in a library that is generic over everything and you have pulled its code generation into your build. Add the trait solver and the borrow checker on top, both of which are doing real proof work, and the result is a compiler that does a lot more per build than a JIT-backed toolchain that defers specialization to run time.

This is not a small complaint from a fringe of users. In the Rust project's own 2025 compiler performance survey, around 45 percent of respondents who had stopped using Rust listed long compile times as one of the reasons. Type checking is separable from code generation, which is why cargo check is so much faster than cargo build, and that separation is the single most useful fact about the Rust build model. It is also what the survey found people already lean on hardest: about 60 percent said they run a cargo command after each change, with cargo check the most common.

Binary size. Ten instantiations of a generic function are ten functions in the binary. Usually irrelevant, occasionally not, and dyn is the lever: one vtable and one copy of the code, in exchange for indirect calls. If a generic function is large and cold, taking &dyn Trait instead of impl Trait is a straight trade of a little speed for a lot of size.

Runtime costs you opted into. Rc increments a plain counter. Arc does an atomic one. RefCell reads and writes a borrow counter on every borrow and panics if the counter says no. dyn costs an indirect call. None of these are free, and none of them are imposed on code that does not use them, which is the second half of the original claim and the more important half.

Your time. The first month is slower. That is a real cost and it belongs on the list.

Where the allocations show up at three thousand requests a second

At the scale of a script none of this is visible. At the scale where it matters, the pattern is consistent and it is about variance rather than averages.

Take a service at 3,000 requests per second where each request runs ten LINQ queries shaped like the one above. At 200 bytes a query that is 6 MB per second of short-lived garbage, which is not a large number, and that is the point worth being honest about. Gen 0 handles it easily, which is exactly the workload it was designed for, and the cost does not appear as latency on the request that allocated it. It appears as collection frequency, and eventually as the occasional promotion into gen 1 and gen 2, and it lands on some unrelated request's tail latency. Server GC and background collection push the pauses down a long way, but they do not make the work disappear, and the query allocation is only one contributor sitting on top of everything else the request allocates.

The Rust version of that pipeline allocates nothing, so there is no collector to schedule and no pause to land anywhere. That is the actual argument for Rust in a hot path, and notice how narrow it is. It is not "Rust is faster". Average throughput on a well-tuned .NET service is frequently competitive and sometimes better, because the JIT knows things an ahead-of-time compiler cannot. The argument is that the p99.9 stops having a component you did not put there.

The second thing that shows up at scale is resident memory. A .NET process holds a heap sized by the collector's policy rather than by your live set, and the gap between the two is often large. If you are running one agent per host across ten thousand hosts, or packing containers by memory limit, that gap is money. A Rust process holds roughly what it allocated.

How to accidentally measure nothing

Measuring a debug build. Rust's debug profile compiles at opt-level = 0 with integer overflow checks on, so the iterator fusion this whole section is about does not happen. Numbers from it tell you nothing about release behavior. If you benchmark once in your first week and conclude Rust is slow, this was why.

Reaching for Box<dyn Fn> because delegates are one type. The C# habit is to type a variable as Func<T, R> and move on. The Rust equivalent of that habit allocates on every closure. Recognize it by seeing Box<dyn Fn...> in a signature that is only ever called with one closure, and fix it by taking impl Fn(...) or a generic F: Fn(...) parameter instead.

Collecting between every stage. .iter().map(...).collect::<Vec<_>>() followed by another .iter() is the mechanical translation of a LINQ chain that someone materialized with ToList for readability. In C# that costs one list. In Rust it costs an allocation and destroys the fusion that made the pipeline free in the first place. Chain the adapters and collect once, at the end.

Cloning to make a borrow error go away in a hot loop. Fine in cold code, deliberate in warm code, and a genuine performance bug in a loop. If the same .clone() runs per iteration, that is the signal to restructure rather than a cost of doing business.

Assuming LINQ is the slow part. Often it is not. Before rewriting a query, measure. Very often the allocation you are chasing is a string.Format or a serializer, and the LINQ chain is a rounding error next to a database round trip.

So when is it worth it

I would not port a healthy ASP.NET service to Rust. Modern .NET is genuinely fast, the ecosystem is deeper for line-of-business work, and a modest throughput gain almost never justifies a rewrite of something that already works.

Where I would reach for Rust:

  • Something in the hot path of every request where tail latency matters more than throughput, and collection pauses are showing up in the p99.
  • Something with a hard memory budget, such as an agent on every host, where resident set is the constraint.
  • A library that has to be called from other languages, where shipping a runtime is not acceptable.
  • CLI tools, where a single static binary with no runtime dependency is worth a lot.
  • Anywhere you would otherwise write C or C++.

Where I would stay on .NET: CRUD services, anything dominated by database or network latency, teams without Rust experience under delivery pressure, and anything needing the enterprise integration surface that NuGet has and crates.io does not.

The interesting version of this decision is rarely all-or-nothing. Rust speaks the C ABI, so the realistic move is to take the one component that is actually hot, write that in Rust, and leave everything else where it is.

That path is short enough to show. On the Rust side, a cdylib crate called fastsum with one exported function:

#[unsafe(no_mangle)]
pub extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
    if ptr.is_null() || len == 0 {
        return 0;
    }
    // SAFETY: the caller guarantees `ptr` points at `len` initialized bytes
    // that stay valid and unwritten for the duration of this call.
    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
    bytes
        .iter()
        .fold(2166136261u32, |h, b| (h ^ *b as u32).wrapping_mul(16777619))
}

And on the .NET side, the LibraryImport source generator, which writes the marshalling code at compile time instead of at run time and therefore works under Native AOT:

using System.Runtime.InteropServices;

internal static partial class Native
{
    [LibraryImport("fastsum")]
    private static partial uint checksum(ref byte data, nuint len);

    public static uint Of(ReadOnlySpan<byte> data) =>
        data.IsEmpty ? 0 : checksum(ref MemoryMarshal.GetReference(data), (nuint)data.Length);
}

A ref parameter of a blittable type is pinned by the generated stub and handed over as a bare pointer, so MemoryMarshal.GetReference gets you from a span to that pointer with no copy and no marshalling layer. nuint is what lines up with Rust's usize. Note the small irony in the project file: the generator requires AllowUnsafeBlocks, so the safe-looking C# side of this boundary is also opting out of verification.

This is the boundary most teams actually adopt Rust across, and the unsafe block on the Rust side sits exactly on it, which is the subject of the rest of this chapter.

Unsafe is a smaller word than you think

Every so often someone points out that Rust has unsafe, therefore the safety guarantees are theater. That argument does not survive contact with what the keyword actually does.

Inside an unsafe block, exactly five things become possible:

  1. Dereference a raw pointer.
  2. Call a function marked unsafe, including anything through a foreign function interface.
  3. Read or write a mutable static.
  4. Implement an unsafe trait, such as Send or Sync, by hand.
  5. Access the fields of a union.

That is the complete list.

Note what is not on it. The borrow checker still runs. Types are still checked. Lifetimes are still enforced. Ownership still moves. Drop still runs. unsafe does not turn off the language, it permits five specific operations the compiler cannot verify.

You can watch it refuse things. The diagnostic is E0133, and it is worth seeing the exact wording, because the note at the bottom tells you what obligation you just picked up:

error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
 --> src/main.rs:2:13
  |
2 |     let _ = *p;
  |             ^^ dereference of raw pointer
  |
  = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior

The same code with a mutable static gets a different note:

error[E0133]: use of mutable static is unsafe and requires unsafe function or block
  |
  = note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

And calling an unsafe fn gets a third, which is the most instructive of the three: "consult the function's documentation for information on how to avoid undefined behavior". The compiler is not saying "this is dangerous". It is saying "somebody wrote down a condition, go read it, and then assert that you have met it".

The keyword is a proof obligation moving hands. That is genuinely all it is.

The boundary is the architecture

The part that takes longest to appreciate: unsafe is not primarily an escape hatch for application code. It is how the standard library is built.

Vec<T> is unsafe inside. Since push_mut stabilized in Rust 1.95, Vec::push is a one-line wrapper that calls it and discards the reference, so the body worth reading is push_mut:

pub fn push_mut(&mut self, value: T) -> &mut T {
    // Inform codegen that the length does not change across grow_one().
    let len = self.len;
    // This will panic or abort if we would allocate > isize::MAX bytes
    // or if the length increment would overflow for zero-sized types.
    if len == self.buf.capacity() {
        self.buf.grow_one();
    }
    unsafe {
        let end = self.as_mut_ptr().add(len);
        ptr::write(end, value);
        self.len = len + 1;
        // SAFETY: We just wrote a value to the pointer that will live
        // the lifetime of the reference.
        &mut *end
    }
}

Look at what is going on. Pointer arithmetic with add. A write into memory that is uninitialized, using ptr::write specifically because assignment would try to drop whatever garbage was already there. A length field updated by hand afterward. Every one of those steps is undefined behavior if the capacity check above it is wrong.

And the signature is completely safe. There is no way to call push incorrectly, no way to observe the uninitialized region, no way to get the length out of step with the initialized prefix, because len and buf are private and every method that touches them maintains the same invariant.

RefCell is unsafe inside. Arc is unsafe inside, including the memory ordering on its counter. Mutex sits on a platform primitive, a futex on Linux and SRWLOCK on Windows. Every one of them exposes a safe API.

You can write the same shape yourself in a few lines. This is a complete program:

use std::slice;

fn split_at_mut<T>(values: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    // SAFETY: `mid <= len` was just checked, so both ranges are inside the
    // same allocation. The two ranges do not overlap, so handing out two
    // mutable slices does not alias.
    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut v, 3);

    left[0] = 100;
    right[0] = 200;
    println!("{left:?} {right:?}");

    println!("{v:?}");
}

Two mutable references into one buffer, which is precisely the thing the borrow checker exists to reject. It is sound anyway, because the assertion plus the arithmetic proves the two ranges are disjoint, and that proof is written down in the comment above the block. The function's callers get an ordinary safe API and never see any of it.

That is the actual architecture: a large safe language sitting on a small unsafe core, with the boundaries explicit and greppable. Compare it to C or C++, where every line has the same authority as every other, and auditing means reading all of it.

The greppable part is not rhetorical. Put #![forbid(unsafe_code)] at the top of a crate and the compiler will reject any unsafe block in it, along with no_mangle, export_name and link_section, which turns "we do not write unsafe code here" from a code review convention into a build failure. It is a crate-level lint, so it says nothing about your dependencies.

The compiler pushes from the other side too. Wrap something safe in an unsafe block and the unused_unsafe lint fires:

warning: unnecessary `unsafe` block

That keeps the marked regions honest as the code underneath them changes.

If you do write it, Miri is the tool. cargo miri test runs your test suite under an interpreter that detects out-of-bounds access and use-after-free, invalid use of uninitialized data, insufficiently aligned accesses, values that violate their type's invariants such as a bool that is not 0 or 1, data races, and, as an experimental feature, violations of the Stacked Borrows or Tree Borrows aliasing rules. Interpretation costs what interpretation costs, and Miri only sees the paths your tests actually execute, and it is still the best thing available by a distance. If a crate of yours has an unsafe core, running Miri over its tests in CI is the single highest-value thing you can do.

The C# comparison, which is closer than you expect

C# has all of this, in pieces, with different names.

unsafe plus AllowUnsafeBlocks gives you raw pointer types and pointer arithmetic. fixed pins a managed object so the collector cannot move it while you hold a pointer into it, which is a problem Rust simply does not have because nothing relocates. stackalloc allocates a block in the stack frame. Marshal does manual allocation, copying and layout work across the native boundary. And System.Runtime.CompilerServices.Unsafe is the genuinely sharp one: Unsafe.As reinterprets a reference as a reference to another type with no check at all, which is the same move as a std::mem::transmute between reference types and just as capable of producing a value that cannot exist.

Same shape as Rust, then: a small marked door out of the safe language, for the cases the safe language cannot express.

Three differences matter.

Scope. C#'s unsafe is a per-assembly compiler flag plus a per-region keyword, and inside a region the whole pointer language switches on at once: pointer types, pointer arithmetic, stackalloc, fixed, sizeof on unmanaged types. Rust's is a five-item list and nothing else, which is why unsafe in Rust reviews as a narrow question and unsafe in C# reviews as a broad one.

Ecosystem convention. In Rust, the safe wrapper around an unsafe core is the expected design, the community reviews crates on that basis, and forbid(unsafe_code) is a badge people put in readmes. In .NET the same discipline exists in the runtime itself and much less consistently outside it, partly because there is far less need for it.

Tooling. There is no Miri for .NET. There is also much less demand for one, and that is the honest reason.

Where C# is genuinely better here

You will almost never need any of it.

That is not a small thing. The reason Rust needs a carefully specified unsafe subset with a dedicated interpreter to check it is that Rust has to build Vec out of raw memory. In C#, the collector already solved the lifetime problem, List<T> already exists, and Span<T> covers the large majority of what raw pointers used to be for while staying inside the safe, bounds-checked, GC-tracked world. A .NET engineer can have a long and productive career without writing unsafe once, and that is a genuine advantage of the platform rather than a gap.

.NET's native interop story is also more mature and much better documented as a product. P/Invoke has been in the platform for two decades, the marshalling rules are specified, the LibraryImport source generator produces readable code you can step through, and the guidance is thorough. Rust's FFI is more primitive by design: you get extern "C", raw pointers, and a lot of hand-written glue, or you reach for a crate that generates the glue.

The mistakes a C# engineer makes with unsafe

Wrapping a borrow error in unsafe to make it go away. This is the reflex, and it does not work. unsafe does not disable the borrow checker, so the error you were fighting is unchanged and you have added a marker that lies about what the code needs. If E0499 or E0502 is what you are seeing, unsafe is not the fix and there is no version of this where it becomes the fix.

Treating unsafe fn as "this function is dangerous". It means "this function has preconditions the compiler cannot check, and the caller must satisfy them". Marking a function unsafe because it does something you find scary, when it has no preconditions, is noise. Marking it safe when it does have preconditions is a bug, and it is the specific bug that makes a crate unsound.

Not writing the safety comment. Every unsafe block should be preceded by a comment naming the invariant that makes it sound, in the style the standard library uses. If you cannot write the sentence, you do not have the proof, and the block should not be there. This is the most valuable habit in the whole subject and it costs nothing.

Reaching for static mut. The direct translation of a C# static field, and in edition 2024 taking a reference to one is an error rather than a warning. What you want is OnceLock for initialize-once, an atomic for a counter, or a Mutex for anything else.

Assuming unsafe means C-like performance. It does not make anything faster on its own. get_unchecked removes a branch the predictor was almost certainly getting right; where it occasionally earns its keep is when the check was the thing stopping a loop from vectorizing. Measure first, and expect the answer to be that the bounds check was not your problem.

You probably will not write it

Realistically, in your first year you will write unsafe if you are doing foreign function interface work, implementing a data structure the borrow checker genuinely cannot express, or chasing a specific performance problem you have already measured. Those are real reasons and there is nothing wrong with any of them.

The rest of the time you will use it constantly without noticing, because it is underneath every Vec, every String, every Mutex, every channel and every allocation you make. Somebody already wrote the proof, wrote it down next to the code, and put a safe type around it. That arrangement, not the keyword, is the thing worth taking away.

Checking yourself

What does "zero cost" actually claim, and what does it not claim? It claims that the abstraction adds nothing over the code you would have written by hand, and that features you do not use cost you nothing. It does not claim the operation is free, that Rust is faster than C#, or that the compiler always succeeds.

A colleague says Rust bounds checks make it slower than C#. What is wrong with that? Both languages bounds check by default and both eliminate the check when they can prove the index is in range, with the same iterate-rather-than-index pattern making it provable in both. It is not a difference between the languages.

Does an unsafe block let you ignore the borrow checker? No. It permits exactly five operations: raw pointer dereference, calling unsafe functions, reading or writing mutable statics, implementing unsafe traits, and reading union fields. Borrow checking, type checking, lifetimes and ownership all still apply, which is why unsafe is not a fix for E0502.

Why is Vec::push safe to call when its body does pointer arithmetic into uninitialized memory? Because the unsafe operations sit behind an invariant the type enforces privately: the capacity check happens before the write, and len and the buffer are inaccessible from outside. The safe API is the boundary, and the proof that the boundary holds is written down next to the code.

What I would actually do in your first month

Everything above is the model. This is the practice: what to install, what to build, what order to learn things in, and which well-intentioned first projects will waste your week.

The toolchain, and why it feels different

Install through rustup rather than a package manager. It manages toolchains the way a version manager does, and it is what every piece of documentation assumes you have.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup component add clippy rustfmt

cargo then does the job of dotnet, NuGet, MSBuild and your test runner at once. The commands worth knowing on day one:

CommandWhat it does.NET equivalent
cargo new namescaffold a projectdotnet new console
cargo checktypecheck without producing a binaryno real equivalent, and you will miss it
cargo build --releaseoptimized builddotnet build -c Release
cargo runbuild and rundotnet run
cargo testunit, integration and documentation testsdotnet test
cargo add serdeadd a dependency and edit the manifestdotnet add package
cargo clippythe lint pass that teaches you idiomanalyzers, but better
cargo fmtformat, non-negotiablydotnet format
cargo doc --openbuild and open your API docsDocFX, without the setup

cargo check is the one to internalize. It runs the whole front end, borrow checking included, and stops before code generation, so it is several times faster than a build. Bind it to your editor's save action and you get the compiler's answer in a second or two rather than waiting for a full compile. Combined with rust-analyzer, which is the language server everyone uses, this is most of what makes the compile times bearable.

Two more things that surprised me coming from .NET. There is no separate test project by convention: unit tests live in the same file as the code they test, inside a module marked #[cfg(test)], so they are compiled out of release builds entirely. And the examples in your documentation comments are compiled and run by cargo test, which means documentation that lies fails the build.

/// Returns true for hostnames inside the internal zone.
///
/// ```
/// assert!(is_internal("db-1.internal"));
/// assert!(!is_internal("example.com"));
/// ```
pub fn is_internal(host: &str) -> bool {
    host.ends_with(".internal")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_external_hosts() {
        assert!(!is_internal("example.com"));
    }
}

Learn to read the diagnostics properly

Rust's error messages are the best I have used in any language, and reading them fully rather than skimming to the first plausible change is the single habit that shortens the learning curve most.

A diagnostic has a consistent shape: an error code, a one-line summary, the primary span with a caret under the offending expression, one or more secondary spans showing the other end of the conflict, and then note and help lines. The secondary span is the part people skip, and it is usually where the actual answer is, because a borrow error is never about one location. It is about two locations that cannot both be true.

When the summary is not enough, rustc --explain E0502 prints several paragraphs on that specific error class with a worked example. There is no equivalent in the .NET tooling and it is genuinely useful in the first month.

Clippy deserves a paragraph of its own. It is not a style checker with opinions about braces. It knows the idiomatic form of hundreds of patterns and will tell you that your loop should be an iterator chain, that your match is a map, or that you are cloning something you could borrow. Treating its output as a tutorial rather than as noise will teach you idiom faster than reading code will.

A four-week shape

Week one, get ownership into your hands. Write a command line tool that reads files and prints something about them. Word counts, a log filter, anything with a real input. Do not model anything with a graph in it. The only goal is that moves stop surprising you.

Clone freely while you do it. Your instinct on the first borrow error will be to fight it for twenty minutes. Write .clone(), keep moving, and leave a comment. Optimizing code you have not finished writing is a waste, and the allocation you are avoiding is almost never one that matters.

Week two, take the clones back out one at a time and let the compiler explain why each one existed. This is where &str against String, and &[T] against Vec<T>, stop being trivia and start being a decision you make deliberately. By the end of it you should be able to look at a function signature and say what it does to its arguments without reading the body, which is a thing you cannot do in C#.

Week three, traits and error handling. Define your own error enum, implement From for the errors you wrap, use ? throughout, and derive Debug, Clone and PartialEq on your types. Add thiserror when writing the conversions by hand gets tedious. Most of what looks like magic in a good Rust codebase turns out to be a trait implementation you have not read yet.

Week four, pick a direction based on what you actually build. For services, go to async: futures, Tokio, and why Send bounds propagate up through everything. For libraries and tools, go to generics and dispatch: monomorphization, dyn, and when each one is right.

Leave unsafe until you have a reason. Read the Rustonomicon then, not before.

The traps, specifically

Do not implement a linked list. It is the classic first data structure and it is unusually hostile in Rust, because a doubly linked list needs shared mutable ownership and drops you into Rc<RefCell<T>> or raw pointers before you understand either. There is an entire well-known book about how bad an idea this is. Write a parser, a log filter, or a small HTTP client instead.

Do not start with an async web service. It stacks async, lifetimes, trait bounds and an unfamiliar framework on top of ownership, all in week one, and when something confuses you there will be no way to tell which of the four is responsible. Write something synchronous first.

Do not port a C# design directly. The class hierarchy with an abstract base and three overrides does not translate, and forcing it produces the worst Rust you will ever write. Ask what data is owned by whom first, then work out where the behavior goes. Composition and traits usually land somewhere simpler than the hierarchy you started with.

Do not reach for Rc<RefCell<T>> on the first borrow error. It is the shape that makes Rust feel like C# again, which is exactly why it is tempting, and it trades compile-time errors for run-time panics. Try restructuring so ownership forms a tree, or use indices into a central Vec, before you decide the type system is the problem.

Do not fight a lifetime error for an hour. If a struct holding references is causing pain in month one, take the allocation. Own the data, move on, and come back when the borrow checker is somewhere you can think in.

Editions, briefly

Editions are opt-in language revisions. They let the language change syntax and idiom without breaking crates written years earlier, because each crate declares which edition it was written against and the compiler handles the mix. The edition guide records what changed in each one.

For a new project this needs no thought: cargo new picks the current edition and you move on. It matters when you read old blog posts, because pre-2018 code has a different module system and different lifetime behavior, and following it will confuse you for reasons that have nothing to do with your code.

The thing to take away

Go back to the first paragraph of this guide. borrow of moved value is not the compiler being pedantic. It is the compiler asking who owns this, and when does it die, which is a question every program has to answer whether or not the language makes you say it. C# answers it at run time, by tracing what is still reachable and reclaiming the rest. You get to not think about it, and you pay in a runtime, in pause behavior under load, and in an entire category of aliasing bug that only appears when two things touch the same object at an unlucky moment.

Rust demands the answer at compile time. In exchange it deletes the collector, makes destruction deterministic and impossible to forget, and turns data races into type errors. The frustration of week one is the price of the answer, and it drops sharply once you stop reading the borrow checker as an obstacle and start reading it as a question you were always supposed to have answered.

Neither answer is better in general. They are different points on the same trade, and knowing both makes you better at choosing between them, including when the right choice is the one you already have. Most of what you know about .NET still applies, because a hash map is still a hash map and a thread pool is still a thread pool. What changes is that the thing you were allowed to leave implicit now has to be said out loud, in the signature, where the next person can read it.

Checking yourself

Why is cargo check worth binding to your editor rather than cargo build? It runs the full front end including borrow checking and stops before code generation, so it gives you every error you care about in a fraction of the time.

What is the first thing to try when a borrow error appears, before reaching for Rc<RefCell<T>>? Clone, or restructure so ownership forms a tree. Interior mutability trades a compile-time error for a possible run-time panic, and it is rarely the cheapest fix in the first month.

Why do editions exist, given that Rust promises backward compatibility? They let syntax and idiom change without breaking old crates, because the edition is declared per crate and the compiler links them together. Backward compatibility is preserved at the ecosystem level rather than by freezing the language.

Sources and further reading

Each chapter links its sources at the claim they support. This is the shorter list of things worth reading in full, rather than consulting.

The official material, in the order it becomes useful

The .NET side of each comparison

Going deeper

On whether any of this is worth it

Continue learning

A path rather than a pile.

Start by making ownership automatic. Write a small tool that reads files and prints something, and clone whenever the compiler complains. The goal is not efficiency, it is getting to the point where moves stop surprising you.

Then take the clones out one at a time and let the borrow checker explain why each existed. This is where owned against borrowed types stop being trivia and become a decision you make deliberately.

Once borrowing is comfortable, learn traits properly, especially the conversion traits, iterators and the derive macros. Most of what feels like magic in a good Rust codebase is a trait implementation you have not read yet.

After that, pick a direction based on what you build. For services, go to async: futures, Tokio, and why the Send bound propagates through everything. For libraries and tools, go to generics and dispatch.

Leave unsafe until last, and only if you need it.

Two things worth understanding alongside all of this, because they explain more than their size suggests: how memory layout and alignment decide what a struct actually costs, and how a tracing collector decides what is still reachable. The second is what makes the first chapter land.

Frequently asked questions

Do I need to know C or C++ first?

No, and coming from C# is arguably an advantage. You already think in types and abstractions rather than in manual allocation, so what you are actually learning is where values live and who owns them, not pointer arithmetic. A C++ background helps with layout intuition and actively hurts with move semantics, because a C++ move leaves a valid, usable object behind and a Rust move does not. C++ engineers spend their first week discovering that the moved-from variable is gone at compile time rather than merely empty.

Is Rust actually faster than C#?

For most application code, not by enough to justify a rewrite. Modern .NET is a genuinely fast runtime, the JIT is very good, and if your bottleneck is a database query or a network round trip then the language is not what is slow.

Rust wins reliably in three specific places. Tail latency, because there is no collector to pause and no allocation rate to tune, so p99 tracks p50 more closely. Memory footprint, because there is no runtime to load, no object header per instance, and much less pointer chasing per logical object. And startup, because there is nothing to warm up, which is what makes it attractive for command line tools and short-lived processes.

Notice that only one of those three is throughput. If you are choosing on average request time alone, you are usually choosing on the wrong axis.

How long before I am productive?

Two to four weeks to write useful code if you are already senior. Two to three months before the borrow checker stops being the main thing you are thinking about. The curve is unusually front-loaded, which is why the language has a reputation for being hard: most of the difficulty arrives in the first fortnight rather than being spread out, and people judge it from inside that window.

What flattens it fastest is resisting the urge to be efficient early. Clone, get the program working, and take the clones out once you understand why each one was there.

Can I call Rust from .NET?

Yes, and it is the sensible way to adopt it. Build the Rust side as a cdylib exposing extern "C" functions, then call it with DllImport, or with the LibraryImport source generator on .NET 7 and later.

The boundary is a C ABI, which carries no lifetime or ownership information, so marshalling and ownership across it are entirely your responsibility. The usual rules apply: decide which side allocates and which side frees, do not pass Rust String or Vec across directly, and expose a free function for anything Rust allocated. Taking the single hot component out of an existing service is a far better first project than a rewrite, and it lets you keep everything the .NET ecosystem is good at.

Should I start with an async web service?

No. It stacks async, lifetimes, trait bounds and an unfamiliar framework on top of ownership all at once, and when you get stuck you will not be able to tell which of the four is responsible. Write something synchronous and small first. You will get to the web service two weeks later and it will take an afternoon.

Does Rust have a garbage collector?

No. Deallocation points are determined at compile time and emitted as ordinary code, which is why destruction is deterministic and why there is no runtime to ship.

Rc and Arc do reference counting where shared ownership is genuinely needed. That is automatic memory management, but it is not a tracing collector and the difference matters: reference counting cannot collect cycles, so two values holding counted handles to each other leak. Weak is the fix, and it is a fix you have to remember to apply. This is the one place where .NET's collector is straightforwardly better than what Rust gives you, and it is worth saying plainly rather than defending.

Does the borrow checker change how I design, or is it just a stricter compiler?

It changes the design, and this is the part that is hard to convey before you have felt it.

In C#, ownership is a documentation concern. Who is allowed to mutate this list, who is responsible for disposing this handle, whether this array is safe to hand out or needs a defensive copy: all real questions, all answered in comments, conventions and code review. Rust moves those questions into the signature, so they get answered when you write the function rather than when someone violates the assumption six months later.

The practical effect is that data flow becomes a tree far more often than it does in C#, because a graph of mutable references is expensive to express and a tree is free. That constraint pushes designs toward clearer ownership than most of us would have arrived at unaided, which is the argument people mean when they say the borrow checker made them a better C# programmer too.

What is the ecosystem genuinely missing compared to NuGet?

Be realistic about this, because it is the most common reason a sensible Rust project fails.

Rust is strong where it grew up: networking, parsing, serialization, command line tools, cryptography, embedded, WebAssembly, and increasingly data infrastructure. Web frameworks and HTTP clients are good. Serialization through serde is better than anything in .NET.

It is weaker in enterprise integration. Object relational mapping exists but nothing has the maturity or the tooling of Entity Framework Core. Desktop UI is unsettled. Cloud provider SDKs have improved a great deal but coverage lags the .NET SDKs. Anything touching the Microsoft ecosystem specifically, from identity libraries to Office formats to line of business connectors, is thin or absent.

If your service is mostly translating HTTP into SQL, .NET has more of what you need and a rewrite buys you very little. If it is mostly moving bytes, parsing protocols or holding state in memory under latency pressure, the gap closes and often reverses.

Is it worth learning if I will not use it at work?

I think so, for a reason that has nothing to do with employment. Rust forces you to be explicit about ownership, aliasing and lifetime, and those concepts exist in every language you already use. They were simply invisible because the runtime was handling them.

After a few months of Rust, the aliasing bugs in C# code start to look obvious rather than unlucky. You notice which methods take a collection and quietly keep a reference to it. You notice which disposal paths are only correct by convention. Whether or not you ship a line of Rust, the model transfers, and that is a better argument for learning it than performance is.