Topic 4.9
Records
In one line
A record is a short way to declare a class that is just a carrier for immutable data. From one line, record Point(int x, int y) {}, Java generates private final fields, a constructor, accessor methods, and correct equals, hashCode and toString.
Think of it like this
A printed train ticket. It lists the passenger, the train, the seat and the date, and once printed it never changes. Two tickets with exactly the same details are interchangeable. You don't need any behaviour from a ticket; it carries data. A record is Java's built-in shape for exactly this kind of thing.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Record
- A special kind of class, declared with
record, whose job is to carry a fixed set of values. Java writes the boilerplate for you. - Component
- One of the values listed in a record's header, like
xandyinrecord Point(int x, int y). - Canonical constructor
- The constructor whose parameters match the components exactly. Java generates it unless you write it.
- Compact constructor
- A canonical constructor written without a parameter list, used to check or adjust the values before the fields are set.
- Accessor
- A method that returns a component's value. In a record it has the component's name:
p.x(). - Boilerplate
- Repetitive code you have to write but that carries no new idea, like hand-written getters,
equalsandhashCode. - Shallow immutability
- The fields can't be reassigned, but objects they point to might still be changeable.
- Record pattern
- A Java 21 pattern like
Point(int x, int y)that checks an object is aPointand pulls out its components in one step.
Step by step
01One line instead of fifty
Topic 4.8's Point needed two fields, a constructor, two getters, equals, hashCode and toString: around 40 lines, and every one a chance for a bug (forgetting a field in hashCode, say).
record Point(int x, int y) {} gives you all of it, correct by construction. javap -p shows exactly what the compiler generated.
02Using a record
Create it with new, like any class. Read components with accessor methods: p.x(). There are no setters; to "change" a record you create a new one (new Point(p.x() + 1, p.y())), often through a small with... method you write yourself.
Two records with equal components are equals and have equal hash codes, so they work as HashMap keys and in HashSets straight away.
record Point(int x, int y) {
Point withX(int newX) { // records can have methods
return new Point(newX, y);
}
}
Point p = new Point(1, 2);
System.out.println(p); // Point[x=1, y=2]
System.out.println(p.x()); // 1
Point q = p.withX(5); // p is unchanged03Validate with a compact constructor
A compact constructor has the record's name and no parentheses. Inside it, the component names refer to the parameters. You can check them, throw, or reassign them. After your code, the compiler adds this.lo = lo; this.hi = hi; automatically.
You can't assign the fields yourself in a compact constructor (this.lo = lo is an error there): that's what keeps the "exactly one assignment" rule simple.
record Range(int lo, int hi) {
Range { // compact canonical constructor
if (lo > hi) {
throw new IllegalArgumentException("lo " + lo + " > hi " + hi);
}
} // fields assigned here, automatically
Range(int hi) { // any other constructor...
this(0, hi); // ...must delegate to the canonical one
}
int length() { return hi - lo; }
}04What a record can and can't contain
Allowed in the body: instance methods, static fields, static methods, nested types, constructors that delegate, and overrides of the accessors, equals, hashCode or toString.
Not allowed: extra instance fields (field declaration must be static), instance initialiser blocks, extends (records already extend Record), or abstract. Records are always final, so class Big extends Range fails with cannot inherit from final Range.
A record declared inside another class is implicitly static, and since Java 16 you can declare a local record inside a method, which is perfect for an intermediate result in a calculation.
05Shallow immutability: copy mutable components
record Order(String id, List<String> items) {} has a final field items, but the list itself can still be changed by whoever passed it in. The record isn't truly immutable.
Fix it in the compact constructor: items = List.copyOf(items); makes an unmodifiable copy (and rejects null elements). Now neither the caller nor anyone calling order.items() can change the record's contents.
Arrays are worse: the generated equals compares array components by reference, and you can't make an array unmodifiable. Prefer List components, or override equals/hashCode/toString with Arrays.equals/Arrays.hashCode/Arrays.toString.
06Record patterns: take a record apart (Java 21)
Because a record's state is exactly its components, Java can deconstruct it. if (obj instanceof Point(int x, int y)) checks the type and binds x and y in one step. Patterns nest: Line(Point(var x1, var y1), Point p2).
With switch pattern matching (Java 21) and sealed interfaces (Topic 5.11), records become Java's way of writing algebraic data types: each case of a shape, an expression or a message is a record, and the compiler checks you handled them all.
static String describe(Object o) {
return switch (o) { // Java 21
case Point(int x, int y) when x == y -> "on the diagonal at " + x;
case Point(int x, int y) -> "point " + x + "," + y;
default -> "something else";
};
}Try it yourself
- 1
Add a method and a static factory
In the first example, add
static Point origin() { return new Point(0, 0); }to the record and use it for the map lookup. Predict the output: does anything change? - 2
Break the defensive copy
In the compact constructor example, delete
items = List.copyOf(items);. Predict theOrderline and the last line, then run. Two things change. - 3
Fix the array record
In the array trap example, override
equalsandhashCodeinScoresusingArrays.equalsandArrays.hashCode(cast the other object witho instanceof Scores s). Predict the first line again.
Code & diagrams
Expected output
Point[x=3, y=4]
x = 3, y = 4
a == b: false, a.equals(b): true
same hash: true
distance: 5.0
a still Point[x=3, y=4], c is Point[x=10, y=4]
set size: 1
lookup: originExpected output
Range[lo=2, hi=5]
Range[lo=0, hi=7]
rejected: lo 5 > hi 2
Order[id=ORD-7, items=[tea, samosa]]
items() is unmodifiableThe generated equals uses the array's own equals, which is identity. A List component compares by content.
Expected output
array records equal? false
arrays equal by content? true
toString starts with: Scores[values=[
list records equal? true
ScoreList[values=[1, 2, 3]]Expected output
point on the diagonal at 2
point 1,5
vertical line at x=3
line from Point[x=0, y=0] to Point[x=4, y=4]
unknown: hello
sum of components: 15Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Try to change a record's field
Inside the record, add void move() { x = 5; }.
Break #2
Add an instance field to a record
Write record Point(int x, int y) { int z; }.
Break #3
Run code before this(...) in a record constructor
Write a non-canonical constructor Range(int hi) { System.out.println("x"); this(0, hi); }.
Myth vs fact
Myth
Records are fully immutable.
Fact
They're shallowly immutable: the fields are final, but a mutable component like an ArrayList or an array can still change. Copy such components in the compact constructor.
Myth
Records have getX() getters.
Fact
Accessors are named after the components: x(). Frameworks that only understand JavaBeans getX() conventions needed updates to support records (most major ones, like Jackson 2.12+, now do).
Myth
Records are just Lombok's @Data built in.
Fact
@Data makes mutable classes with setters. Records are final, immutable, transparent carriers whose shape the language understands, which is what enables record patterns and safer serialization.
Myth
You can't add methods or constructors to a record.
Fact
You can add instance and static methods, static fields, extra constructors (delegating to the canonical one) and implement interfaces. Only extra instance fields and inheritance are forbidden.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The generated
equals,hashCodeandtoStringaren't written out as bytecode loops: they'reinvokedynamiccall sites bootstrapped byjava.lang.runtime.ObjectMethods, which builds efficient method handles at first call. ThehashCodealgorithm is deliberately unspecified, so never persist or print record hash codes. - ▸
Record serialization (Java 16) is special: deserialization always goes through the canonical constructor, so compact-constructor validation runs on untrusted input too. Ordinary classes are rebuilt without running constructors, which has been a long source of security bugs.
- ▸
Reflection exposes the shape:
Point.class.isRecord()andgetRecordComponents()return each component's name, type and accessor. Libraries like Jackson use this to map JSON to records without setters. - ▸
Since records are final with final fields, the JIT can trust their fields more aggressively, and they're designed to migrate towards Project Valhalla's value classes, where identity-free objects may be flattened into arrays and other objects.
Remember this
- 1
record Point(int x, int y) {}declares a record class with two components,xandy. The compiler generates: aprivate finalfield for each component; a canonical constructorPoint(int x, int y)that assigns them; a public accessor for each, named after the component (x(), notgetX()); andequals,hashCodeandtoStringbased on all components.toStringlooks likePoint[x=1, y=2]. - 2
Records arrived as a preview in Java 14, again in 15, and became a standard feature in Java 16 (JEP 395). A record is implicitly
final(no subclasses), implicitly extendsjava.lang.Record, and can't extend anything else. It can implement interfaces, declare static fields and methods, add instance methods, and override the generated methods. - 3
A record can't declare extra instance fields: all state must be in the header. That's what makes the generated
equalsandhashCodecomplete. Writingint z;in the body givesfield declaration must be static. - 4
To validate or normalise, write a compact constructor:
Point { if (x < 0) throw ...; }, with no parameter list. It runs before the fields are assigned, and you can reassign the parameters (name = name.trim();); the compiler assigns the fields at the end. Any other constructor you add must delegate to the canonical one withthis(...). - 5
Records are shallowly immutable. The fields are
final, but if a component is a mutable object (aList, an array), its contents can still change. Copy it in the compact constructor (items = List.copyOf(items);). Arrays are a trap: the generatedequalscompares array components with==-style reference equality, so two records holding equal arrays aren't equal. - 6
Records shine as value objects (money, coordinates, ranges), as
HashMapkeys (Topic 4.8's contract is generated for you), as return values carrying several results, and as messages and DTOs. With record patterns (Java 21) you can take them apart ininstanceofandswitch:if (o instanceof Point(int x, int y)).
Explain it without notes
What exactly does the compiler generate for record Point(int x, int y) {}?
What is a compact constructor, and what can and can't you do in it?
Why can't a record declare additional instance fields or extend another class?
Explain shallow immutability in records and the array-component pitfall.
When would you choose a record, and when a normal class?
Practice
Declare record Rgb(int r, int g, int b) that rejects values outside 0..255 and has a method hex() returning #RRGGBB in upper case. Print new Rgb(255, 165, 0).hex() and a rejected colour.
Use a local record inside main to find the longest word in {"tea", "samosa", "chai"} together with its length, and print the record.
Declare record Student(String name, int marks) and use a TreeMap<String, Student> keyed by name to store three students, then print the map.
Trade-offs
- ↔
Records remove boilerplate and bugs, but they fix your API to the component list: the accessors, constructor and
equalsare public promises, so you can't later hide or rename a component without breaking callers. Use a class when the representation should stay private. - ↔
Records are immutable, so every "change" allocates a new object. That's cheap for small values and makes sharing between threads safe, but a hot loop that updates a big record millions of times may prefer a mutable class.
- ↔
Some frameworks (older JPA providers, JavaBeans-based tools) expect no-arg constructors and setters, so records don't fit everywhere. They're ideal for DTOs, API payloads, keys and messages.
Done when you can
Done when you can list everything a record generates, and the
toStringformat.Done when you can write a compact constructor that validates and normalises.
Done when you know what a record can't contain and why.
Done when you copy mutable components and avoid array components.
Done when you can deconstruct records with record patterns (Java 21).
Done when you can choose between a record and a class for a given type.