← Open in the full interactive course (progress tracking, search & more)

You've used out since Foundations without asking why the compiler trusts you. Here's precisely what it's checking.

You've written this line more times than you can count, going all the way back to early Foundations:

if (int.TryParse(input, out int number))
{
    Console.WriteLine($"Parsed: {number}");
}

Notice something: you never assigned number before this line, and yet the compiler let you read it immediately afterward, inside the if block, with total confidence it holds a real value. That's not luck, and it's not a special case for TryParse — it's a precise, general, compiler-enforced rule about what out parameters guarantee. Advanced Part I's value-vs-reference-types lesson also handed you a working preview of in parameters and promised the full mechanics later, in this Part.

In this lesson, you'll get the precise rules behind out, ref, and in — three ways of passing a parameter that go beyond ordinary pass-by-value — plus a brief look at ref returns and ref locals, and honest guidance on how often any of this actually matters in everyday code.

What Is It?

The Simple Explanation

By default in C#, arguments are passed by value — the method receives a copy, and changes to that copy don't affect the caller's original variable. ref, in, and out are three modifiers that change this default, each in a different, precise way:

The Technical Definition

All three modifiers change a parameter from ordinary pass-by-value into a form of pass-by-reference — the parameter becomes an alias for the caller's actual storage location, rather than an independent copy. What differs between them is the compiler-enforced contract layered on top of that reference:

Why Does It Exist?

The Problem — pass-by-value alone can't express these needs

Ordinary pass-by-value handles the overwhelming majority of method calls perfectly well — but it genuinely cannot express a few specific, common needs:

The Solution

out, ref, and in each solve exactly one of these needs, with the compiler enforcing the safety contract that makes the tool trustworthy: out gives you a second, guaranteed-assigned output channel; ref gives you genuine two-way pass-by-reference; in gives you the copy-avoidance of ref with the safety of ordinary pass-by-value, for the read-only case.

Big Picture

FOUR WAYS TO PASS A PARAMETER
(none) — PASS BY VALUE, THE DEFAULT
out — ONE-WAY, CALLEE → CALLER, MANDATORY
ref — TWO-WAY, READ AND WRITE, FREELY
in — READ-ONLY BY REFERENCE, NO COPY, NO MUTATION

How It Works

out — definite assignment, precisely

THE RULE THE COMPILER ACTUALLY ENFORCES
1. CALLER SUPPLIES NO INITIAL VALUE
bool ok = int.TryParse("42", out int number); // 'number' declared, uninitialized, right here
2. CALLEE MUST ASSIGN IT ON EVERY CODE PATH BEFORE RETURNING
public bool TryDivide(int a, int b, out int result)
{
    if (b == 0)
    {
        result = 0;   //  compile error without this line — this path doesn't assign 'result'
        return false;
    }
    result = a / b;   // this path assigns it too
    return true;
}
3. CALLER CAN THEN SAFELY READ IT — GUARANTEED, NOT ASSUMED
if (TryDivide(10, 2, out int quotient))
    Console.WriteLine(quotient); // guaranteed assigned, by the compiler's own rule

ref — genuine two-way pass-by-reference

void Swap(ref int a, ref int b)
{
    int temp = a;
    a = b;
    b = temp;
}

int x = 1, y = 2;
Swap(ref x, ref y);
Console.WriteLine($"{x}, {y}"); // 2, 1 — the CALLER's actual variables changed

What's happening: a and b inside Swap aren't copies — they're aliases for x and y themselves. Assigning to a inside the method is, physically, assigning to x. This is why the caller must explicitly write ref at the call site too (Swap(ref x, ref y), not Swap(x, y)) — C# deliberately makes this visible at every call, so nobody reading the caller's code is surprised that their variables might change.

in — pass-by-reference, read-only, enforced

public readonly struct Matrix4x4 // 16 floats — 64 bytes, as in Advanced Part I's preview
{
    public readonly float M11, M12, M13, M14; // ...and 12 more fields
    public float Determinant() => /* reads fields, computes a value */ 0f;
}

float ComputeDeterminant(in Matrix4x4 m)
{
    // m.M11 = 0; //  compile error: cannot assign to a member of 'm' because it is an 'in' parameter
    return m.Determinant(); // reading is completely fine
}

What's happening: the method receives a reference to the caller's actual 64-byte struct — no copy — but the compiler statically rejects any attempt to write through m, whether directly or by calling a member that would mutate it. You get ref's performance (no copy) with plain pass-by-value's safety guarantee (the caller's data can't change).

Simple Example — all three, side by side, on the same underlying problem

public readonly struct Point(double X, double Y)
{
    public double X { get; } = X;
    public double Y { get; } = Y;
}

// out — produce a brand-new value the caller didn't have to provide
bool TryCreatePoint(double x, double y, out Point point)
{
    if (double.IsNaN(x) || double.IsNaN(y))
    {
        point = default;
        return false;
    }
    point = new Point(x, y);
    return true;
}

// ref — mutate the caller's own variable in place
void Translate(ref Point point, double dx, double dy)
{
    point = new Point(point.X + dx, point.Y + dy); // reassigns the CALLER's variable
}

// in — read a (potentially large) struct without copying it, and without risk of mutation
double DistanceFromOrigin(in Point point)
{
    return Math.Sqrt(point.X * point.X + point.Y * point.Y); // read-only access, no copy
}

Three different jobs, three different modifiers: TryCreatePoint hands back a value the caller never had; Translate changes a value the caller already owns; DistanceFromOrigin only needs to look, so it borrows the caller's data instead of copying it.

Real-World Example

A financial reporting service processes an array of transaction totals, each represented as a sizable readonly struct Money (currency code plus decimal amount), and needs to compute both a total and a flag for whether any transaction looked suspicious — without incurring a struct copy for every comparison in a large batch:

public readonly struct Money(string currency, decimal amount)
{
    public string Currency { get; } = currency;
    public decimal Amount { get; } = amount;
}

// 'in' — read each Money value without copying it, for every element in a potentially large batch
bool IsSuspicious(in Money transaction) => transaction.Amount > 10_000m;

// 'out' — report two independent results from one pass, without allocating a tuple or a result object
void Summarize(Money[] transactions, out decimal total, out bool anySuspicious)
{
    total = 0m;
    anySuspicious = false;

    foreach (Money transaction in transactions)
    {
        total += transaction.Amount;
        if (IsSuspicious(transaction)) // implicitly passed by 'in' at the call site — no copy needed
            anySuspicious = true;
    }
}

Summarize(dailyTransactions, out decimal dayTotal, out bool flagged);
Console.WriteLine($"Total: {dayTotal:C}, Flagged: {flagged}");

in avoids copying Money on every single call to IsSuspicious across a potentially large array — meaningful at volume, exactly the kind of struct-copy cost Advanced Part I's value types lesson flagged. out lets Summarize report two genuinely independent results from a single pass over the data, with the compiler guaranteeing both are actually set before the method returns — no risk of the caller reading an uninitialized "did we find anything suspicious?" flag.

Analogy

Handing someone your actual mailbox key, not a photocopy

Ordinary pass-by-value is handing someone a photocopy of a letter — they can read it, scribble on their copy, even burn it, and your original letter is completely unaffected. ref is handing someone the actual key to your mailbox: they can take mail out, put new mail in, and every change is real, visible in your mailbox the moment you check it again. in is letting someone look through the glass window of a mailbox they can't open — they can read what's inside without ever touching it, and you never had to photocopy the letter just so they could look. out is handing someone an empty mailbox with an explicit promise: "you must put something in here before you leave" — and the mail carrier (the compiler) genuinely won't let them walk away until they have.

Under the Hood

WHAT'S ACTUALLY PASSED, AND ref RETURNS / ref LOCALS
1. ALL THREE PASS AN ADDRESS, NOT A COPY OF THE VALUE
2. ref RETURNS AND ref LOCALS — A BRIEF, NICHE EXTENSION OF THE SAME IDEA
public class Grid
{
    private readonly int[] _cells = new int[100];

    // Returns a REFERENCE to the actual array element, not a copy of its value
    public ref int At(int index) => ref _cells[index];
}

var grid = new Grid();
ref int cell = ref grid.At(5); // 'cell' is an alias for _cells[5] itself
cell = 42;                     // this writes directly into _cells[5]
Console.WriteLine(grid.At(5)); // 42

Common Confusion

1. "out and ref are basically interchangeable"

They share the underlying reference-passing mechanism, but their contracts point in different directions. ref requires the caller to already have a meaningful value (the callee may or may not use it) and places no assignment obligation on the callee. out requires nothing from the caller but places a strict assignment obligation on the callee. Using ref where you mean out forces callers to needlessly initialize a variable before a call that was always going to overwrite it; using out where you mean ref throws away any initial value the caller had, since out parameters are treated as definitely-unassigned on entry.

2. "in always makes a call faster"

Not automatically. For a small struct — a couple of int fields, say — the cost of passing by value is already tiny, and passing by reference instead can sometimes cost more in specific patterns, because the compiler may need to generate a defensive copy anyway when calling a non-readonly member through an in reference (the same defensive-copy mechanism Advanced Part I's readonly struct lesson covered). in earns its keep specifically for larger structs, and pairs best with a readonly struct, which eliminates the defensive-copy concern entirely.

3. "out parameters are basically the same as multiple return values in other languages"

The end result feels similar — a caller walks away with more than one piece of data — but the mechanism is genuinely different: an out parameter is the compiler proving, at compile time, that a specific variable gets written through a reference before the method returns. Tuple returns ((bool, int) TryDivide(...)) are a different, more modern C# idiom for a similar goal, and are often the more natural choice today — out remains common chiefly because of the huge amount of existing framework API (like TryParse) already built around it.

Common Mistakes

Mistake 1 — Missing an assignment path on an out parameter

Writing a method with multiple branches and forgetting to assign the out parameter on one of them, expecting the compiler to somehow let it slide.

public bool TryGetValue(string key, out string value)
{
    if (_data.ContainsKey(key))
    {
        value = _data[key];
        return true;
    }
    //  compile error: 'value' is not definitely assigned on this path
    return false;
}

Assign value = null; (or an appropriate default) on every remaining path before returning — the compiler is doing you a favor by refusing to compile this, since the alternative would be silently handing callers an unassigned variable.

Mistake 2 — Reaching for ref when the intent is really "give me a new value"

Using ref for a parameter the method only ever overwrites, never reads — forcing every caller to pre-initialize a variable pointlessly.

void Compute(ref int result) => result = 42; // never reads 'result' — 'out' says this intent honestly

int x = 0; // pointless initialization, forced by using 'ref' instead of 'out'
Compute(ref x);

If the method never reads the parameter's incoming value, that's the signal it should be out, not ref — it documents the real contract and removes the caller's unnecessary initialization.

Mistake 3 — Reaching for in/ref on small structs or reference types "for performance"

Sprinkling in across parameters of type int, bool, or small structs, or across reference-type parameters, expecting a meaningful speedup.

For a reference type, the "copy" being passed by value is already just a small pointer — in/ref add no benefit there and only add visual noise. For small value types, the copy cost is already negligible. Reserve in for parameters that are genuinely large structs (as a rough guide, meaningfully larger than a machine word or two) on paths that are actually called often enough to matter.

When Should I Use It?

Reach for them when

Skip them when

Rule of thumb: These modifiers matter mostly in performance-sensitive code working with sizable structs — most everyday application code, working with reference types and small structs, doesn't need any of them. Reach for out when the framework convention (TryXxx) calls for it, and reach for ref/in deliberately, on measured hot paths involving large structs — not as a reflexive habit.

Mental Model

out = "You don't need to give me anything — I promise to give you something back."
ref = "Give me your actual variable — I might read it, write it, or both."
in = "Give me your actual variable to read — I promise I won't touch it."

Remember: all three avoid copying the value on the way in; what differs is entirely the compiler-enforced promise about reading and writing that follows.

Key Takeaway


Check Your Understanding

You've used out for a long time and just met the precise rule behind it, alongside ref and in. Let's confirm the distinctions are solid.

1. Why does int.TryParse(input, out int number) let you safely read number immediately after the call, even though you never assigned it beforehand?

Show answer

Correct: B

Why B is correct: This is the precise mechanism the lesson names: the compiler performs static analysis on the body of any method with an out parameter, confirming that every code path assigns it before the method returns. That compile-time guarantee is exactly what makes reading it immediately after the call safe.

Why A is incorrect: There's no automatic zero-initialization for out variables from the caller's side — the guarantee comes entirely from the callee being required to assign it.

Why C is incorrect: Uninitialized local variables of any type, including int, are not safe to read in C# in general — the definite-assignment rule for out parameters is precisely what makes this particular case safe.

Why D is incorrect: TryParse is an ordinary method following the same out parameter rules any developer-written method with an out parameter must follow — no special compiler exemption is involved.

Reinforcement: Definite assignment isn't a TryParse-specific trick — it's the general rule behind every out parameter in C#.

2. A method signature is void Increment(ref int counter). What must be true about the argument at the call site?

Show answer

Correct: B

Why B is correct: Unlike out, a ref parameter requires the caller to have already assigned a value (since the callee is free to read it, not just write it), and C# requires the ref keyword at the call site too, so the possibility of the callee mutating the caller's variable is visible wherever the call appears.

Why A is incorrect: That's the out rule specifically — ref requires prior initialization precisely because the callee may read the value before writing anything.

Why C is incorrect: ref parameters exist specifically to reference actual variables so the callee can modify them — a constant, which can't be modified, wouldn't satisfy that purpose and isn't accepted as a ref argument.

Why D is incorrect: A ref argument can be a local variable, a field, an array element, or a property with both a getter and setter (via some additional rules) — it's not restricted to newly-declared locals.

Reinforcement: The caller-side ref keyword requirement is a deliberate visibility choice — it makes "this call might change my variable" impossible to miss when reading the calling code.

3. Why does passing a large readonly struct with in avoid the performance cost that plain pass-by-value would incur, while still being safe?

Show answer

Correct: B

Why B is correct: in uses the same underlying pass-by-reference mechanism as ref — passing an address, not a copy — which eliminates the field-by-field copy cost. Layered on top, the compiler enforces that the callee cannot write through that reference, which is what keeps it just as safe as ordinary pass-by-value from the caller's perspective.

Why A is incorrect: The struct remains a value type throughout — in changes how it's passed to this specific method, not its fundamental type category.

Why C is incorrect: There's no compression involved — the savings come from not copying the data at all, not from copying a smaller version of it.

Why D is incorrect: in works with structs of any size — it's specifically larger structs where the copy-avoidance benefit becomes meaningful; there's no such size restriction on where in can be applied.

Reinforcement: in's value proposition is precisely "ref's performance, pass-by-value's safety" — both halves of that sentence matter equally.

4. A developer writes void Fill(ref string[] items) for a method that only ever assigns a brand-new array to items and never reads the array the caller originally passed in. What's the more accurate parameter modifier, and why?

Show answer

Correct: B

Why B is correct: This is exactly the "reaching for ref when the intent is out" mistake covered in the lesson — since the method never reads the parameter's incoming value, out more precisely documents the real contract and removes any pointless obligation for the caller to initialize the variable first.

Why A is incorrect: While ref would technically compile and work, it's a less accurate signal of intent than out here, and unnecessarily forces callers to provide (and often waste) an initial value.

Why C is incorrect: in is for read-only access to the caller's data — the exact opposite of what this method does, which is write a new array without ever reading the old one.

Why D is incorrect: Plain pass-by-value passes a copy of the array reference — reassigning that local copy inside the method (items = newArray;) would not change what the caller's own variable points to. A modifier (here, out or ref) is genuinely required to reassign the caller's own variable.

Reinforcement: Choosing between ref and out comes down to one question: does the method ever need to read the parameter's incoming value? If no, out is the more honest, more caller-friendly choice.

5. What is the main reason ref returns and ref locals are described in this lesson as a niche, advanced feature rather than an everyday tool?

Show answer

Correct: C

Why C is correct: ref returns/locals solve a specific, comparatively rare problem — exposing a direct, mutable reference to existing storage from a method, useful mainly in high-performance, indexer-style library code. Ordinary application code almost always has its needs met by regular methods, auto-properties, and standard indexers, which is exactly why the lesson flags this as niche rather than something to reach for regularly.

Why A is incorrect: They're a fully supported, standard part of the C# language — not deprecated or on any removal path.

Why B is incorrect: They work with any type T, exactly like ref/in/out parameters — the Grid.At example in Under the Hood used int, not string.

Why D is incorrect: ref returns and locals are ordinary, safe C# — no unsafe block or manual pointer arithmetic is required to use them.

Reinforcement: "Niche" here means narrowly-applicable, not deprecated or unsafe — it's a real, useful tool for the specific, comparatively rare scenario it targets.

You now have the precise rules behind out, ref, and in — the exact tool for each job, and honest judgment about when reaching for any of them is actually worth it. This closes out the vocabulary this Part needed — next, you'll see it all put to work together.


dotnetmadeeasy.com — Learn C# and .NET, the right way.