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.
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.
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 Listitems = 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(); }
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();
}
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();
}
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());
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
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.
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";
};
}
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";
};
}
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";
};
}
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");
}
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.
Let us look at six real-world scenarios where unnamed variables clean up your code significantly.
// 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));
// 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
}
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;
};
}
// BEFORE Mapscores = 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
// 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);
}
}
// 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());
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 |
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.
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);
}
});
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();
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();
}
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.