A record is a class whose entire purpose is to hold data. You declare the fields once and Java writes the constructor, accessors, equals, hashCode and toString. Use a record when the type is a value with no hidden state; use a class when it has behaviour, mutability, or an identity of its own.
The same type, both ways#
// The class version, roughly 40 lines in practice
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; }
public int y() { return y; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
// The record version
public record Point(int x, int y) {}
Those are equivalent. The record has a canonical constructor taking every component, an accessor per component named after it (no get prefix), and value-based equals, hashCode and toString.
What a record cannot do#
- Change after construction. Every component is final. There are no setters.
- Extend another class. Records implicitly extend
java.lang.Record. They can implement interfaces. - Declare extra instance fields. The state is exactly the components. Static fields are allowed.
- Be extended. Records are implicitly final.
Those are not limitations to work around; they are the definition. If you need any of them, you want a class.
Record or class#
| The type… | Use |
|---|---|
| is a bundle of values, compared by content | record |
| is a DTO, API response, config, event, message | record |
| is a map key or set element | record |
| has fields that change over time | class |
| has identity: two with the same data are different things | class |
| is a JPA entity | class (JPA needs a no-arg constructor and mutability) |
| has real behaviour beyond derived values | usually class |
The one-line test: if two instances with the same field values are interchangeable, it is a record.
Validation and derived values#
A compact constructor runs before the fields are assigned and is where validation and normalisation go:
public record Range(int low, int high) {
public Range { // no parameter list
if (low > high) throw new IllegalArgumentException("low > high");
}
public int length() { return high - low; } // methods are fine
public static Range of(int a, int b) { // static factories are fine
return new Range(Math.min(a, b), Math.max(a, b));
}
}
You can also reassign a parameter inside the compact constructor to normalise it — trimming a string, say — and the normalised value is what gets stored.
Records vs Lombok#
Lombok’s @Data and @Value generate similar boilerplate by rewriting the class at compile time. The comparison:
| Record | Lombok @Value | Lombok @Data | |
|---|---|---|---|
| Part of the language | yes | no, annotation processor | no |
| Immutable | always | yes | no, generates setters |
| Accessor style | x() |
getX() |
getX() |
| Can extend a class | no | yes | yes |
| Extra fields | no | yes | yes |
| Breaks on new JDK releases | never | occasionally, until Lombok updates | same |
| Works with pattern matching | yes, deconstruction | no | no |
For new immutable data types, records win: no dependency, no build-tool configuration, no lag when a new JDK ships. Lombok remains useful for mutable beans, for builders on large classes, and for codebases that already use it everywhere. Mixing the two in one project is fine; use the record where you can and Lombok where you must.
Records in pattern matching#
Records can be deconstructed, which is where they stop being merely convenient and start changing how you write code:
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Square(double side) -> side * side;
};
}
The switch is exhaustive because the interface is sealed, and each case pulls the components straight out. Nested patterns work too: case Line(Point(var x1, var y1), Point(var x2, var y2)).
Records as keys#
record Key(String region, int year) {}
Map<Key, Report> reports = new HashMap<>();
reports.put(new Key("EU", 2026), report);
reports.get(new Key("EU", 2026)); // found - equals and hashCode are by value
A class would need hand-written equals and hashCode for this to work. A record gets them right automatically, which removes an entire category of subtle bugs.
Questions people ask#
Can a record have methods?
Yes: instance methods, static methods, static fields, and nested types. Only extra instance fields and setters are ruled out.
Can I add a second constructor?
Yes, as long as it delegates to the canonical one with this(...).
Are records slower?
No. They compile to ordinary classes. The generated equals and hashCode are at least as efficient as typical hand-written ones.
Which Java version introduced records?
Final in Java 16. Any supported LTS — 17, 21, 25 — has them.
Where to go next#
- OOP in Java — the class side of the comparison.
- Lombok not working on Java 21 or 25 — the breakage records avoid.
- What’s new in Java 25 — the other modern features.