if, switch and the switch expression
From if/else and fall-through to switch expressions, pattern matching, record patterns and case null, plus how javac turns a switch into tableswitch or lookupswitch.
On this page
🔀 What are if and switch?
if/else picks a path by testing a boolean. switch picks a path by matching one value against many labels, like a receptionist routing a visitor by badge colour instead of asking twenty yes/no questions in a row. Modern Java has two flavours: the classic switch statement with case X: labels that fall through, and the switch expression with case X -> arms that produce a value, which became standard in Java 14 and learned to match types and records in Java 21.
For this lesson, we'll start with if, walk through the classic switch and its fall-through behaviour, then build up to switch expressions, pattern matching, record patterns, guards and case null. Along the way we'll open the bytecode, because "how does a switch on a String work?" is a favourite follow-up that most candidates can't answer.
Below the line (out of scope)
- Sealed classes and records themselves get their own lessons later in the course
- Preview features beyond Java 21, such as primitive patterns in switch
The 30-second answer
if/else tests booleans; switch matches one selector value. Classic switch works on int-compatible types (byte, short, char, int and their wrappers), String (since Java 7, via hashCode() then equals()) and enums, and it falls through without break. Since Java 14, arrow labels don't fall through and a switch expression returns a value, using yield from a block, and must be exhaustive. Java 21 adds type patterns, record patterns, when guards and case null. Under the hood, javac compiles dense cases to a tableswitch (O(1) jump table) and sparse ones to a lookupswitch (sorted key search).
What you’ll learn
- Explain classic fall-through and the selector types a switch accepts, including
Stringand enums - Write switch expressions with arrow labels and
yield(Java 14) and explain exhaustiveness - Use pattern matching, record patterns,
whenguards andcase nullin a Java 21 switch - Describe how javac compiles switches to
tableswitch,lookupswitchandhashCode()plusequals()for strings
01Picture it first: two kinds of switch
Think of the classic switch as a row of doors in a corridor. You enter at the matching door and keep walking through every door after it until something (break, return, throw) stops you. The modern arrow switch is a set of separate rooms: you enter exactly one, do its work, and leave.
Classic colon-label switch falls through into later cases until a break, while an arrow switch runs exactly one arm and must cover every possible value
Before we get to switch, a word on if. The condition must be a real boolean, so if (count) doesn't compile the way it would in C. The classic trap is if (flag = true): it compiles for a boolean flag, assigns, and is always taken. And a dangling else binds to the nearest if, which is why most style guides insist on braces even for one-line bodies.
02Under the hood: the classic switch statement
Try it yourself first
Before reading on, predict what classic(1), classic(2) and classic(9) return. Then say which bytecode instruction javac will pick for this switch.
public class Classic {
static int classic(int x) {
int count = 0;
switch (x) {
case 1:
count++; // no break: falls into case 2
case 2:
count++;
case 3:
count++;
break;
default:
count = -1;
}
return count;
}
static int httpCode(String method) {
switch (method) { // NullPointerException if method is null
case "GET": return 200;
case "POST": return 201;
default: return 405;
}
}
public static void main(String[] args) {
System.out.println(classic(1) + " " + classic(2) + " " + classic(9)); // 3 2 -1
System.out.println(httpCode("POST")); // 201
}
}
Notice three things:
- Fall-through is the default.
classic(1)enters atcase 1and runs three increments. It's occasionally useful for grouping (case 'a': case 'e':), but forgetting abreakis one of the oldest bugs in C-family languages. - Labels must be compile-time constants of the selector's type, with no duplicates.
long,float,doubleandbooleanselectors are not allowed in Java 21. - A
nullselector throwsNullPointerExceptionin every switch unless it has an explicitcase null(Java 21).
Now the bytecode. The JVM has two dedicated instructions, and javac picks one with a cost heuristic that weighs table size against the number of labels.
tableswitch indexes straight into a jump table for dense keys, while lookupswitch searches sorted key and offset pairs for sparse keys
tableswitch: used when the case values are dense, like1, 2, 3, 4. It stores alow, ahighand one jump offset per value in between. The JVM checks bounds and indexes into the table: O(1).lookupswitch: used when values are sparse, like1, 1000, 100000. It stores sorted key/offset pairs, and the JVM may binary search them: O(log n).
A String switch is compiled in two stages. javac calls hashCode() and does a lookupswitch on the hash; inside each hash bucket it calls equals() to confirm the match (two different strings can share a hash, like "Aa" and "BB", both 2112) and records a case index; then a second switch on that index runs your code. That's why a null selector throws: the very first instruction is method.hashCode().
For an enum from another class, javac traditionally generates a synthetic $SwitchMap$ array that maps each constant's ordinal() to a case index. That indirection means reordering the enum's constants later won't silently break already-compiled switches.
03Switch expressions, arrows and yield (Java 14)
JEP 361 made switch expressions standard in Java 14. You get three upgrades at once: arrow labels that never fall through, multiple constants per label separated by commas, and the ability to use the whole switch as a value.
import java.time.DayOfWeek;
public class Expressions {
static String kind(DayOfWeek day) {
return switch (day) { // no default needed: all 7 covered
case SATURDAY, SUNDAY -> "weekend";
case FRIDAY -> {
String mood = "almost weekend";
yield mood.toUpperCase(); // yield returns a value from a block
}
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY -> "weekday";
};
}
public static void main(String[] args) {
System.out.println(kind(DayOfWeek.FRIDAY)); // ALMOST WEEKEND
}
}
yield is the keyword for "this is the value of the switch" inside a block arm. You can't use return there, because return would leave the enclosing method. A switch expression must be exhaustive: every possible selector value needs an arm. For enums, covering every constant is enough and you can drop default. If someone later adds a constant and doesn't recompile your class, the switch throws MatchException at runtime in Java 21 (older releases threw IncompatibleClassChangeError), so stale code fails loudly instead of returning garbage.
You can also use arrows in a switch statement. It still won't fall through, and a statement that only uses old-style constants doesn't have to be exhaustive.
04Pattern matching, record patterns, guards and null (Java 21)
Java 21 finalised pattern matching for switch (JEP 441) and record patterns (JEP 440). The selector can now be any reference type, and labels can test the type, take records apart and add a when guard.
public class Shapes {
sealed interface Shape permits Circle, Square, Rect {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Rect(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) { // exhaustive: sealed
case Circle c -> Math.PI * c.r() * c.r();
case Square(double side) -> side * side; // record pattern
case Rect(double w, double h) when w == h -> w * w; // guard
case Rect(double w, double h) -> w * h;
};
}
static String describe(Object o) {
return switch (o) {
case null -> "nothing"; // no NPE
case Integer i when i > 100 -> "big number";
case Integer i -> "number " + i;
case String str -> "text of length " + str.length();
default -> "something else";
};
}
public static void main(String[] args) {
System.out.println(area(new Rect(2, 3))); // 6.0
System.out.println(describe(null)); // nothing
System.out.println(describe(42)); // number 42
}
}
Notice four rules the compiler enforces for you:
- Sealed hierarchies make
defaultunnecessary. BecauseShapepermits exactly three records, covering them all is exhaustive. Add a fourth permitted type and every switch that misses it stops compiling. That's the real payoff. - Dominance. Labels are tried top to bottom, so a broader pattern can't sit above a narrower one.
case CharSequence csbeforecase String sis a compile error: "this case label is dominated by a preceding case label". Guarded cases go before the unguarded case of the same type. case nullis the only way to stop a switch from throwing on null. You can combine it ascase null, default ->.- Pattern switches must be exhaustive even as statements. Once a switch uses patterns or
case null, missing coverage is a compile error.
Under the hood, a pattern switch doesn't compile to a chain of instanceof checks. javac emits an invokedynamic to SwitchBootstraps.typeSwitch, which returns the index of the first matching label, followed by a tableswitch on that index. The runtime is free to optimise the type test without javac baking in a strategy.
05Real world: where switches go wrong
The most common switch incident in older code bases is a missing break that nobody noticed because the fall-through case was rare: a "refund" case that drops into "cancel" and sends a second email. The second is a new enum constant (say PaymentStatus.DISPUTED) added in one module, while a classic switch statement with no default in another module silently does nothing for it. Switch expressions over enums and sealed types fix both: no fall-through, and missing cases fail at compile time or with a MatchException.
My default for new code is a switch expression with arrows whenever each branch produces a value, and an arrow switch statement when it performs actions. I keep if/else for conditions that aren't about one value, such as ranges or combinations of flags.
06Advanced: choosing the right branch tool
| Need | Use | Why |
|---|---|---|
| Two or three unrelated conditions, ranges | if/else if |
switch can only match one selector |
| Map one value to a result | switch expression with -> |
no fall-through, compiler checks coverage |
| Branch on type or shape of data | pattern switch (Java 21) | binds variables, exhaustive over sealed types |
| Hundreds of string keys, configurable at runtime | Map<String, Handler> |
data, not code; no recompile to add a key |
Many candidates are quick to say "a switch is always faster than if/else". It depends. A dense tableswitch is a single indexed jump, which beats a long if chain. But a String switch still pays for hashCode() (cached after the first call on that string) plus at least one equals(), and the JIT often turns short if chains and small switches into similar machine code anyway. Choose for readability and exhaustiveness checks; mention the bytecode to show depth, not as the main reason.
For an int switch, javac compares rough costs: a tableswitch takes about 4 + (high - low + 1) words of space and a time cost of 3, while a lookupswitch takes about 3 + 2 × labels words and a time cost equal to the number of labels. It picks the table when space + 3 × time is no worse. That's why 1, 2, 3 becomes a table but 1, 1000, 100000 becomes a lookup: a table for the second one would need 100,000 entries.
07How to say it in the interview
When I ask about switch, I'm listening for three things: do you know about fall-through and that arrows remove it, can you explain exhaustiveness and why sealed types make it powerful, and can you say something true about how a String switch compiles. If you also mention case null and dominance, you've shown me you've used Java 21 for real.
In the interview
"A classic switch falls through without break and throws on null. Since Java 14, arrow labels don't fall through and a switch expression returns a value, with yield for block arms, and it must be exhaustive. Java 21 adds type and record patterns, when guards and case null, and over a sealed interface the compiler proves coverage so I don't need default. In bytecode, dense int cases become a tableswitch, sparse ones a lookupswitch, and a String switch is a lookupswitch on hashCode() followed by equals() checks."
Here's how this might look on the whiteboard:
On the whiteboard
switch
- selector: int-like + wrappers, String (Java 7), enum; any ref type with patterns (Java 21)
case X:falls through;case X ->never does- expression: yields a value,
yieldin blocks, must be exhaustive - Java 21: type/record patterns,
whenguards,case null, dominance check - null selector without
case null: NPE - bytecode: dense, tableswitch O(1); sparse, lookupswitch O(log n)
- String: hashCode, lookupswitch, equals, then index switch