Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Java 21 Record Patterns

1. Introduction

Java records, introduced in Java 16, gave us a concise way to define immutable data carriers. But accessing the data inside a record still required calling accessor methods one at a time. Record Patterns, finalized in Java 21 (JEP 440), change that by letting you deconstruct a record directly in a pattern, extracting all its components in a single expression.

Think of it like the difference between receiving a wrapped gift and having to unwrap it yourself versus the gift magically appearing unwrapped in your hands. Record patterns let Java unwrap the record for you and place each component directly into named variables.

Here is the core idea in one comparison:

record Point(int x, int y) {}

// Without record patterns: extract fields manually
Object obj = new Point(3, 7);

if (obj instanceof Point p) {
    int x = p.x();      // manual extraction
    int y = p.y();      // manual extraction
    System.out.println("Point at (" + x + ", " + y + ")");
}

// With record patterns: fields extracted automatically
if (obj instanceof Point(int x, int y)) {
    // x and y are already available -- no accessor calls needed
    System.out.println("Point at (" + x + ", " + y + ")");
}

The savings might look modest for a two-field record, but the power becomes clear when you have nested records, records with many fields, or when you combine record patterns with switch expressions and sealed types. Across a codebase, the reduction in boilerplate is substantial.

Record patterns work in two places:

  • instanceof expressionsif (obj instanceof Point(int x, int y))
  • switch casescase Point(int x, int y) ->
Feature JEP Java Version Status
Records JEP 395 Java 16 Final
Pattern Matching for instanceof JEP 394 Java 16 Final
Record Patterns (preview) JEP 405 Java 19 Preview
Record Patterns (second preview) JEP 432 Java 20 Preview
Record Patterns JEP 440 Java 21 Final
Pattern Matching for switch JEP 441 Java 21 Final

Record patterns and pattern matching for switch were finalized together in Java 21, and they were designed to work hand-in-hand. This post focuses on record patterns specifically — how they work, how to nest them, how to use them with generics and sealed types, and when they make your code significantly better.

2. Basic Record Patterns

A record pattern matches a record type and simultaneously extracts its components into variables. The syntax mirrors the record’s constructor: you write the record name followed by the component types and names in parentheses.

The general form is: RecordType(ComponentType1 name1, ComponentType2 name2, ...)

// Define some records
record Point(int x, int y) {}
record Name(String first, String last) {}
record Email(String address, boolean verified) {}

// Basic record pattern with instanceof
public void demonstrateBasics(Object obj) {
    // Deconstruct a Point
    if (obj instanceof Point(int x, int y)) {
        System.out.println("x = " + x + ", y = " + y);
        double distance = Math.sqrt(x * x + y * y);
        System.out.println("Distance from origin: " + distance);
    }

    // Deconstruct a Name
    if (obj instanceof Name(String first, String last)) {
        System.out.println("Full name: " + first + " " + last);
        System.out.println("Initials: " + first.charAt(0) + "." + last.charAt(0) + ".");
    }

    // Deconstruct an Email
    if (obj instanceof Email(String address, boolean verified)) {
        if (verified) {
            System.out.println("Verified email: " + address);
        } else {
            System.out.println("Unverified email: " + address + " -- please verify");
        }
    }
}

How It Works Under the Hood

When the compiler sees instanceof Point(int x, int y), it generates code that:

  1. Checks if the object is an instance of Point
  2. If yes, calls point.x() and assigns the result to x
  3. Calls point.y() and assigns the result to y
  4. Makes x and y available in the scope where the pattern matched

The component types in the pattern must match the record’s component types. You cannot write Point(String x, String y) when Point has int components — the compiler will reject it.

Record Patterns with Conditions

record Temperature(double value, String unit) {}

public String classifyTemperature(Object obj) {
    if (obj instanceof Temperature(double value, String unit)) {
        double celsius = unit.equals("F") ? (value - 32) * 5.0 / 9.0 : value;

        if (celsius < 0) return "freezing";
        if (celsius < 15) return "cold";
        if (celsius < 25) return "comfortable";
        if (celsius < 35) return "warm";
        return "hot";
    }
    return "not a temperature";
}

// Record patterns work in negative conditions too
public void processNonPoint(Object obj) {
    if (!(obj instanceof Point(int x, int y))) {
        System.out.println("Not a point, handling differently");
        return;
    }
    // x and y are in scope here because the negative check returned
    System.out.println("Processing point at (" + x + ", " + y + ")");
}

Notice the flow scoping in the second example. When you negate the pattern and return early, the pattern variables (x and y) are available after the if block because the compiler knows the object must be a Point in that code path.

3. Record Patterns in Switch

Record patterns become even more powerful inside switch expressions. You can deconstruct records, combine them with guard clauses using when, and match multiple record types in a single switch. The switch expression form means the whole thing returns a value.

// Record patterns in switch expressions
record Circle(double radius) {}
record Rectangle(double width, double height) {}
record Triangle(double base, double height) {}

public String describeShape(Object shape) {
    return switch (shape) {
        case Circle(double r) when r <= 0
            -> "invalid circle: radius must be positive";
        case Circle(double r) when r < 1
            -> "tiny circle with radius " + r;
        case Circle(double r)
            -> "circle with radius " + r + " and area " + String.format("%.2f", Math.PI * r * r);

        case Rectangle(double w, double h) when w == h
            -> "square with side " + w;
        case Rectangle(double w, double h)
            -> "rectangle " + w + " x " + h;

        case Triangle(double b, double h)
            -> "triangle with base " + b + " and height " + h;

        default -> "unknown shape: " + shape;
    };
}

Combining Record Patterns with Guard Clauses

The when clause in a switch case can reference the variables extracted by the record pattern. This is extremely powerful for building validation and routing logic.

record Order(String id, String status, double total, String customerId) {}

public String processOrder(Object obj) {
    return switch (obj) {
        case Order(var id, var status, var total, var cust) when status.equals("PENDING") && total > 10000
            -> "High-value order " + id + " from " + cust + " requires manual approval";

        case Order(var id, var status, var total, var cust) when status.equals("PENDING")
            -> "Auto-approving order " + id + " for $" + total;

        case Order(var id, var status, var total, var cust) when status.equals("SHIPPED")
            -> "Order " + id + " is in transit to customer " + cust;

        case Order(var id, var status, var total, var cust) when status.equals("DELIVERED")
            -> "Order " + id + " delivered. Total: $" + total;

        case Order(var id, var status, var total, var cust) when status.equals("CANCELLED")
            -> "Order " + id + " was cancelled. Refund $" + total + " to " + cust;

        case Order(var id, var status, var total, var cust)
            -> "Order " + id + " in unknown status: " + status;

        default -> "Not an order";
    };
}

// Multiple record types in one switch
record Deposit(String accountId, double amount) {}
record Withdrawal(String accountId, double amount) {}
record Transfer(String fromAccount, String toAccount, double amount) {}

public String processTransaction(Object transaction) {
    return switch (transaction) {
        case Deposit(var acct, var amt) when amt > 10000
            -> "Large deposit to " + acct + ": $" + amt + " -- flagged for review";
        case Deposit(var acct, var amt)
            -> "Deposited $" + amt + " to " + acct;

        case Withdrawal(var acct, var amt) when amt > 5000
            -> "Large withdrawal from " + acct + ": $" + amt + " -- requires approval";
        case Withdrawal(var acct, var amt)
            -> "Withdrew $" + amt + " from " + acct;

        case Transfer(var from, var to, var amt) when from.equals(to)
            -> "Error: cannot transfer to the same account";
        case Transfer(var from, var to, var amt)
            -> "Transferred $" + amt + " from " + from + " to " + to;

        case null -> "Error: null transaction";
        default -> "Unknown transaction type";
    };
}

Each case deconstructs the record and applies a guard condition in a single, readable line. The switch reads like a decision table: for this type with these conditions, do this. Compare that to nested if-else chains with manual accessor calls, and the readability improvement is dramatic.

4. Nested Record Patterns

This is where record patterns really set themselves apart. When a record contains other records as components, you can deconstruct them all at once in a single pattern. Instead of extracting the outer record and then manually calling accessors on the inner records, you write a nested pattern that reaches straight into the inner structure.

Think of it like Russian nesting dolls (matryoshkas). Without nested patterns, you open the outer doll, take out the middle doll, open it, take out the inner doll. With nested patterns, you describe the shape of all the dolls at once, and Java opens them all for you simultaneously.

// Define a hierarchy of records
record Point(int x, int y) {}
record Line(Point start, Point end) {}
record Triangle(Point a, Point b, Point c) {}
record ColoredPoint(Point point, String color) {}
record BoundingBox(Point topLeft, Point bottomRight) {}

// Nested deconstruction: one level deep
public String describeLine(Object obj) {
    return switch (obj) {
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            -> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
        default -> "not a line";
    };
}

// Without nested patterns, the same code looks like this:
public String describeLineOld(Object obj) {
    if (obj instanceof Line line) {
        Point start = line.start();
        Point end = line.end();
        int x1 = start.x();
        int y1 = start.y();
        int x2 = end.x();
        int y2 = end.y();
        return String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
    }
    return "not a line";
}

// Two levels deep: Triangle with three Points
public double triangleArea(Object obj) {
    return switch (obj) {
        case Triangle(Point(var x1, var y1), Point(var x2, var y2), Point(var x3, var y3))
            -> Math.abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0);
        default -> 0.0;
    };
}

// Nested with mixed types
public String describeColoredPoint(Object obj) {
    return switch (obj) {
        case ColoredPoint(Point(var x, var y), var color) when color.equals("red")
            -> "Red point at (" + x + ", " + y + ") -- danger zone!";
        case ColoredPoint(Point(var x, var y), var color)
            -> color + " point at (" + x + ", " + y + ")";
        default -> "not a colored point";
    };
}

// BoundingBox with guards on nested components
public String classifyBoundingBox(Object obj) {
    return switch (obj) {
        case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
                when x1 == x2 || y1 == y2
            -> "degenerate bounding box (zero area)";
        case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
                when Math.abs(x2 - x1) == Math.abs(y2 - y1)
            -> "square bounding box: side " + Math.abs(x2 - x1);
        case BoundingBox(Point(var x1, var y1), Point(var x2, var y2))
            -> "bounding box: " + Math.abs(x2 - x1) + " x " + Math.abs(y2 - y1);
        default -> "not a bounding box";
    };
}

Three Levels Deep

Nesting can go as deep as your record hierarchy. Here is a three-level example:

record Street(String name, String number) {}
record City(String name, String state) {}
record Address(Street street, City city, String zip) {}
record Customer(String name, Address address) {}

// Three levels deep: Customer -> Address -> Street/City
public String formatAddress(Object obj) {
    return switch (obj) {
        case Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip))
            -> String.format("%s\n%s %s\n%s, %s %s", name, num, st, city, state, zip);
        default -> "not a customer";
    };
}

// Usage:
var customer = new Customer("Alice Smith",
    new Address(
        new Street("Main St", "123"),
        new City("Portland", "OR"),
        "97201"
    )
);
System.out.println(formatAddress(customer));
// Output:
// Alice Smith
// 123 Main St
// Portland, OR 97201

Without nested record patterns, extracting those six values from a Customer would require six lines of accessor calls. The nested pattern does it in one line. This is not just about fewer characters -- it is about expressing the structure of your data directly in the code.

Readability tip: When nested patterns get wide, break them across lines for clarity. The compiler does not care about whitespace, but your teammates will thank you.

5. var in Record Patterns

You do not always need to spell out the component types in a record pattern. Java lets you use var to let the compiler infer the type for you. This reduces noise and keeps patterns concise, especially when the types are obvious from context.

record Point(int x, int y) {}
record Employee(String name, String department, int salary) {}
record Pair(Object first, Object second) {}

// Explicit types
if (obj instanceof Point(int x, int y)) {
    System.out.println(x + ", " + y);
}

// var -- compiler infers int x, int y
if (obj instanceof Point(var x, var y)) {
    System.out.println(x + ", " + y);
}

// var is especially useful with long type names or nested records
record OrderLine(String productId, String productName, int quantity, double unitPrice) {}
record Order(String orderId, List lines, double total) {}

// Explicit types (verbose)
if (obj instanceof Order(String orderId, List lines, double total)) {
    // ...
}

// var (clean)
if (obj instanceof Order(var orderId, var lines, var total)) {
    // ...
}

// var in switch cases
public double calculateDiscount(Object obj) {
    return switch (obj) {
        case Employee(var name, var dept, var salary) when dept.equals("Sales") && salary > 100000
            -> 0.15;
        case Employee(var name, var dept, var salary) when dept.equals("Sales")
            -> 0.10;
        case Employee(var name, var dept, var salary)
            -> 0.05;
        default -> 0.0;
    };
}

Mixing var and Explicit Types

You can mix var and explicit types in the same pattern. Use explicit types for components where the type is important to the reader, and var for the rest.

record HttpResponse(int statusCode, String contentType, byte[] body) {}

// Mix var and explicit types
if (obj instanceof HttpResponse(int code, var contentType, var body)) {
    // 'code' is explicitly int -- important for the reader
    // contentType and body types are obvious from the record definition
    if (code == 200) {
        process(contentType, body);
    }
}

// Nested record patterns with var
record Point(int x, int y) {}
record Line(Point start, Point end) {}

// All var -- concise
if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
    double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
    System.out.println("Length: " + length);
}

When to Use var vs Explicit Types

Use var Use Explicit Types
Record has few components with obvious types Types are not obvious from the record name
You are focused on the values, not the types You want to document the type for readers
The pattern is already long (nested records) Generic records where the type parameter matters
Component type is a well-known type (String, int, etc.) Component is an interface with multiple implementations

6. Generic Record Patterns

Records can be generic, and record patterns work with generic records. The compiler uses type inference to determine the component types, so you do not need to specify generic type arguments in the pattern itself.

// Generic records
record Pair(A first, B second) {}
record Result(T value, boolean success, String message) {}
record Wrapper(T content, Map metadata) {}

// Pattern matching with generic records
public void processGeneric(Object obj) {
    // The compiler infers generic types from the actual object
    if (obj instanceof Pair(var first, var second)) {
        System.out.println("Pair: " + first + " and " + second);
    }

    if (obj instanceof Result(var value, var success, var message)) {
        if (success) {
            System.out.println("Success: " + value);
        } else {
            System.out.println("Failure: " + message);
        }
    }
}

// Generic records in switch
public String describeResult(Object obj) {
    return switch (obj) {
        case Result(var value, var success, var msg) when success && value instanceof String s
            -> "String result: " + s;
        case Result(var value, var success, var msg) when success && value instanceof Integer i
            -> "Integer result: " + i;
        case Result(var value, var success, var msg) when success
            -> "Result: " + value;
        case Result(var value, var success, var msg)
            -> "Failed: " + msg;
        default -> "not a result";
    };
}

Type Inference with Generic Records

When you use var with generic record patterns, the compiler infers the erased type. At runtime, generic type information is not available due to type erasure, so the pattern variable will have the erased type (usually Object for unbounded type parameters).

record Box(T content) {}
record Either(L left, R right, boolean isLeft) {}

// var infers the erased type
public void processBox(Object obj) {
    if (obj instanceof Box(var content)) {
        // content is Object at runtime (type erasure)
        // But we can add further type checks
        if (content instanceof String s) {
            System.out.println("Box contains string: " + s);
        } else if (content instanceof Integer i) {
            System.out.println("Box contains integer: " + i);
        }
    }
}

// Either type with record patterns
public String processEither(Object obj) {
    return switch (obj) {
        case Either(var left, var right, var isLeft) when isLeft
            -> "Left: " + left;
        case Either(var left, var right, var isLeft)
            -> "Right: " + right;
        default -> "not an either";
    };
}

// Generic record with bounded type parameter
record NumberBox(T value, String label) {}

public String describeNumberBox(Object obj) {
    return switch (obj) {
        case NumberBox(var value, var label) when value.doubleValue() > 100
            -> label + " is large: " + value;
        case NumberBox(var value, var label) when value.doubleValue() < 0
            -> label + " is negative: " + value;
        case NumberBox(var value, var label)
            -> label + ": " + value;
        default -> "not a number box";
    };
    // Because T extends Number, 'value' is inferred as Number (the bound),
    // so doubleValue() is available without casting
}

Generic record patterns work seamlessly with the rest of Java's type system. The key insight is that var gives you the erased type, and you can combine record patterns with type patterns (nested instanceof checks) to narrow the type when you need specific operations.

7. Record Patterns with Sealed Types

Record patterns combined with sealed types create the most type-safe dispatching mechanism Java has ever had. When your sealed interface is implemented by records, you get exhaustive pattern matching with automatic deconstruction -- the compiler verifies you handled every case, and each case gives you direct access to the record's fields.

// Sealed interface with record implementations
public sealed interface Shape permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double a, double b, double c) implements Shape {}

// Exhaustive switch with record patterns -- no default needed
public double area(Shape shape) {
    return switch (shape) {
        case Circle(var r)          -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
        case Triangle(var a, var b, var c) -> {
            double s = (a + b + c) / 2;
            yield Math.sqrt(s * (s - a) * (s - b) * (s - c)); // Heron's formula
        }
    };
}

public double perimeter(Shape shape) {
    return switch (shape) {
        case Circle(var r)          -> 2 * Math.PI * r;
        case Rectangle(var w, var h) -> 2 * (w + h);
        case Triangle(var a, var b, var c) -> a + b + c;
    };
}

public String describe(Shape shape) {
    return switch (shape) {
        case Circle(var r)
            -> String.format("Circle: radius=%.2f, area=%.2f", r, Math.PI * r * r);
        case Rectangle(var w, var h) when w == h
            -> String.format("Square: side=%.2f, area=%.2f", w, w * h);
        case Rectangle(var w, var h)
            -> String.format("Rectangle: %.2f x %.2f, area=%.2f", w, h, w * h);
        case Triangle(var a, var b, var c) when a == b && b == c
            -> String.format("Equilateral triangle: side=%.2f", a);
        case Triangle(var a, var b, var c)
            -> String.format("Triangle: sides %.2f, %.2f, %.2f", a, b, c);
    };
}

Multi-Level Sealed Hierarchy with Records

// Multi-level sealed hierarchy
public sealed interface Expr permits Literal, UnaryOp, BinaryOp {}

public record Literal(double value) implements Expr {}

public sealed interface UnaryOp extends Expr permits Negate, Sqrt {}
public record Negate(Expr operand) implements UnaryOp {}
public record Sqrt(Expr operand) implements UnaryOp {}

public sealed interface BinaryOp extends Expr permits Add, Subtract, Multiply, Divide {}
public record Add(Expr left, Expr right) implements BinaryOp {}
public record Subtract(Expr left, Expr right) implements BinaryOp {}
public record Multiply(Expr left, Expr right) implements BinaryOp {}
public record Divide(Expr left, Expr right) implements BinaryOp {}

// Exhaustive evaluation -- compiler verifies completeness across the entire hierarchy
public double evaluate(Expr expr) {
    return switch (expr) {
        case Literal(var v)         -> v;
        case Negate(var e)          -> -evaluate(e);
        case Sqrt(var e)            -> Math.sqrt(evaluate(e));
        case Add(var l, var r)      -> evaluate(l) + evaluate(r);
        case Subtract(var l, var r) -> evaluate(l) - evaluate(r);
        case Multiply(var l, var r) -> evaluate(l) * evaluate(r);
        case Divide(var l, var r)   -> {
            double divisor = evaluate(r);
            if (divisor == 0) throw new ArithmeticException("Division by zero");
            yield evaluate(l) / divisor;
        }
    };
}

// Pretty printer -- also exhaustive
public String prettyPrint(Expr expr) {
    return switch (expr) {
        case Literal(var v)         -> String.valueOf(v);
        case Negate(var e)          -> "-(" + prettyPrint(e) + ")";
        case Sqrt(var e)            -> "sqrt(" + prettyPrint(e) + ")";
        case Add(var l, var r)      -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
        case Subtract(var l, var r) -> "(" + prettyPrint(l) + " - " + prettyPrint(r) + ")";
        case Multiply(var l, var r) -> "(" + prettyPrint(l) + " * " + prettyPrint(r) + ")";
        case Divide(var l, var r)   -> "(" + prettyPrint(l) + " / " + prettyPrint(r) + ")";
    };
}

// Usage example
// Expression: sqrt(3^2 + 4^2) = 5
Expr expr = new Sqrt(
    new Add(
        new Multiply(new Literal(3), new Literal(3)),
        new Multiply(new Literal(4), new Literal(4))
    )
);
System.out.println(prettyPrint(expr));  // sqrt(((3.0 * 3.0) + (4.0 * 4.0)))
System.out.println(evaluate(expr));     // 5.0

The combination of sealed types and record patterns gives you what functional programming languages call algebraic data types (ADTs). You define a closed set of data variants, each carrying different data, and you process them with exhaustive pattern matching. Java now supports this pattern natively with full IDE support, debugging, and integration with the rest of the Java ecosystem.

Benefit How It Works
Exhaustiveness Compiler verifies all sealed subtypes are handled
No boilerplate Record patterns extract fields automatically
Compile-time safety Adding a new sealed subtype breaks all incomplete switches
Recursive patterns Nested records like Add(Expr, Expr) can be recursively processed
No Visitor needed Pattern matching replaces the Visitor pattern for most use cases

8. Before vs After

Let us see six real-world scenarios where record patterns dramatically simplify the code.

Example 1: Coordinate Distance Calculation

record Point(double x, double y) {}

// BEFORE: manual accessor calls
public double distanceBefore(Object a, Object b) {
    if (a instanceof Point && b instanceof Point) {
        Point p1 = (Point) a;
        Point p2 = (Point) b;
        double dx = p2.x() - p1.x();
        double dy = p2.y() - p1.y();
        return Math.sqrt(dx * dx + dy * dy);
    }
    throw new IllegalArgumentException("Expected two Points");
}

// AFTER: record patterns extract directly
public double distanceAfter(Object a, Object b) {
    if (a instanceof Point(var x1, var y1) && b instanceof Point(var x2, var y2)) {
        return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
    }
    throw new IllegalArgumentException("Expected two Points");
}

Example 2: HTTP Response Processing

record ApiResponse(int statusCode, String body, Map headers) {}

// BEFORE: extract fields manually
public void handleResponseBefore(Object response) {
    if (response instanceof ApiResponse) {
        ApiResponse r = (ApiResponse) response;
        int code = r.statusCode();
        String body = r.body();
        Map headers = r.headers();

        if (code >= 200 && code < 300) {
            parseSuccess(body, headers.get("Content-Type"));
        } else if (code == 401) {
            refreshAuth(headers.get("WWW-Authenticate"));
        } else if (code == 429) {
            int wait = Integer.parseInt(headers.getOrDefault("Retry-After", "60"));
            retryAfter(wait);
        } else {
            logError(code, body);
        }
    }
}

// AFTER: record pattern extracts fields, switch dispatches
public void handleResponseAfter(Object response) {
    switch (response) {
        case ApiResponse(var code, var body, var headers) when code >= 200 && code < 300
            -> parseSuccess(body, headers.get("Content-Type"));
        case ApiResponse(var code, var body, var headers) when code == 401
            -> refreshAuth(headers.get("WWW-Authenticate"));
        case ApiResponse(var code, var body, var headers) when code == 429
            -> retryAfter(Integer.parseInt(headers.getOrDefault("Retry-After", "60")));
        case ApiResponse(var code, var body, var headers)
            -> logError(code, body);
        default -> throw new IllegalArgumentException("Not an API response");
    }
}

Example 3: User Permission Check

record User(String name, String role, List permissions) {}
record Resource(String type, String owner, boolean isPublic) {}
record AccessRequest(User user, Resource resource, String action) {}

// BEFORE: deeply nested accessor calls
public boolean checkAccessBefore(Object request) {
    if (request instanceof AccessRequest) {
        AccessRequest ar = (AccessRequest) request;
        User user = ar.user();
        Resource resource = ar.resource();
        String action = ar.action();

        if (user.role().equals("admin")) {
            return true;
        }
        if (resource.isPublic() && action.equals("read")) {
            return true;
        }
        if (resource.owner().equals(user.name())) {
            return true;
        }
        return user.permissions().contains(resource.type() + ":" + action);
    }
    return false;
}

// AFTER: nested record patterns make it declarative
public boolean checkAccessAfter(Object request) {
    return switch (request) {
        case AccessRequest(User(var name, var role, var perms), var resource, var action)
                when role.equals("admin")
            -> true;
        case AccessRequest(var user, Resource(var type, var owner, var isPublic), var action)
                when isPublic && action.equals("read")
            -> true;
        case AccessRequest(User(var name, var role, var perms), Resource(var type, var owner, var pub), var action)
                when owner.equals(name)
            -> true;
        case AccessRequest(User(var name, var role, var perms), Resource(var type, var owner, var pub), var action)
            -> perms.contains(type + ":" + action);
        default -> false;
    };
}

Example 4: JSON-like Data Extraction

record JsonObject(Map fields) {}
record JsonArray(List elements) {}
record JsonString(String value) {}
record JsonNumber(double value) {}
record JsonBool(boolean value) {}

// BEFORE: casting and manual field extraction
public String extractNameBefore(Object json) {
    if (json instanceof JsonObject) {
        JsonObject obj = (JsonObject) json;
        Object nameField = obj.fields().get("name");
        if (nameField instanceof JsonString) {
            return ((JsonString) nameField).value();
        }
    }
    return "unknown";
}

// AFTER: record pattern extracts the map directly
public String extractNameAfter(Object json) {
    return switch (json) {
        case JsonObject(var fields) when fields.get("name") instanceof JsonString(var name)
            -> name;
        default -> "unknown";
    };
}

Example 5: Event Transformation

record MouseEvent(int x, int y, String button) {}
record KeyEvent(int keyCode, boolean shift, boolean ctrl) {}
record TouchEvent(int x, int y, int fingers) {}

// BEFORE: instanceof + manual extraction
public String describeEventBefore(Object event) {
    if (event instanceof MouseEvent) {
        MouseEvent me = (MouseEvent) event;
        return "Mouse " + me.button() + " at (" + me.x() + "," + me.y() + ")";
    } else if (event instanceof KeyEvent) {
        KeyEvent ke = (KeyEvent) event;
        String modifiers = "";
        if (ke.ctrl()) modifiers += "Ctrl+";
        if (ke.shift()) modifiers += "Shift+";
        return "Key " + modifiers + ke.keyCode();
    } else if (event instanceof TouchEvent) {
        TouchEvent te = (TouchEvent) event;
        return te.fingers() + "-finger touch at (" + te.x() + "," + te.y() + ")";
    }
    return "Unknown event";
}

// AFTER: record patterns in switch
public String describeEventAfter(Object event) {
    return switch (event) {
        case MouseEvent(var x, var y, var button)
            -> "Mouse " + button + " at (" + x + "," + y + ")";
        case KeyEvent(var code, var shift, var ctrl) when ctrl && shift
            -> "Key Ctrl+Shift+" + code;
        case KeyEvent(var code, var shift, var ctrl) when ctrl
            -> "Key Ctrl+" + code;
        case KeyEvent(var code, var shift, var ctrl) when shift
            -> "Key Shift+" + code;
        case KeyEvent(var code, var shift, var ctrl)
            -> "Key " + code;
        case TouchEvent(var x, var y, var fingers) when fingers > 1
            -> fingers + "-finger touch at (" + x + "," + y + ")";
        case TouchEvent(var x, var y, var fingers)
            -> "Touch at (" + x + "," + y + ")";
        default -> "Unknown event";
    };
}

Example 6: Configuration Validation

record DatabaseConfig(String host, int port, String database) {}
record CacheConfig(String host, int port, int ttlSeconds) {}
record AppConfig(DatabaseConfig db, CacheConfig cache, boolean debugMode) {}

// BEFORE: nested extraction and validation
public List validateConfigBefore(Object config) {
    List errors = new ArrayList<>();
    if (config instanceof AppConfig) {
        AppConfig app = (AppConfig) config;
        DatabaseConfig db = app.db();
        CacheConfig cache = app.cache();

        if (db.host() == null || db.host().isEmpty()) {
            errors.add("Database host is required");
        }
        if (db.port() <= 0 || db.port() > 65535) {
            errors.add("Database port must be between 1 and 65535");
        }
        if (cache.ttlSeconds() < 0) {
            errors.add("Cache TTL cannot be negative");
        }
        if (app.debugMode() && db.host().equals("production-db")) {
            errors.add("Debug mode cannot be used with production database");
        }
    }
    return errors;
}

// AFTER: nested record patterns for direct access
public List validateConfigAfter(Object config) {
    List errors = new ArrayList<>();
    if (config instanceof AppConfig(
            DatabaseConfig(var dbHost, var dbPort, var database),
            CacheConfig(var cacheHost, var cachePort, var ttl),
            var debug)) {

        if (dbHost == null || dbHost.isEmpty()) errors.add("Database host is required");
        if (dbPort <= 0 || dbPort > 65535) errors.add("Database port must be between 1 and 65535");
        if (ttl < 0) errors.add("Cache TTL cannot be negative");
        if (debug && dbHost.equals("production-db")) errors.add("Debug mode cannot be used with production database");
    }
    return errors;
}

In every example, record patterns eliminate the manual extraction step. Instead of receiving an object and then pulling it apart with accessor calls, you declare the shape you expect and the fields are placed directly into variables. The code reads as a direct statement of intent: "if this is a Customer with a name and an Address containing a city and state, then..."

9. Practical Examples

Let us build three complete, realistic examples that show how record patterns work in larger applications.

JSON-like Data Model

Here is a complete JSON data model using sealed interfaces and records, with operations built entirely on record patterns.

// Complete JSON data model
public sealed interface JsonValue permits
        JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject {}

public record JsonNull() implements JsonValue {}
public record JsonBool(boolean value) implements JsonValue {}
public record JsonNumber(double value) implements JsonValue {}
public record JsonString(String value) implements JsonValue {}
public record JsonArray(List elements) implements JsonValue {}
public record JsonObject(Map fields) implements JsonValue {}

// Serialize to JSON string
public String toJson(JsonValue value) {
    return switch (value) {
        case JsonNull()        -> "null";
        case JsonBool(var b)   -> String.valueOf(b);
        case JsonNumber(var n) -> {
            // Avoid trailing .0 for whole numbers
            if (n == Math.floor(n) && !Double.isInfinite(n)) {
                yield String.valueOf((long) n);
            }
            yield String.valueOf(n);
        }
        case JsonString(var s) -> "\"" + escapeJson(s) + "\"";
        case JsonArray(var elements) -> elements.stream()
                .map(this::toJson)
                .collect(Collectors.joining(", ", "[", "]"));
        case JsonObject(var fields) -> fields.entrySet().stream()
                .map(e -> "\"" + escapeJson(e.getKey()) + "\": " + toJson(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
    };
}

// Type-safe field access
public Optional getField(JsonValue value, String fieldName) {
    return switch (value) {
        case JsonObject(var fields) -> Optional.ofNullable(fields.get(fieldName));
        default -> Optional.empty();
    };
}

// Deep equality check
public boolean jsonEquals(JsonValue a, JsonValue b) {
    return switch (a) {
        case JsonNull() -> b instanceof JsonNull;
        case JsonBool(var v1) -> b instanceof JsonBool(var v2) && v1 == v2;
        case JsonNumber(var v1) -> b instanceof JsonNumber(var v2) && v1 == v2;
        case JsonString(var v1) -> b instanceof JsonString(var v2) && v1.equals(v2);
        case JsonArray(var e1) -> {
            if (b instanceof JsonArray(var e2) && e1.size() == e2.size()) {
                for (int i = 0; i < e1.size(); i++) {
                    if (!jsonEquals(e1.get(i), e2.get(i))) yield false;
                }
                yield true;
            }
            yield false;
        }
        case JsonObject(var f1) -> {
            if (b instanceof JsonObject(var f2) && f1.size() == f2.size()) {
                for (var entry : f1.entrySet()) {
                    JsonValue v2 = f2.get(entry.getKey());
                    if (v2 == null || !jsonEquals(entry.getValue(), v2)) yield false;
                }
                yield true;
            }
            yield false;
        }
    };
}

// Count total nodes
public int countNodes(JsonValue value) {
    return switch (value) {
        case JsonNull()     -> 1;
        case JsonBool _     -> 1;
        case JsonNumber _   -> 1;
        case JsonString _   -> 1;
        case JsonArray(var elements)
            -> 1 + elements.stream().mapToInt(this::countNodes).sum();
        case JsonObject(var fields)
            -> 1 + fields.values().stream().mapToInt(this::countNodes).sum();
    };
}

Expression Evaluator with Records

// Complete expression evaluator
public sealed interface Expr permits
        Num, Var, BinOp, UnOp, Let, IfExpr {}

public record Num(double value) implements Expr {}
public record Var(String name) implements Expr {}
public record BinOp(String op, Expr left, Expr right) implements Expr {}
public record UnOp(String op, Expr operand) implements Expr {}
public record Let(String name, Expr value, Expr body) implements Expr {}
public record IfExpr(Expr condition, Expr then, Expr otherwise) implements Expr {}

// Evaluator using record patterns
public double eval(Expr expr, Map env) {
    return switch (expr) {
        case Num(var value) -> value;

        case Var(var name) -> {
            if (!env.containsKey(name)) {
                throw new RuntimeException("Undefined variable: " + name);
            }
            yield env.get(name);
        }

        case BinOp(var op, var left, var right) -> {
            double l = eval(left, env);
            double r = eval(right, env);
            yield switch (op) {
                case "+" -> l + r;
                case "-" -> l - r;
                case "*" -> l * r;
                case "/" -> {
                    if (r == 0) throw new ArithmeticException("Division by zero");
                    yield l / r;
                }
                case "%" -> l % r;
                case "^" -> Math.pow(l, r);
                default  -> throw new RuntimeException("Unknown operator: " + op);
            };
        }

        case UnOp(var op, var operand) -> switch (op) {
            case "-"    -> -eval(operand, env);
            case "abs"  -> Math.abs(eval(operand, env));
            case "sqrt" -> Math.sqrt(eval(operand, env));
            default     -> throw new RuntimeException("Unknown operator: " + op);
        };

        case Let(var name, var value, var body) -> {
            double v = eval(value, env);
            var newEnv = new HashMap<>(env);
            newEnv.put(name, v);
            yield eval(body, newEnv);
        }

        case IfExpr(var cond, var then, var otherwise) -> {
            double condValue = eval(cond, env);
            yield condValue != 0 ? eval(then, env) : eval(otherwise, env);
        }
    };
}

// Pretty printer using record patterns
public String format(Expr expr) {
    return switch (expr) {
        case Num(var v) -> String.valueOf(v);
        case Var(var name) -> name;
        case BinOp(var op, var l, var r) -> "(" + format(l) + " " + op + " " + format(r) + ")";
        case UnOp(var op, var e) -> op + "(" + format(e) + ")";
        case Let(var name, var val, var body) -> "let " + name + " = " + format(val) + " in " + format(body);
        case IfExpr(var cond, var then, var otherwise)
            -> "if " + format(cond) + " then " + format(then) + " else " + format(otherwise);
    };
}

// Usage: let x = 5 in let y = 3 in x * y + 2
Expr program = new Let("x", new Num(5),
    new Let("y", new Num(3),
        new BinOp("+",
            new BinOp("*", new Var("x"), new Var("y")),
            new Num(2)
        )
    )
);
System.out.println(format(program));  // let x = 5.0 in let y = 3.0 in ((x * y) + 2.0)
System.out.println(eval(program, Map.of()));  // 17.0

Event System

// Complete event system with record patterns
public sealed interface AppEvent permits
        UIEvent, NetworkEvent, SystemEvent {}

// UI Events
public sealed interface UIEvent extends AppEvent permits Click, Scroll, KeyPress, Resize {}
public record Click(int x, int y, String elementId) implements UIEvent {}
public record Scroll(int deltaX, int deltaY) implements UIEvent {}
public record KeyPress(char key, boolean ctrl, boolean alt) implements UIEvent {}
public record Resize(int width, int height) implements UIEvent {}

// Network Events
public sealed interface NetworkEvent extends AppEvent permits
        RequestSent, ResponseReceived, ConnectionLost {}
public record RequestSent(String url, String method, long timestamp) implements NetworkEvent {}
public record ResponseReceived(String url, int statusCode, long latencyMs) implements NetworkEvent {}
public record ConnectionLost(String host, String reason) implements NetworkEvent {}

// System Events
public sealed interface SystemEvent extends AppEvent permits
        MemoryWarning, DiskFull, CpuThrottle {}
public record MemoryWarning(long usedMB, long totalMB) implements SystemEvent {}
public record DiskFull(String drive, long availableMB) implements SystemEvent {}
public record CpuThrottle(double usagePercent) implements SystemEvent {}

// Event logger -- exhaustive, uses record patterns
public String logEvent(AppEvent event) {
    return switch (event) {
        // UI Events
        case Click(var x, var y, var id)
            -> String.format("[UI] Click on '%s' at (%d, %d)", id, x, y);
        case Scroll(var dx, var dy)
            -> String.format("[UI] Scroll delta=(%d, %d)", dx, dy);
        case KeyPress(var key, var ctrl, var alt) when ctrl && alt
            -> String.format("[UI] Key Ctrl+Alt+%c", key);
        case KeyPress(var key, var ctrl, var alt) when ctrl
            -> String.format("[UI] Key Ctrl+%c", key);
        case KeyPress(var key, var ctrl, var alt)
            -> String.format("[UI] Key '%c'", key);
        case Resize(var w, var h)
            -> String.format("[UI] Resize to %dx%d", w, h);

        // Network Events
        case RequestSent(var url, var method, var ts)
            -> String.format("[NET] %s %s at %d", method, url, ts);
        case ResponseReceived(var url, var code, var latency) when code >= 400
            -> String.format("[NET] ERROR %d from %s (%dms)", code, url, latency);
        case ResponseReceived(var url, var code, var latency)
            -> String.format("[NET] %d from %s (%dms)", code, url, latency);
        case ConnectionLost(var host, var reason)
            -> String.format("[NET] Lost connection to %s: %s", host, reason);

        // System Events
        case MemoryWarning(var used, var total)
            -> String.format("[SYS] Memory warning: %dMB / %dMB (%.0f%%)", used, total, 100.0 * used / total);
        case DiskFull(var drive, var available)
            -> String.format("[SYS] Disk %s almost full: %dMB remaining", drive, available);
        case CpuThrottle(var usage) when usage > 95
            -> String.format("[SYS] CRITICAL CPU: %.1f%%", usage);
        case CpuThrottle(var usage)
            -> String.format("[SYS] CPU throttle: %.1f%%", usage);
    };
}

// Event filtering with record patterns
public boolean isHighPriority(AppEvent event) {
    return switch (event) {
        case ConnectionLost _                                      -> true;
        case ResponseReceived(var url, var code, var latency) when code >= 500 -> true;
        case MemoryWarning(var used, var total) when used > total * 0.9 -> true;
        case DiskFull(var drive, var available) when available < 100 -> true;
        case CpuThrottle(var usage) when usage > 95                -> true;
        default                                                    -> false;
    };
}

These three examples demonstrate the full power of record patterns in production-style code. The JSON data model shows recursive processing. The expression evaluator shows nested environments and operator dispatch. The event system shows a multi-level hierarchy with filtering and formatting. In all cases, record patterns eliminate boilerplate and make the code read as a direct description of the logic.

10. Best Practices

Record patterns are a powerful feature, but using them well requires some judgment. Here are guidelines for writing clean, maintainable code with record patterns.

1. Use Record Patterns When You Need the Components, Not the Record

If you only need the record itself (not its individual components), use a regular type pattern instead of a record pattern. Do not deconstruct a record just because you can.

record Employee(String name, String department, int salary) {}

// GOOD: use record pattern when you need individual fields
case Employee(var name, var dept, var salary) when salary > 100000
    -> "High earner: " + name + " in " + dept;

// GOOD: use type pattern when you need the whole record
case Employee e -> processEmployee(e);

// AVOID: deconstructing just to reconstruct or pass the whole object
case Employee(var name, var dept, var salary)
    -> processEmployee(new Employee(name, dept, salary)); // Pointless deconstruction

2. Use var for Conciseness When Types Are Clear

Record patterns can get long, especially with nested records. Use var to keep the pattern readable when the types are obvious from context.

// GOOD: var keeps it concise
case Point(var x, var y) -> ...
case Line(Point(var x1, var y1), Point(var x2, var y2)) -> ...

// VERBOSE: explicit types add noise when types are obvious
case Point(int x, int y) -> ...
case Line(Point(int x1, int y1), Point(int x2, int y2)) -> ...

3. Break Long Nested Patterns Across Lines

When patterns get wide, format them across multiple lines. Line breaks are free and make the structure visible.

// GOOD: multi-line for readability
case Customer(
        var name,
        Address(Street(var st, var num), City(var city, var state), var zip)
    ) -> formatAddress(name, num, st, city, state, zip);

// HARD TO READ: everything on one line
case Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip)) -> formatAddress(name, num, st, city, state, zip);

4. Choose Meaningful Variable Names

Since record patterns create local variables, pick names that are clear in context. You do not need to match the record component names exactly.

record Line(Point start, Point end) {}

// GOOD: names describe the role in this context
case Line(Point(var startX, var startY), Point(var endX, var endY))
    -> drawLine(startX, startY, endX, endY);

// ALSO GOOD: short names when the context is clear
case Line(Point(var x1, var y1), Point(var x2, var y2))
    -> Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));

// AVOID: meaningless single letters that hide intent
case Line(Point(var a, var b), Point(var c, var d))
    -> doSomething(a, b, c, d);

5. Combine with Sealed Types for Maximum Safety

Record patterns with sealed types give you exhaustive matching and automatic deconstruction. This is the gold standard for type-safe dispatching in Java 21+.

// GOLD STANDARD: sealed + records + exhaustive switch with record patterns
public sealed interface Result permits Success, Failure {}
public record Success(T value) implements Result {}
public record Failure(String error, Exception cause) implements Result {}

public  void handle(Result result) {
    switch (result) {
        case Success(var value) -> process(value);
        case Failure(var error, var cause) -> {
            log.error(error, cause);
            notifyOps(error);
        }
    }
    // Exhaustive -- no default needed
    // If you add a new Result subtype, compiler flags this switch
}

6. Do Not Over-Nest

Just because you can nest three or four levels deep does not mean you should. If a pattern is hard to read, extract a helper method or break it into multiple steps.

// TOO DEEP: hard to read
case Order(var id, Customer(var name, Address(Street(var st, var num), City(var city, var state), var zip)), var items, var total)
    -> // this is getting unwieldy

// BETTER: extract and simplify
case Order(var id, var customer, var items, var total) -> {
    String address = formatAddress(customer); // Let the method handle the nesting
    yield createShippingLabel(id, address, items);
}

Summary of Best Practices

Practice Guideline
When to deconstruct Only when you need the components, not the whole record
Type inference Use var when types are obvious; explicit types when they add clarity
Formatting Break long patterns across multiple lines
Naming Use meaningful names that describe the role, not just abbreviations
Sealed types Combine with sealed types for exhaustive, compiler-verified matching
Nesting depth Stop at 2-3 levels; extract methods for deeper hierarchies
Guard clauses Use when with deconstructed fields for precise matching

Record patterns are one of the most expressive features added to Java in years. They let you write code that describes the shape of your data directly, eliminating the ceremony of accessor calls and temporary variables. Combined with sealed types and switch expressions, they bring Java closer to the pattern matching capabilities of languages like Scala, Kotlin, and Rust -- while retaining the type safety, performance, and tooling that make Java the backbone of enterprise software.

March 1, 2026

Java 21 Other Improvements

1. Introduction

Java 21 is a Long-Term Support (LTS) release, which means it is the version that most enterprises will upgrade to and run in production for years. While the headline features — virtual threads, pattern matching for switch, record patterns, and sequenced collections — get all the attention, Java 21 ships with a collection of smaller but significant improvements that affect performance, security, developer experience, and the language’s future direction.

Some of these features are fully finalized and production-ready. Others are in preview or incubator status, giving you a look at where Java is headed. This post covers everything that did not get its own dedicated tutorial: string templates, ZGC improvements, cryptography APIs, the Foreign Function and Memory API, simplified main methods, deprecations, and performance improvements.

Here is an overview of what we will cover:

Feature JEP Status in Java 21 Impact
String Templates JEP 430 Preview High — string interpolation at last
Generational ZGC JEP 439 Standard High — major GC performance improvement
Key Encapsulation Mechanism API JEP 452 Standard Medium — post-quantum crypto readiness
Foreign Function & Memory API JEP 442 Third Preview High — JNI replacement
Unnamed Classes & Instance Main JEP 445 Preview Medium — beginner-friendly Java
Deprecations and Removals Various Standard Medium — check your codebase
Performance Improvements Various Standard High — faster startup and throughput

2. String Templates (Preview)

String templates (JEP 430) bring string interpolation to Java. If you have used template literals in JavaScript (`Hello ${name}`), f-strings in Python (f"Hello {name}"), or string interpolation in Kotlin ("Hello $name"), you know how much cleaner embedded expressions make string construction. Java finally has its own version, and it is more powerful than any of those.

Important note: String templates were a preview feature in Java 21. They were subsequently removed in later JDK versions after community feedback. The concept may return in a different form in future Java releases. We cover them here for educational purposes and because they demonstrate Java’s evolving approach to string handling.

2.1 The STR Template Processor

The STR processor is the basic string template processor. It evaluates embedded expressions and produces a String:

// Before: String concatenation or String.format
String name = "Alice";
int age = 30;
String city = "Seattle";

// Old way 1: concatenation (messy with many variables)
String msg1 = "Hello, " + name + "! You are " + age + " years old and live in " + city + ".";

// Old way 2: String.format (positional, error-prone)
String msg2 = String.format("Hello, %s! You are %d years old and live in %s.", name, age, city);

// Old way 3: MessageFormat (verbose)
String msg3 = MessageFormat.format("Hello, {0}! You are {1} years old and live in {2}.", name, age, city);

// Java 21 Preview: String templates with STR processor
String msg4 = STR."Hello, \{name}! You are \{age} years old and live in \{city}.";
// Result: "Hello, Alice! You are 30 years old and live in Seattle."

2.2 Expressions Inside Templates

Template expressions are not limited to simple variables. You can embed any valid Java expression:

// Method calls
String greeting = STR."Hello, \{name.toUpperCase()}!";

// Arithmetic
String calc = STR."The sum of 5 and 3 is \{5 + 3}.";

// Ternary operator
boolean isAdmin = true;
String role = STR."User role: \{isAdmin ? "Administrator" : "Regular User"}";

// Array/collection access
String[] fruits = {"Apple", "Banana", "Cherry"};
String pick = STR."First fruit: \{fruits[0]}";

// Chained method calls
record User(String firstName, String lastName) {
    String fullName() { return firstName + " " + lastName; }
}
User user = new User("John", "Doe");
String info = STR."Welcome, \{user.fullName()}!";

// Multi-line string templates
String html = STR."""
    

\{user.fullName()}

Role: \{isAdmin ? "Admin" : "User"}

Registered: \{LocalDate.now()}

""";

2.3 The FMT Template Processor

The FMT processor works like STR but supports format specifiers similar to String.format():

// FMT supports format specifiers
double price = 49.99;
int quantity = 3;
double total = price * quantity;

String receipt = FMT."Item: %s\{name} | Qty: %d\{quantity} | Total: $%.2f\{total}";
// Result: "Item: Alice | Qty: 3 | Total: $149.97"

// Table formatting with alignment
record Product(String name, double price, int stock) {}
List products = List.of(
    new Product("Laptop", 999.99, 15),
    new Product("Mouse", 29.50, 200),
    new Product("Keyboard", 79.99, 85)
);

for (Product p : products) {
    System.out.println(FMT."%-15s\{p.name()} $%8.2f\{p.price()} %5d\{p.stock()} units");
}
// Output:
// Laptop           $ 999.99    15 units
// Mouse            $  29.50   200 units
// Keyboard         $  79.99    85 units

2.4 Safety Advantages

Unlike string interpolation in many other languages, Java’s string templates are processed through a template processor, which can validate and sanitize the embedded values. This design prevents injection attacks when building SQL queries, HTML, or JSON:

// The key insight: template processors can do more than just concatenation
// A hypothetical SQL processor could prevent SQL injection:
// String query = SQL."SELECT * FROM users WHERE name = \{userInput}";
// The SQL processor would automatically parameterize the query

// A hypothetical JSON processor could handle escaping:
// String json = JSON."""
//     {
//         "name": \{userName},
//         "bio": \{userBio}
//     }
//     """;
// The JSON processor would properly escape special characters

The template processor architecture is what sets Java’s approach apart from simple string interpolation. While STR and FMT just produce strings, the framework allows custom processors to produce any type — PreparedStatements, JSON objects, XML documents, or any validated output.

3. Generational ZGC

The Z Garbage Collector (ZGC) was introduced in Java 15 as a low-latency collector designed for applications that need consistently short pause times (under 1 millisecond). Java 21 enhances ZGC with generational support (JEP 439), which significantly improves its throughput and reduces memory overhead.

3.1 What Is Generational GC?

Most Java objects are short-lived. A typical request handler creates dozens of objects — strings, DTOs, temporary collections — that become garbage as soon as the request completes. The generational hypothesis says: most objects die young. Generational collectors exploit this by dividing the heap into a young generation (for newly created objects) and an old generation (for long-lived objects). Young generation collections are fast and frequent. Old generation collections are slow and rare.

Before Java 21, ZGC treated the entire heap as a single generation. It scanned everything during every collection cycle. This was simple but wasteful — why scan long-lived objects that are not going to be collected?

3.2 Performance Improvements

Generational ZGC delivers measurable improvements:

Metric Non-Generational ZGC Generational ZGC Improvement
Allocation throughput Baseline Higher Less memory pressure on the collector
Pause times Sub-millisecond Sub-millisecond Maintained (still ultra-low)
Memory overhead Higher Lower More efficient heap usage
CPU overhead Higher Lower Less scanning of long-lived objects
Application throughput Baseline 10-20% better in typical workloads Significant for high-allocation apps

3.3 How to Enable Generational ZGC

// Enable Generational ZGC (Java 21)
// JVM arguments:
// java -XX:+UseZGC -XX:+ZGenerational -jar myapp.jar

// In Java 23+, Generational ZGC becomes the default mode for ZGC:
// java -XX:+UseZGC -jar myapp.jar

// To use the OLD non-generational ZGC (Java 23+):
// java -XX:+UseZGC -XX:-ZGenerational -jar myapp.jar

// Typical production configuration
// java \
//   -XX:+UseZGC \
//   -XX:+ZGenerational \
//   -Xms4g \
//   -Xmx4g \
//   -XX:SoftMaxHeapSize=3g \
//   -jar myapp.jar

3.4 When to Use Generational ZGC

Generational ZGC is ideal for:

  • Latency-sensitive applications — microservices, real-time systems, trading platforms
  • Large heaps — applications with 8GB+ heap sizes where G1 pause times become noticeable
  • High allocation rates — applications that create many short-lived objects (web servers, stream processors)
  • Applications requiring consistent response times — SLA-bound services where 99th percentile latency matters

For most applications, Generational ZGC is a drop-in improvement over the non-generational version. There is no code change required — just update your JVM flags.

4. Key Encapsulation Mechanism API

Java 21 introduces a Key Encapsulation Mechanism (KEM) API (JEP 452). This is a cryptographic API that enables Java applications to use KEM-based key agreement schemes, which are a building block for post-quantum cryptography.

4.1 Why KEM Matters

Traditional key exchange algorithms like RSA and Diffie-Hellman rely on mathematical problems (integer factorization, discrete logarithms) that quantum computers could potentially solve efficiently. Post-quantum cryptography uses different mathematical foundations that are believed to resist quantum attacks. KEM is the mechanism that most post-quantum standards use for key exchange.

Think of it this way: when you send an encrypted message, you first need to agree on a shared secret key with the recipient. KEM provides a standardized way to do this that works with both classical and post-quantum algorithms.

4.2 How KEM Works

A KEM has three operations:

  1. Key pair generation — generate a public/private key pair
  2. Encapsulate — using the recipient’s public key, generate a shared secret and an encapsulated key (ciphertext)
  3. Decapsulate — using the private key, recover the shared secret from the encapsulated key
import javax.crypto.KEM;
import java.security.KeyPairGenerator;
import java.security.KeyPair;

public class KEMExample {

    public static void main(String[] args) throws Exception {

        // 1. Receiver generates a key pair
        KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("X25519");
        KeyPair receiverKeyPair = keyPairGen.generateKeyPair();

        // 2. Sender encapsulates a shared secret using receiver's public key
        KEM kem = KEM.getInstance("DHKEM");
        KEM.Encapsulator encapsulator = kem.newEncapsulator(receiverKeyPair.getPublic());
        KEM.Encapsulated encapsulated = encapsulator.encapsulate();

        // The sender gets:
        // - A shared secret key (for encryption)
        byte[] senderSharedSecret = encapsulated.key().getEncoded();
        // - An encapsulation (ciphertext to send to receiver)
        byte[] encapsulation = encapsulated.encapsulation();
        // - Optional parameters
        byte[] params = encapsulated.params();

        // 3. Receiver decapsulates using their private key
        KEM.Decapsulator decapsulator = kem.newDecapsulator(receiverKeyPair.getPrivate());
        javax.crypto.SecretKey receiverSharedSecret = decapsulator.decapsulate(encapsulation);

        // Both sides now have the same shared secret
        System.out.println("Sender secret:   " + bytesToHex(senderSharedSecret));
        System.out.println("Receiver secret: " + bytesToHex(receiverSharedSecret.getEncoded()));
        // They match! This shared secret is used to derive encryption keys.
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

4.3 Practical Impact

For most application developers, you will not use the KEM API directly. It is a building block used by:

  • TLS/SSL libraries — future versions of Java’s TLS implementation will use KEM for key exchange
  • Cryptographic library authors — Bouncy Castle, Google Tink, and other libraries will build on this API
  • Government and financial systems — organizations mandated to prepare for post-quantum threats

The important takeaway: Java 21 is laying the groundwork for post-quantum security. When quantum computers become practical, Java will be ready with standardized APIs that work with post-quantum algorithms. You do not need to change your code today, but you should know that Java is preparing.

5. Foreign Function & Memory API (Third Preview)

The Foreign Function & Memory (FFM) API (JEP 442) is a replacement for JNI (Java Native Interface). If you have ever worked with JNI, you know it is painful: you write C header files, compile native code, manage memory manually, and deal with cryptic crash dumps when something goes wrong. The FFM API makes calling native code from Java almost as easy as calling a Java method.

5.1 The Problem with JNI

JNI has been the only official way to call native (C/C++) code from Java since Java 1.1. But it has serious problems:

  • Complex — requires writing C code, generating headers with javac -h, and managing shared libraries
  • Unsafe — manual memory management, no protection from buffer overflows or use-after-free bugs
  • Brittle — native crashes take down the entire JVM with no useful error message
  • Slow — JNI call overhead is significant for frequently called functions

5.2 Calling C Functions from Java

With the FFM API, you can call a C standard library function directly from Java without writing any C code:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class FFMExample {

    public static void main(String[] args) throws Throwable {

        // 1. Get a linker for the current platform
        Linker linker = Linker.nativeLinker();

        // 2. Look up the C standard library function "strlen"
        SymbolLookup stdlib = linker.defaultLookup();
        MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();

        // 3. Define the function signature: long strlen(char* s)
        FunctionDescriptor strlenDesc = FunctionDescriptor.of(
            ValueLayout.JAVA_LONG,    // return type: long (size_t)
            ValueLayout.ADDRESS       // parameter: pointer to char
        );

        // 4. Create a method handle for the function
        MethodHandle strlen = linker.downcallHandle(strlenAddr, strlenDesc);

        // 5. Allocate memory and call the function
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom("Hello, Foreign Function API!");
            long length = (long) strlen.invoke(cString);
            System.out.println("String length: " + length); // 28
        }
        // Memory is automatically freed when the Arena closes
    }
}

5.3 Working with Native Memory

The FFM API provides safe, structured access to off-heap memory through MemorySegment and Arena:

import java.lang.foreign.*;

public class MemoryExample {

    public static void main(String[] args) {

        // Allocate native memory with automatic cleanup
        try (Arena arena = Arena.ofConfined()) {

            // Allocate a struct-like layout: { int x; int y; double value; }
            MemoryLayout pointLayout = MemoryLayout.structLayout(
                ValueLayout.JAVA_INT.withName("x"),
                ValueLayout.JAVA_INT.withName("y"),
                ValueLayout.JAVA_DOUBLE.withName("value")
            );

            // Allocate memory for the struct
            MemorySegment point = arena.allocate(pointLayout);

            // Write values using var handles
            var xHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("x"));
            var yHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("y"));
            var valueHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("value"));

            xHandle.set(point, 0L, 10);
            yHandle.set(point, 0L, 20);
            valueHandle.set(point, 0L, 3.14);

            // Read values back
            int x = (int) xHandle.get(point, 0L);
            int y = (int) yHandle.get(point, 0L);
            double value = (double) valueHandle.get(point, 0L);

            System.out.println("Point: (" + x + ", " + y + ") = " + value);
            // Output: Point: (10, 20) = 3.14

            // Allocate an array of 100 integers
            MemorySegment intArray = arena.allocate(ValueLayout.JAVA_INT, 100);
            for (int i = 0; i < 100; i++) {
                intArray.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
            }
            System.out.println("Element 10: " + intArray.getAtIndex(ValueLayout.JAVA_INT, 10)); // 100

        } // All native memory freed here -- no leaks possible
    }
}

5.4 Comparison with JNI

Aspect JNI FFM API
Requires C code? Yes -- must write and compile native code No -- call native functions directly from Java
Memory management Manual (malloc/free) Automatic via Arena (try-with-resources)
Type safety Minimal -- pointer casting everywhere Strong -- MemoryLayout defines structure
Crash behavior JVM crash with no Java stack trace Java exception with meaningful error
Performance Higher overhead per call Lower overhead, optimized by JIT
Cross-platform Need to compile native code per platform Pure Java -- works everywhere

The FFM API is a game-changer for applications that need to interact with native libraries -- database drivers, GPU computing, operating system APIs, and any C/C++ library integration.

6. Unnamed Classes and Instance Main Methods (Preview)

Java has always been criticized for requiring too much ceremony to write a simple program. The classic "Hello World" in Java requires understanding classes, access modifiers, static methods, string arrays, and the special main method signature -- all before you print a single line. Java 21 addresses this with unnamed classes and instance main methods (JEP 445).

6.1 The Problem

// Traditional "Hello World" -- 5 concepts a beginner must understand:
public class HelloWorld {                             // 1. What is a class?
    public static void main(String[] args) {          // 2. What is public? static? void?
                                                      // 3. What is String[] args?
        System.out.println("Hello, World!");          // 4. What is System.out?
    }                                                 // 5. Why so many brackets?
}

6.2 The Simplified Solution

With unnamed classes and instance main methods, the same program becomes:

// Java 21 Preview: Simplified Hello World
void main() {
    System.out.println("Hello, World!");
}

// That's it. No class declaration, no public, no static, no String[] args.
// Save as HelloWorld.java and run: java --enable-preview HelloWorld.java

6.3 How It Works

The compiler wraps your code in an unnamed class automatically. The main method can be an instance method (not static) and does not need to accept String[] args. The launch protocol tries these signatures in order:

  1. static void main(String[] args) -- traditional
  2. static void main() -- no args
  3. void main(String[] args) -- instance method
  4. void main() -- simplest form
// You can define methods and fields alongside main
// Everything lives in an unnamed class

String greeting = "Hello";

String greet(String name) {
    return greeting + ", " + name + "!";
}

void main() {
    System.out.println(greet("Alice")); // Hello, Alice!
    System.out.println(greet("Bob"));   // Hello, Bob!
}

// You can even import packages
// import java.util.List;
//
// void main() {
//     var names = List.of("Alice", "Bob", "Charlie");
//     names.forEach(System.out::println);
// }

6.4 Teaching Benefits

This feature is primarily designed for:

  • Beginners -- learn I/O, variables, and control flow before classes and access modifiers
  • Scripting -- write quick utility programs without boilerplate
  • Prototyping -- test ideas quickly without creating a full project structure
  • Education -- instructors can teach concepts incrementally

For production code, you will still use properly named classes. But for learning, scripting, and quick experiments, this is a welcome reduction in ceremony.

7. Deprecations and Removals

Every Java LTS release deprecates or removes features that are outdated, insecure, or replaced by better alternatives. Java 21 continues this pattern. If you are migrating from Java 17 or earlier, check this list carefully.

7.1 Deprecated Features

Feature Status Replacement Action Required
Thread.stop() Degraded -- throws UnsupportedOperationException Use Thread.interrupt() and cooperative cancellation Rewrite thread cancellation logic
Windows 32-bit (x86) port Deprecated for removal Use 64-bit (x64) JDK Migrate to 64-bit
Dynamic loading of agents Warning issued (will be disabled by default later) Use -javaagent command-line flag Update agent loading to use command-line

7.2 Finalization Deprecation Progress

Object finalization (finalize() methods) has been deprecated since Java 9 and deprecated for removal since Java 18. Java 21 continues this trajectory:

// DEPRECATED -- do not use finalize() in new code
public class ResourceHolder {
    @Override
    @Deprecated(since = "9", forRemoval = true)
    protected void finalize() throws Throwable {
        // This is unreliable -- GC may never call it
        releaseNativeResource();
    }
}

// CORRECT -- use AutoCloseable and try-with-resources
public class ResourceHolder implements AutoCloseable {
    @Override
    public void close() {
        releaseNativeResource(); // deterministic cleanup
    }
}

// Or use Cleaner for reference-based cleanup
import java.lang.ref.Cleaner;

public class ResourceHolder implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    public ResourceHolder() {
        long nativePtr = allocateNative();
        this.cleanable = CLEANER.register(this, () -> freeNative(nativePtr));
    }

    @Override
    public void close() {
        cleanable.clean(); // explicit cleanup
    }
}

7.3 Removed Features

Removed Was Deprecated Since Alternative
Applet API (java.applet) Java 9 No direct replacement -- use web technologies
Security Manager for authorization Java 17 Use container-level security, OS-level sandboxing
RMI Activation Java 15 Use modern RPC (gRPC, REST, message queues)

8. Performance Improvements

Java 21 includes several performance improvements beyond Generational ZGC that benefit all applications.

8.1 Virtual Thread Scheduler Improvements

The virtual thread scheduler in Java 21 is more efficient than the preview versions in Java 19-20:

  • Better work-stealing -- idle carrier threads steal work from busy ones more efficiently
  • Reduced pinning -- fewer scenarios where virtual threads pin their carrier thread
  • Improved synchronization -- monitors (synchronized blocks) interact better with virtual threads

8.2 Compiler Optimizations

The C2 JIT compiler includes improvements for:

  • Auto-vectorization -- more loop patterns are automatically converted to SIMD operations
  • Escape analysis -- better at detecting objects that do not escape a method scope, allowing stack allocation
  • Inlining decisions -- improved heuristics for when to inline method calls

8.3 Startup Time

Java 21 continues improvements to startup time through CDS (Class Data Sharing) enhancements:

// Generate a CDS archive for your application
// Step 1: Record class loading
// java -Xshare:off -XX:DumpLoadedClassList=classes.lst -jar myapp.jar

// Step 2: Create the archive
// java -Xshare:dump -XX:SharedClassListFile=classes.lst \
//       -XX:SharedArchiveFile=myapp.jsa -jar myapp.jar

// Step 3: Run with the archive (faster startup)
// java -Xshare:on -XX:SharedArchiveFile=myapp.jsa -jar myapp.jar

// Typical startup improvement: 20-40% reduction in startup time
// Especially impactful for microservices and serverless functions

8.4 Memory Footprint Reduction

Several internal changes reduce the memory footprint of Java applications:

  • Compact object headers (experimental) -- reduces object header size from 128 bits to 64 bits, saving 10-20% heap space for object-heavy applications
  • String deduplication improvements -- more aggressive deduplication of identical string values
  • Metaspace improvements -- better memory management for class metadata

8.5 Platform-Specific Optimizations

Platform Optimization Benefit
x86-64 Better use of AVX-512 instructions Faster mathematical operations and data processing
AArch64 (ARM) Improved code generation Better performance on Apple Silicon, AWS Graviton
All platforms Improved intrinsic methods Faster String, Math, and Array operations

9. Summary Table

Here is every improvement covered in this post with its status and a one-line description:

Feature JEP Status Description
String Templates 430 Preview (later removed) String interpolation with template processors for safe, readable string construction
Generational ZGC 439 Standard Adds generational collection to ZGC for improved throughput and reduced overhead
Key Encapsulation Mechanism API 452 Standard New cryptographic API for KEM-based key agreement, enabling post-quantum readiness
Foreign Function & Memory API 442 Third Preview Safe, efficient replacement for JNI to call native code and manage off-heap memory
Unnamed Classes & Instance Main 445 Preview Simplified program entry points -- write main() without class boilerplate
Thread.stop() Degraded -- Standard Thread.stop() now throws UnsupportedOperationException -- use interrupt() instead
Dynamic Agent Loading Warning 451 Standard Warns when agents are loaded dynamically; use -javaagent flag instead
Finalization Deprecation -- Deprecated for removal finalize() continues on the path to removal -- use Cleaner or AutoCloseable
Windows 32-bit Deprecation 449 Deprecated for removal Windows x86 (32-bit) port deprecated -- migrate to x64
Generational ZGC Performance 439 Standard 10-20% throughput improvement for high-allocation workloads
C2 Compiler Optimizations -- Standard Better auto-vectorization, escape analysis, and inlining decisions
CDS Enhancements -- Standard Improved Class Data Sharing for 20-40% faster startup times
Compact Object Headers (Experimental) -- Experimental Reduces object header size from 128 to 64 bits, saving 10-20% heap space
AArch64 Optimizations -- Standard Improved code generation for ARM platforms (Apple Silicon, Graviton)

Java 21 is more than its headline features. The improvements to ZGC, the cryptographic APIs preparing for the post-quantum era, the Foreign Function & Memory API replacing JNI, and the simplified entry points for beginners all contribute to making Java a more modern, performant, and accessible platform. If you are planning a migration from Java 17, these smaller improvements provide additional motivation beyond virtual threads and pattern matching.

March 1, 2026

Java 21 Migration Guide (17→21)

1. Introduction

Java 21 is the next Long-Term Support (LTS) release after Java 17, released in September 2023. If your organization runs on Java 17 — which most modern Java shops do — Java 21 is the natural next upgrade target. It is not a “maybe someday” upgrade; it is a “plan this for your next quarter” upgrade, because the features it brings are transformative.

Between Java 17 and Java 21, four major versions shipped (18, 19, 20, 21), each adding language features, API improvements, and runtime enhancements. Some of the biggest changes include virtual threads (which fundamentally change concurrency), pattern matching (which modernizes how you write conditional logic), record patterns (which make destructuring first-class), and sequenced collections (which fix a 25-year API gap).

LTS Support Timelines

Version Release Date Type Oracle Premier Support Until Extended Support Until
Java 17 September 2021 LTS September 2026 September 2029
Java 18 March 2022 Non-LTS September 2022 N/A
Java 19 September 2022 Non-LTS March 2023 N/A
Java 20 March 2023 Non-LTS September 2023 N/A
Java 21 September 2023 LTS September 2028 September 2031

Why migrate now? Java 17 premier support ends in September 2026. Planning your migration to Java 21 now gives you ample time to test, fix issues, and deploy before you are running on an end-of-life JDK. Additionally, frameworks like Spring Boot 3.2+ and Quarkus 3.x are optimized for Java 21 and unlock features like virtual thread support only on 21+.

2. New Language Features Summary (Java 18-21)

Here is a comprehensive table of every significant feature added between Java 18 and Java 21. Features marked Final are production-ready. Features marked Preview require --enable-preview to use.

Feature JEP Introduced Finalized Status in Java 21
Virtual Threads 444 Java 19 (preview) Java 21 Final
Pattern Matching for switch 441 Java 17 (preview) Java 21 Final
Record Patterns 440 Java 19 (preview) Java 21 Final
Sequenced Collections 431 Java 21 Java 21 Final
String Templates 430 Java 21 Preview
Unnamed Patterns and Variables 443 Java 21 Preview
Unnamed Classes and Instance Main Methods 445 Java 21 Preview
Structured Concurrency 462 Java 19 (incubator) Preview
Scoped Values 464 Java 20 (incubator) Preview
Foreign Function & Memory API 442 Java 14 (incubator) Third Preview
Vector API 448 Java 16 (incubator) Sixth Incubator
Generational ZGC 439 Java 21 Java 21 Final
Key Encapsulation Mechanism API 452 Java 21 Java 21 Final
Deprecate the Windows 32-bit x86 Port 449 Java 21 Java 21 Final
Prepare to Disallow Dynamic Loading of Agents 451 Java 21 Java 21 Final
UTF-8 by Default 400 Java 18 Java 18 Final
Simple Web Server 408 Java 18 Java 18 Final
Code Snippets in Java API Documentation 413 Java 18 Java 18 Final

3. Breaking Changes

Most Java version upgrades are backward-compatible, but there are changes between 17 and 21 that can break existing code or change behavior. Review these carefully before migrating.

UTF-8 by Default (JEP 400 — Java 18)

This is the most impactful breaking change. Before Java 18, the default charset was platform-dependent — on Windows it was typically Windows-1252, on macOS/Linux it was usually UTF-8. Starting with Java 18, the default is always UTF-8 regardless of platform.

What breaks: Code that reads or writes files without specifying a charset and relies on the platform default. On Windows servers that previously used Windows-1252, files will now be read as UTF-8, potentially corrupting non-ASCII characters.

// This code behaves DIFFERENTLY on Java 21 vs Java 17 (on Windows)
// Before Java 18: uses platform default (e.g., Windows-1252)
// Java 18+: uses UTF-8
String content = Files.readString(Path.of("data.txt")); // Now always UTF-8

// FIX: Be explicit about charset (you should have been doing this all along)
String content = Files.readString(Path.of("data.txt"), StandardCharsets.UTF_8);

// Or, if you truly need the old behavior:
String content = Files.readString(Path.of("data.txt"), Charset.forName("windows-1252"));

Finalization Deprecation (JEP 421 — Java 18)

The finalize() method is deprecated for removal. If your code overrides finalize() in any class, you will get deprecation warnings, and the feature will eventually be removed entirely.

// BAD: This pattern is deprecated for removal
public class ResourceHolder {
    @Override
    protected void finalize() throws Throwable {
        // cleanup resources
        super.finalize();
    }
}

// GOOD: Use try-with-resources and AutoCloseable instead
public class ResourceHolder implements AutoCloseable {
    @Override
    public void close() {
        // cleanup resources
    }
}

// Or use Cleaner for cases where try-with-resources is not practical
public class ResourceHolder {
    private static final Cleaner cleaner = Cleaner.create();

    ResourceHolder() {
        cleaner.register(this, () -> {
            // cleanup action
        });
    }
}

Dynamic Agent Loading Warning (JEP 451 — Java 21)

Java 21 issues warnings when agents are loaded dynamically into a running JVM (via the Attach API). This affects tools like Byte Buddy, Mockito (in certain test scenarios), application performance monitors (APMs), and profilers. In a future Java release, dynamic loading will be disallowed by default.

// WARNING: A Java agent has been loaded dynamically...
// WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading

// FIX: Add this JVM flag to suppress the warning (for now)
// java -XX:+EnableDynamicAgentLoading -jar myapp.jar

// Or, better: load agents at startup instead of dynamically
// java -javaagent:myagent.jar -jar myapp.jar

Pattern Matching Refinements

If you used pattern matching for switch while it was in preview (Java 17-20), the syntax has changed in the final version. Guarded patterns now use when instead of &&.

// PREVIEW syntax (Java 17-20) -- NO LONGER VALID
String result = switch (obj) {
    case String s && s.length() > 5 -> "long string";  // OLD syntax
    default -> "other";
};

// FINAL syntax (Java 21) -- USE THIS
String result = switch (obj) {
    case String s when s.length() > 5 -> "long string"; // NEW: 'when' keyword
    default -> "other";
};

Other Breaking Changes

Change Version Impact Fix
Thread.stop() throws UnsupportedOperationException Java 20 Code calling Thread.stop() will crash Use Thread.interrupt() instead
Locale data source changed to CLDR Java 18 Date/number formatting may differ slightly Test locale-sensitive output
Removed legacy SecurityManager features Java 18-21 Applications using SecurityManager see warnings Migrate away from SecurityManager
sun.misc.Unsafe memory access methods deprecated Java 21 Libraries using Unsafe directly will get warnings Use VarHandle or Foreign Memory API

4. Step-by-Step Migration Checklist

Follow these 10 steps to migrate from Java 17 to Java 21 with minimum risk.

Step 1: Upgrade Your JDK

Download and install Java 21 from one of these distributions:

Distribution License Best For
Oracle JDK 21 Oracle NFTC (free for production) Commercial support available
Eclipse Temurin (Adoptium) 21 GPLv2 + Classpath Exception Community, open-source projects
Amazon Corretto 21 GPLv2 + Classpath Exception AWS deployments
Azul Zulu 21 GPLv2 + Classpath Exception Enterprise support option
GraalVM for JDK 21 GraalVM Free Terms Native image, polyglot

Step 2: Update JAVA_HOME and PATH

# Linux/macOS
export JAVA_HOME=/path/to/jdk-21
export PATH=$JAVA_HOME/bin:$PATH

# Verify
java -version
# openjdk version "21.0.2" 2024-01-16 LTS

# Windows (PowerShell)
$env:JAVA_HOME = "C:\Program Files\Java\jdk-21"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"

Step 3: Compile Your Project

Try compiling your project with Java 21. Do not change any code yet — just compile and note the warnings and errors.

# Maven
mvn clean compile -DskipTests

# Gradle
./gradlew compileJava

# Note ALL warnings -- especially:
# - Deprecation warnings (finalize, SecurityManager, etc.)
# - Access warnings (illegal reflective access)
# - Preview feature usage from Java 17

Step 4: Run Your Test Suite

Run all tests. Pay special attention to:

  • Character encoding tests — UTF-8 default may change behavior
  • Locale-dependent tests — Date/number formatting may differ
  • Reflection-heavy tests — Strong encapsulation continues to tighten
  • Mockito/Byte Buddy tests — May need library upgrades

Step 5: Fix Compilation Errors

Address any errors found in Steps 3 and 4. Common fixes are listed in the Common Migration Issues section below.

Step 6: Update Dependencies

Many libraries need minimum versions to work with Java 21. See the Framework Compatibility section for details.

Step 7: Update Build Plugins

Maven and Gradle plugins must support Java 21. See the Build Tool Updates section.

Step 8: Test in a Staging Environment

Deploy to a staging environment that mirrors production. Run integration tests, load tests, and smoke tests.

Step 9: Enable New Features Gradually

Do not adopt every new feature at once. Start with the safest changes:

  1. Pattern matching for switch (final, safe)
  2. Record patterns (final, safe)
  3. Sequenced collections (final, safe)
  4. Virtual threads (final, but test pinning first)
  5. Generational ZGC (final, but benchmark first)

Step 10: Deploy to Production

Roll out incrementally — canary deployment first, then gradual rollout. Monitor for performance regressions, increased error rates, or unexpected behavior.

5. Build Tool Updates

Your build tools need to know about Java 21. Here are the required configurations for Maven and Gradle.

Maven



    21
    21
    21




    
        
            org.apache.maven.plugins
            maven-compiler-plugin
            3.12.1
            
                21
                
                
            
        

        
        
            org.apache.maven.plugins
            maven-surefire-plugin
            3.2.3
            
                
                
            
        

        
        
            org.apache.maven.plugins
            maven-jar-plugin
            3.3.0
        
    

Gradle

// build.gradle (Groovy DSL)
plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

// Or set source/target compatibility directly
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21

// For preview features:
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs += ['--enable-preview']
}

tasks.withType(Test).configureEach {
    jvmArgs += ['--enable-preview']
}

tasks.withType(JavaExec).configureEach {
    jvmArgs += ['--enable-preview']
}
// build.gradle.kts (Kotlin DSL)
plugins {
    java
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

Minimum Build Tool Versions

Tool Minimum Version for Java 21 Recommended Version
Maven 3.9.0+ 3.9.6
maven-compiler-plugin 3.12.0+ 3.12.1
maven-surefire-plugin 3.2.0+ 3.2.3
Gradle 8.4+ 8.5
IntelliJ IDEA 2023.2+ 2023.3
Eclipse 2023-12+ 2024-03
VS Code (Java Extension Pack) 1.25+ Latest

6. Framework Compatibility

Major Java frameworks have been updated for Java 21. Here are the minimum versions you need.

Spring Boot

Spring Boot Version Java 21 Support Virtual Threads Notes
Spring Boot 3.0.x Compiles but not fully tested No Not recommended
Spring Boot 3.1.x Supported Experimental Minimum for Java 21
Spring Boot 3.2.x Fully supported Yes (one property) Recommended
Spring Boot 3.3.x+ Fully supported Yes Latest features


    org.springframework.boot
    spring-boot-starter-parent
    3.2.3



    21

Other Frameworks

Framework Minimum Version for Java 21 Virtual Thread Support
Quarkus 3.5+ Yes (with @RunOnVirtualThread)
Micronaut 4.2+ Yes (configurable)
Jakarta EE 10+ Container-dependent
Hibernate / JPA 6.4+ Works (uses JDBC)
Apache Kafka Client 3.6+ Compatible
Jackson (JSON) 2.16+ N/A (not thread-dependent)
Lombok 1.18.30+ N/A
Mockito 5.8+ N/A (test framework)
JUnit 5 5.10+ N/A (test framework)

Libraries That Need Special Attention

  • Byte Buddy (used by Mockito, Hibernate) — Upgrade to 1.14.10+ for Java 21 support
  • ASM (bytecode manipulation) — Upgrade to 9.6+ for Java 21 class file format
  • Guava — Version 33.0+ officially supports Java 21
  • Apache Commons — Most modules work unchanged; verify individually
  • Netty — Version 4.1.100+ for Java 21; virtual thread-aware in 4.2+

7. Common Migration Issues

Here are the most common issues developers encounter when migrating from Java 17 to 21, along with their solutions.

# Issue Symptom Solution
1 UTF-8 default charset Non-ASCII characters corrupted on Windows Explicitly specify charset in all I/O: Files.readString(path, StandardCharsets.UTF_8)
2 Dynamic agent loading warning Warning on startup about dynamically loaded agents Add -XX:+EnableDynamicAgentLoading JVM flag or load agents at startup
3 Mockito/Byte Buddy incompatibility IllegalArgumentException or ClassFormatError in tests Upgrade to Mockito 5.8+ and Byte Buddy 1.14.10+
4 Lombok not recognizing Java 21 features Compilation fails on records or sealed classes Upgrade to Lombok 1.18.30+
5 Illegal reflective access InaccessibleObjectException at runtime Add --add-opens flags or migrate to public APIs
6 ASM / bytecode library version UnsupportedClassVersionError Upgrade ASM to 9.6+, ensure all bytecode tools support class file version 65
7 Pattern matching switch syntax change case X s && guard no longer compiles Change to case X s when guard
8 Thread.stop() removed UnsupportedOperationException at runtime Use Thread.interrupt() for cooperative cancellation
9 SecurityManager deprecation Warnings or failures with custom SecurityManager Remove SecurityManager usage; use OS-level security instead
10 finalize() deprecation warnings Compiler warnings on classes overriding finalize() Migrate to AutoCloseable + try-with-resources or Cleaner
11 javax.* to jakarta.* namespace ClassNotFoundException for javax packages This is a Jakarta EE 10 / Spring Boot 3 change, not Java 21 itself. Update imports.
12 GC behavior change with Generational ZGC Different memory/GC characteristics Benchmark with -XX:+UseZGC -XX:+ZGenerational before switching in production

Fixing Illegal Reflective Access

If your application or its dependencies use reflection to access internal JDK APIs, you will see errors like this:

// Error:
// java.lang.reflect.InaccessibleObjectException:
// Unable to make field private final byte[] java.lang.String.value accessible:
// module java.base does not "opens java.lang" to unnamed module

// Fix: Add --add-opens to your JVM startup flags
// java --add-opens java.base/java.lang=ALL-UNNAMED -jar myapp.jar

// Common --add-opens needed for popular libraries:
// --add-opens java.base/java.lang=ALL-UNNAMED          (Byte Buddy, Mockito)
// --add-opens java.base/java.lang.reflect=ALL-UNNAMED   (Spring, Hibernate)
// --add-opens java.base/java.util=ALL-UNNAMED            (Various libraries)
// --add-opens java.base/java.io=ALL-UNNAMED              (Serialization libraries)
// --add-opens java.base/sun.nio.ch=ALL-UNNAMED           (Netty)

8. Performance Improvements

Java 21 includes significant performance improvements that your application gets for free just by upgrading the JDK.

Generational ZGC (JEP 439)

ZGC has been available since Java 15, but Java 21 adds generational support — meaning ZGC now separates objects into young and old generations, just like G1GC. This dramatically improves throughput for applications that create many short-lived objects (which is most Java applications).

Metric Non-Generational ZGC Generational ZGC (Java 21)
Max pause time < 1 ms < 1 ms
Throughput Good Significantly better (up to 2x for allocation-heavy apps)
Memory overhead Higher Lower (young gen collected more efficiently)
Short-lived object collection Treats all objects equally Collects young objects separately (faster)
# Enable Generational ZGC
java -XX:+UseZGC -XX:+ZGenerational -jar myapp.jar

# In Java 23+, ZGenerational is the default when using ZGC
# For Java 21, you must explicitly enable it

# Compare GC performance:
# Run your app with G1GC (default) and monitor
java -XX:+UseG1GC -Xlog:gc -jar myapp.jar

# Then run with Generational ZGC and compare
java -XX:+UseZGC -XX:+ZGenerational -Xlog:gc -jar myapp.jar

Virtual Threads Impact on Throughput

For I/O-bound applications (web servers, microservices, data pipelines), virtual threads can dramatically improve throughput without changing your code. Typical improvements:

Application Type Before (Platform Threads) After (Virtual Threads) Improvement
REST API with JDBC ~200 concurrent requests ~10,000+ concurrent requests 50x
Microservice calling 3 APIs Limited by thread pool Limited by API latency only 10-100x
Batch data processor 50 concurrent file reads 10,000+ concurrent file reads 200x
CPU-bound computation N cores utilized N cores utilized 0% (no benefit)

Other Runtime Improvements

  • JIT compiler improvements — The C2 compiler has better optimization heuristics in Java 21, improving steady-state performance by 5-15% for typical workloads
  • Startup time — CDS (Class Data Sharing) improvements reduce startup time
  • Memory footprint — String and object header compression improvements reduce memory usage
  • Cryptography — New Key Encapsulation Mechanism (KEM) API improves post-quantum cryptography readiness

9. Docker and CI/CD

Containerized applications need updated base images and build configurations for Java 21.

Updated Dockerfiles

# BEFORE: Java 17 Dockerfile
FROM eclipse-temurin:17-jre-alpine
COPY target/myapp.jar /app/myapp.jar
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]

# AFTER: Java 21 Dockerfile
FROM eclipse-temurin:21-jre-alpine
COPY target/myapp.jar /app/myapp.jar
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]

# RECOMMENDED: Multi-stage build with Java 21
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && mvn clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/myapp.jar ./myapp.jar

# Optimized JVM flags for containers
ENTRYPOINT ["java", \
    "-XX:+UseZGC", \
    "-XX:+ZGenerational", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+UseContainerSupport", \
    "-jar", "myapp.jar"]

Docker Image Options

Image Size Best For
eclipse-temurin:21-jre-alpine ~80 MB Smallest size, production
eclipse-temurin:21-jre-jammy ~220 MB Ubuntu-based, more tools
eclipse-temurin:21-jdk-alpine ~200 MB Build stage, includes compiler
amazoncorretto:21-alpine ~190 MB AWS deployments
azul/zulu-openjdk-alpine:21 ~190 MB Azul support

GraalVM Native Image

If you are using GraalVM native images, Java 21 support requires GraalVM for JDK 21. The native image compilation benefits from the new language features but has some considerations:

# GraalVM native image with Java 21
# Install GraalVM for JDK 21
sdk install java 21.0.2-graalce

# Build native image (Spring Boot with Spring Native / GraalVM)
./mvnw -Pnative native:compile

# Or with Gradle
./gradlew nativeCompile

# Important: Virtual threads work in native images since GraalVM for JDK 21
# But some preview features may not be supported in native image mode

CI/CD Pipeline Updates

# GitHub Actions: .github/workflows/build.yml
name: Build and Test

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Build with Maven
        run: mvn clean verify

      - name: Build Docker image
        run: docker build -t myapp:latest .

10. Best Practices for Adopting Java 21

Here is how to adopt Java 21 features safely and effectively in a production codebase.

Adopt Final Features First

Focus on features that are final (non-preview) first. These are stable, well-tested, and will not change in future versions.

Priority Feature Risk Level Effort Benefit
1 Pattern matching for switch Low Low — refactor existing switch/if-else Cleaner, safer type checking
2 Record patterns Low Low — use in new code, refactor gradually Concise destructuring
3 Sequenced collections Low Low — use new methods on existing collections Cleaner first/last access
4 Virtual threads Medium Medium — test for pinning, update executors Massive throughput improvement
5 Generational ZGC Medium Low — just a JVM flag, but benchmark first Better GC performance

Using Preview Features Safely

Preview features (--enable-preview) are for experimentation and feedback. They may change or be removed in the next Java version. Use them with caution:

  • Do use in: Personal projects, prototypes, internal tools, test code
  • Do not use in: Public libraries, frameworks, production services that cannot easily upgrade JDK
  • Always document: If you use preview features, document them so the team knows what to update when upgrading

Incremental Migration Strategy

// Week 1-2: Upgrade JDK and build tools, fix compilation issues
// Week 3-4: Run full test suite, fix failures
// Week 5-6: Deploy to staging, run integration tests
// Week 7-8: Deploy to production (canary)
// Week 9+: Start adopting new language features in new code

// Example: Adopting pattern matching for switch gradually
// BEFORE (Java 17 style)
public String describe(Object obj) {
    if (obj instanceof String s) {
        return "String of length " + s.length();
    } else if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof List list) {
        return "List with " + list.size() + " elements";
    } else {
        return "Unknown: " + obj.getClass().getSimpleName();
    }
}

// AFTER (Java 21 style -- pattern matching for switch)
public String describe(Object obj) {
    return switch (obj) {
        case String s       -> "String of length " + s.length();
        case Integer i      -> "Integer: " + i;
        case List list   -> "List with " + list.size() + " elements";
        case null           -> "null";
        default             -> "Unknown: " + obj.getClass().getSimpleName();
    };
}

Sequenced Collections Example

// BEFORE (Java 17): Getting first/last elements was awkward
List names = List.of("Alice", "Bob", "Charlie");

// First element
String first = names.get(0);                    // works but ugly for other collections
String first2 = names.iterator().next();         // generic but verbose

// Last element
String last = names.get(names.size() - 1);       // error-prone
// For LinkedList: ((LinkedList)names).getLast(); // casting!

// AFTER (Java 21): Sequenced collections make it clean
SequencedCollection names = List.of("Alice", "Bob", "Charlie");

String first = names.getFirst();    // "Alice" -- works on ALL SequencedCollections
String last  = names.getLast();     // "Charlie" -- clean, universal

// Reverse view (no copying!)
SequencedCollection reversed = names.reversed();
// reversed: ["Charlie", "Bob", "Alice"]

// Works on Sets too (LinkedHashSet, TreeSet)
SequencedSet orderedSet = new LinkedHashSet<>(List.of("X", "Y", "Z"));
orderedSet.getFirst();  // "X"
orderedSet.getLast();   // "Z"

// Works on Maps (LinkedHashMap, TreeMap)
SequencedMap map = new LinkedHashMap<>();
map.put("first", 1);
map.put("second", 2);
map.put("third", 3);

map.firstEntry();  // "first"=1
map.lastEntry();   // "third"=3
map.reversed();    // reversed view of the map

Migration Summary

Migrating from Java 17 to Java 21 is one of the most rewarding JDK upgrades in Java’s history. The combination of virtual threads, pattern matching, record patterns, sequenced collections, and Generational ZGC gives you a genuinely better language, library, and runtime with minimal breaking changes. Start planning your migration today — the longer you wait, the more you miss out on, and the closer you get to Java 17’s end of support.

What You Get What It Costs
Virtual threads (massive concurrency) Test for thread pinning
Pattern matching for switch (cleaner code) Update preview syntax if used
Record patterns (destructuring) Minimal — new feature
Sequenced collections (cleaner APIs) None — additive API
Generational ZGC (better GC) Benchmark before switching
5-15% general performance improvement Update JDK and test
UTF-8 consistency across platforms Fix charset-dependent code
March 1, 2026

Java 21 Pattern Matching for switch

1. Introduction

The switch statement has been part of Java since version 1.0, but for most of its life it was limited to matching exact values — integers, enums, and eventually strings. If you needed to branch based on the type of an object, you were stuck with if-else instanceof chains. That changed dramatically with Java 21.

Here is the journey that got us here:

Java Version Feature JEP What It Added
Java 14 Switch Expressions JEP 361 Switch can return a value with arrow syntax and yield
Java 16 Pattern Matching for instanceof JEP 394 Combine type check and cast in one expression
Java 17 Sealed Classes JEP 409 Restrict which classes can extend a type
Java 21 Pattern Matching for switch JEP 441 Use type patterns, guarded patterns, and null handling in switch
Java 21 Record Patterns JEP 440 Deconstruct records directly in patterns

Each feature built on the previous one. Switch expressions gave us a cleaner syntax. Pattern matching for instanceof let us avoid redundant casts. Sealed classes gave the compiler a closed set of subtypes to reason about. And now, Pattern Matching for switch in Java 21 (JEP 441) brings all of these together into a single, powerful construct.

Think of it like building a house. Switch expressions laid the foundation. Sealed classes put up the walls. Pattern matching for instanceof was the wiring. Java 21 pattern matching for switch is the roof that ties everything together into a complete, weatherproof structure.

Before Java 21, if you wanted to process an object differently based on its runtime type, you wrote code like this:

// Pre-Java 21: the instanceof chain everybody wrote
public String describe(Object obj) {
    if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof Long l) {
        return "Long: " + l;
    } else if (obj instanceof Double d) {
        return "Double: " + d;
    } else if (obj instanceof String s) {
        return "String of length " + s.length() + ": " + s;
    } else if (obj instanceof List list) {
        return "List with " + list.size() + " elements";
    } else if (obj == null) {
        return "null value";
    } else {
        return "Unknown: " + obj.getClass().getSimpleName();
    }
}

That works, but it has problems. Every branch is an if-else, so the compiler cannot verify exhaustiveness. You have to handle null separately before or inside the chain. And the structure — “match this object against a series of patterns and return a result” — is exactly what switch was designed for, yet you could not use it.

Java 21 changes that. Here is the same logic with pattern matching for switch:

// Java 21: pattern matching for switch
public String describe(Object obj) {
    return switch (obj) {
        case Integer i  -> "Integer: " + i;
        case Long l     -> "Long: " + l;
        case Double d   -> "Double: " + d;
        case String s   -> "String of length " + s.length() + ": " + s;
        case List list -> "List with " + list.size() + " elements";
        case null       -> "null value";
        default         -> "Unknown: " + obj.getClass().getSimpleName();
    };
}

Same logic, but cleaner, safer, and more expressive. The switch expression communicates intent: “I am dispatching on the type and value of this object.” Let us explore every feature that makes this possible.

2. Type Patterns in Switch

The most fundamental feature of pattern matching for switch is type patterns. A type pattern checks whether the switch selector matches a particular type and, if it does, binds it to a pattern variable — all in one step. No explicit cast. No separate variable declaration.

The syntax is case Type variableName ->. When the selector matches the type, the variable is automatically cast and available in that case branch.

// Type patterns in switch -- the basics
public double calculateArea(Object shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        default          -> throw new IllegalArgumentException("Unknown shape: " + shape);
    };
}

// Compare to the old if-else instanceof chain
public double calculateAreaOld(Object shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.base() * t.height();
    } else {
        throw new IllegalArgumentException("Unknown shape: " + shape);
    }
}

Both versions do the same thing, but the switch version has several advantages:

  • Expression form — The switch returns a value directly. No need for a local variable or multiple return statements.
  • Exhaustiveness — If you use a sealed type (covered in section 5), the compiler can verify you handled every case.
  • Clarity of intent — A switch says “I am dispatching.” An if-else chain says “I am checking conditions.” Dispatching on type is conceptually a switch, so the switch form matches how developers think about the problem.

Working with Multiple Types

Type patterns work with any reference type — classes, interfaces, arrays, even generic types with some limitations.

public String processValue(Object value) {
    return switch (value) {
        case Integer i    -> "int: " + i;
        case Long l       -> "long: " + l;
        case Float f      -> "float: " + f;
        case Double d     -> "double: " + d;
        case Boolean b    -> "boolean: " + b;
        case Character c  -> "char: '" + c + "'";
        case String s     -> "string: \"" + s + "\"";
        case int[] arr    -> "int array of length " + arr.length;
        case Object[] arr -> "object array of length " + arr.length;
        default           -> "other: " + value.getClass().getName();
    };
}

// Usage
System.out.println(processValue(42));          // int: 42
System.out.println(processValue("hello"));     // string: "hello"
System.out.println(processValue(new int[]{1,2,3})); // int array of length 3

Type Patterns Replace Visitor Pattern

For years, Java developers used the Visitor pattern to dispatch on types without instanceof chains. Type patterns in switch offer a simpler alternative for many cases where the Visitor pattern was historically used.

// Before: Visitor pattern (lots of boilerplate)
interface ShapeVisitor {
    T visitCircle(Circle c);
    T visitRectangle(Rectangle r);
    T visitTriangle(Triangle t);
}

interface Shape {
     T accept(ShapeVisitor visitor);
}

class Circle implements Shape {
    double radius;
    public  T accept(ShapeVisitor visitor) { return visitor.visitCircle(this); }
}

class Rectangle implements Shape {
    double width, height;
    public  T accept(ShapeVisitor visitor) { return visitor.visitRectangle(this); }
}

// After: Type pattern in switch (minimal boilerplate)
public double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius * c.radius;
        case Rectangle r -> r.width * r.height;
        case Triangle t  -> 0.5 * t.base * t.height;
    };
}

The Visitor pattern still has its place — especially when you need double dispatch or when the operations need to be defined in separate classes. But for simple type-based dispatching, pattern matching for switch is dramatically simpler.

3. Guarded Patterns

Sometimes matching on type alone is not enough. You need to match on type and a condition about the value. Java 21 introduces guarded patterns using the when keyword. A guarded pattern is a type pattern followed by when and a boolean expression.

The syntax is: case Type variable when condition ->

Think of it like a bouncer at a club. The type pattern checks your ID (are you the right type?). The when clause checks the guest list (do you meet the additional criteria?).

// Guarded patterns with the 'when' clause
public String classifyString(Object obj) {
    return switch (obj) {
        case String s when s.isEmpty()       -> "empty string";
        case String s when s.length() <= 5   -> "short string: " + s;
        case String s when s.length() <= 20  -> "medium string: " + s;
        case String s                        -> "long string: " + s.substring(0, 20) + "...";
        default                              -> "not a string";
    };
}

// Guarded patterns with numeric ranges
public String classifyNumber(Object obj) {
    return switch (obj) {
        case Integer i when i < 0    -> "negative integer: " + i;
        case Integer i when i == 0   -> "zero";
        case Integer i when i <= 100 -> "small positive integer: " + i;
        case Integer i               -> "large positive integer: " + i;
        case Double d when d.isNaN() -> "NaN";
        case Double d when d.isInfinite() -> "infinite";
        case Double d                -> "double: " + d;
        default                      -> "not a number";
    };
}

Important: The order of guarded cases matters. Java evaluates cases from top to bottom and takes the first match. If you put case String s (unguarded) before case String s when s.isEmpty(), the unguarded pattern would match every string, and the guarded one would never execute. The compiler will flag this as an error — the guarded pattern is dominated by the unguarded one.

Complex Guard Conditions

The when clause accepts any boolean expression. You can call methods, use logical operators, and reference the pattern variable.

// Complex conditions in when clauses
public String analyzeCollection(Object obj) {
    return switch (obj) {
        case List list when list.isEmpty()
            -> "empty list";
        case List list when list.size() == 1
            -> "singleton list: " + list.get(0);
        case List list when list.size() > 1000
            -> "large list with " + list.size() + " elements";
        case List list when list.stream().allMatch(e -> e instanceof String)
            -> "string list with " + list.size() + " elements";
        case List list
            -> "mixed list with " + list.size() + " elements";
        case Set set when set.isEmpty()
            -> "empty set";
        case Set set
            -> "set with " + set.size() + " elements";
        case Map map when map.containsKey("id")
            -> "map with id: " + map.get("id");
        case Map map
            -> "map with " + map.size() + " entries";
        default
            -> "unknown collection type";
    };
}

// Guard with method calls
record Employee(String name, String department, int salary) {}

public String evaluateEmployee(Object obj) {
    return switch (obj) {
        case Employee e when e.department().equals("Engineering") && e.salary() > 150000
            -> e.name() + " is a senior engineer";
        case Employee e when e.department().equals("Engineering")
            -> e.name() + " is an engineer";
        case Employee e when e.salary() > 200000
            -> e.name() + " is a high earner in " + e.department();
        case Employee e
            -> e.name() + " works in " + e.department();
        default
            -> "not an employee";
    };
}

Guarded patterns give you the power of if-else conditions with the structure and clarity of switch. They make it possible to express complex dispatching logic that would previously require nested if-else chains inside switch cases.

4. Null Handling in Switch

Before Java 21, passing null to a switch statement threw a NullPointerException. Always. No exceptions (well, one exception — the NPE itself). This meant you always had to add a null check before the switch, which was easy to forget and led to runtime crashes.

Java 21 lets you handle null directly as a case label. No more defensive null checks before the switch. No more surprise NPEs.

// Before Java 21: null check required before switch
public String formatValueOld(Object value) {
    if (value == null) {
        return "no value";
    }
    return switch (value) {
        case Integer i -> String.format("%,d", i);
        case Double d  -> String.format("%.2f", d);
        case String s  -> "\"" + s + "\"";
        default        -> value.toString();
    };
}

// Java 21: null is just another case
public String formatValue(Object value) {
    return switch (value) {
        case null      -> "no value";
        case Integer i -> String.format("%,d", i);
        case Double d  -> String.format("%.2f", d);
        case String s  -> "\"" + s + "\"";
        default        -> value.toString();
    };
}

Combining null with default

You can combine null and default into a single case if you want them to behave the same way. This is useful when null and unknown types should be handled identically.

// null combined with default
public String processInput(Object input) {
    return switch (input) {
        case String s  -> "text: " + s;
        case Integer i -> "number: " + i;
        case null, default -> "unsupported input";
    };
}

// null as its own case with specific behavior
public String handleRequest(Object request) {
    return switch (request) {
        case null -> {
            logWarning("Received null request");
            yield "error: null request";
        }
        case HttpRequest http -> processHttp(http);
        case GrpcRequest grpc -> processGrpc(grpc);
        default -> {
            logWarning("Unknown request type: " + request.getClass());
            yield "error: unsupported request type";
        }
    };
}

Key rules for null in switch:

  • case null can appear anywhere in the switch, but conventionally it is placed first or last.
  • If no case null is present and the selector is null, a NullPointerException is thrown — same behavior as before Java 21.
  • case null can be combined with default (case null, default) but not with type patterns.
  • With case null, you no longer need a separate null check before the switch. The switch itself handles it.

5. Sealed Classes with Pattern Matching

This is where pattern matching for switch truly shines. When you use a sealed class or sealed interface as the selector type, the compiler knows every possible subtype. This means the compiler can verify that your switch covers all cases — no default branch needed. If you add a new subtype to the sealed hierarchy, every switch that does not handle it will fail to compile. This is exhaustiveness checking, and it is a game-changer for maintainability.

Think of it like a multiple-choice exam where the teacher gives you exactly four options: A, B, C, D. If you answer all four, you are covered. If the teacher adds option E next semester, your answer key automatically flags the missing answer.

// Sealed interface with exhaustive switch
public sealed interface PaymentMethod permits
        CreditCard, DebitCard, BankTransfer, DigitalWallet {}

public record CreditCard(String number, String expiry, int cvv) implements PaymentMethod {}
public record DebitCard(String number, String pin) implements PaymentMethod {}
public record BankTransfer(String routingNumber, String accountNumber) implements PaymentMethod {}
public record DigitalWallet(String provider, String email) implements PaymentMethod {}

// Exhaustive switch -- no default needed!
public String processPayment(PaymentMethod method, double amount) {
    return switch (method) {
        case CreditCard cc     -> chargeCreditCard(cc, amount);
        case DebitCard dc      -> chargeDebitCard(dc, amount);
        case BankTransfer bt   -> initiateBankTransfer(bt, amount);
        case DigitalWallet dw  -> chargeDigitalWallet(dw, amount);
        // No default needed -- compiler knows these are ALL the types
    };
}

// If you later add: public record Cryptocurrency(...) implements PaymentMethod {}
// The compiler will immediately flag processPayment() as non-exhaustive.
// You MUST add a case for Cryptocurrency before the code compiles.

Sealed Hierarchies with Multiple Levels

Sealed hierarchies can be nested. A sealed class can have sealed subclasses, each with their own permitted subtypes. Pattern matching handles this naturally.

// Multi-level sealed hierarchy
public sealed interface Expr permits Literal, BinaryOp, UnaryOp {}

public record Literal(double value) implements Expr {}

public sealed interface BinaryOp extends Expr permits Add, Subtract, Multiply, Divide {}
public record Add(Expr left, Expr right) implements BinaryOp {}
public record Subtract(Expr left, Expr right) implements BinaryOp {}
public record Multiply(Expr left, Expr right) implements BinaryOp {}
public record Divide(Expr left, Expr right) implements BinaryOp {}

public sealed interface UnaryOp extends Expr permits Negate, Abs {}
public record Negate(Expr operand) implements UnaryOp {}
public record Abs(Expr operand) implements UnaryOp {}

// Exhaustive evaluation
public double evaluate(Expr expr) {
    return switch (expr) {
        case Literal(var value)       -> value;
        case Add(var l, var r)        -> evaluate(l) + evaluate(r);
        case Subtract(var l, var r)   -> evaluate(l) - evaluate(r);
        case Multiply(var l, var r)   -> evaluate(l) * evaluate(r);
        case Divide(var l, var r)     -> {
            double divisor = evaluate(r);
            if (divisor == 0) throw new ArithmeticException("Division by zero");
            yield evaluate(l) / divisor;
        }
        case Negate(var operand)      -> -evaluate(operand);
        case Abs(var operand)         -> Math.abs(evaluate(operand));
    };
    // No default needed -- all types covered
}

The compiler checks exhaustiveness across the entire hierarchy. If you add a Modulo operation to BinaryOp, every switch over Expr will fail to compile until you handle it. This compile-time safety is one of the strongest arguments for combining sealed types with pattern matching.

Without Sealed + Pattern Matching With Sealed + Pattern Matching
New subtype added silently Compiler error at every incomplete switch
Default branch hides missing cases No default needed — compiler verifies completeness
Runtime errors when unknown type encountered Compile-time guarantee of exhaustiveness
Must manually search codebase for all dispatches Compiler finds them all for you

6. Dominance and Ordering

When you have multiple type patterns in a switch, the order matters. Java 21 enforces dominance rules: a more general pattern cannot appear before a more specific one, because the general pattern would match everything the specific one matches, making the specific one unreachable.

Think of it like sorting mail. If you have a slot labeled “All Mail” before a slot labeled “Letters from Mom,” nothing will ever reach the Mom slot. The compiler catches this mistake.

// COMPILE ERROR: dominated pattern
public String badOrdering(Object obj) {
    return switch (obj) {
        case Object o   -> "object";     // This matches EVERYTHING
        case String s   -> "string";     // UNREACHABLE -- dominated by Object
        case Integer i  -> "integer";    // UNREACHABLE -- dominated by Object
    };
    // Compiler error: "this case label is dominated by a preceding case label"
}

// CORRECT: specific types before general types
public String goodOrdering(Object obj) {
    return switch (obj) {
        case String s   -> "string";     // Most specific first
        case Integer i  -> "integer";    // Also specific
        case Number n   -> "number";     // More general than Integer, but after it
        case Object o   -> "object";     // Most general last
    };
}

// Dominance also applies to guarded vs unguarded patterns of the same type
public String guardOrdering(Object obj) {
    return switch (obj) {
        // CORRECT: guarded patterns before unguarded of same type
        case String s when s.isEmpty() -> "empty string";
        case String s when s.length() < 10 -> "short string";
        case String s -> "long string";   // Catches all remaining strings
        default -> "not a string";
    };
}

// COMPILE ERROR: unguarded before guarded of same type
public String badGuardOrdering(Object obj) {
    return switch (obj) {
        case String s -> "any string";           // Matches ALL strings
        case String s when s.isEmpty() -> "empty"; // UNREACHABLE -- dominated
        default -> "not a string";
    };
}

Dominance Rules Summary

Rule Example Compiler Behavior
Supertype dominates subtype case Object before case String Compile error
Unguarded dominates guarded (same type) case String s before case String s when ... Compile error
Guarded does NOT dominate unguarded case String s when ... before case String s OK — unguarded catches remaining
Unrelated types case String s before case Integer i OK — no dominance relationship
default dominates everything default before case String s Compile error

The compiler enforces these rules to prevent dead code. If you see a “dominated pattern” error, move the more specific pattern above the more general one.

7. Parenthesized Patterns

Java 21 supports parenthesized patterns, which allow you to group pattern expressions for clarity. While simple pattern matching does not require parentheses, they become useful when combining patterns with guard conditions that involve complex boolean logic.

Parenthesized patterns help disambiguate how the when clause binds to the pattern, making your intent clear to both the compiler and other developers reading your code.

// Parenthesized patterns for clarity
public String classifyValue(Object obj) {
    return switch (obj) {
        case (String s) when s.contains("@") && s.contains(".")
            -> "email-like string: " + s;
        case (String s) when s.matches("\\d{3}-\\d{3}-\\d{4}")
            -> "phone number: " + s;
        case (String s) when s.startsWith("http://") || s.startsWith("https://")
            -> "URL: " + s;
        case (String s)
            -> "plain string: " + s;
        case (Integer i) when i >= 0 && i <= 255
            -> "byte-range integer: " + i;
        case (Integer i)
            -> "integer: " + i;
        default
            -> "other: " + obj;
    };
}

// Without parentheses, the same code works -- parentheses are optional
// but they can improve readability for complex guard conditions
public String withoutParens(Object obj) {
    return switch (obj) {
        case String s when s.contains("@") && s.contains(".")
            -> "email-like string: " + s;
        case String s
            -> "plain string: " + s;
        default
            -> "other: " + obj;
    };
}

Parenthesized patterns are primarily a readability tool. Use them when your guard conditions are complex enough that grouping the pattern visually separates it from the condition, making the code easier to scan.

8. Switch with Records

Records and pattern matching for switch are natural partners. A record pattern lets you deconstruct a record directly in a switch case, extracting its components into variables in one step. This is one of the most powerful features introduced in Java 21 (JEP 440, finalized alongside JEP 441).

Think of it like opening a package. Instead of receiving the box (the record) and then opening it to get the contents (calling accessor methods), record patterns let you describe what is inside the box and have the contents placed directly into your hands.

// Basic record pattern in switch
record Point(int x, int y) {}

public String describePoint(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x == 0 && y == 0 -> "origin";
        case Point(int x, int y) when x == 0            -> "on y-axis at y=" + y;
        case Point(int x, int y) when y == 0            -> "on x-axis at x=" + x;
        case Point(int x, int y)                        -> "point at (" + x + ", " + y + ")";
        default -> "not a point";
    };
}

// Record pattern with var
public String describePointVar(Object obj) {
    return switch (obj) {
        case Point(var x, var y) when x == 0 && y == 0 -> "origin";
        case Point(var x, var y) -> "point at (" + x + ", " + y + ")";
        default -> "not a point";
    };
}

Nested Record Patterns

The real power comes with nested deconstruction. If a record contains other records as components, you can deconstruct them all in a single pattern.

// Nested record deconstruction
record Point(int x, int y) {}
record Line(Point start, Point end) {}
record Circle(Point center, double radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}

public String describeShape(Object obj) {
    return switch (obj) {
        // Nested deconstruction -- extract points AND their coordinates
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            -> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);

        case Circle(Point(var cx, var cy), var r)
            -> String.format("Circle at (%d,%d) with radius %.1f", cx, cy, r);

        case Rectangle(Point(var x1, var y1), Point(var x2, var y2))
            -> String.format("Rectangle from (%d,%d) to (%d,%d)", x1, y1, x2, y2);

        default -> "unknown shape";
    };
}

// Compare to the old way
public String describeShapeOld(Object obj) {
    if (obj instanceof Line line) {
        Point start = line.start();
        Point end = line.end();
        int x1 = start.x();
        int y1 = start.y();
        int x2 = end.x();
        int y2 = end.y();
        return String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
    }
    // ... and so on for each type
    return "unknown shape";
}

The nested pattern extracts all four coordinate values in a single line. The old way required six lines of accessor calls just for the Line type. Across multiple types in a switch, the savings are enormous.

Records with Guard Clauses

// Records with guard clauses in switch
record HttpResponse(int statusCode, String body) {}

public String handleResponse(Object obj) {
    return switch (obj) {
        case HttpResponse(var code, var body) when code >= 200 && code < 300
            -> "Success: " + body;
        case HttpResponse(var code, var body) when code == 301 || code == 302
            -> "Redirect: " + body;
        case HttpResponse(var code, var body) when code == 404
            -> "Not Found";
        case HttpResponse(var code, var body) when code >= 500
            -> "Server Error: " + body;
        case HttpResponse(var code, var body)
            -> "HTTP " + code + ": " + body;
        default -> "not an HTTP response";
    };
}

// Deeply nested records with guards
record Address(String city, String state, String zip) {}
record Customer(String name, Address address) {}

public double calculateShipping(Object obj) {
    return switch (obj) {
        case Customer(var name, Address(var city, var state, var zip))
                when state.equals("CA") || state.equals("OR") || state.equals("WA")
            -> 5.99;
        case Customer(var name, Address(var city, var state, var zip))
                when state.equals("NY") || state.equals("NJ")
            -> 8.99;
        case Customer(var name, Address(var city, var state, var zip))
            -> 12.99;
        default -> throw new IllegalArgumentException("Not a customer");
    };
}

Record patterns with guard clauses let you express complex routing logic in a clean, declarative style. The switch reads almost like a decision table: for each combination of type and condition, here is what to do.

9. Practical Patterns

Let us look at real-world scenarios where pattern matching for switch transforms the code you write every day. These are not toy examples — they are patterns you will find in production codebases dealing with events, commands, API responses, and business rules.

Type-Safe Event Handling

// Type-safe event system with sealed types and pattern matching
public sealed interface DomainEvent permits
        UserCreated, UserUpdated, UserDeleted,
        OrderPlaced, OrderShipped, OrderDelivered {}

public record UserCreated(String userId, String email, Instant createdAt)
        implements DomainEvent {}
public record UserUpdated(String userId, Map changes, Instant updatedAt)
        implements DomainEvent {}
public record UserDeleted(String userId, String reason, Instant deletedAt)
        implements DomainEvent {}
public record OrderPlaced(String orderId, String userId, BigDecimal total, Instant placedAt)
        implements DomainEvent {}
public record OrderShipped(String orderId, String trackingNumber, Instant shippedAt)
        implements DomainEvent {}
public record OrderDelivered(String orderId, Instant deliveredAt)
        implements DomainEvent {}

// Event handler -- exhaustive, no default, compiler-verified
public void handleEvent(DomainEvent event) {
    switch (event) {
        case UserCreated(var id, var email, var at) -> {
            sendWelcomeEmail(email);
            createUserProfile(id);
            log.info("User {} created at {}", id, at);
        }
        case UserUpdated(var id, var changes, var at) -> {
            applyChanges(id, changes);
            log.info("User {} updated at {}: {}", id, at, changes.keySet());
        }
        case UserDeleted(var id, var reason, var at) -> {
            archiveUser(id);
            log.info("User {} deleted at {} for: {}", id, at, reason);
        }
        case OrderPlaced(var orderId, var userId, var total, var at) -> {
            reserveInventory(orderId);
            chargeCustomer(userId, total);
            log.info("Order {} placed by {} for ${} at {}", orderId, userId, total, at);
        }
        case OrderShipped(var orderId, var tracking, var at) -> {
            notifyCustomer(orderId, tracking);
            log.info("Order {} shipped with tracking {} at {}", orderId, tracking, at);
        }
        case OrderDelivered(var orderId, var at) -> {
            closeOrder(orderId);
            log.info("Order {} delivered at {}", orderId, at);
        }
    }
}

Command Processing

// Command pattern with pattern matching
public sealed interface Command permits
        CreateUser, UpdateUser, DeleteUser, ResetPassword {}

public record CreateUser(String email, String name) implements Command {}
public record UpdateUser(String userId, String field, String value) implements Command {}
public record DeleteUser(String userId) implements Command {}
public record ResetPassword(String email) implements Command {}

public sealed interface CommandResult permits Success, Failure, Pending {}
public record Success(String message, Object data) implements CommandResult {}
public record Failure(String error, int code) implements CommandResult {}
public record Pending(String taskId) implements CommandResult {}

public CommandResult execute(Command command) {
    return switch (command) {
        case CreateUser(var email, var name) when !email.contains("@")
            -> new Failure("Invalid email", 400);
        case CreateUser(var email, var name) when userExists(email)
            -> new Failure("User already exists", 409);
        case CreateUser(var email, var name)
            -> new Success("User created", createUser(email, name));

        case UpdateUser(var id, var field, var value) when !userExists(id)
            -> new Failure("User not found", 404);
        case UpdateUser(var id, var field, var value)
            -> new Success("User updated", updateField(id, field, value));

        case DeleteUser(var id) when !userExists(id)
            -> new Failure("User not found", 404);
        case DeleteUser(var id)
            -> new Success("User deleted", deleteUser(id));

        case ResetPassword(var email)
            -> new Pending(queuePasswordReset(email));
    };
}

// Then process the result with pattern matching too!
public String toHttpResponse(CommandResult result) {
    return switch (result) {
        case Success(var msg, var data) -> "200 OK: " + msg;
        case Failure(var err, var code) -> code + " Error: " + err;
        case Pending(var taskId)        -> "202 Accepted: task=" + taskId;
    };
}

Response Handling

// API response handling with pattern matching
public sealed interface ApiResponse permits
        ApiResponse.Ok, ApiResponse.Error, ApiResponse.Loading {}

public record Ok(T data, Map headers) implements ApiResponse {}
public record Error(int code, String message, List details) implements ApiResponse {}
public record Loading(double progress) implements ApiResponse {}

// Processing API responses
public  void handleApiResponse(ApiResponse response) {
    switch (response) {
        case Ok(var data, var headers) -> {
            updateUI(data);
            cacheResponse(data, headers);
        }
        case Error(var code, var msg, var details) when code == 401 -> {
            refreshToken();
            retryRequest();
        }
        case Error(var code, var msg, var details) when code == 429 -> {
            int retryAfter = parseRetryAfter(details);
            scheduleRetry(retryAfter);
        }
        case Error(var code, var msg, var details) -> {
            showError(msg);
            logDetails(details);
        }
        case Loading(var progress) when progress > 0.9 -> {
            showAlmostDone();
        }
        case Loading(var progress) -> {
            updateProgressBar(progress);
        }
    }
}

Expression Evaluation

// Mathematical expression evaluator
public sealed interface MathExpr permits
        Num, Var, Add, Mul, Pow, Neg {}

public record Num(double value) implements MathExpr {}
public record Var(String name) implements MathExpr {}
public record Add(MathExpr left, MathExpr right) implements MathExpr {}
public record Mul(MathExpr left, MathExpr right) implements MathExpr {}
public record Pow(MathExpr base, MathExpr exponent) implements MathExpr {}
public record Neg(MathExpr operand) implements MathExpr {}

public double eval(MathExpr expr, Map env) {
    return switch (expr) {
        case Num(var v)          -> v;
        case Var(var name)       -> env.getOrDefault(name, 0.0);
        case Add(var l, var r)   -> eval(l, env) + eval(r, env);
        case Mul(var l, var r)   -> eval(l, env) * eval(r, env);
        case Pow(var b, var e)   -> Math.pow(eval(b, env), eval(e, env));
        case Neg(var operand)    -> -eval(operand, env);
    };
}

// Pretty printer using the same pattern
public String prettyPrint(MathExpr expr) {
    return switch (expr) {
        case Num(var v)         -> String.valueOf(v);
        case Var(var name)      -> name;
        case Add(var l, var r)  -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
        case Mul(var l, var r)  -> "(" + prettyPrint(l) + " * " + prettyPrint(r) + ")";
        case Pow(var b, var e)  -> prettyPrint(b) + "^" + prettyPrint(e);
        case Neg(var operand)   -> "-" + prettyPrint(operand);
    };
}

// Simplifier that reduces expressions
public MathExpr simplify(MathExpr expr) {
    return switch (expr) {
        case Add(Num(var a), Num(var b))           -> new Num(a + b);
        case Mul(Num(var a), Num(var b))           -> new Num(a * b);
        case Add(var e, Num n) when n.value() == 0 -> simplify(e);
        case Add(Num n, var e) when n.value() == 0 -> simplify(e);
        case Mul(var e, Num n) when n.value() == 1 -> simplify(e);
        case Mul(Num n, var e) when n.value() == 1 -> simplify(e);
        case Mul(var e, Num n) when n.value() == 0 -> new Num(0);
        case Mul(Num n, var e) when n.value() == 0 -> new Num(0);
        case Neg(Neg(var e))                       -> simplify(e);
        case MathExpr e                            -> e;
    };
}

Each of these examples demonstrates a key pattern: define your domain as sealed types (often records), then process them with exhaustive switch expressions. The result is code that is concise, type-safe, and self-documenting.

10. Before vs After

Nothing demonstrates the value of a feature like side-by-side comparisons with real code. Here are six examples of common patterns rewritten from pre-Java 21 style to Java 21 pattern matching for switch.

Example 1: Shape Area Calculation

// BEFORE: instanceof chain with manual casts
public double calculateArea(Object shape) {
    if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return Math.PI * c.getRadius() * c.getRadius();
    } else if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return r.getWidth() * r.getHeight();
    } else if (shape instanceof Triangle) {
        Triangle t = (Triangle) shape;
        return 0.5 * t.getBase() * t.getHeight();
    } else if (shape instanceof Polygon) {
        Polygon p = (Polygon) shape;
        return p.calculateArea();
    } else {
        throw new IllegalArgumentException("Unknown shape: " + shape.getClass());
    }
}

// AFTER: pattern matching switch expression
public double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        case Polygon p   -> p.calculateArea();
    };
}

Example 2: JSON Value Formatting

// BEFORE: nested if-else with instanceof
public String formatJson(Object value) {
    if (value == null) {
        return "null";
    } else if (value instanceof String) {
        return "\"" + ((String) value).replace("\"", "\\\"") + "\"";
    } else if (value instanceof Number) {
        return value.toString();
    } else if (value instanceof Boolean) {
        return value.toString();
    } else if (value instanceof List) {
        List list = (List) value;
        return list.stream()
                .map(this::formatJson)
                .collect(Collectors.joining(", ", "[", "]"));
    } else if (value instanceof Map) {
        Map map = (Map) value;
        return map.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + formatJson(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
    } else {
        throw new IllegalArgumentException("Cannot serialize: " + value.getClass());
    }
}

// AFTER: clean switch with null handling
public String formatJson(Object value) {
    return switch (value) {
        case null           -> "null";
        case String s       -> "\"" + s.replace("\"", "\\\"") + "\"";
        case Number n       -> n.toString();
        case Boolean b      -> b.toString();
        case List list   -> list.stream()
                .map(this::formatJson)
                .collect(Collectors.joining(", ", "[", "]"));
        case Map map   -> map.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + formatJson(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
        default -> throw new IllegalArgumentException("Cannot serialize: " + value.getClass());
    };
}

Example 3: Error Response Mapping

// BEFORE: traditional approach with if-else and manual field extraction
public ResponseEntity handleError(Exception ex) {
    if (ex instanceof ValidationException) {
        ValidationException ve = (ValidationException) ex;
        return ResponseEntity.badRequest()
                .body(Map.of("error", ve.getMessage(), "fields", ve.getFieldErrors()));
    } else if (ex instanceof NotFoundException) {
        NotFoundException nfe = (NotFoundException) ex;
        return ResponseEntity.status(404)
                .body(Map.of("error", nfe.getMessage(), "resource", nfe.getResourceType()));
    } else if (ex instanceof UnauthorizedException) {
        return ResponseEntity.status(401)
                .body(Map.of("error", "Unauthorized"));
    } else if (ex instanceof RateLimitException) {
        RateLimitException rle = (RateLimitException) ex;
        return ResponseEntity.status(429)
                .header("Retry-After", String.valueOf(rle.getRetryAfterSeconds()))
                .body(Map.of("error", "Rate limited", "retryAfter", rle.getRetryAfterSeconds()));
    } else {
        return ResponseEntity.status(500)
                .body(Map.of("error", "Internal server error"));
    }
}

// AFTER: pattern matching with sealed exception hierarchy
public ResponseEntity handleError(AppException ex) {
    return switch (ex) {
        case ValidationException(var msg, var fields)
            -> ResponseEntity.badRequest()
                .body(Map.of("error", msg, "fields", fields));
        case NotFoundException(var msg, var resource)
            -> ResponseEntity.status(404)
                .body(Map.of("error", msg, "resource", resource));
        case UnauthorizedException(var msg)
            -> ResponseEntity.status(401)
                .body(Map.of("error", msg));
        case RateLimitException(var msg, var retryAfter)
            -> ResponseEntity.status(429)
                .header("Retry-After", String.valueOf(retryAfter))
                .body(Map.of("error", msg, "retryAfter", retryAfter));
    };
}

Example 4: Database Result Processing

// BEFORE: processing different query result types
public void processResult(Object result) {
    if (result == null) {
        System.out.println("No results found");
    } else if (result instanceof Integer) {
        int count = (Integer) result;
        System.out.println("Affected rows: " + count);
    } else if (result instanceof List) {
        List rows = (List) result;
        if (rows.isEmpty()) {
            System.out.println("Query returned empty result set");
        } else {
            System.out.println("Found " + rows.size() + " rows");
            rows.forEach(System.out::println);
        }
    } else if (result instanceof Map) {
        Map row = (Map) result;
        System.out.println("Single row: " + row);
    } else {
        System.out.println("Unexpected result: " + result.getClass());
    }
}

// AFTER: sealed result type with pattern matching
public sealed interface QueryResult permits
        NoResult, RowCount, SingleRow, MultipleRows {}

public record NoResult() implements QueryResult {}
public record RowCount(int count) implements QueryResult {}
public record SingleRow(Map data) implements QueryResult {}
public record MultipleRows(List> rows) implements QueryResult {}

public void processResult(QueryResult result) {
    switch (result) {
        case NoResult()                  -> System.out.println("No results found");
        case RowCount(var count)         -> System.out.println("Affected rows: " + count);
        case SingleRow(var data)         -> System.out.println("Single row: " + data);
        case MultipleRows(var rows) when rows.isEmpty()
            -> System.out.println("Query returned empty result set");
        case MultipleRows(var rows)      -> {
            System.out.println("Found " + rows.size() + " rows");
            rows.forEach(System.out::println);
        }
    }
}

Example 5: Configuration Value Parsing

// BEFORE: parsing config values with type checking
public Object parseConfigValue(String key, Object rawValue) {
    if (rawValue == null) {
        throw new ConfigException("Missing required config: " + key);
    } else if (rawValue instanceof String) {
        String s = (String) rawValue;
        // Try to parse as number
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException e1) {
            try {
                return Double.parseDouble(s);
            } catch (NumberFormatException e2) {
                if ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
                    return Boolean.parseBoolean(s);
                }
                return s;
            }
        }
    } else if (rawValue instanceof Number) {
        return rawValue;
    } else if (rawValue instanceof Boolean) {
        return rawValue;
    } else if (rawValue instanceof List) {
        return ((List) rawValue).stream()
                .map(v -> parseConfigValue(key, v))
                .toList();
    } else {
        throw new ConfigException("Unsupported config type for " + key + ": " + rawValue.getClass());
    }
}

// AFTER: pattern matching with guards
public Object parseConfigValue(String key, Object rawValue) {
    return switch (rawValue) {
        case null -> throw new ConfigException("Missing required config: " + key);

        case String s when s.matches("-?\\d+")
            -> Integer.parseInt(s);
        case String s when s.matches("-?\\d+\\.\\d+")
            -> Double.parseDouble(s);
        case String s when s.equalsIgnoreCase("true") || s.equalsIgnoreCase("false")
            -> Boolean.parseBoolean(s);
        case String s
            -> s;

        case Number n  -> n;
        case Boolean b -> b;

        case List list
            -> list.stream().map(v -> parseConfigValue(key, v)).toList();

        default -> throw new ConfigException(
                "Unsupported config type for " + key + ": " + rawValue.getClass());
    };
}

Example 6: Message Routing

// BEFORE: message routing with instanceof chain
public void routeMessage(Object message) {
    if (message == null) {
        log.warn("Received null message");
        return;
    }
    if (message instanceof TextMessage) {
        TextMessage tm = (TextMessage) message;
        if (tm.getText().startsWith("/")) {
            handleCommand(tm);
        } else {
            handleText(tm);
        }
    } else if (message instanceof ImageMessage) {
        ImageMessage im = (ImageMessage) message;
        if (im.getSize() > MAX_IMAGE_SIZE) {
            rejectLargeImage(im);
        } else {
            handleImage(im);
        }
    } else if (message instanceof VideoMessage) {
        handleVideo((VideoMessage) message);
    } else if (message instanceof LocationMessage) {
        handleLocation((LocationMessage) message);
    } else {
        log.warn("Unknown message type: " + message.getClass());
    }
}

// AFTER: pattern matching with guards for sub-conditions
public void routeMessage(Message message) {
    switch (message) {
        case null -> log.warn("Received null message");

        case TextMessage tm when tm.text().startsWith("/")
            -> handleCommand(tm);
        case TextMessage tm
            -> handleText(tm);

        case ImageMessage im when im.size() > MAX_IMAGE_SIZE
            -> rejectLargeImage(im);
        case ImageMessage im
            -> handleImage(im);

        case VideoMessage vm  -> handleVideo(vm);
        case LocationMessage lm -> handleLocation(lm);
    }
}

In every example, the Java 21 version is shorter, clearer, and safer. The switch communicates the dispatching structure. The pattern variables eliminate redundant casts. The guard clauses keep sub-conditions inline rather than nesting more if-else blocks inside each branch.

11. Pattern Matching Comparison Table

Java now offers three primary ways to dispatch on types: instanceof pattern matching, switch pattern matching, and the Visitor pattern. Each has strengths and ideal use cases. Here is a comprehensive comparison to help you choose the right tool for each situation.

Aspect instanceof Pattern Matching switch Pattern Matching Visitor Pattern
Introduced Java 16 (JEP 394) Java 21 (JEP 441) GoF Pattern (any version)
Syntax if (obj instanceof Type t) case Type t -> visitor.visit(element)
Exhaustiveness No — compiler cannot verify all types handled Yes — with sealed types, compiler enforces completeness Yes — interface methods enforce completeness
Null handling Must check separately case null built-in Must check separately
Expression form No — requires separate return/assign Yes — switch can be an expression Depends on implementation
Guard conditions Regular if conditions after check when clause inline Logic inside visit methods
Boilerplate Low Low High — visitor interface, accept methods
Adding new types Must find all if-else chains manually Compiler finds all switches (sealed types) Add method to visitor interface (compiler finds all)
Adding new operations Easy — add new if-else chain Easy — add new switch Add new visitor implementation
Nested patterns Requires nested if-else Supports nested record patterns Not applicable
Best for Simple single-type checks, 1-2 types Multi-type dispatching, domain modeling When operations must be in separate classes

When to Use Each

  • Use instanceof when you are checking one or two types in a simple conditional. No need for a switch when you just need “if this is a String, do X.”
  • Use switch pattern matching when you are dispatching over three or more types, especially with sealed hierarchies. This is the default choice for domain modeling in Java 21+.
  • Use the Visitor pattern when operations need to be defined in separate classes (e.g., plugins, modular systems) or when you need double dispatch. The Visitor pattern separates the operation from the data structure, which pattern matching does not do.

12. Best Practices

Pattern matching for switch is a powerful feature, but like any powerful tool, it can be misused. Here are the best practices I have learned from using it in production code.

1. Order Cases from Most Specific to Most General

The compiler enforces dominance rules, but within non-dominating cases, order still affects which case runs first for guarded patterns. Put your most specific, most restrictive cases first.

// GOOD: specific to general
return switch (obj) {
    case String s when s.isEmpty()  -> "empty";
    case String s when s.isBlank()  -> "blank";
    case String s                   -> "text: " + s;
    case Number n                   -> "number: " + n;
    default                         -> "other";
};

// BAD (conceptually): less restrictive guard before more restrictive
// While this may compile, the semantics might surprise you
return switch (obj) {
    case String s when s.isBlank()  -> "blank";     // isEmpty() is also blank
    case String s when s.isEmpty()  -> "empty";     // This NEVER matches -- empty is always blank
    case String s                   -> "text: " + s;
    default                         -> "other";
};

2. Prefer Sealed Types Over default

Whenever you control the type hierarchy, make it sealed. The default branch hides missing cases. A sealed type with no default turns missing cases into compile errors.

// GOOD: sealed type, no default
public sealed interface Shape permits Circle, Rectangle, Triangle {}

public double area(Shape s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // Compiler verifies this is exhaustive -- no default needed
    };
}

// AVOID: non-sealed type with default that hides missing cases
public double area(Object s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        default          -> 0.0; // Silently returns 0 for triangles, pentagons, etc.
    };
}

3. Keep Cases Simple — Extract Complex Logic

If a case body grows beyond 3-4 lines, extract it into a method. The switch should read as a dispatch table, not contain all your business logic.

// GOOD: switch as a clean dispatch table
public CommandResult execute(Command cmd) {
    return switch (cmd) {
        case CreateUser cu      -> handleCreate(cu);
        case UpdateUser uu      -> handleUpdate(uu);
        case DeleteUser du      -> handleDelete(du);
        case ResetPassword rp   -> handleReset(rp);
    };
}

// AVOID: complex logic inline
public CommandResult execute(Command cmd) {
    return switch (cmd) {
        case CreateUser(var email, var name) -> {
            validateEmail(email);
            validateName(name);
            var user = new User(email, name);
            userRepository.save(user);
            emailService.sendWelcome(email);
            auditLog.record("CREATE", user.id());
            yield new Success("Created", user);
        }
        // ... many more lines per case
    };
}

4. Handle null Explicitly

If your switch selector can be null, handle it with case null. Do not rely on the caller to check for null. Being explicit about null handling documents the contract of your method.

// GOOD: explicit null handling
public String format(Object value) {
    return switch (value) {
        case null       -> "N/A";
        case String s   -> s;
        case Integer i  -> String.format("%,d", i);
        default         -> value.toString();
    };
}

// AVOID: hoping the caller checks for null
public String format(Object value) {
    // Throws NPE if value is null -- surprise!
    return switch (value) {
        case String s   -> s;
        case Integer i  -> String.format("%,d", i);
        default         -> value.toString();
    };
}

5. Use var for Record Patterns When Types Are Obvious

When deconstructing records, var reduces noise if the types are obvious from context. Use explicit types when clarity is needed.

// GOOD: var when types are obvious from the record definition
case Point(var x, var y) -> ...      // Point has int x, int y -- obvious
case Employee(var name, var dept) -> ...  // Clearly String, String

// GOOD: explicit types when working with complex or generic records
case Pair(String key, Integer value) -> ...
case Result>(List employees) -> ...

6. Prefer switch Expressions Over switch Statements

When the switch produces a value, use it as an expression. This prevents accidentally forgetting to return or assign in one branch.

// GOOD: switch expression
String label = switch (status) {
    case Active a   -> "Active since " + a.since();
    case Inactive i -> "Inactive: " + i.reason();
    case Pending p  -> "Pending approval";
};

// AVOID: switch statement when an expression works
String label;
switch (status) {
    case Active a   -> label = "Active since " + a.since();
    case Inactive i -> label = "Inactive: " + i.reason();
    case Pending p  -> label = "Pending approval";
    // Easy to forget 'label =' in one branch
}

Summary of Best Practices

Practice Why
Order cases specific to general Prevents dominance errors and ensures correct matching
Use sealed types, avoid default Compiler catches missing cases when hierarchy changes
Keep cases simple, extract methods Switch reads as a dispatch table, not a logic dump
Handle null explicitly Documents the null contract, prevents surprise NPEs
Use var for obvious record types Reduces noise without sacrificing clarity
Prefer switch expressions Ensures every branch produces a value, prevents omissions
Combine sealed types + records + patterns Maximum type safety with minimum boilerplate

Pattern matching for switch is one of the most significant additions to Java in years. It transforms how you model domains and dispatch on types. Combined with sealed classes and records, it brings Java closer to the expressiveness of functional languages while retaining the type safety and tooling that make Java great for large-scale applications.

March 1, 2026

Java 21 Unnamed Variables and Patterns

1. Introduction

Every Java developer has seen this: you declare a variable because the language requires it, but you never actually use it. The IDE highlights it with a warning. Your linter flags it. Your team’s code review says “unused variable.” So you add a @SuppressWarnings("unused") annotation or name the variable ignored or dummy — all workarounds that add noise to your code without solving the underlying problem.

Java 21 introduces unnamed variables and unnamed patterns using the underscore character _. This feature lets you explicitly declare that a variable or pattern component is intentionally unused. It is not a hack or a workaround. It is a language-level statement that says: “I know this value exists, and I do not need it.”

Think of it like a P.O. Box where you only care about specific mail. Before Java 21, you had to accept every piece of mail and throw away the ones you did not want. Now you can tell the postal service upfront: “Do not even bother delivering those.”

This feature was previewed in Java 21 (JEP 443) and finalized in Java 22 (JEP 456). Since Java 21 is an LTS release and this feature is stable, it is production-ready and worth adopting now.

2. Unnamed Variables

An unnamed variable uses the underscore _ in place of a variable name. The variable is declared and initialized, but you cannot refer to it afterward. This is useful in any context where you are forced to declare a variable that you do not need.

2.1 Enhanced For Loops

Sometimes you need to iterate a certain number of times but do not care about the element itself:

// Before Java 21 -- variable 'item' is never used
List items = List.of("a", "b", "c", "d", "e");
int count = 0;
for (String item : items) { // IDE warning: 'item' is never used
    count++;
}

// Java 21 -- unnamed variable makes intent clear
List items = List.of("a", "b", "c", "d", "e");
int count = 0;
for (String _ : items) { // explicit: we only care about the count
    count++;
}

// Another example: side-effect iteration
for (var _ : dataSource.fetchBatch()) {
    recordsProcessed++;
    progressBar.increment();
}

2.2 Try-With-Resources

Sometimes you open a resource for its side effects (like acquiring a lock) but never reference the resource variable:

// Before Java 21 -- 'lock' variable is never read
try (var lock = lockManager.acquireLock("resource-123")) {
    // do critical work, but never reference 'lock'
    updateSharedState();
}

// Java 21 -- unnamed variable
try (var _ = lockManager.acquireLock("resource-123")) {
    updateSharedState();
}

// Works with multiple resources too
try (var _ = acquireConnection(); var _ = startTransaction()) {
    executeStatements();
}

2.3 Catch Blocks

When you catch an exception but do not need to reference it:

// Before Java 21
try {
    int value = Integer.parseInt(input);
    return Optional.of(value);
} catch (NumberFormatException e) { // 'e' is never used
    return Optional.empty();
}

// Java 21 -- unnamed exception variable
try {
    int value = Integer.parseInt(input);
    return Optional.of(value);
} catch (NumberFormatException _) { // we know it failed, don't need details
    return Optional.empty();
}

// Multiple catch blocks
try {
    return parseAndValidate(data);
} catch (ParseException _) {
    return getDefault();
} catch (ValidationException _) {
    return getDefault();
}

2.4 Lambda Parameters

Many functional interfaces pass arguments that you do not always need. BiFunction, BiConsumer, and Map operations are common examples:

// Before Java 21 -- 'key' is never used
map.forEach((key, value) -> {
    process(value);
});

// Java 21 -- unnamed lambda parameter
map.forEach((_, value) -> {
    process(value);
});

// Before: unused parameters in BiFunction
map.computeIfAbsent("key", k -> computeExpensiveValue());
// 'k' is never used

// Java 21
map.computeIfAbsent("key", _ -> computeExpensiveValue());

// Multiple unused parameters in reduce
map.merge("key", newValue, (existing, incoming) -> existing);
// 'incoming' is never used

// Java 21
map.merge("key", newValue, (existing, _) -> existing);

// Event handlers where you don't need the event object
button.setOnAction(_ -> refreshUI());

2.5 Assignment Statements

When a method returns a value you do not need but you want to be explicit about ignoring it:

// Before Java 21 -- common to just ignore the return value silently
queue.offer(newItem); // returns boolean, silently ignored

// Java 21 -- explicitly acknowledging and discarding the return value
var _ = queue.offer(newItem); // clear intent: return value intentionally ignored

3. Unnamed Patterns

Unnamed patterns use _ in pattern matching contexts — instanceof patterns, switch patterns, and record patterns. They let you match a type or destructure a record without binding the matched value to a variable.

3.1 Switch Patterns

When you match on a type in a switch but do not need the variable:

// Before Java 21
String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "It's an integer"; // 'i' never used
        case String s  -> "It's a string";   // 's' never used
        case Double d  -> "It's a double";   // 'd' never used
        default        -> "Unknown type";
    };
}

// Java 21 -- unnamed patterns
String describe(Object obj) {
    return switch (obj) {
        case Integer _ -> "It's an integer";
        case String _  -> "It's a string";
        case Double _  -> "It's a double";
        default        -> "Unknown type";
    };
}

3.2 Record Patterns

Record patterns let you destructure a record into its components. When you only care about some components, use _ for the rest:

record Point(int x, int y, int z) {}

// Before Java 21 -- forced to name all components
static String describePoint(Object obj) {
    return switch (obj) {
        case Point(int x, int y, int z) -> "x=" + x; // y, z unused
        default -> "not a point";
    };
}

// Java 21 -- unnamed pattern variables for unused components
static String describePoint(Object obj) {
    return switch (obj) {
        case Point(int x, int _, int _) -> "x=" + x;
        default -> "not a point";
    };
}

// More realistic: checking only specific components
record Order(String id, String customer, double total, String status) {}

String getOrderSummary(Object obj) {
    return switch (obj) {
        case Order(var id, _, var total, _) -> "Order " + id + ": $" + total;
        default -> "Unknown";
    };
}

3.3 Nested Record Patterns

Unnamed patterns are especially useful with nested records where you only care about deeply nested values:

record Address(String street, String city, String state) {}
record Customer(String name, Address address) {}
record Order(String id, Customer customer, double total) {}

// Extract just the city from a nested order structure
String getOrderCity(Object obj) {
    return switch (obj) {
        case Order(_, Customer(_, Address(_, var city, _)), _) -> city;
        default -> "unknown";
    };
}

// Extract the customer name and total, ignore everything else
String formatOrder(Object obj) {
    return switch (obj) {
        case Order(_, Customer(var name, _), var total) ->
            name + " ordered $" + total;
        default -> "unknown order";
    };
}

3.4 instanceof Patterns

When you only need to check the type without using the matched variable:

// Before Java 21
if (obj instanceof String s) {
    // only checking type, not using 's'
    System.out.println("Got a string");
}

// Java 21
if (obj instanceof String _) {
    System.out.println("Got a string");
}

// With record patterns in instanceof
record Pair(Object first, Object second) {}

if (obj instanceof Pair(String _, Integer _)) {
    System.out.println("It's a String-Integer pair");
}

4. Multiple Unnamed Variables

One of the most important properties of _ is that you can use it multiple times in the same scope. Unlike named variables, where every name must be unique within a scope, _ can appear as many times as needed. This is what makes it practical for record destructuring and multi-catch scenarios.

// Multiple _ in the same scope -- perfectly valid
record Config(String host, int port, String protocol, boolean ssl, int timeout) {}

// Use _ for everything except host and port
static String connectionString(Config config) {
    return switch (config) {
        case Config(var host, var port, var _, var _, var _) ->
            host + ":" + port;
    };
}

// Multiple _ in try-with-resources
try (var _ = openConnection();
     var _ = beginTransaction();
     var _ = acquireLock()) {
    executeCriticalSection();
}

// Multiple _ in lambda parameters
triFunction.apply(_, _, validParam);

// Multiple _ in for loops (nested)
for (var _ : outerCollection) {
    for (var _ : innerCollection) {
        totalCombinations++;
    }
}

This is a significant advantage over the old workaround of naming variables ignored1, ignored2, etc. The underscore is recognized by the compiler as a special case — it does not occupy a slot in the local variable table the way named variables do.

5. Before vs After: Practical Comparisons

Let us look at six real-world scenarios where unnamed variables clean up your code significantly.

5.1 Map Iteration — Value Only

// BEFORE: unused 'key' variable
Map> tagIndex = buildIndex();
for (Map.Entry> entry : tagIndex.entrySet()) {
    String key = entry.getKey(); // unused
    List values = entry.getValue();
    processValues(values);
}

// AFTER: clean unnamed variable
Map> tagIndex = buildIndex();
tagIndex.forEach((_, values) -> processValues(values));

5.2 Queue Draining

// BEFORE: unused counter variable with @SuppressWarnings
@SuppressWarnings("unused")
BlockingQueue queue = new LinkedBlockingQueue<>();
while (!queue.isEmpty()) {
    Task task = queue.poll(); // sometimes we just want to drain
    // not using 'task' -- just clearing the queue
}

// AFTER: explicit intent to discard
while (!queue.isEmpty()) {
    var _ = queue.poll(); // draining the queue
}

5.3 Sealed Interface Exhaustive Switch

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height, double hypotenuse) implements Shape {}

// BEFORE
double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height(); // t.hypotenuse() unused
    };
}

// AFTER -- cleaner destructuring
double area(Shape shape) {
    return switch (shape) {
        case Circle(var r) -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
        case Triangle(var base, var height, var _) -> 0.5 * base * height;
    };
}

5.4 Stream Reduce with Unused Accumulator

// BEFORE
Map scores = Map.of("Alice", 95, "Bob", 88, "Charlie", 92);
int maxScore = scores.values().stream()
    .reduce(0, (max, score) -> Math.max(max, score)); // both used here

// But in merge functions:
scores.merge("Alice", 100, (oldVal, newVal) -> newVal); // oldVal unused

// AFTER
scores.merge("Alice", 100, (_, newVal) -> newVal); // keep the new value

5.5 Testing Exception Types

// BEFORE -- suppressing warnings or naming 'expected'
@Test
void shouldThrowOnInvalidInput() {
    try {
        parser.parse("invalid data");
        fail("Should have thrown");
    } catch (ParseException expected) {
        // 'expected' variable exists only to avoid the "unused" warning
        assertTrue(true);
    }
}

// AFTER -- clean unnamed catch variable
@Test
void shouldThrowOnInvalidInput() {
    try {
        parser.parse("invalid data");
        fail("Should have thrown");
    } catch (ParseException _) {
        assertTrue(true);
    }
}

5.6 Event Handling with Unused Parameters

// BEFORE -- GUI event handlers with unused event objects
panel.addMouseListener(new MouseAdapter() {
    @Override
    public void mouseClicked(MouseEvent e) { // 'e' unused
        refreshPanel();
    }

    @Override
    public void mouseEntered(MouseEvent e) { // 'e' unused
        highlightPanel();
    }
});

// AFTER -- in contexts where the parameter type allows _
// (Note: method overrides cannot use _ for parameters, but lambdas can)
button.addActionListener(_ -> refreshPanel());

6. Where _ Can Be Used

Here is the complete list of contexts where you can use unnamed variables and patterns:

Context Example Notes
Enhanced for loop variable for (var _ : list) When you only need the loop count
Basic for loop variable for (int _ = 0; condition; update) Rare but valid
Local variable declaration var _ = method(); When discarding a return value
Try-with-resources try (var _ = resource()) When resource is used for side effects only
Catch block parameter catch (Exception _) When exception details are not needed
Lambda parameter (_, value) -> process(value) Very common in Map operations
Switch pattern variable case Integer _ -> ... Type check without binding
Record pattern component case Point(var x, var _) -> x Partial destructuring
instanceof pattern obj instanceof String _ Type check without binding

7. Where _ Cannot Be Used

The underscore is not a wildcard that works everywhere. There are specific restrictions:

Restriction Example Why
Cannot read or reference _ var _ = 5; print(_); — compile error The whole point is that it is unused
Cannot use in method parameters void foo(String _) — not allowed in method declarations Method parameters are part of the API contract
Cannot use as field names private int _ = 5; — compile error Fields are part of class state and should be named
Cannot use for method names void _() {} — compile error Methods must be identifiable
Cannot use in record components record Pair(int _, int y) — compile error Record components define the public API
// These are ALL compile errors:

// 1. Cannot read an unnamed variable
var _ = getValue();
System.out.println(_); // ERROR: cannot reference _

// 2. Cannot use in method declaration parameters
public void process(String _) { } // ERROR

// 3. Cannot use as a field
class MyClass {
    private int _ = 0; // ERROR
}

// 4. Cannot use in record component declarations
record BadRecord(int _, String name) { } // ERROR

// 5. Cannot assign to _ after declaration
var _ = 5;
_ = 10; // ERROR: _ is not a variable you can assign to

Historical note: Before Java 9, _ was a valid identifier. Java 9 made it a reserved keyword to prepare for this feature. If you have legacy code using _ as a variable name, it will not compile on Java 9+. This is extremely rare in practice.

8. Best Practices

8.1 Use _ When the Intent is “Unused”

The primary purpose of _ is to communicate intent. Use it when a variable is structurally required but semantically irrelevant. Do not use it to avoid thinking about a name — if you might need the value later, give it a real name.

// Good -- the key is genuinely irrelevant to this operation
map.forEach((_, value) -> process(value));

// Bad -- you might need the key for error messages later
map.forEach((_, value) -> {
    try {
        process(value);
    } catch (Exception e) {
        log.error("Failed to process entry"); // which entry? you discarded the key!
    }
});

// Better -- keep the key for debuggability
map.forEach((key, value) -> {
    try {
        process(value);
    } catch (Exception e) {
        log.error("Failed to process entry: " + key, e);
    }
});

8.2 Prefer _ Over @SuppressWarnings(“unused”)

If you previously used @SuppressWarnings("unused") or named variables ignored, migrate to _. It is more idiomatic, compiler-enforced, and unambiguous:

// Old pattern -- annotation-based suppression
@SuppressWarnings("unused")
var ignored = someMethod();

// Old pattern -- naming convention
var unused = someMethod();
var dummy = someMethod();

// Java 21 -- clean, idiomatic, compiler-enforced
var _ = someMethod();

8.3 Use _ in Record Patterns for Partial Destructuring

When you only need a few fields from a record, use _ for the rest rather than binding everything:

record Employee(String id, String name, String department, double salary, LocalDate hireDate) {}

// Only need name and department
String getNameAndDept(Employee emp) {
    return switch (emp) {
        case Employee(var _, var name, var dept, var _, var _) -> name + " (" + dept + ")";
    };
}

// When you need most fields, just use the record directly
// -- don't destructure if you'd use most of the components
String fullDescription(Employee emp) {
    // Better to use emp.name(), emp.department(), etc. when using most fields
    return emp.name() + " in " + emp.department() + " earning $" + emp.salary();
}

8.4 Readability Guidelines

Follow these guidelines when deciding between _ and a named variable:

Scenario Use _? Reasoning
Variable is never read after assignment Yes Communicates intent clearly
Catch block where you log the exception No You need the exception for logging
Catch block where you just return a default Yes Exception details are irrelevant
Lambda parameter in simple Map.forEach Yes, for unused params Common pattern, well understood
Lambda parameter in complex multi-line logic Consider keeping the name May need it for debugging
Record destructuring with 1-2 unused fields Yes Clean partial destructuring
Record destructuring with 5+ unused fields Use accessor methods instead Too many _ becomes unreadable

Unnamed variables and patterns are a small feature with a big impact on code clarity. They eliminate an entire category of IDE warnings, remove naming ceremony for variables you never use, and make pattern matching significantly cleaner. The underscore tells your team, your future self, and your IDE: “This value is intentionally discarded.” That is a powerful statement of intent that no @SuppressWarnings annotation can match.

March 1, 2026