Pattern Matching in Java: switch, Record Patterns and Primitive Patterns (JDK 21 to 27)
Java pattern matching from JDK 21 to 27, verified on three JDKs: type patterns, switch guards, record patterns, sealed exhaustiveness and the MatchException that separate compilation can still cause, null and unnamed patterns, and JEP 532 primitive patterns (preview).
Almost every Java program has a moment where it holds a value of a general type and needs to act on a more specific one. For decades that meant three steps written out by hand: test the type with instanceof, cast, then use the result. Pattern matching folds those three steps into one and, in newer releases, lets you take a record apart in the same breath and have the compiler prove that you handled every case.
This article walks that feature in the order you would meet it, from the smallest thing that works up to the newest preview: type patterns, switch over types with guards, record patterns, sealed types and exhaustiveness, the two small features that make real code tidier (null cases and _), and finally the primitive patterns that are still a preview in JDK 27. Every claim comes from a real compile and run on three JDKs, and each code block links to its file in the companion repository.
Versions. Tested on JDK 21.0.10 (OpenJDK, Ubuntu build), JDK 25.0.4.1 (Temurin, LTS) and JDK 27+35 (Temurin, GA 15 September 2026). Everything up to the unnamed-pattern section compiles and runs unchanged on all three with no flags. Unnamed patterns (_) need no flag on 25 and 27 but are still a preview on 21. Primitive patterns are JEP 532, the fifth preview in JDK 27, and need --enable-preview on 25 and on 27. openjdk.org/jeps returned HTTP 403 to the tooling used for this article, so preview status comes from my earlier Java 27 article and from what javac itself reports.
Test, cast, use: what a type pattern replaces
The oldest form of pattern matching is the instanceof you already write. What changed is that the test can now introduce a variable, so you no longer repeat yourself. Both styles are in one file so you can compare them:
public class TypePatterns {
// The old way: test, cast, then use.
static String oldStyle(Object o) {
if (o instanceof String) {
String s = (String) o;
return "string of length " + s.length();
}
return "something else";
}
// The pattern way: test and bind in one step.
static String newStyle(Object o) {
if (o instanceof String s) {
return "string of length " + s.length();
}
return "something else";
}
// The binding variable stays in scope wherever the test is known to have succeeded.
static int lengthOrMinusOne(Object o) {
if (!(o instanceof String s)) {
return -1;
}
return s.length(); // s is in scope here: we only get here if the test succeeded
}
// A switch can test many types in a row.
static String describe(Object o) {
return switch (o) {
case Integer i -> "int " + (i + 1);
case String s -> "string " + s.toUpperCase();
case int[] arr -> "int array of " + arr.length;
default -> "other " + o.getClass().getSimpleName();
};
}
public static void main(String[] args) {
System.out.println(oldStyle("hello") + " / " + newStyle("hello"));
System.out.println(lengthOrMinusOne("abc") + " " + lengthOrMinusOne(42));
System.out.println(describe(41));
System.out.println(describe("java"));
System.out.println(describe(new int[]{1, 2, 3}));
System.out.println(describe(3.5));
}
}
Source: TypePatterns.java. oldStyle tests, casts on the next line, then uses the cast variable. newStyle writes o instanceof String s and gets s already typed as a String. The variable s (a binding variable) exists only where the compiler can prove the test succeeded. In lengthOrMinusOne that is the code after an early return, which is the trick worth learning first: if (!(o instanceof String s)) return -1; and then s is usable for the rest of the method. Run on all three JDKs, it prints the same thing:
$ javac src/TypePatterns.java && java TypePatterns (27+35)
string of length 5 / string of length 5
3 -1
int 42
string JAVA
int array of 3
other Double
Output: 01-type-patterns.txt, which also holds the JDK 21 and 25 runs (identical). The same file has a describe method with a switch that tests several types in a row. That form is the subject of the next section, but it shows why the feature is worth having: what used to be a chain of if/else if/casts is now a list of cases.
The top row is the boilerplate, and the bottom row is the same logic with the middle step removed. The point of the last line is that a hand-written cast can name the wrong type (it fails at run time), while a binding from a pattern always has the type that was tested.
The one idea to carry forward. A pattern is a test and a set of variables. If the test fails, the variables do not exist; if it succeeds, they do. Every later feature in this article (guards, record patterns, exhaustiveness) is a way of writing tests that bind more, or of making the compiler check that the tests between them cover everything.
Going deeper on this section
Companion repo: patterns README (how to regenerate every transcript)
A switch that looks at types: guards and the order rule
A switch can test the type of its subject the same way. Each case is a pattern, and you can add a guard — a boolean condition after when — to say “this type, but only if…”. This one sorts strings by length and integers by sign:
public class Guards {
static String size(Object o) {
return switch (o) {
case String s when s.isEmpty() -> "empty string";
case String s when s.length() < 5 -> "short string: " + s;
case String s -> "long string: " + s;
case Integer i when i < 0 -> "negative " + i;
case Integer i -> "int " + i;
default -> "other";
};
}
public static void main(String[] args) {
for (Object o : new Object[]{"", "abc", "abcdefgh", -4, 12, 1.5}) System.out.println(size(o));
}
}
$ javac src/Guards.java && java Guards (25.0.4.1+1-LTS)
empty string
short string: abc
long string: abcdefgh
negative -4
int 12
other
Output: 02-guards-and-dominance.txt. The cases are tried from top to bottom and the first one that matches wins. That is why the guarded String cases sit above the plain String case: the plain one matches every string, so anything placed after it would never run. The compiler enforces this, and it is one of the friendlier compile errors in the language:
public class Dominated {
static String f(Object o) {
return switch (o) {
case CharSequence cs -> "chars";
case String s -> "string"; // String is a CharSequence, so this can never be reached
default -> "other";
};
}
public static void main(String[] args) { System.out.println(f("x")); }
}
Source: Dominated.java. String is a CharSequence, so the second case can never be reached.
$ javac broken/Dominated.java (25.0.4.1+1-LTS)
broken/Dominated.java:5: error: this case label is dominated by a preceding case label
case String s -> "string"; // String is a CharSequence, so this can never be reached
^
1 error
Output: 02-guards-and-dominance.txt (27 says the same). A guard does not rescue you if the general case comes first. GuardBeforeGeneral.java puts case String s above case String s when s.isEmpty() and gets the same “dominated by a preceding case label” error on both JDKs; the file has the full transcript.
The top stack is the code that does not compile, and the bottom stack is the fix. The rule is the one from the guards: put narrow cases above wide ones, and remember that a guarded case is narrower than the same case without a guard.
Going deeper on this section
Companion repo: output/02 (the dominance errors on 25 and 27)
A record pattern matches a record and pulls out its components in one go. It is the pattern you would write if you wanted to say “a Point whose x is this and y is that” without calling accessors yourself. Patterns nest, so a Line made of two Points can be taken apart all the way down:
public class RecordPatterns {
record Point(int x, int y) {}
record Line(Point from, Point to) {}
record Box<T>(T content) {}
// Deconstruct a record: the components are bound to x and y.
static String where(Object o) {
if (o instanceof Point(int x, int y)) {
return "point at " + x + "," + y;
}
return "not a point";
}
// Patterns nest: take a Line apart into its four coordinates in one step.
static int length1(Line line) {
if (line instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
return Math.abs(x2 - x1) + Math.abs(y2 - y1);
}
return -1;
}
// A nested pattern can also test the component's type.
static String box(Box<Object> b) {
return switch (b) {
case Box<Object>(String s) -> "box of string " + s;
case Box<Object>(Integer i) -> "box of int " + i;
case Box<Object>(var other) -> "box of " + other;
};
}
public static void main(String[] args) {
System.out.println(where(new Point(3, 4)));
System.out.println(where("nope"));
System.out.println(length1(new Line(new Point(0, 0), new Point(3, 4))));
System.out.println(box(new Box<>("hi")));
System.out.println(box(new Box<>(7)));
System.out.println(box(new Box<>(2.5)));
}
}
Source: RecordPatterns.java. where matches a single record. length1 (the Manhattan distance between the ends) matches a Line and both its Points in one pattern, with var letting the compiler infer each component’s type. box shows that a nested pattern can also test the component’s type: Box<Object>(String s) only matches when the content is a string.
$ javac src/RecordPatterns.java && java RecordPatterns (27+35)
point at 3,4
not a point
7
box of string hi
box of int 7
box of 2.5
Output: 03-record-patterns.txt (the 21 and 25 runs are identical). There is one failure mode worth knowing. A record pattern calls the record’s accessor methods, and an accessor can throw. The language does not let that exception escape as-is; it wraps it:
public class MatchExceptionDemo {
record Fragile(int v) {
public int v() { throw new IllegalStateException("accessor blew up"); }
}
public static void main(String[] args) {
Object o = new Fragile(1);
try {
if (o instanceof Fragile(int v)) System.out.println("matched " + v);
} catch (MatchException e) {
System.out.println("MatchException: " + e.getMessage());
System.out.println("cause: " + e.getCause());
}
}
}
$ javac src/MatchExceptionDemo.java && java MatchExceptionDemo (25.0.4.1+1-LTS)
MatchException: java.lang.IllegalStateException: accessor blew up
cause: java.lang.IllegalStateException: accessor blew up
Output: 03-record-patterns.txt. The IllegalStateException from the accessor arrives as the cause of a java.lang.MatchException. If you have a pattern-heavy switch and a stack trace that ends in MatchException, look at the cause first: it is your code, not the pattern machinery.
Deconstruction runs your accessors. A record pattern is not a field read. It calls the public accessor methods, so a record with a custom or expensive accessor runs that code every time the pattern matches. For an ordinary record with no custom accessors that is invisible; for one that does work in an accessor, it is not.
Sealed types make switch exhaustive — and where that guarantee ends
Guards and record patterns help you write cases. The compiler helps you know you wrote enough of them. A sealed type lists its permitted subtypes, so a switch over it can be checked: if every permitted subtype has a case, no default is needed, and if one is missing the code does not compile.
public class SealedExhaustive {
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 {}
// No default: the compiler checks that every permitted subtype is covered.
static double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Square(double side) -> side * side;
case Rect(double w, double h) -> w * h;
};
}
public static void main(String[] args) {
for (Shape s : new Shape[]{new Circle(1), new Square(2), new Rect(2, 3)}) {
System.out.printf("%s -> %.2f%n", s, area(s));
}
}
}
Source: SealedExhaustive.java. There is no default; the three cases cover Circle, Square and Rect, which are all the type permits. It prints the same areas on 21, 25 and 27 (see 04-exhaustiveness.txt). Now remove the Rect case, and compare how the two newest JDKs report it:
$ javac broken/NotExhaustive.java (25.0.4.1+1-LTS)
broken/NotExhaustive.java:8: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 error
$ javac broken/NotExhaustive.java (27+35)
broken/NotExhaustive.java:8: error: the switch expression does not cover all possible input values
return switch (s) {
^
missing patterns:
Rect _
1 error
Source: NotExhaustive.java, output in 04-exhaustiveness.txt. This is a real improvement in JDK 27: the same error now ends with a missing patterns: section that names the case you forgot (Rect _). On 21 and 25 you only learn that something is missing. I did not find this change in the release notes, so treat it as observed on 27+35. The error is not limited to sealed types: a switch over Object with only String and Integer cases fails the same way (NotExhaustiveObject.java, and on 27 it reports Object _ as missing).
The compile-time check has one blind spot, and it is the surprise in this article. The compiler proves exhaustiveness against the sealed type as it exists when you compile. If the type later gains a subtype and your switch is not recompiled, nothing re-checks it. The demo compiles a client against version 1 of a Shape (circle and square), then swaps in version 2 with a Triangle without recompiling the client:
public class Client {
// Compiled against Shape v1, where the only permitted subtypes are Circle and Square.
public static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Square q -> q.side() * q.side();
};
}
public static void main(String[] args) {
System.out.println("circle: " + area(new Circle(1)));
System.out.println("square: " + area(new Square(2)));
}
}
$ java -cp new:old Main (Shape gained Triangle; Client.class NOT recompiled) (27+35)
Exception in thread "main" java.lang.MatchException
at Client.area(Client.java:4)
at Main.main(Main.java:4)
$ javac -cp new sep/client/Client.java (now recompile Client against v2) (27+35)
sep/client/Client.java:4: error: the switch expression does not cover all possible input values
return switch (s) {
^
missing patterns:
Triangle _
1 error
Output: 05-separate-compilation.txt, which holds the 25 run as well (same behaviour). The old client threw a bare MatchException the moment a Triangle reached it, with no cause and no message. Recompiling the client against version 2 gives the compile error above instead. So the guarantee is real but only as strong as your build: a library that adds a permitted subtype is a source-compatible change and a run-time-breaking one for every downstream switch that was not rebuilt.
The blue and green boxes are the world at compile time, where everything is checked. The orange box is the change that happens later, and the two red-to-green boxes below it are the two outcomes: skip the rebuild and you find out in production, or rebuild and you find out in the compiler. The sentence at the bottom is the practical rule.
Fingerprint of this bug. A java.lang.MatchException with no cause and no message, thrown from a switch that compiled cleanly, right after a dependency upgrade. The fix is to recompile the code that switches over the dependency’s sealed type; do not paper over it with a default, which would turn a loud failure into silent wrong behaviour.
Going deeper on this section
Companion repo: sep/ (both versions of the hierarchy and the client) and output/04 (the 21, 25 and 27 diagnostics)
Related on this site: Java 27 Is Out, for the rest of what changed in 27
Two small features: null cases and the underscore
A switch over an object throws NullPointerException if the subject is null, unless one of the cases says otherwise. The pattern form gives you a way to say so. The underscore, on the other hand, is for the cases where you must name a pattern but do not care about the value:
public class NullCase {
static String withoutCaseNull(Object o) {
return switch (o) {
case String s -> "string";
default -> "other";
};
}
static String withCaseNull(Object o) {
return switch (o) {
case null -> "null!";
case String s -> "string";
default -> "other";
};
}
static String combined(Object o) {
return switch (o) {
case String s -> "string";
case null, default -> "null or something else";
};
}
public static void main(String[] args) {
try { System.out.println(withoutCaseNull(null)); }
catch (NullPointerException e) { System.out.println("without case null: NullPointerException"); }
System.out.println("with case null: " + withCaseNull(null));
System.out.println("case null, default: " + combined(null));
System.out.println("instanceof with null: " + (null instanceof String));
}
}
$ javac src/NullCase.java && java NullCase (27+35)
without case null: NullPointerException
with case null: null!
case null, default: null or something else
instanceof with null: false
Output: 06-null-and-unnamed.txt. Without a case null, the first method throws; with one, null is just another case; case null, default handles it together with everything unmatched. Note the last line: null instanceof String is false, not an exception, so instanceof and switch disagree about null by design.
Now the underscore. In a record pattern you often want x and not y, and in a sealed switch you often care about the type and not the value:
public class Unnamed {
record Point(int x, int y) {}
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
static String kind(Shape s) {
return switch (s) {
case Circle _ -> "round"; // the type matters, the value does not
case Square _ -> "angular";
};
}
static int xOnly(Object o) {
if (o instanceof Point(int x, _)) return x; // do not care about y
return -1;
}
public static void main(String[] args) {
System.out.println(kind(new Circle(1)) + " " + kind(new Square(1)));
System.out.println(xOnly(new Point(5, 9)));
}
}
$ javac src/Unnamed.java && java Unnamed (21.0.10+7-Ubuntu-124.04)
src/Unnamed.java:9: error: unnamed variables are a preview feature and are disabled by default.
case Circle _ -> "round"; // the type matters, the value does not
^
(use --enable-preview to enable unnamed variables)
1 error
Output: 06-null-and-unnamed.txt. On 21 it is rejected as a preview feature; on 25 and 27 it compiles with no flag and prints round angular and 5. So if you must still build with --release 21, keep naming the variables.
Until now a pattern could only name a reference type. JEP 532 lets it name a primitive: i instanceof byte b, case int i, Reading(int whole). The meaning is not “cast it”; it is “can this value be converted to that type without losing information?”. Without the preview flag the compiler refuses, on both JDKs:
$ javac preview/PrimitiveInstanceof.java (27+35)
preview/PrimitiveInstanceof.java:3: error: primitive patterns are a preview feature and are disabled by default.
if (i instanceof byte b) return i + " fits in a byte: " + b;
^
(use --enable-preview to enable primitive patterns)
1 error
Output: 07-primitive-patterns.txt. With --enable-preview --release N it compiles and runs on 25 and on 27. Here is the instanceof form:
public class PrimitiveInstanceof {
static String describe(int i) {
if (i instanceof byte b) return i + " fits in a byte: " + b;
if (i instanceof short s) return i + " fits in a short: " + s;
return i + " needs an int";
}
static void floats(double d) {
if (d instanceof float f) System.out.println(d + " converts to float " + f + " without loss");
else System.out.println(d + " does NOT convert to float without loss");
}
public static void main(String[] args) {
System.out.println(describe(100));
System.out.println(describe(1000));
System.out.println(describe(100000));
floats(0.5);
floats(0.1);
}
}
$ javac --enable-preview --release 27 preview/PrimitiveInstanceof.java && java --enable-preview PrimitiveInstanceof (27+35)
100 fits in a byte: 100
1000 fits in a short: 1000
100000 needs an int
0.5 converts to float 0.5 without loss
0.1 does NOT convert to float without loss
Output: 07-primitive-patterns.txt (the 25 run is identical). 100 fits in a byte, 1000 does not but fits a short, and 0.1 does not convert to float without changing the value. The same test is available in a switch, including over types a switch could not take before. On JDK 21 a switch on a long is rejected outright, and on 25 and 27 the message points you at the preview:
$ javac broken/SwitchOnLong.java (21.0.10+7-Ubuntu-124.04)
broken/SwitchOnLong.java:4: error: selector type long is not allowed
String s = switch (n) { // switch on long: not allowed without the preview
^
1 error
$ javac broken/SwitchOnLong.java (27+35)
broken/SwitchOnLong.java:4: error: primitive patterns are a preview feature and are disabled by default.
String s = switch (n) { // switch on long: not allowed without the preview
^
(use --enable-preview to enable primitive patterns)
1 error
$ javac --enable-preview --release 27 preview/PrimitiveSwitch.java && java --enable-preview PrimitiveSwitch (27+35)
OK | other success 204 | client error 404 | something else 500
yes no
zero one many (9223372036854775807)
Output: 07-primitive-patterns.txt. Primitive patterns also work as record components, so a double component can be matched against an int pattern:
public class PrimitiveInRecord {
record Reading(double celsius) {}
static String classify(Object o) {
return switch (o) {
case Reading(int whole) -> "whole degrees: " + whole; // double component matched against an int pattern
case Reading(double d) -> "fractional: " + d;
default -> "not a reading";
};
}
public static void main(String[] args) {
System.out.println(classify(new Reading(21.0)));
System.out.println(classify(new Reading(21.5)));
System.out.println(classify("x"));
}
}
$ javac --enable-preview --release 27 preview/PrimitiveInRecord.java && java --enable-preview PrimitiveInRecord (27+35)
whole degrees: 21
fractional: 21.5
not a reading
Source: PrimitiveInRecord.java, output in 07-primitive-patterns.txt. 21.0 matches int whole and 21.5 falls through to the double case. The edge cases are where the “without losing information” rule earns its name, so I ran a set of values through both tests:
public class PrimitiveEdges {
public static void main(String[] args) {
double[] ds = {0.0, -0.0, Double.NaN, 16777217.0, 1e10, 3.0};
for (double d : ds) {
System.out.printf("%-12s int? %-5b float? %-5b%n", d, d instanceof int, d instanceof float);
}
long big = 1L << 40;
System.out.println(big + " instanceof int? " + (big instanceof int));
Object o = 42;
if (o instanceof int i) System.out.println("Object holding Integer matches int pattern: " + i);
Object s = (short) 7;
if (s instanceof int i) System.out.println("Short in Object matches int? " + i); else System.out.println("Short in Object does not match int");
int x = 65;
if (x instanceof char c) System.out.println("65 as char: " + c);
}
}
$ javac --enable-preview --release 27 preview/PrimitiveEdges.java && java --enable-preview PrimitiveEdges (27+35)
0.0 int? true float? true
-0.0 int? false float? true
NaN int? false float? true
1.6777217E7 int? true float? false
1.0E10 int? false float? true
3.0 int? true float? true
1099511627776 instanceof int? false
Object holding Integer matches int pattern: 42
Short in Object does not match int
65 as char: A
Output: 07-primitive-patterns.txt. Read the rows against the rule. 0.0 converts to int exactly, but -0.0 does not (an int has no negative zero), and NaN does not convert to int at all. 16777217.0 is exactly an int but is not exactly representable as a float (that is 224+1; that explanation is my reading, the output only shows the results). 1.0E10 is too big for an int but converts to float exactly. A long of 240 is not an int. And a short stored in an Object does not match int, while an Integer does.
Each row is one value and each pair of boxes is the verdict of the two tests, copied from the program output above. The lesson of the last sentence is that d instanceof int does not tell you anything about d instanceof float; each asks whether that target can hold the value exactly.
Two limits are worth seeing before you try it. The boxed types are not allowed as the source of a narrowing test: Integer boxed = 300; boxed instanceof byte b is a compile error (Integer cannot be converted to byte, in BoxedToNarrower.java; the transcript is in 07-primitive-patterns.txt). And, more importantly for anyone shipping code, preview class files are stamped with the JDK that built them:
$ java --enable-preview PrimitiveSwitch (class compiled on 25, run on 27+35)
Error: LinkageError occurred while loading main class PrimitiveSwitch
java.lang.UnsupportedClassVersionError: PrimitiveSwitch (class file version 69.65535) was compiled with preview features that are unsupported. This version of the Java Runtime only recognizes preview features for class file version 71.65535
Output: 08-preview-class-files.txt. The class compiled with --enable-preview on 25 has minor version 65535, and JDK 27 refuses to load it even with --enable-preview, because it only accepts preview class files built by 27 itself (the same file shows the no-flag failure on 25 too). That is the practical cost of using a preview feature: you cannot hand the compiled classes to a different JDK release.
Do not ship preview patterns in a library. Any class that uses a primitive pattern needs the exact JDK it was compiled with, plus --enable-preview, at run time. For application code you build and run on one JDK, that is a manageable experiment. For a jar somebody else will run, it is a bug you are shipping.
Yes, for everything except the primitive patterns. Type patterns, guards, record patterns, case null and sealed exhaustiveness are final, run the same on 21, 25 and 27, and remove real boilerplate. Unnamed patterns are also final on 25 and 27 and only cost you a --release 21 if you still need one. Start with the instanceof binding, then move any chain of if/else if/casts to a switch.
For primitive patterns, try them on a scratch branch and wait. They need a flag on both 25 and 27, the compiled classes only run on the JDK that built them, and this is the fifth preview round, so the details can still move. The one habit worth adopting from this article is the rule for sealed types: the compiler checks exhaustiveness only when it compiles, so rebuild every switch that depends on a sealed type you do not own, and resist adding a default just to silence a warning.
What I did not test. Pattern matching on generic types beyond the simple Box<Object> case (type-inference corner cases), performance of pattern switch compared with an if chain, IDE and static-analysis support, and any JEP number other than 532 (I could not open the JEP pages). The sealed-type section shows how the check works; it does not cover permits rules or the module and package restrictions on sealed hierarchies.
No Comments yet!