Conditions are bool

Every decision in a program depends on a condition: an expression that is either true or false. The general idea of selection is the same in every language; this page shows how C# does it. In C#, a condition is an expression of type bool, usually a comparison or a combination of comparisons with &&, || and ! (see variables, types and operators).

int openTickets = 3;
bool needsAttention = openTickets > 0;

Console.WriteLine(needsAttention);
Console.WriteLine(openTickets > 5);
Console.WriteLine(needsAttention.GetType());
True
False
System.Boolean

C# is strict about this. A number, a string or an object is never accepted where a bool is expected, so the compiler stops you before the program runs:

int openTickets = 3;
if (openTickets)
{
    Console.WriteLine("Work to do");
}
example.cs(2,5): error CS0029: Cannot implicitly convert type 'int' to 'bool'

The same strictness catches a classic typo. In C and JavaScript, if (status = "Open") assigns instead of comparing and silently runs the block. In C# the result of that assignment is a string, not a bool, so it is a compile error.

if, else if and else

An if statement runs a block when its condition is true. The condition always sits in parentheses, and the block is enclosed in braces { }. Indentation means nothing to the compiler; it is there for human readers. An optional else block runs when the condition is false, and else if adds more alternatives. C# tests the conditions from top to bottom and runs only the block of the first condition that is true.

double temperature = 31.5;

if (temperature >= 30)
{
    Console.WriteLine("Heat alert: check the transformer cooling.");
}
else if (temperature <= 0)
{
    Console.WriteLine("Frost alert: check the outdoor meters.");
}
else
{
    Console.WriteLine("Normal temperature.");
}
Heat alert: check the transformer cooling.

There is no special elif keyword: else if is simply an else whose block is another if statement. Because the first true condition wins, put the narrowest test first, exactly as in any else-if chain.

Always use braces

When a block contains a single statement, C# allows you to leave out the braces. Only that one statement then belongs to the if; the next line runs every time, however it is indented.

int usageKwh = 120;

if (usageKwh > 500)
    Console.WriteLine("High usage this month.");
    Console.WriteLine("Sending usage warning to the customer.");
Sending usage warning to the customer.

The conditional operator

When an if/else only chooses between two values, the conditional operator ?: (often called the ternary operator, because it takes three operands) is shorter:

condition ? valueIfTrue : valueIfFalse

It is an expression, so you can use it anywhere a value is expected. Both values must have a common type: stock > 0 ? "in stock" : 0 does not compile.

int stock = 0;
string status = stock > 0 ? "in stock" : "sold out";
Console.WriteLine(status);

int items = 1;
Console.WriteLine($"{items} {(items == 1 ? "item" : "items")} in your cart");
sold out
1 item in your cart

The switch statement

A switch statement compares one value against a list of case labels and runs the matching switch section. Several labels can share one section, and the optional default section runs when no label matches. C# can switch on numbers, characters, strings, enums and, with patterns, on almost anything.

string priority = "P2";

switch (priority)
{
    case "P1":
        Console.WriteLine("Respond within 15 minutes.");
        break;
    case "P2":
    case "P3":
        Console.WriteLine("Respond within 4 hours.");
        break;
    default:
        Console.WriteLine("Respond on the next working day.");
        break;
}
Respond within 4 hours.

No fall-through

In C, Java and JavaScript, a case without break continues into the next one ("falls through"), which is a well-known source of bugs. C# does not allow it: the end of every switch section must be unreachable, so each section ends with break, return, throw, continue (inside a loop) or goto case. Stacking labels with nothing in between, as with "P2" and "P3" above, is still allowed.

int level = 1;

switch (level)
{
    case 1:
        Console.WriteLine("Level 1 support");
    case 2:
        Console.WriteLine("Level 2 support");
        break;
}
example.cs(5,5): error CS0163: Control cannot fall through from one case label ('case 1:') to another

If you really want one section to continue with another, say so with goto case 2;. That is rare; usually the cleaner fix is to stack the labels or move the shared code into a method.

Switch expressions

Most switches exist to pick a value: a label for a status code, a price for a tariff. For that, C# 8 added the switch expression: the value comes first, then the keyword switch, then a list of arms in braces. Each arm is pattern => result, the arms are separated by commas, and the whole construct produces the result of the first arm whose pattern matches.

int[] codes = [200, 302, 404, 500];

foreach (int code in codes)
{
    string meaning = code switch
    {
        200 => "OK",
        301 or 302 => "Redirect",
        404 => "Not found",
        _ => "Other status"
    };
    Console.WriteLine($"{code} {meaning}");
}
200 OK
302 Redirect
404 Not found
500 Other status

Compared with the switch statement there is no case, no break and no default, and the result can go straight into a variable, a return or an argument. 301 or 302 is one pattern made of two alternatives (more on or below). The foreach loop around it just runs the switch for each code.

The discard pattern and exhaustiveness

The last arm, _ => "Other status", uses the discard pattern _, which matches any value and gives it no name. It plays the role of default or else.

A switch expression must produce a value for every possible input. When the compiler can see that some inputs are not covered, it warns. If such an input really arrives, the program stops with a SwitchExpressionException:

string tariff = "Night";

decimal pricePerKwh = tariff switch
{
    "Day" => 0.32m,
    "Evening" => 0.28m
};
Console.WriteLine(pricePerKwh);
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.

The build already warned about it (the checker hides warnings):

example.cs(3,30): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.

Take that warning seriously: either add the missing arms, or add a final _ arm that does something deliberate, such as throwing an exception with a clear message (see exceptions and error handling).

The order of the arms

The arms are tested from top to bottom, so order matters as soon as patterns overlap. A pattern that can never be reached because an earlier arm already covers it, such as anything after _, is a compile error (CS8510), not a warning.

int score = 85;

string grade = score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    _ => "F"
};
Console.WriteLine(grade);
B

Patterns

A pattern is a test that a value either matches or does not, sometimes taking the value apart at the same time. C# has grown a small language of patterns since version 7, and you can use them in three places: after is (value is pattern, which gives a bool), in case labels of a switch statement, and in the arms of a switch expression. The simplest ones you have already seen: a constant pattern such as 404 or "Day", and the discard _.

Type patterns

A type pattern checks whether a value has a certain type at run time. Followed by a name, it also declares a new variable of that type, ready to use, so a type test and a cast happen in one step. This is useful when a variable's static type is general, such as object, but the actual values differ.

object[] values = [42, "INC-20481", 3.5, true];

foreach (object value in values)
{
    string description = value switch
    {
        int n => $"a whole number: {n + 1} is one more",
        string s => $"text of {s.Length} characters",
        double d => $"a double, half is {d / 2}",
        _ => $"something else: {value}"
    };
    Console.WriteLine(description);
}
a whole number: 43 is one more
text of 9 characters
a double, half is 1.75
something else: True

With is, the new variable is available inside the if block, where the compiler knows the test succeeded:

object reading = 412.5;

if (reading is double kwh)
{
    Console.WriteLine($"Meter reading: {kwh:F1} kWh");
}

if (reading is not string)
{
    Console.WriteLine("Not text, so no parsing needed.");
}
Meter reading: 412.5 kWh
Not text, so no parsing needed.

Relational and logical patterns

A relational pattern compares the value with a constant using <, <=, > or >=. It turns a chain of range checks into a compact table. Patterns can be combined with the keywords and, or and not (the logical patterns, C# 9), and grouped with parentheses.

double[] temperatures = [-5, 0, 18, 35];

foreach (double t in temperatures)
{
    string label = t switch
    {
        < 0 => "below freezing",
        0 => "freezing point",
        >= 30 => "hot",
        _ => "mild"
    };
    Console.WriteLine($"{t,3} °C: {label}");
}

int percent = 104;
if (percent is < 0 or > 100)
{
    Console.WriteLine($"{percent}% is not a valid percentage.");
}

char grade = 'b';
Console.WriteLine(grade is >= 'a' and <= 'f');
 -5 °C: below freezing
  0 °C: freezing point
 18 °C: mild
 35 °C: hot
104% is not a valid percentage.
True

percent is < 0 or > 100 reads almost like English and tests percent only once. The constants in a pattern must be known at compile time: >= limit with a variable limit does not compile. For that, use a when guard or an ordinary if.

Property patterns

A property pattern looks inside an object: { Priority: "P1" } matches when the object's Priority property matches the pattern after the colon. That inner pattern can itself be any pattern (a constant, a relational pattern, another property pattern), and several properties are combined with commas, which means "and". The example below uses a small record type, declared after the top-level code.

Ticket[] tickets =
[
    new("INC-20481", "P1", "Network", 0),
    new("INC-20482", "P2", "Network", 3),
    new("INC-20483", "P3", "Billing", 1),
    new("INC-20484", "P3", "Portal", 9),
];

foreach (Ticket ticket in tickets)
{
    string route = ticket switch
    {
        { Priority: "P1" } => "page the on-call engineer",
        { Category: "Network", Priority: "P2" } => "NOC queue",
        { Category: "Billing" } => "billing team",
        { Reopened: >= 5 } => "problem management",
        _ => "service desk queue"
    };
    Console.WriteLine($"{ticket.Id}: {route}");
}

record Ticket(string Id, string Priority, string Category, int Reopened);
INC-20481: page the on-call engineer
INC-20482: NOC queue
INC-20483: billing team
INC-20484: problem management

Property patterns also check for null: { } matches any object that is not null, and a property pattern never matches a null reference. Nested properties can be written with a dot, as in { Customer.City: "Delft" } (C# 10).

List patterns

A list pattern (C# 11) matches an array or list by its elements, in square brackets. Each position has its own pattern, so the length must match too. Inside the brackets, .. (the slice pattern) stands for zero or more elements and may appear once; .. var rest captures them. var name captures a single element of any value.

string[][] commands =
[
    ["quit"],
    ["restart", "meter-0042"],
    ["assign", "INC-20481", "Amara", "Mei"],
    [],
    ["dance"],
];

foreach (string[] command in commands)
{
    string result = command switch
    {
        ["quit"] => "Goodbye",
        ["restart", var device] => $"Restarting {device}",
        ["assign", var id, .. var people] => $"Assigning {id} to {string.Join(" and ", people)}",
        [] => "Empty command",
        _ => $"Unknown command: {string.Join(" ", command)}"
    };
    Console.WriteLine(result);
}

int[] readings = [412, 415, 419, 431];
if (readings is [var first, .., var last])
{
    Console.WriteLine($"Used {last - first} kWh over {readings.Length} readings");
}
Goodbye
Restarting meter-0042
Assigning INC-20481 to Amara and Mei
Empty command
Unknown command: dance
Used 19 kWh over 4 readings

when guards

Sometimes a pattern alone cannot express the test, for example when it compares with a variable or with another property. A when clause adds an ordinary bool condition to a case label or a switch arm: the arm only matches when the pattern matches and the condition is true.

decimal monthlyLimit = 500m;
Reading[] readings =
[
    new("MTR-0042", 320m, 300m),
    new("MTR-0043", 980m, 310m),
    new("MTR-0044", -4m, 280m),
];

foreach (Reading r in readings)
{
    string verdict = r switch
    {
        { Kwh: < 0 } => "invalid: negative usage",
        _ when r.Kwh > r.LastMonth * 2 => "spike: check the meter",
        _ when r.Kwh > monthlyLimit => "above the monthly limit",
        _ => "normal"
    };
    Console.WriteLine($"{r.MeterId}: {verdict}");
}

record Reading(string MeterId, decimal Kwh, decimal LastMonth);
MTR-0042: normal
MTR-0043: spike: check the meter
MTR-0044: invalid: negative usage

Prefer real patterns where you can, and use when for what patterns cannot say. The compiler can check exhaustiveness and unreachable arms for patterns, but it cannot reason about the condition inside a when.

Choosing the right construct

All of these constructs change the control flow; they differ in whether they run statements or produce a value, and in how many cases they handle comfortably.

Construct Produces Best for
if / else nothing (runs statements) one or two conditions, or conditions on different variables
?: a value choosing between exactly two short values
switch statement nothing (runs statements) many cases on one value, where each case does several things
switch expression a value mapping one value to a result: labels, prices, routing decisions
is pattern a bool (plus variables) one test on type, range, shape or null

A good rule of thumb: when every branch of an if/else if chain assigns the same variable, a switch expression says the same thing in fewer lines and lets the compiler check that no case is forgotten. When the branches do different kinds of work, keep the statements. And when the same switch on a "kind" field appears in many places, consider polymorphism instead.