Control Statements in C#

C# Control Statements: if, switch, loops, patterns

Learn clear C# control flow with if, switch, loops, and pattern matching. Guard clauses, pitfalls to avoid, and clean code tips.

.NET Fundamentals·By amarozka · October 12, 2025

C# Control Statements: if, switch, loops, patterns

Are you sure that every if and switch in your code pulls its weight? I’ve reviewed enough PRs to know that many bugs live inside simple branches and loops. The good news: with a few rules of thumb and some modern C# features, you can write branches that read like plain English and loops that are fast, safe, and easy to test. In this post I’ll show you the patterns I use daily, why they work, and where they save real time in projects.

Why control flow still matters

Control statements decide what runs and when. A messy condition or the wrong loop type can:

  • hurt performance (unneeded allocations, extra checks),
  • hide bugs (off‑by‑one, wrong order of checks),
  • reduce readability (future you won’t thank present you).

In my team, the biggest wins came not from “faster algorithms”, but from clearer branches, early returns, and a bit of pattern matching.

if/else: keep it short and honest

1) Guard clauses first

Skip deep nesting. Fail fast, then continue with the happy path.

public static Order PlaceOrder(User? user, Cart cart)
{
    if (user is null) throw new ArgumentNullException(nameof(user));
    if (!cart.Items.Any()) throw new InvalidOperationException("Cart is empty");
    if (!user.IsVerified) return Order.Rejected("User not verified");

    // Happy path is now flat and readable
    return Order.Confirm(user, cart);
}

Tip: prefer return, continue, or throw over adding another else level.

2) Short‑circuiting is your friend

Use && and || to avoid extra work and null checks.

if (customer != null && customer.IsActive && customer.Email?.EndsWith("@example.com") == true)
{
    SendPromo(customer);
}

The checks stop as soon as the result is known.

3) Cache repeated values

Avoid calling properties or methods in every comparison inside a loop.

for (int i = 0, n = items.Count; i < n; i++)
{
    if (items[i].Price > limit) break;
}

switch: from statement to expression

C# gives you two flavors:

  • switch statement – classic branching with case labels.
  • switch expression – compact mapping that returns a value.

When to use which

  • Use the expression when you map an input to a value: status → text, enum → handler, range → category.
  • Use the statement when you run sequences of steps or need multiple actions per branch.

Modern switch expression examples

Map status codes to a group

public static string ClassifyStatus(int code) => code switch
{
    >= 200 and < 300  => "Success",
    304               => "NotModified",
    400 or 401 or 403 => "ClientError",
    404               => "NotFound",
    429               => "TooManyRequests",
    >= 500            => "ServerError",
    _                 => "Other"
};

Notes:

  • Order matters: first matching arm wins.
  • Always end with _ (the catch‑all) unless you really want the compiler to warn about missing cases.

Type and property patterns

using System.Globalization;

public static string FormatCell(object? value) => value switch
{
    null => "<null>",
    string s when s.Length > 32 => s[..32] + "…",
    string s => s,
    int n => n.ToString("N0", CultureInfo.InvariantCulture),
    decimal d => d.ToString("F2", CultureInfo.InvariantCulture),
    User { Age: >= 18, IsVerified: true } => "Verified adult",
    IEnumerable<int> nums => "[" + string.Join(",", nums.Take(5)) + (nums.Skip(5).Any() ? ",…]" : "]"),
    _ => value?.ToString() ?? "<unknown>"
};

Here we mix type patterns, guards (when), relational patterns (>=), and property patterns.

A safe “fallthrough”

C# doesn’t allow implicit fallthrough, but you can share logic by pointing to the same arm:

var priceBand = countryCode switch
{
    "US" or "CA" => "NorthAmerica",
    "DE" or "FR" or "NL" => "WesternEurope",
    _ => "Other"
};

Loops: for, foreach, while, do

Each loop has a sweet spot. Pick the one that makes intent clear.

foreach: readability first

Use it for most collection walks.

foreach (var item in orders)
{
    if (item.Total <= 0) continue;
    Process(item);
}

Avoid modifying the collection you iterate. If you must remove items, iterate backwards with for.

for (int i = list.Count - 1; i >= 0; i--)
{
    if (ShouldRemove(list[i])) list.RemoveAt(i);
}

for: indexes and spans

Use for when you need an index or you work with Span<T>/arrays.

Span<int> squares = stackalloc int[10];
for (int i = 0; i < squares.Length; i++)
{
    squares[i] = i * i;
}

while: unknown length streams

using var reader = File.OpenText(path);
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
    // Process each line
}

do/while: run at least once

string input;
do
{
    input = Console.ReadLine() ?? string.Empty;
} while (input.Length == 0);

break, continue, return

  • break – leave the current loop.
  • continue – skip to next iteration.
  • return – when you’re done with the whole method.

Use the smallest hammer that expresses your intent.

Pattern matching for business rules

Pattern matching often makes boolean soup readable.

public record User(int Age, bool IsVerified, string Country);

public static bool CanRentCar(User u) => u switch
{
    { Country: "US", Age: >= 25 } => true,
    { Country: "US", Age: >= 21, IsVerified: true } => true,
    { Country: "DE", Age: >= 21 } => true,
    _ => false
};

This reads like a policy document, not like nested if chains.

Trick: keep the most specific cases first, then fall back to broader ones.

Replace boolean soup with named checks

Give long expressions names. Your future self will thank you.

bool IsHoliday(Order o) => o.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
bool IsHighValue(Order o) => o.Total >= 10_000m;

if (IsHoliday(order) || IsHighValue(order))
{
    RequireManagerApproval(order);
}

Error handling inside loops

Throwing on every iteration is slow and noisy. Prefer Try patterns.

if (int.TryParse(input, out var n))
{
    numbers.Add(n);
}
else
{
    _logger.LogWarning("Invalid number: {Input}", input);
}

For async work in loops, pass a CancellationToken and honor it:

public static async Task ProcessAllAsync(IEnumerable<Job> jobs, CancellationToken ct)
{
    foreach (var job in jobs)
    {
        ct.ThrowIfCancellationRequested();
        await RunJobAsync(job, ct);
    }
}

Small performance wins (without micro‑optimizing)

  • Avoid extra allocations in hot paths. For simple transforms, prefer switch expressions and span‑based loops.
  • Hoist constants: cache items.Count or array.Length when it saves repeated work.
  • Use Regex precompilation if a regex runs in a loop.
  • Prefer foreach for IEnumerable<T>; use for when the index matters or for arrays/spans.
  • Don’t build strings in loops. Use StringBuilder or write to a Span<char> if you need low‑level control.
  • Break early when you already know the answer.

Example of early exit:

public static bool HasDuplicateId(IEnumerable<Customer> customers)
{
    var seen = new HashSet<int>();
    foreach (var c in customers)
    {
        if (!seen.Add(c.Id)) return true; // found duplicate, we’re done
    }
    return false;
}

When to use LINQ vs loops

LINQ reads nicely for transforms and filters, but it can hide allocations or multiple passes. A simple rule:

  • Readability first. Prefer LINQ when it keeps the intent clear and performance is fine.
  • Switch to loops for hot paths, custom indexing, or when profiling shows a problem.
// Clear intent, good default
var activeEmails = users
    .Where(u => u.IsActive)
    .Select(u => u.Email)
    .ToList();

// Hot path alternative
var list = new List<string>(users.Count);
for (int i = 0; i < users.Count; i++)
{
    var u = users[i];
    if (u.IsActive) list.Add(u.Email);
}

State machines with switch

A compact state loop often reads better than scattered flags.

enum ParseState { Start, Header, Body, Done, Error }

ParseState Next(ParseState s, Token t) => (s, t.Type) switch
{
    (ParseState.Start, TokenType.Hash)   => ParseState.Header,
    (ParseState.Header, TokenType.NewLn) => ParseState.Body,
    (ParseState.Body, TokenType.EOF)     => ParseState.Done,
    _                                    => ParseState.Error
};

You can then drive it:

var state = ParseState.Start;
foreach (var token in tokens)
{
    state = Next(state, token);
    if (state is ParseState.Done or ParseState.Error) break;
}

Testing branches and loops

  • One assert per scenario keeps tests focused.
  • Name tests by rule (“RentCar_US_21_Verified_true”).
  • Use data‑driven tests for tables of inputs.
[Theory]
[InlineData("US", 25, true,  true)]
[InlineData("US", 21, true,  true)]
[InlineData("US", 21, false, false)]
[InlineData("DE", 21, false, true)]
public void CanRentCar_rules(string country, int age, bool verified, bool expected)
{
    var u = new User(age, verified, country);
    Assert.Equal(expected, CanRentCar(u));
}

Naming and clarity checklist

Use this quick pass on every PR:

  • Are guard clauses placed at the top?
  • Are conditions small, named, and ordered from simple to complex?
  • Would a switch expression read better than nested if?
  • Are loops using the right tool (foreach by default, for when needed)?
  • Is there an early exit where possible?
  • Do tests cover the tricky edges first?

A tiny flow diagram you can copy into a PR

+----------+    no    +------------------+
|  Input   | ----->   |  Guard returns   |
+----------+          +------------------+
     | yes
     v
+-----------------+   map   +------------------+
| Simple mapping  | ----->  | switch expression|
+-----------------+         +------------------+
     | else
     v
+--------------------+      +-----------------+
| Multi-step branch  | ---> | switch statement|
+--------------------+      +-----------------+

FAQ: short answers to common questions

Should I always add an _ catch‑all in a switch expression?

Usually yes. It keeps the compiler from complaining and makes intent clear. Omit it only when you want warnings for new enum members.

Are switch expressions slower than if chains?

In most real code, they’re comparable. The choice should be readability first. Measure if the path is hot.

Is goto ever OK?

Rarely in modern C#. Clear branches and loops beat goto. State machines or small functions are cleaner.

How do I avoid deep nesting?

Guard clauses, early returns, and extracting small methods. Each method should do one thing.

When should I pick for over foreach?

When you need the index, when you modify by index, or when working with arrays/spans.

What about exceptions inside loops?

Avoid them for expected cases. Use TryParse/TryGetValue and log.

Can I mix pattern matching with if?

Yes. is with patterns in if is great for single checks; use a switch expression when mapping many patterns.

Conclusion: cleaner branches, safer loops, happier code

Strong control flow is about clarity first and small, local wins: guards at the top, switch expressions for mapping, the right loop for the job, and early exits. Start applying the checklist in your next PR and see how much noise it removes.

What’s the smallest control‑flow tweak that saved you the most time? Share it in the comments – I’ll add the best ones to my checklist.

Leave a Reply

Your email address will not be published. Required fields are marked *