Control Flow and Switch Expressions
Making decisions in Java: if and else, the conditional operator, the classic switch statement and modern switch expressions with arrows and pattern matching.
The examples on this page are
compact source files: a void main() method with IO.println,
which you can save as Main.java and run with
java Main.java. The decisions work exactly the same
inside an ordinary class with
public static void main(String[] args).
Conditions are boolean
Every decision depends on a condition: an expression that is true or
false. The general idea of
selection
is the same in every language. In Java, a condition is an expression
of type boolean, usually a comparison
(<, >=, ==,
!=) or several comparisons joined with
&& (and), || (or) and
! (not). && and
|| stop as soon as the answer is known (see
variables, types and operators).
void main() {
int openTickets = 3;
boolean needsAttention = openTickets > 0;
IO.println(needsAttention);
IO.println(openTickets > 5);
IO.println(openTickets > 0 && openTickets < 10);
}
true false true
Java is strict about this. A number, a string or an object is never
accepted where a boolean is expected, so the compiler
stops you before the program runs:
void main() {
int openTickets = 3;
if (openTickets) {
IO.println("Work to do");
}
}
Main.java:3: error: incompatible types: int cannot be converted to boolean
The same strictness catches most cases of the classic typo
= (assignment) instead of == (comparison).
if (status = "Open") assigns a String,
which is not a boolean, so it does not compile. There
is one exception, and it is a real trap: when the variable itself is
a boolean, the assignment is a valid
condition.
void main() {
boolean escalated = false;
if (escalated = true) {
IO.println("Paging the incident manager");
}
IO.println("escalated is now " + escalated);
}
Paging the incident manager escalated is now true
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 in
braces { }; indentation means nothing to the compiler.
An optional else block runs when the condition is
false, and else if adds more alternatives. Java tests
the conditions from top to bottom and runs only the block of the
first condition that is true.
void main() {
double temperature = 31.5;
if (temperature >= 30) {
IO.println("Heat alert: check the transformer cooling.");
} else if (temperature <= 0) {
IO.println("Frost alert: check the outdoor meters.");
} else {
IO.println("Normal temperature.");
}
}
Heat alert: check the transformer cooling.
There is no elif keyword: else if is an
else whose block happens to be another
if statement. Because the first true condition wins,
put the narrowest test first, as in
any else-if chain.
Always use braces
When a block contains a single statement, Java 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.
void main() {
int usageKwh = 120;
if (usageKwh > 500)
IO.println("High usage this month.");
IO.println("Sending usage warning to the customer.");
}
Sending usage warning to the customer.
The warning is sent although usage is far below the limit. Most Java style guides therefore require braces around every block, even a one-line one, and tools such as Checkstyle can enforce that (see testing and code quality).
The conditional operator
When an if/else only chooses between two
values, the conditional operator ?: (often called the
ternary operator, because it has three operands) is
shorter. condition ? valueIfTrue : valueIfFalse is an
expression, so it can stand anywhere a value is expected.
void main() {
int stock = 0;
String status = stock > 0 ? "in stock" : "sold out";
IO.println(status);
int tickets = 1;
IO.println(tickets + (tickets == 1 ? " ticket" : " tickets") + " open");
int score = 85;
String grade = score >= 90 ? "A" : score >= 80 ? "B" : "C";
IO.println(grade);
}
sold out 1 ticket open B
Note the parentheses around the conditional operator inside the
string concatenation: without them, + would be applied
first. Nested conditional operators, as in the last example, work
but quickly become hard to read; a switch expression (below) or an
if chain is usually clearer.
The classic switch statement
A
switch statement
compares one value against a list of case labels and
jumps to the one that matches. The optional
default label is used when nothing matches. Since Java
14, one label can list several values separated by commas.
void main() {
String priority = "P2";
switch (priority) {
case "P1":
IO.println("Respond within 15 minutes.");
break;
case "P2", "P3":
IO.println("Respond within 4 hours.");
break;
default:
IO.println("Respond on the next working day.");
}
}
Respond within 4 hours.
A switch works on int, char,
short and byte (and their wrapper
classes), on String and on enums. With patterns (below) it also works on any other reference type. A long,
double, float or boolean
selector is rejected in Java 25 with
error: primitive patterns are a preview feature:
switching on those types is still a preview feature in JDK 25–27.
Fall-through
With colon labels (case X:), a case is
only an entry point. Once Java has jumped to a matching label, it
runs every statement below it, through the following
labels, until it meets a break or the end of the
switch. This behavior, inherited from C, is called fall-through, and
a forgotten break is one of the best-known bugs in Java
code.
void main() {
Status status = Status.IN_PROGRESS;
switch (status) {
case OPEN:
IO.println("open");
break;
case IN_PROGRESS:
IO.println("in progress");
// break forgotten here
case RESOLVED:
IO.println("resolved (fell through)");
break;
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
in progress resolved (fell through)
The ticket is in progress, but the program also reports it as
resolved. The enum Status, declared below
main, is explained on
records, enums and sealed types; inside the switch its constants are written without the
Status. prefix.
The position of default does not change this rule. In
the next example default sits in the middle: a value
that matches no label jumps to it and then falls through into the
cases below it.
void main() {
for (String priority : List.of("P1", "P9")) {
IO.println("Priority " + priority + ":");
switch (priority) {
case "P1":
IO.println(" page the on-call engineer");
default:
IO.println(" create a ticket");
case "P2":
IO.println(" notify the service desk");
break;
case "P3":
IO.println(" add to the backlog");
}
}
}
Priority P1: page the on-call engineer create a ticket notify the service desk Priority P9: create a ticket notify the service desk
Switch expressions
Most switches exist to pick a value: a response time for a priority,
a price for a tariff. For that, Java 14 added the
switch expression: a
switch that produces a value, so it can be assigned,
returned or passed as an argument. It ends with a semicolon like any
other expression statement.
void main() {
for (String priority : List.of("P1", "P2", "P3", "P4")) {
int minutes = switch (priority) {
case "P1" -> 15;
case "P2" -> 60;
case "P3" -> 240;
default -> 1440;
};
IO.println(priority + ": respond within " + minutes + " minutes");
}
}
P1: respond within 15 minutes P2: respond within 60 minutes P3: respond within 240 minutes P4: respond within 1440 minutes
The for-each loop around it runs the switch once for each priority.
Arrow labels
An
arrow case label,
case X ->, is followed by exactly one thing: an
expression, a block in braces, or a throw statement.
Only that part runs; there is no fall-through and no
break. Arrow labels work in switch expressions and in
ordinary switch statements, which makes them the safer choice for
new code even when no value is produced.
void main() {
Status status = Status.IN_PROGRESS;
switch (status) {
case OPEN -> IO.println("open");
case IN_PROGRESS -> IO.println("in progress");
case RESOLVED -> {
IO.println("resolved");
IO.println("sending satisfaction survey");
}
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
in progress
One switch uses either colons or arrows, never both. Mixing them is a compile error.
yield: a value from a block
When one case of a switch expression needs several statements, put
them in a block and finish it with a
yield statement,
which hands the value back to the switch expression. A
return would try to leave the whole method instead, and
is not allowed there.
void main() {
String tariff = "Dynamic";
int hour = 18;
double pricePerKwh = switch (tariff) {
case "Day" -> 0.32;
case "Night" -> 0.24;
case "Dynamic" -> {
double base = 0.25;
boolean peak = hour >= 17 && hour < 21;
yield peak ? base * 1.5 : base;
}
default -> throw new IllegalArgumentException("Unknown tariff: " + tariff);
};
IO.println("Price per kWh: " + pricePerKwh);
}
Price per kWh: 0.375
A block that ends without yield (or throw)
is a compile error:
void main() {
int level = 2;
String team = switch (level) {
case 1 -> "Service desk";
case 2 -> {
IO.println("Escalating to second line");
}
default -> "Engineering";
};
}
Main.java:7: error: switch rule completes without providing a value
yield also works in a switch expression with colon
labels (case "Day": yield 0.32;), but that form falls
through like the classic statement. Prefer arrows.
Exhaustiveness
A switch expression must produce a value for every input it could
receive. This property is called
exhaustiveness, and in Java it
is checked by the compiler: if a value could slip through, the code
does not compile. For a String or an
int there are endless possible values, so you need a
default.
void main() {
String tariff = "Night";
double pricePerKwh = switch (tariff) {
case "Day" -> 0.32;
case "Evening" -> 0.28;
};
IO.println(pricePerKwh);
}
Main.java:3: error: the switch expression does not cover all possible input values
For an enum, listing every constant is enough, and it is better than
adding a default. If someone later adds a constant such
as ON_HOLD to the enum, every switch expression that
does not handle it stops compiling, so the compiler shows you each
place to update. A default would silently swallow the
new value.
void main() {
for (Status status : Status.values()) {
String label = switch (status) {
case OPEN -> "Waiting for an engineer";
case IN_PROGRESS -> "Being worked on";
case RESOLVED -> "Fixed, waiting for confirmation";
};
IO.println(status + ": " + label);
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
OPEN: Waiting for an engineer IN_PROGRESS: Being worked on RESOLVED: Fixed, waiting for confirmation
Exhaustiveness is required for switch expressions and for switch
statements that use patterns or case null. A classic
switch statement on a string, number or enum may still leave cases
out; it then simply does nothing for them.
Pattern matching for instanceof
Sometimes a variable has a general type, such as
Object, and you need to know what it really holds. The
instanceof operator tests that at run time. Before Java
16 you then had to cast the value yourself. With
pattern matching for instanceof, the test and the cast happen in one step:
o instanceof String s is true when o holds
a String, and in that case declares a variable
s of type String.
void main() {
Object[] fields = { "INC-10042", 350, "Amara Okafor" };
for (Object field : fields) {
if (field instanceof String s && s.startsWith("INC-")) {
IO.println("ticket number " + s.substring(4));
} else if (field instanceof Integer kwh) {
IO.println("reading of " + kwh + " kWh, costs EUR " + kwh * 0.30);
} else {
IO.println("other: " + field);
}
}
}
ticket number 10042 reading of 350 kWh, costs EUR 105.0 other: Amara Okafor
The variable exists only where the compiler can prove the test
succeeded. After && that is the case, so
s.startsWith is safe. After || it is not,
because the right-hand side also runs when the test failed:
void main() {
Object field = 350;
if (field instanceof String s || s.isEmpty()) {
IO.println("text field");
}
}
Main.java:3: error: cannot find symbol
The scope follows the flow of the program. A common style tests for
the wrong type first and leaves the method early; after that
if, the variable is available for the rest of the
method.
void main() {
IO.println(describe("INC-10042"));
IO.println(describe(42));
}
String describe(Object value) {
if (!(value instanceof String id)) {
return "not a ticket id";
}
return "ticket " + id + " (" + id.length() + " characters)";
}
ticket INC-10042 (9 characters) not a ticket id
null instanceof String is always false, so a pattern
variable is never null. Writing your own methods such as
describe is covered on
methods and parameters.
Pattern matching for switch
Since Java 21, a case label can hold a pattern instead
of a constant. A type pattern such as
case Integer i matches when the value is an
Integer and gives it a name, exactly like
instanceof. The cases are tried from top to bottom and
the first match wins.
Guards with when
A guard adds an extra
condition to a pattern with the keyword when: the case
only matches when the pattern matches and the
boolean after when is true. A plain
case null handles a null value, and
default catches the rest.
void main() {
Object[] values = { 5, 42, "INC-10042", null, 1.5 };
for (Object value : values) {
IO.println(kind(value));
}
}
String kind(Object value) {
return switch (value) {
case null -> "missing value";
case Integer i when i > 10 -> "large number " + i;
case Integer i -> "small number " + i;
case String s -> "text of " + s.length() + " characters";
default -> "something else: " + value.getClass().getSimpleName();
};
}
small number 5 large number 42 text of 9 characters missing value something else: Double
Order matters. A case that can never be reached because an earlier case already matches everything it would match is said to be dominated, and the compiler rejects it. Put the guarded, more specific case first:
void main() {
Object value = 42;
String label = switch (value) {
case Integer i -> "number";
case Integer i when i > 10 -> "large number";
default -> "other";
};
IO.println(label);
}
Main.java:5: error: this case label is dominated by a preceding case label
null in a switch
Without a case null, a switch throws a
NullPointerException as soon as the selector is null,
even when there is a default. This was the rule before
patterns existed, and it still is:
void main() {
String priority = null;
switch (priority) {
case "P1" -> IO.println("urgent");
default -> IO.println("normal");
}
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null
The message says "<local2>" instead of a variable
name. In source-file mode the compiler does not store the names of
local variables in the class file, so the JVM can only report a slot
number; here it is a hidden copy of priority that the
compiler made for the switch. How to read these messages is
explained on
null, Optional and NullPointerException.
Sealed types and record patterns
Patterns become most useful together with two kinds of types from
records, enums and sealed types. A sealed interface lists all the types that may
implement it, so the compiler knows the switch below is exhaustive
without a default. A record pattern such as
Electricity(String id, double kwh) takes a record apart
into its components, and a guard can use them right away.
void main() {
List<Reading> readings = List.of(
new Electricity("E-001", 350.5),
new Electricity("E-002", 1500.0),
new Gas("G-007", 12.25));
for (Reading reading : readings) {
String line = switch (reading) {
case Electricity(String id, double kwh) when kwh > 1000 ->
id + ": high use, " + kwh + " kWh";
case Electricity(String id, double kwh) -> id + ": " + kwh + " kWh";
case Gas(String id, double m3) -> id + ": " + m3 + " m3";
};
IO.println(line);
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
record Gas(String meterId, double m3) implements Reading {}
E-001: 350.5 kWh E-002: high use, 1500.0 kWh G-007: 12.25 m3
Choosing the right construct
All of these constructs change the flow of a program; they differ in whether they run statements or produce a value, and in how much the compiler can check for you.
| 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 with arrows | nothing (runs statements) | many cases on one value, each doing some work |
| switch expression | a value | mapping one value to a result, with the compiler checking that every case is covered |
instanceof pattern |
a boolean (plus a variable) |
one type test followed by code that uses the value |
| classic switch with colons | nothing (runs statements) | existing code; deliberate fall-through (rare, and worth a comment) |
A good rule of thumb: when every branch of an
if/else if chain assigns the same
variable, a switch expression says the same in fewer lines and lets
the compiler check that no case is forgotten. When the same switch
on a "kind" of object appears in many places, consider
polymorphism
instead, so that each type carries its own behavior.