Skip to main content
C# beginner Lesson 6 of 25

Control Flow in C#

Master if/else, switch expressions, for/foreach/while loops, and pattern matching in C#.

if / else

if/else is the most fundamental control flow tool — it lets your program take different paths based on a condition. The conditions are boolean expressions, and C# evaluates them top to bottom, taking the first branch whose condition is true.

int temperature = 22;

if (temperature > 30)
{
    Console.WriteLine("Hot");
}
else if (temperature > 20)
{
    Console.WriteLine("Warm");   // This branch runs — first true condition wins
}
else if (temperature > 10)
{
    Console.WriteLine("Cool");
}
else
{
    Console.WriteLine("Cold");
}

Single-line bodies can omit braces, but adding them is generally safer and prevents bugs when adding more lines later:

// Acceptable for very simple cases like guard clauses
if (string.IsNullOrEmpty(name))
    throw new ArgumentNullException(nameof(name));

// Ternary — compact conditional assignment on one line
string label = score >= 50 ? "Pass" : "Fail";

switch Statement

The classic switch tests a single value against a fixed set of constants. It is cleaner than a long chain of if/else if when you have many specific values to handle. Multiple case labels can share a body by falling through to the same break.

string day = "Monday";

switch (day)
{
    case "Monday":
    case "Tuesday":
    case "Wednesday":
    case "Thursday":
    case "Friday":
        Console.WriteLine("Weekday");  // all five cases share this body
        break;

    case "Saturday":
    case "Sunday":
        Console.WriteLine("Weekend");
        break;

    default:
        Console.WriteLine("Unknown day");
        break;
}

switch Expression (C# 8+)

Switch expressions are more concise than switch statements and return a value directly. The compiler performs exhaustiveness checking — if there is a possible input that no arm handles, you get a compile-time warning. This makes switch expressions safer than chains of ternaries.

string DayType(string day) => day switch
{
    "Saturday" or "Sunday" => "Weekend",
    "Monday" or "Tuesday" or "Wednesday"
         or "Thursday" or "Friday"     => "Weekday",
    _ => throw new ArgumentOutOfRangeException(nameof(day), day, null)
};

Console.WriteLine(DayType("Saturday"));  // Weekend

Switch expressions really shine when combined with pattern matching, letting you branch on ranges, types, and property values all in one place:

// Range patterns — clean alternative to nested if/else if chains
string GetGrade(int score) => score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    >= 60 => "D",
    _     => "F"
};

// Type patterns — dispatch logic based on the runtime type of an object
static string Describe(object obj) => obj switch
{
    int n when n < 0 => $"Negative int: {n}",
    int n            => $"Positive int: {n}",
    string s         => $"String of length {s.Length}",
    null             => "null",
    _                => $"Unknown: {obj.GetType().Name}"
};

// Property patterns — match on multiple properties at once
record Order(decimal Total, bool IsPriority);

string ShippingLabel(Order order) => order switch
{
    { IsPriority: true, Total: >= 100 } => "Free Priority",
    { IsPriority: true }                => "Priority",
    { Total: >= 50 }                    => "Free Standard",
    _                                   => "Standard ($5)"
};

for Loop

Use for when you need an explicit index or a precise number of iterations. The three parts of the for header — initializer, condition, iterator — give you full control over how the loop counter behaves.

// Classic for loop — index goes 0 to 9
for (int i = 0; i < 10; i++)
    Console.Write(i + " ");  // 0 1 2 3 4 5 6 7 8 9

// Reverse — count down from 9 to 0
for (int i = 9; i >= 0; i--)
    Console.Write(i + " ");  // 9 8 7 6 5 4 3 2 1 0

// Multiple variables in one for loop
for (int i = 0, j = 10; i < j; i++, j--)
    Console.Write($"({i},{j}) ");

// Loop over array by index — useful when you need the position
string[] names = { "Alice", "Bob", "Carol" };
for (int i = 0; i < names.Length; i++)
    Console.WriteLine($"{i}: {names[i]}");

foreach Loop

foreach is the idiomatic way to iterate a collection when you do not need the index. It is cleaner than a for loop and works with any type that implements IEnumerable<T> — not just arrays and lists.

var fruits = new List<string> { "apple", "banana", "cherry" };

// Simple iteration — no index management needed
foreach (string fruit in fruits)
    Console.WriteLine(fruit);

// With index using LINQ's Select overload — when you need both value and position
foreach (var (fruit, index) in fruits.Select((f, i) => (f, i)))
    Console.WriteLine($"{index}: {fruit}");

// Iterating a dictionary — deconstruct each key-value pair
var scores = new Dictionary<string, int>
{
    ["Alice"] = 95,
    ["Bob"]   = 82
};

foreach (var (name, score) in scores)
    Console.WriteLine($"{name}: {score}");

// Iterate a string — yields each character in order
foreach (char c in "Hello")
    Console.Write(c + " ");  // H e l l o

while and do-while

while runs as long as a condition is true, checking it before each iteration. do-while always executes the body at least once before checking. Use do-while for input loops and retry patterns where you always need at least one attempt.

// while — checks condition before each iteration
int count = 0;
while (count < 5)
{
    Console.Write(count + " ");
    count++;
}
// 0 1 2 3 4

// do-while — body runs at least once, condition checked afterwards
// Perfect for "keep asking until valid input" patterns
string input;
do
{
    Console.Write("Enter 'quit' to exit: ");
    input = Console.ReadLine() ?? "";
} while (input != "quit");

break and continue

break and continue give you fine-grained control over loop execution without restructuring the entire loop. break exits immediately; continue skips the rest of the current iteration and moves on to the next check.

// break — exit the loop immediately when condition is met
for (int i = 0; i < 100; i++)
{
    if (i == 5)
        break;
    Console.Write(i + " ");  // 0 1 2 3 4
}

// continue — skip even numbers, process only odd ones
for (int i = 0; i < 10; i++)
{
    if (i % 2 == 0)
        continue;  // jump to next iteration
    Console.Write(i + " ");  // 1 3 5 7 9
}

// break in nested loops — only breaks the innermost loop
for (int i = 0; i < 3; i++)
{
    for (int j = 0; j < 3; j++)
    {
        if (j == 1) break;           // exits inner loop only
        Console.Write($"({i},{j}) ");
    }
}
// (0,0) (1,0) (2,0)

To break out of an outer loop, either use a flag or restructure into a method. The method approach is generally cleaner:

// Using a flag — works but adds noise
bool found = false;
for (int i = 0; i < rows && !found; i++)
    for (int j = 0; j < cols && !found; j++)
        if (grid[i][j] == target)
            found = true;

// Better: extract to a method — return exits all loops at once
bool ContainsTarget(int[][] grid, int target)
{
    for (int i = 0; i < grid.Length; i++)
        for (int j = 0; j < grid[i].Length; j++)
            if (grid[i][j] == target) return true;
    return false;
}

Pattern Matching in Control Flow

Pattern matching lets if and switch do more than just compare values. You can simultaneously check the type, bind a variable, and test properties — all in one readable expression.

// is pattern with variable binding — type check and cast in one step
object result = GetResult();

if (result is string message)
    Console.WriteLine($"Success: {message}");
else if (result is Exception ex)
    Console.WriteLine($"Error: {ex.Message}");
else if (result is int code and > 0)
    Console.WriteLine($"Code: {code}");

// Tuple patterns — match on multiple values simultaneously
var point = (x: 3, y: -1);

string quadrant = point switch
{
    (> 0, > 0) => "Q1",
    (< 0, > 0) => "Q2",
    (< 0, < 0) => "Q3",
    (> 0, < 0) => "Q4",
    _           => "On axis"
};
Console.WriteLine(quadrant);  // Q4

Exception-Based Control Flow

Exceptions exist for exceptional situations — unexpected failures, not expected outcomes. Using exceptions for ordinary control flow (like checking if a string is a valid number) is expensive and obscures intent. Prefer Try* methods that return bool for expected failure cases.

// Avoid using exceptions for normal control flow
// Bad — exceptions are expensive and not meant for this
bool ParseIntBad(string s, out int result)
{
    try
    {
        result = int.Parse(s);
        return true;
    }
    catch
    {
        result = 0;
        return false;
    }
}

// Good — TryParse is specifically designed for "try this, it might fail"
bool ParseIntGood(string s, out int result)
    => int.TryParse(s, out result);

Practical Example: FizzBuzz with Switch

This example shows how tuple patterns and switch expressions combine into readable, compact logic — no nested ifs required.

for (int i = 1; i <= 20; i++)
{
    // Tuple pattern: test both (i % 3) and (i % 5) simultaneously
    string output = (i % 3, i % 5) switch
    {
        (0, 0) => "FizzBuzz",  // divisible by both
        (0, _) => "Fizz",      // divisible by 3 only
        (_, 0) => "Buzz",      // divisible by 5 only
        _       => i.ToString() // neither
    };
    Console.WriteLine(output);
}

Frequently Asked Questions

When should I use a switch expression vs a switch statement?
Prefer switch expressions when you are mapping a value to another value — they are terser and exhaustiveness-checked by the compiler. Use switch statements when each case has side effects or multiple lines of logic.
What is the difference between break and continue?
break exits the loop entirely. continue skips the rest of the current iteration and moves to the next one.
Can foreach iterate over anything?
foreach works on any type that implements IEnumerable or IEnumerable<T>, or that has a GetEnumerator method. This includes arrays, List<T>, Dictionary<K,V>, strings (iterates characters), and any custom type you make enumerable.