C# · 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
- What does
breakdo inside aswitchcase? Modify the if/else temperature example to use aswitchstatement instead. What happens if you remove allbreakstatements?
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: ....
- 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.
- Convert a for loop to foreach. Take the array iteration example (iterating
namesby index) and rewrite it usingforeach. 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.