Skip to main content
C# · lesson 6 of 26
C# beginner Lesson 6 of 26

Control Flow in C#

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

By SkillByExample · Updated

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");
}
$ dotnet run
Warm

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;
}
$ dotnet run
Weekday

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
$ dotnet run
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
string GetGrade(int score) => score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    >= 60 => "D",
    _     => "F"
};

Console.WriteLine(GetGrade(85));  // B

// Type patterns
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}"
};

Console.WriteLine(Describe("hello"));  // String of length 5
Console.WriteLine(Describe(-42));      // Negative int: -42

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

Console.WriteLine(ShippingLabel(new Order(150, true)));  // Free Priority

// Type declarations must come after top-level statements
record Order(decimal Total, bool IsPriority);
$ dotnet run
B
String of length 5
Negative int: -42
Free Priority

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]}");
$ dotnet run
0 1 2 3 4 5 6 7 8 9
9 8 7 6 5 4 3 2 1 0
(0,10) (1,9) (2,8) (3,7) (4,6)
0: Alice
1: Bob
2: Carol

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
$ dotnet run
apple
banana
cherry
0: apple
1: banana
2: cherry
Alice: 95
Bob: 82
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++;
}
// Output: 0 1 2 3 4
$ dotnet run
0 1 2 3 4

do-while always executes the body at least once before checking the condition. This makes it ideal for input-validation loops where you need at least one prompt:

// 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)
$ dotnet run
0 1 2 3 4
1 3 5 7 9
(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 = "Operation completed";

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
$ dotnet run
Success: Operation completed
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);
}
$ dotnet run
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

Practice

  1. What does break do inside a switch case? Modify the if/else temperature example to use a switch statement instead. What happens if you remove all break statements?
Solution

In C#, switch cases do not fall through by default — you don’t need break. But if you try to stack cases, you can use case labels without a body:

switch (temp)
{
    case <= 0:
        Console.WriteLine("Freezing");
        break;
    case <= 15:
        Console.WriteLine("Cold");
        break;
    case <= 25:
        Console.WriteLine("Warm");
        break;
    default:
        Console.WriteLine("Hot");
        break;
}

Unlike C or Java, C# will not compile if two case labels would both match — each case must be mutually exclusive. You can use an empty case body to stack labels: case 1: case 2: ....

  1. Extend the FizzBuzz. Modify the FizzBuzz example to also print “Bazz” for every number divisible by 7. What will the output for 21 look like?
Solution
for (int i = 1; i <= 21; i++)
{
    string output = (i % 3, i % 5, i % 7) switch
    {
        (0, 0, _) => "FizzBuzz",
        (0, _, 0) => "FizzBazz",
        (_, 0, 0) => "BuzzBazz",
        (0, _, _) => "Fizz",
        (_, 0, _) => "Buzz",
        (_, _, 0) => "Bazz",
        _ => i.ToString()
    };
    Console.WriteLine(output);
}

At 21 (divisible by both 3 and 7), the output is FizzBazz.

  1. Convert a for loop to foreach. Take the array iteration example (iterating names by index) and rewrite it using foreach. When would you prefer one over the other?
Solution
// for loop — need index to modify or access other arrays by position
for (int i = 0; i < names.Length; i++)
    Console.WriteLine(names[i]);

// foreach — simpler, but no index available
foreach (string name in names)
    Console.WriteLine(name);

Use foreach when you don’t need the index. Use for when you need to modify elements in place, track position, or iterate multiple arrays in lockstep.

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.