Mastering Java 21 Record Patterns — A Comprehensive Guide
Java 21 significantly improves pattern matching by introducing record patterns. These let you destructure compact data carriers (records) directly in match constructs like instanceof and switch, reducing boilerplate and making control flow clearer. This guide explains record patterns end-to-end: syntax, nested/guarded patterns, migration strategies, best practices, and real-world examples.
What are Record Patterns?
A record is a concise immutable data carrier (introduced in Java 14+) and a record pattern lets you bind record components directly inside pattern constructs. Instead of calling getters, you declare the shape you expect and get destructured bindings in-scope.
Simple example
record Point(int x, int y) {}
void print(Point p) {
if (p instanceof Point(int x, int y)) {
System.out.println("x = " + x + ", y = " + y);
}
}
Now x and y are local variables available directly inside the if block — no getters, no casts.
Pattern matching in instanceof
Record patterns integrate with type patterns in instanceof. Use them when you need short, conditional destructuring.
Object obj = ...;
if (obj instanceof Person(String name, int age)) {
System.out.println("Person: " + name + " (" + age + ")");
}
This is equivalent to the old pattern: check type, cast, call getters. The new form is safer and more concise.
Using record patterns in switch (expressions & statements)
Java 21 allows matching using record patterns in switch expressions and statements. This turns switch into a powerful algebraic matching tool.
sealed interface Shape {}
record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
void describe(Shape s) {
switch (s) {
case Circle(double r) -> System.out.println("Circle r=" + r);
case Rectangle(double w, double h) -> System.out.println("Rect " + w + "x" + h);
default -> System.out.println("Unknown");
}
}
Note: sealed types and records pair well — the compiler can ensure exhaustive matching.
Nested Record Patterns
You can nest patterns. This is useful when records contain other records or nested structures like optional addresses.
record Address(String city, String zip) {}
record User(String name, Address addr) {}
if (u instanceof User(String name, Address(String city, String zip))) {
System.out.println(name + " lives in " + city);
}
Nested destructuring keeps the read path shallow and readable even for deep structures.
Guarded Patterns (Guards)
Guards (pattern with an additional boolean condition) let you apply more granular matching. In switch cases, you can add a when guard to the case.
record Person(String name, int age) {}
switch (obj) {
case Person(String n, int a) when a >= 18 -> System.out.println(n + " is adult");
case Person(String n, int a) -> System.out.println(n + " is minor");
default -> System.out.println("Not a person");
}
A guard can reference the variables bound by the pattern (e.g., a).
Refactoring example: Replace getters & casts
Here’s a quick refactor path: start with classic code and transform to pattern matching.
// Old
if (obj instanceof Person) {
Person p = (Person) obj;
if (p.age() > 18) ...
}
// New (record pattern)
if (obj instanceof Person(String name, int age)) {
if (age > 18) ...
}
This is not just syntactic sugar — it reduces lines and potential casting mistakes.
Record patterns + null handling
Pattern matching follows normal Java semantics: if the tested reference is null, the pattern fails. Use null checks or upstream validation if you need special handling.
Point p = null;
if (p instanceof Point(int x, int y)) {
// this block won't run; pattern fails for null
}
Best practices & design guidelines
- Use records for data carriers — DTOs and value objects benefit the most.
- Keep patterns small — avoid huge nested matches that hide business logic.
- Prefer switch + sealed types when you can make matching exhaustive and checked by the compiler.
- Avoid side effects in patterns — patterns should destructure, not compute.
- Name your bound variables clearly — they are local in scope and improve readability.
Limitations and caveats
- Record patterns work only with records (not arbitrary classes).
- Preview features: depending on your JDK/IDE, you may need to enable preview or update toolchain.
- IDE support: some tooling may lag in code completion or refactoring for guarded/nested patterns.
- Overuse can make logic harder to follow — balance declarative patterns with clear method extraction.
Migration notes — how to adopt record patterns safely
- Start small: Replace simple instanceof + cast cases with patterns.
- Add tests: Ensure behavior remains the same; pattern binding is compile-time — tests verify runtime semantics.
- Use feature flags: Keep compilation with
--enable-previewuntil runtime and tooling are standardized in your environment. - Prefer sealed types: Where feasible, declare closed hierarchies so you can rely on exhaustiveness checks.
Tooling & how to compile/run
To compile and run code with preview features (until they are final in your JDK), use:
javac --enable-preview --release 21 Example.java
java --enable-preview Example
In Maven, set the compiler plugin options to enable preview features.
Performance considerations
Pattern matching and record creation are lightweight. Records are immutable and can be optimized by the VM. The main cost is creating objects; avoid unnecessary allocations in hot loops or tight parsing loops — consider pooling/primitive arrays when performance-critical.
Real-world use cases & examples
API payload routing
Use record patterns to route message types quickly and safely:
record CreateUser(String name, String email) {}
record DeleteUser(long id) {}
void handle(Event e) {
switch (e) {
case CreateUser(String n, String e) -> create(n, e);
case DeleteUser(long id) -> delete(id);
}
}
Parsing & validation pipeline
Deconstruct records to validate nested payloads in one place with guarded patterns.
record Address(String city, String zip) {}
record User(String name, Address addr) {}
switch (obj) {
case User(String name, Address(String city, String zip)) when zip.matches("\\d{5}") ->
System.out.println("Valid user in " + city);
default -> System.out.println("Invalid or unknown");
}
Troubleshooting common errors
| Error | Cause | Fix |
|---|---|---|
| Cannot find symbol (pattern) | Using JDK < 21 or preview off | Use JDK 21 and --enable-preview compile flags |
| IDE not parsing patterns | Old IDE/tooling | Upgrade IDE or install preview language support |
| Ambiguous bindings | Names collide with existing locals | Rename bound variables to avoid shadowing |
FAQ — short
- Do patterns introduce new types? No — bound variables are local values, not new types.
- Are patterns slower? No — compile-time checks and JIT optimize usual cases; object creation dominates costs.
- Should I convert all getters to patterns? Prefer for concise checks, but don’t restructure large codebases in one sweep — incremental adoption is safest.
Summary
Java 21 record patterns are a clean, expressive tool for destructuring and matching data. Use them for DTOs, routing, and validation. Combine with sealed types for exhaustive matching. Adopt incrementally, keep patterns readable, and prefer small records to model domain values.