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.




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

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *