Skip to content
Java modern java 7 min read

Pattern Matching

Pattern matching lets you test a value’s type and extract its contents in a single, concise step — no more redundant casts, no more boilerplate. Java has been rolling out pattern matching progressively since Java 14, with each release adding more expressive power.

The Problem Pattern Matching Solves

Before pattern matching, every instanceof check forced you to write the same thing three times: check the type, cast the value, then use it.

// Old-school approach (Java 15 and earlier)
Object obj = "Hello, world!";

if (obj instanceof String) {
    String s = (String) obj;   // redundant cast
    System.out.println(s.toUpperCase());
}

This works, but it is noisy. The cast on line 3 carries zero new information — the compiler already knows obj is a String at that point. Pattern matching eliminates the ceremony.

Pattern Matching for instanceof (Java 16)

Java 16 (JEP 394) finalized type patterns for instanceof. You declare a binding variable directly in the test:

Object obj = "Hello, world!";

if (obj instanceof String s) {
    System.out.println(s.toUpperCase());
}

Output:

HELLO, WORLD!

The variable s is bound automatically and is scoped to where it is definitely assigned — inside the if block (and the right-hand side of &&).

Note: The binding variable s is a fresh, effectively-final copy. It is not the same reference as obj — it is just cast and named for you.

Negated Patterns

You can use the pattern in a negated check to exit early:

static void printLength(Object obj) {
    if (!(obj instanceof String s)) {
        System.out.println("Not a string");
        return;
    }
    // s is in scope here — Java knows obj was a String
    System.out.println("Length: " + s.length());
}

Combining with &&

The binding variable flows into the right-hand side of a logical &&:

Object value = "Java";

if (value instanceof String s && s.length() > 3) {
    System.out.println("Long string: " + s);
}

Output:

Long string: Java

Warning: Do not use || with a binding variable on the left side — the variable is not guaranteed to be assigned when the right side evaluates, so the compiler will reject it.

Pattern Matching in switch (Java 21)

The real leap comes with switch pattern matching, finalized in Java 21 (JEP 441). You can match on types directly in a switch expression or statement. See also Switch Expressions for the modern switch syntax foundations.

static String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case Double  d -> "Double: "  + d;
        case String  s -> "String of length " + s.length();
        case null      -> "null value";
        default        -> "Something else: " + obj.getClass().getSimpleName();
    };
}

public static void main(String[] args) {
    System.out.println(describe(42));
    System.out.println(describe(3.14));
    System.out.println(describe("hello"));
    System.out.println(describe(null));
    System.out.println(describe(true));
}

Output:

Integer: 42
Double: 3.14
String of length 5
null value
Something else: Boolean

A few things to notice:

  • case null is now a legal pattern — no more NullPointerException surprise before the switch.
  • Cases are checked top to bottom in order. The first matching arm wins.
  • default catches everything else.

Guarded Patterns (when clause)

Add a when guard to attach a boolean condition to a type pattern:

static String classify(Object obj) {
    return switch (obj) {
        case Integer i when i < 0    -> "Negative integer";
        case Integer i when i == 0   -> "Zero";
        case Integer i               -> "Positive integer: " + i;
        case String s when s.isEmpty() -> "Empty string";
        case String s                -> "String: " + s;
        default                      -> "Other";
    };
}

public static void main(String[] args) {
    System.out.println(classify(-5));
    System.out.println(classify(0));
    System.out.println(classify(7));
    System.out.println(classify(""));
    System.out.println(classify("hi"));
}

Output:

Negative integer
Zero
Positive integer: 7
Empty string
String: hi

Tip: The when clause replaces the old if guard inside case blocks. It is evaluated only after the type test succeeds, so i and s are already bound when the guard runs.

Record Patterns (Java 21)

Java 21 (JEP 440) finalized record patterns, which let you destructure a record directly in a pattern. Instead of extracting components yourself, the pattern unpacks them for you.

record Point(int x, int y) {}
record Line(Point start, Point end) {}

static void printPoint(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println("Point at (" + x + ", " + y + ")");
    }
}

Output:

Point at (3, 7)

Nested Record Patterns

Record patterns can be nested to destructure deeply:

static void describeSegment(Object obj) {
    if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
        System.out.printf("Line from (%d,%d) to (%d,%d)%n", x1, y1, x2, y2);
    }
}

public static void main(String[] args) {
    describeSegment(new Line(new Point(0, 0), new Point(5, 10)));
}

Output:

Line from (0,0) to (5,10)

Record Patterns in switch

Combine record patterns with switch for clean data processing:

sealed interface Shape permits Circle, Rect {}
record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}

static double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)         -> Math.PI * r * r;
        case Rect(double w, double h) -> w * h;
    };
}

public static void main(String[] args) {
    System.out.printf("Circle area: %.2f%n", area(new Circle(3)));
    System.out.printf("Rect area:   %.2f%n", area(new Rect(4, 5)));
}

Output:

Circle area: 28.27
Rect area:   20.00

Notice there is no default case — the compiler knows Shape is a sealed interface with exactly two permitted types, so the switch is exhaustive.

Exhaustiveness with Sealed Types

When the switch selector type is a sealed class or interface, the compiler verifies that every permitted subtype is covered. If you forget a case, the code will not compile:

sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}

// Compiler error if you omit the Add case:
static int eval(Expr e) {
    return switch (e) {
        case Num(int v)         -> v;
        case Add(Expr l, Expr r) -> eval(l) + eval(r);
        // No default needed — compiler confirms coverage
    };
}

This exhaustiveness guarantee is what makes the combination of sealed types and pattern matching so powerful for domain modeling.

Dominance: Order Matters

In a switch with type patterns, a more specific pattern must come before a more general one. Placing a broader type pattern first would make a narrower one unreachable, which is a compile error:

// COMPILE ERROR — Integer is a Number, so case Integer is dominated
static String bad(Number n) {
    return switch (n) {
        case Number x  -> "any number";  // dominates everything below
        case Integer i -> "integer";     // unreachable — compiler rejects this
    };
}

// CORRECT — specific type first
static String good(Number n) {
    return switch (n) {
        case Integer i -> "integer";
        case Double  d -> "double";
        case Number  x -> "some other number";
    };
}

Feature Timeline

FeaturePreviewFinalizedJEP
Pattern matching for instanceofJava 14, 15Java 16394
Pattern matching for switchJava 17–20Java 21441
Record patternsJava 19, 20Java 21440
Unnamed patterns & variables (_)Java 21 (preview)Java 22456

Note: Unnamed patterns (using _ to ignore a component) were previewed in Java 21 and finalized in Java 22. They let you write case Point(int x, _) to ignore the y-component entirely.

Under the Hood

instanceof Pattern Lowering

The compiler lowers obj instanceof String s to a normal instanceof test followed by a checkcast bytecode. The binding variable is simply the result of the cast stored in a new local variable slot. At runtime there is no extra overhead compared to the old manual approach — the JIT can inline and eliminate the checkcast if the type is already proven.

switch Pattern Dispatch

Pattern switch is compiled to a tableswitch or lookupswitch bytecode plus a helper method generated by the compiler (an internal typeSwitch or enumSwitch bootstrap). The JDK uses an invokedynamic instruction (via java.lang.runtime.SwitchBootstraps) to dispatch at runtime. This means the heavy lifting is handled by a bootstrap method the first time it runs, and subsequent calls are fast.

Record Deconstruction

Record patterns use the record’s accessor methods (x(), y(), etc.) to extract components. There is no reflection or special bytecode for deconstruction — it compiles down to regular method calls. The JIT will typically inline these trivial accessors, making record pattern matching as fast as manual field access.

  • Switch Expressions — the modern switch syntax that pattern matching builds on top of.
  • Sealed Classes — sealed hierarchies enable exhaustive pattern switches with no default needed.
  • Records — records are the natural target of record patterns and deconstruction.
  • instanceof Operator — the classic type check that pattern matching replaces and extends.
  • Java 21 LTS Features — the full list of what landed in the Java 21 release.
  • Modern Java (9–21) — overview of all major language features since Java 9.
Last updated June 13, 2026
Was this helpful?