Records in Java
What javac generates for a record, compact constructors for validation, shallow immutability and List.copyOf, the restrictions and why they exist, record patterns, serialization, and when to pick records over Lombok or classes.
On this page
🧾 What is a record?
A record is a class whose whole job is to carry a fixed set of values. You list the values once in the header, record Money(long amountMinor, String currency) {}, and the compiler writes the private final fields, the constructor, the accessors and value-based equals, hashCode and toString for you. Two Money(500, "INR") objects are equal, print as Money[amountMinor=500, currency=INR] and can be used as map keys without a single line of boilerplate.
Records became a standard feature in Java 16 (JEP 395), after previews in Java 14 and 15. Interviewers ask about them for two reasons. First, they're the modern answer to "write me an immutable value class", so a candidate who still hand-writes forty lines of getters looks out of date. Second, the details are subtle: what exactly is generated, why a record holding a List isn't really immutable, and why you can't use one as a JPA entity.
What you’ll learn
- Exactly what
javacgenerates for a record, includingequals,hashCodeandtoStringbootstrapped through **invokedynamic** - How to validate and normalise inputs in a compact constructor, and why every constructor must reach the canonical one
- Why records are only shallowly immutable, and how
List.copyOffixes it - The restrictions (final,
extends Record, no instance fields) and the single rule behind all of them - Nested and local records, and deconstructing records with record patterns in Java 21
- When to choose a record over Lombok
@Valueor a class, and why records can't be JPA entities
For this lesson, we'll focus on what the compiler generates (including the invokedynamic trick behind equals and toString), compact constructors, shallow immutability, the restrictions and why they exist, nested and local records, record patterns from Java 21, serialization, and how records compare with Lombok and ordinary classes.
Below the line (out of scope)
- The
equalsandhashCodecontracts in general: covered in The equals and hashCode contracts - Pattern matching for
switchbeyond record patterns: covered in Pattern matching for switch - Project Valhalla value classes (not in Java 21)
The 30-second answer
A record is a transparent, shallowly immutable data carrier. From record Point(int x, int y) the compiler generates a final class extending java.lang.Record with private final fields, a canonical constructor, accessors x() and y(), and value-based equals, hashCode and toString that are wired through invokedynamic to ObjectMethods.bootstrap. You validate or normalise inputs in a compact constructor. Records can't extend classes or declare extra instance fields, but they can implement interfaces and have static members and methods. Immutability is shallow, so copy mutable inputs with List.copyOf. They're ideal for DTOs, map keys and pattern matching, and wrong for JPA entities.
Interview radar
Asked very often: What does the compiler generate for a record, and how is a record different from a class or Lombok @Value?
Senior rounds probe: How compact constructors validate input, why a record is only shallowly immutable, and why records don't fit JPA entities.
Production angle: A record holding a caller's ArrayList whose contents (and hashCode) change after it was used as a map key.
01Picture it first: a sealed envelope with a window
Think of a record as a window envelope from your bank. The address on the front is the record header: it tells everyone exactly what's inside, and nothing else can be stuffed in later. Anyone can read the contents through the window (the accessors), but nobody can swap them once it's sealed (the final fields). Two envelopes with the same contents are treated as the same letter.
The "same contents means same letter" part is the key mental shift. An ordinary class has identity: two new User(...) objects are different users unless you write equals to say otherwise. A record says up front that it is its data. That's why Brian Goetz describes records as "nominal tuples": a tuple of values with a name and a type attached.
Here's what you write, next to what javac produces from it:
On the left a one-line record declaration; on the right the class javac generates: final, extends Record, private final fields, canonical constructor, accessors, and equals, hashCode and toString delegating through invokedynamic to ObjectMethods.bootstrap
02What the compiler generates under the hood
Try it yourself first
Before reading on, list every member you think javac adds to this one-line record. Then guess what the last three lines print.
public record Money(long amountMinor, String currency) {}
Money a = new Money(500, "INR");
Money b = new Money(500, "INR");
System.out.println(a == b); // ?
System.out.println(a.equals(b)); // ?
System.out.println(a); // ?
false
true
Money[amountMinor=500, currency=INR]
Run javap -p Money.class and you'll see the full list:
public final class Money extends java.lang.Record {
private final long amountMinor;
private final java.lang.String currency;
public Money(long, java.lang.String);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public long amountMinor();
public java.lang.String currency();
}
Notice four things:
- The class is
finaland extendsjava.lang.Record. That's why a record can't extend anything else: Java has single inheritance and the slot is taken. - Every component becomes a
private finalfield plus a public accessor with the same name. It'samountMinor(), notgetAmountMinor(). Frameworks that look for JavaBean getters needed updates (Jackson added record support in 2.12). - The canonical constructor takes the components in declaration order and assigns them.
a == bis stillfalse: records are objects with their own identity, they don't use it forequals. equals,hashCodeandtoStringexist, but there's no Java code for them in the class file. Let's look at that next.
equals, hashCode and toString through invokedynamic
If you run javap -c on Money, the toString body is two instructions:
aload_0
invokedynamic #0:toString:(LMoney;)Ljava/lang/String;
areturn
The invokedynamic call site points to the bootstrap method java.lang.runtime.ObjectMethods.bootstrap, and the class file passes it the record class plus method handles for each field. The first time the call site runs, the bootstrap builds a method handle chain that reads each component and combines them. After that, the JVM links the call site permanently and the JIT inlines it like ordinary code.
Why not generate bytecode directly, the way Lombok or your IDE does? Two reasons. The class file stays small, because the logic lives in the JDK, not in every record. And the JDK can improve the implementation without recompiling your code, the same idea that string concatenation uses since Java 9 with StringConcatFactory.
The semantics are specified in the Record javadoc. Reference components are compared with Objects.equals. Primitive components are compared as if by the wrapper's compare method, so Double.NaN equals Double.NaN inside a record, unlike with ==. The hashCode combines component hashes, but the exact formula is unspecified, so never persist it.
Compact constructors: validate and normalise
A record's canonical constructor is generated for you, but you can take control of it. The usual way is a compact constructor: no parameter list, and the field assignments happen automatically at the end.
Try it yourself first
Before reading on: where would you put a check that amountMinor isn't negative and that the currency is always upper case?
public record Money(long amountMinor, String currency) {
public Money {
if (amountMinor < 0) {
throw new IllegalArgumentException("amount must be >= 0, got " + amountMinor);
}
Objects.requireNonNull(currency, "currency");
currency = currency.toUpperCase(Locale.ROOT); // reassigns the parameter, not the field
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(Math.addExact(amountMinor, other.amountMinor), currency);
}
public static Money zero(String currency) {
return new Money(0, currency);
}
}
System.out.println(new Money(250, "inr")); // prints: Money[amountMinor=250, currency=INR]
Notice three rules:
- The parameters (
amountMinor,currency) are implicit, and you may reassign them. Whatever value they hold at the end is what goes into the fields. - You can't write
this.currency = ...in a compact constructor. It's a compile error, because the compiler assigns the fields after your body runs. - The canonical constructor must be at least as accessible as the record. A
public recordwith aprivatecanonical constructor doesn't compile, so a record can't hide its constructor behind a factory the way a normal class can.
You can also add other constructors, but each must start by calling this(...), so every path goes through the canonical one. That's the guarantee: if your validation is in the canonical constructor, no Money can exist without passing it.
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Shallow immutability and defensive copies
- The rules, and why each one exists
- Real world: DTOs, map keys and the JPA trap
- Advanced: serialization, reflection and cost
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal