Java Records Complete Guide: Patterns, JPA, Jackson and Pitfalls
What is a Java record?
A Java record is a class that declares its data in its header and gets its constructor, accessors, equals, hashCode and toString from the compiler.
public record Point(int x, int y) {}var point = new Point(1, 2);
System.out.println(point);
System.out.println(point.x());Point[x=1, y=2]
1Records are a language feature, final since Java 16. The JEP that delivered them calls them "transparent carriers for immutable data". Transparent means that a record's state is its API: its accessors expose everything it holds, and you can rebuild an equal record from them. Immutable needs a qualifier: shallowly immutable, as its fields are final, but an object they point to can still change.
That qualifier is behind the most common bug with records, and it's only one of several the syntax won't warn you about, which is why this guide spends more time on pitfalls than on syntax. I'll cover the language up to pattern matching, and then what happens when a record meets the tools you use every day: Jackson, Hibernate, Spring Data, Lombok and Kotlin.
Note
Every output in this guide comes from a real run on Temurin JDK 25.0.5, the latest LTS as of November 2026. The libraries are Jackson 2.22.4 and 3.2.4, Hibernate ORM 7.4.15, Hibernate Validator 9.1.6, Spring Boot and Spring Data JPA 4.1.2, Lombok 1.18.48 and Kotlin 2.4.21. The Spring Data runs use the versions Spring Boot manages, which include Hibernate ORM 7.4.13. Each feature names the Java version it needs.
Why records?
Before records, carrying two integers around properly took a class like this one.
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 other) {
if (!(other instanceof Point)) {
return false;
}
Point that = (Point) other;
return x == that.x && y == that.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}The problem with that class isn't only the typing. Every line of it is a place to forget a field when the class gains a new one, and a reader has to go through all of it to confirm it does nothing special.
I've seen what that costs on a real project.
Eclipse JKube had hundreds of hand-written builder methods, and nobody looks after boilerplate.
Some builders fell behind their classes, and some had typos that set an argument on the wrong field.
One class even had a field spelled exludes, with a matching getExludes().
That's why we moved those classes to Lombok in 2020, even though I had always been a detractor of that library.
A record removes much of that code from the language itself: its accessors, equals, hashCode and toString can't fall behind its fields.
The intent fits in one line: this type is its data, and nothing else.
Less boilerplate is the benefit people mention first, and it's the smaller one. The bigger one is that the compiler knows a record's shape, so record patterns can take it apart again. No class generated by Lombok or your IDE can do that. I'll compare them in Record vs class vs Lombok vs Kotlin data class.
Java records timeline
Records took three releases to become final, and the features built on them took several more.
| Java | Released | JEP | What changed |
|---|---|---|---|
| 14 | March 2020 | JEP 359 | Records, first preview |
| 15 | September 2020 | JEP 384 | Second preview: canonical constructor access rules, no field assignment in a compact constructor |
| 16 | March 2021 | JEP 395 | Records final; inner classes may declare member records |
| 17 | September 2021 | JEP 409 | Sealed classes final |
| 19 | September 2022 | JEP 405 | Record patterns, first preview |
| 20 | March 2023 | JEP 432 | Record patterns, second preview |
| 21 | September 2023 | JEP 440, JEP 441 | Record patterns and pattern matching for switch final |
| 22 | March 2024 | JEP 456 | Unnamed variables and patterns (_) final |
| 25 | September 2025 | JEP 513 | Flexible constructor bodies final |
Java 17 is the first LTS with records and sealed classes, and Java 21 the first with record patterns.
As for what's next, as of November 2026 two JEPs are worth knowing about.
JEP 401, Value Objects is integrated for JDK 28, due March 2027, as a preview feature, and it calls records "often good candidates to be value classes".
JEP 468, Derived Record Creation is the proposal for with expressions, and it's still a Candidate that no release targets.
I come back to it in Builders and withers.
Creating a record
A record declaration has a name, a list of components in parentheses, and a body, which is often empty.
Each component becomes a private final field and a public accessor, so that one line generates a full class.
To see it, compile the record and ask javap what's inside.
javac Point.java
javap -p PointCompiled from "Point.java"
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int, int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int x();
public int y();
}Four things in that output explain most of how records behave:
- The class is
final, so nothing can extend it. - It extends
java.lang.Record, so it can't extend anything else. - Its fields are
private final, so they can't be reassigned after construction. - The constructor takes every component, in declaration order: it's called the canonical constructor.

The accessors are named after the component: x(), not getX().
That's deliberate, and it's the first thing to break when a record replaces a JavaBean, because any tool that looks for get prefixes won't find one.
I'll get back to that in Migrating a class to a record.
Constructors, validation and defensive copies
The compiler writes a constructor for you, but you'll often want to change what it does. This is where a record stops being a one-liner, and where most of its bugs are written or avoided.
The canonical constructor
You can write the canonical constructor by hand, with the same parameter names and types as the components. It must assign every field, and it must be at least as accessible as the record itself.
public record Point(int x, int y) {
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}That constructor does nothing the generated one doesn't, so you'll rarely write it.
The compiler rejects a less accessible version, for example if you drop public from that constructor:
Point.java:2: error: invalid canonical constructor in record Point
Point(int x, int y) {
^
(attempting to assign stronger access privileges; was public)
1 errorThe compact constructor
A compact constructor is the form you'll actually use. It has no parameter list: the parameters are implicit, and the fields are assigned for you when its body ends. You use it to check or normalize the arguments, and to normalize you reassign the parameter, not the field.
public record Name(String value) {
public Name {
value = value.strip();
}
}new Name(" Ada Lovelace ").value() returns Ada Lovelace.
The assignment to the field happens after the body, so writing this.value inside it is a compile error:
Name.java:3: error: cannot assign a value to final variable value
this.value = value.strip();
^
1 errorValidating a record
The compact constructor is also the natural place for validation, because a constructor that throws means an invalid instance never exists.
public record Range(int start, int end) {
public Range {
if (start > end) {
throw new IllegalArgumentException("start > end: " + start + " > " + end);
}
}
}new Range(5, 1) throws IllegalArgumentException: start > end: 5 > 1.
Bean Validation works differently, and the difference surprises people.
The annotations go on the components, but they do nothing until a Validator runs, so constructing the record always succeeds.
public record User(@NotBlank String name, @Email String email) {}var user = new User(" ", "not-an-email");
System.out.println(user);
try (var factory = Validation.buildDefaultValidatorFactory()) {
factory.getValidator().validate(user).stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.forEach(System.out::println);
}User[name= , email=not-an-email]
email: must be a well-formed email address
name: must not be blankThe invalid user exists, and it only gets reported because something asked.
In a Spring MVC application that something is @Valid on a controller parameter.
Warning
A custom constraint on a record component needs ElementType.FIELD in its @Target.
Without it, Validator.validate(), which is what @Valid triggers, silently ignores the constraint.
I ran the same constraint with three targets on Hibernate Validator 9.1.6: FIELD reported the violation, while METHOD and PARAMETER alone reported none and raised no error.
Use the compact constructor for what must hold for every instance, and Bean Validation for input you want to report on, with messages, field by field.
Defensive copies
This is the most important section of this guide.
A record's fields are final, but final only protects the reference.
If a component is mutable, the record shares it with everyone who holds it.
public record Team(String name, List<String> members) {}var members = new ArrayList<>(List.of("Alice", "Bob"));
var team = new Team("core", members);
members.add("Mallory");
team.members().add("Eve");
System.out.println(team);Team[name=core, members=[Alice, Bob, Mallory, Eve]]Both changes reached the record: one through the list the caller kept, the other through the accessor. The fix is a copy in the compact constructor.
public record Team(String name, List<String> members) {
public Team {
members = List.copyOf(members);
}
}With this version, members.add("Mallory") no longer reaches the record, and team.members().add("Eve") throws UnsupportedOperationException.
The team stays Team[name=core, members=[Alice, Bob]].
List.copyOf returns an unmodifiable list, so one copy covers both directions, and it's the right default.
It also copies the list, not its elements: if those are mutable, the record is only as immutable as they are.
The catch is null, which List.copyOf rejects with a NullPointerException, both for a null element and for a null list.
A list that may hold a null needs Collections.unmodifiableList(new ArrayList<>(members)), and an optional list needs a default:
public record Team(String name, List<String> members) {
public Team {
members = members == null ? List.of() : List.copyOf(members);
}
}Caution
Collections.unmodifiableList(members) on its own is not a copy.
It's a read-only view of the caller's list, so the accessor can't change it, but the caller still can:
a Team built this way printed members=[Alice, Bob, Mallory] after members.add("Mallory").
Arrays are worse, as they have no unmodifiable form, so a copy in the constructor isn't enough: the accessor has to copy too. A record also compares arrays by reference, so two records with equal arrays aren't equal.
public record Scores(int[] values) {}var scores = new Scores(new int[] {1, 2, 3});
scores.values()[0] = 99;
System.out.println(Arrays.toString(scores.values()));
System.out.println(new Scores(new int[] {1, 2}).equals(new Scores(new int[] {1, 2})));[99, 2, 3]
falseFixing it properly takes everything the record was supposed to save you from.
public record Scores(int[] values) {
public Scores {
values = values.clone();
}
@Override
public int[] values() {
return values.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof Scores that && Arrays.equals(values, that.values);
}
@Override
public int hashCode() {
return Arrays.hashCode(values);
}
@Override
public String toString() {
return "Scores[values=" + Arrays.toString(values) + "]";
}
}With that many overrides, I'd rather use a List<Integer> with List.copyOf, or wrap the array in a small class with its own behavior.
The same goes for any mutable class, such as java.util.Date: choose an immutable type like Instant for the component instead of copying it everywhere.
Static factories and other constructors
A record can declare static methods, so a named factory is a clean way to build one from other inputs.
public record Money(BigDecimal amount, Currency currency) {
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), Currency.getInstance(currency));
}
public static Money zero(String currency) {
return of("0", currency);
}
}System.out.println(Money.of("19.99", "EUR"));
System.out.println(Money.zero("USD"));Money[amount=19.99, currency=EUR]
Money[amount=0, currency=USD]A factory has a name that explains what it's for, it can parse or convert before calling the constructor, and it can return a shared instance. I prefer one over an extra constructor whenever the arguments need any work first. A non-canonical constructor is the alternative, and it has one strict rule: it has to delegate to another constructor of the record.
public record Range(int start, int end) {
public Range(int end) {
this(0, end);
}
}new Range(10) prints Range[start=0, end=10].
Flexible constructor bodies
Say you want to build an Interval from a string such as "10..20".
You'd expect to split the text first and then call the canonical constructor, but until Java 25 you couldn't: this(...) had to be the very first statement of a constructor.
All the work had to happen inside that call's arguments, which usually meant a static helper, here called twice:
public record Interval(int start, int end) {
public Interval(String text) {
this(parse(text)[0], parse(text)[1]);
}
private static int[] parse(String text) {
String[] bounds = text.split("\\.\\.");
if (bounds.length != 2) {
throw new IllegalArgumentException("expected start..end: " + text);
}
return new int[] {Integer.parseInt(bounds[0]), Integer.parseInt(bounds[1])};
}
}JEP 513, final in Java 25, lifts that restriction, so the code can read in the order you'd explain it: check the input, then call this(...).
public record Interval(int start, int end) {
public Interval(String text) {
String[] bounds = text.split("\\.\\.");
if (bounds.length != 2) {
throw new IllegalArgumentException("expected start..end: " + text);
}
this(Integer.parseInt(bounds[0]), Integer.parseInt(bounds[1]));
}
}new Interval("10..20") prints Interval[start=10, end=20], and new Interval("20") throws IllegalArgumentException: expected start..end: 20.
There are limits: the code before this(...) can prepare the arguments, but it can't touch the record being built, so no accessors, no instance methods, and no setting fields.
Only a non-canonical constructor can call this(...), so this does nothing for compact and canonical constructors.
For this example I'd still write a static factory, as Interval.parse("10..20") says that text is being parsed.
Flexible constructors help when the constructor has to stay.
Components, methods and limitations
A record body can hold more than you might expect. Instance methods, static methods, static fields and nested types are all allowed, and a record can implement interfaces.
public record Point(int x, int y) {
public static final Point ORIGIN = new Point(0, 0);
public double distanceTo(Point other) {
return Math.hypot(x - other.x, y - other.y);
}
}new Point(3, 4).distanceTo(Point.ORIGIN) returns 5.0.
What it can't have is more state. Any instance field is rejected, with a hint that says what to do instead, and instance initializers are rejected too:
Point.java:2: error: field declaration must be static
private int z;
^
(consider replacing field with record component)
1 errorThere are a few more rules.
An explicit accessor must be public, and it can't declare a throws clause.
A record can't be abstract, sealed or non-sealed.
Eight names are illegal for a component, because they would clash with Object's methods: clone, finalize, getClass, hashCode, notify, notifyAll, toString and wait.
Are Java records immutable?
Shallowly, yes.
You can't reassign a record's fields, but nothing stops a mutable component from changing, as Defensive copies shows.
A record is deeply immutable only when all its components are: primitives, String, Instant, other such records, or collections from List.copyOf that hold immutable elements.
Can a record extend a class?
No.
A record's superclass is always java.lang.Record, so the grammar has no extends clause, and the compiler stops at the word:
Point.java:3: error: '{' expected
public record Point(int x, int y) extends Base {}
^
1 errorNothing can extend a record either, since it's final: cannot inherit from final Point.
A record can implement any number of interfaces, and that's how you share behavior between records: with default methods.
Can a record have a no-arg constructor?
Yes, as long as it delegates to another constructor, so that all the fields get a value.
public record Point(int x, int y) {
public Point() {
this(0, 0);
}
}new Point() prints Point[x=0, y=0].
A no-arg constructor that doesn't delegate is rejected: constructor is not canonical, so it must invoke another constructor of class Point.
That won't help a framework that needs a no-arg constructor in order to create an empty object and set its fields afterwards. The fields are final, so there's nothing it can set later, and that's why a record can't be a JPA entity.
equals, hashCode and toString
The generated equals compares every component: Objects.equals-style for references and the wrapper's compare method for primitives.
So new Point(1, 2).equals(new Point(1, 2)) is true, but floating-point components have a behavior of their own.
public record Measure(double value) {}System.out.println(new Measure(Double.NaN).equals(new Measure(Double.NaN)));
System.out.println(new Measure(0.0).equals(new Measure(-0.0)));
System.out.println(Double.NaN == Double.NaN);
System.out.println(0.0 == -0.0);true
false
false
trueThe two records answer the opposite of ==, and that's the right trade-off for equals: a NaN that isn't equal to itself would be lost in a HashSet forever.
Equality also compares BigDecimal components with their own equals, which takes the scale into account: Money with 1.50 and with 1.5 aren't equal.
You can override any of the three methods when you need to, but the API docs set limits on what you can rely on from the generated ones.
The hashCode algorithm is unspecified, and it may change between runs, so never persist it.
The toString format is also subject to change, and the docs say it shouldn't be parsed to recover values.
Advanced records
A few features deserve a closer look, some of them more useful than their place at the end of a tutorial suggests.
Generics work as they do in a class.
public record Pair<A, B>(A first, B second) {}new Pair<>("answer", 42) prints Pair[first=answer, second=42].
Annotations on components propagate to every place their @Target allows: the field, the accessor, the constructor parameter and the record component itself.
That's why one annotation can work for several libraries, and why the FIELD warning above exists.
Here's what reflection finds for a component annotated with four annotations, one per target:
component: [@OnComponent()]
field: [@OnField()]
accessor: [@OnMethod()]
parameter: [@OnParameter()]There are two exceptions to remember: an annotation doesn't propagate to an accessor you write by hand, nor to a canonical constructor you write in long form.
With an explicit name() method, the same run printed accessor: [], so you'd have to repeat it there.
A compact constructor keeps its parameter annotations.
Comparable is an ordinary interface, so a record can implement it, and a static Comparator keeps the method short.
public record Version(int major, int minor) implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major).thenComparingInt(Version::minor);
@Override
public int compareTo(Version other) {
return ORDER.compare(this, other);
}
}var versions = new ArrayList<>(List.of(new Version(2, 0), new Version(1, 9), new Version(1, 10)));
Collections.sort(versions);
System.out.println(versions);[Version[major=1, minor=9], Version[major=1, minor=10], Version[major=2, minor=0]]Local and nested records are the most underrated of the bunch.
You can declare one inside a method, to name an intermediate result in a stream instead of passing around Map.Entry or Object[].
int threshold = 6;
record Scored(String name, int score) {}
System.out.println(Stream.of("record", "pattern", "sealed")
.map(name -> new Scored(name, name.length()))
.filter(s -> s.score() > threshold)
.map(Scored::name)
.toList());[pattern]Nested and local records are implicitly static, so a record can't capture what surrounds it.
Using threshold from a method of Scored fails with non-static variable threshold cannot be referenced from a static context.
The lambda above can use it because the lambda isn't part of the record.
Reflection has dedicated support: Point.class.isRecord() returns true, and getRecordComponents() lists the components in declaration order.
For Point, printing each name and type gives [x:int, y:int].
Libraries such as Jackson rely on this to find the canonical constructor without any annotation.
Record serialization
Java serialization treats records differently from any other class, and more safely.
A record isn't serializable by default: like any class, it has to implement Serializable.
A serializable record is rebuilt through its canonical constructor, so validation runs on deserialization too, and a byte stream can't produce a record that the constructor would reject.
Custom writeObject and readObject methods are ignored, while writeReplace and readResolve are honored.
The matching of serialVersionUID is also waived, so a mismatch that would break a class doesn't affect a record.
To see all of that at once, I serialized a plain class and two Temperature records, one of them at -300 degrees, with serialVersionUID = 1L in both classes.
Then I changed it to 2L in both, gave Temperature the code below, and read everything back.
public record Temperature(int celsius) implements Serializable {
private static final long serialVersionUID = 2L;
public Temperature {
if (celsius < -273) {
throw new IllegalArgumentException("below absolute zero: " + celsius);
}
}
private void readObject(ObjectInputStream in) {
System.out.println("never printed");
}
}Temperature[celsius=21]
java.io.InvalidObjectException: below absolute zero: -300
java.io.InvalidClassException: Plain; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2The first record survived the mismatch, the second failed the new validation, and the plain class failed on the mismatch.
readObject never ran.
For the classes that still need one, I explain how to generate a serialVersionUID in IntelliJ in another post.
Records and sealed interfaces
Records can't extend anything, but they can implement interfaces, and since Java 17 that interface can be sealed. A sealed interface lists the only types allowed to implement it, and a record is the ideal one because it's final by definition.
public sealed interface Shape permits Circle, Rectangle {}
record Point(int x, int y) {}
record Circle(Point center, int radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}This pair is how you model "one of these things, each with its own data" in Java, what other languages call a sum type or a tagged union.
A Shape is a Circle or a Rectangle, and the compiler knows that's all it can be.
On its own that's little more than documentation, and it pays off with the feature of the next section.
Record patterns
A record pattern takes a record apart: it checks the type and binds its components to variables in one step. Record patterns are final since Java 21 (JEP 440).
Record patterns with instanceof
They extend the type patterns I cover in Pattern Matching for instanceof, which you'll want to know first.
Object value = new Point(3, 4);
if (value instanceof Point(int x, int y)) {
System.out.println(x + y);
}7Using var for a component works too, and it's what you'll normally write.
Older tutorials also show record patterns in an enhanced for loop: that was a preview feature of Java 20 that was removed in Java 21, and it doesn't compile on Java 25.
Record patterns in switch
The real use of a record pattern is in a switch, where each case takes apart a different record (JEP 441).
static double area(Shape shape) {
return switch (shape) {
case Circle(var center, var radius) -> Math.PI * radius * radius;
case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) ->
Math.abs(x2 - x1) * Math.abs(y2 - y1);
};
}System.out.printf("%.2f%n", area(new Circle(new Point(0, 0), 2)));
System.out.println(area(new Rectangle(new Point(0, 0), new Point(3, 4))));12.57
12.0Nested record patterns
The Rectangle case above already shows that patterns nest: Point(var x1, var y1) takes apart the component that Rectangle(...) just extracted.
A when guard adds a condition on the values that were bound, and cases are tried from top to bottom.
static String describe(Shape shape) {
return switch (shape) {
case Circle(Point(var x, var y), var radius) when x == 0 && y == 0 -> "circle at the origin";
case Circle c -> "circle of radius " + c.radius();
case Rectangle(var topLeft, var bottomRight) when topLeft.equals(bottomRight) -> "empty rectangle";
case Rectangle r -> "rectangle";
};
}System.out.println(describe(new Circle(new Point(0, 0), 2)));
System.out.println(describe(new Circle(new Point(1, 0), 2)));
System.out.println(describe(new Rectangle(new Point(1, 1), new Point(1, 1))));circle at the origin
circle of radius 2
empty rectangleExhaustive switch
Neither switch above has a default, and that's the point.
Shape is sealed, so the compiler knows the cases cover every value, and it checks that for you.
Add a new record to the sealed interface and every switch that doesn't handle it stops compiling, with the error the switch expression does not cover all possible input values.
That check happens when the switch is recompiled: code compiled earlier throws MatchException when it meets the new record, which matters if you ship a library.
With a default you'd lose that check, so I avoid one on sealed types.
Caution
null matches no record pattern.
instanceof returns false for it, and a switch on null throws NullPointerException, unless it has a case null.
A null inside a record is easy to miss: area(new Rectangle(null, new Point(3, 4))) throws MatchException, since Point(var x1, var y1) doesn't match null and no other case applies, even though the switch compiles as exhaustive.
Any pattern that has to test the component fails this way: a nested record pattern, or a type pattern narrower than the component's type.
var, or a type pattern that covers the whole component, does match null, so new Circle(null, 2) gets through area and its center is null.
Unnamed patterns
When a case only needs part of a record, _ skips the rest.
This one is Java 22 (JEP 456), not 21, where it was still a preview feature.
static String side(Shape shape) {
return switch (shape) {
case Circle(Point(var x, _), _) when x >= 0 -> "right";
case Circle _ -> "left";
case Rectangle _ -> "n/a";
};
}On Java 25 this works as expected, and on Java 21 it fails:
Main.java:4: error: unnamed variables are a preview feature and are disabled by default.
case Circle(Point(var x, _), _) when x >= 0 -> "right";
^
(use --enable-preview to enable unnamed variables)
1 error_ tells the reader which components matter, and case Circle _ says that the case doesn't use the value at all.
Records with Jackson
Jackson finds a record's canonical constructor and its component names by itself, so you need neither @JsonCreator nor a no-arg constructor.
Compact constructors and @JsonProperty on a component work as you'd expect.
public record Employee(@JsonProperty("full_name") String fullName, List<String> skills) {
public Employee {
skills = List.copyOf(skills);
}
}Jackson 2
Jackson has supported records since 2.12, but use 2.18.8, or 2.21.4 and later. Version 2.15 changed how records are introspected, with regressions, and 2.18 rewrote property introspection, which fixed the handling of component annotations.
var mapper = new ObjectMapper();
var employee = new Employee("Ada Lovelace", List.of("math", "engines"));
var json = mapper.writeValueAsString(employee);
System.out.println(json);
var read = mapper.readValue(json, Employee.class);
System.out.println(read);
System.out.println(read.equals(employee));{"full_name":"Ada Lovelace","skills":["math","engines"]}
Employee[fullName=Ada Lovelace, skills=[math, engines]]
trueJackson 3
The same record, unchanged, gives the same result on Jackson 3, which needs Java 17 and moved its classes to tools.jackson.
@JsonProperty, @JsonIgnore and @JsonCreator stay in com.fasterxml.jackson.annotation, so a record that only uses those doesn't change.
In the code around it, the imports change, and JsonMapper.builder() is the usual way to create the mapper:
var mapper = JsonMapper.builder().build();Watch out for @JsonSerialize, @JsonDeserialize and @JsonNaming, though: they moved to tools.jackson.databind.annotation, and with Jackson 2 still on the classpath, Jackson 3 ignores the old ones without an error.
Spring Boot 4 uses Jackson 3 by default: its auto-configured mapper is a tools.jackson.databind.json.JsonMapper.
Jackson 2 itself isn't deprecated, but Spring's support for it is: Spring Boot 4 deprecated its spring-boot-jackson2 module, and Spring Framework 7 marked its Jackson 2 converters for removal.
We went through the same move in the Fabric8 Kubernetes Client for its 8.0 release.
Jackson 3 changes some defaults, and for records one of them stands out: SORT_PROPERTIES_ALPHABETICALLY is true, which reorders a plain class, but a record keeps its declaration order.
public record Ordered(String zeta, String alpha) {}
public class Plain {
public String zeta = "z";
public String alpha = "a";
}{"zeta":"z","alpha":"a"}
{"alpha":"a","zeta":"z"}Jackson record pitfalls
The one you're most likely to hit is a helper method that looks like a getter.
Jackson doesn't limit a record's properties to its components, so a getFullName() method becomes an extra property.
public record Person(String firstName, String lastName) {
public String getFullName() {
return firstName + " " + lastName;
}
}{"firstName":"Ada","lastName":"Lovelace","fullName":"Ada Lovelace"}Jackson 2 and Jackson 3 both write that, but the consequences differ. Jackson 2 can't read its own output, because it fails on unknown properties by default:
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "fullName" (class Person), not marked as ignorable (2 known properties: "lastName", "firstName")Jackson 3 ignores unknown properties by default, so the round trip passes and the extra field stays there unnoticed.
The fix on both is @JsonIgnore on the method, or naming it fullName(), without the get.
Jackson 3.1 and later also have a global switch:
var mapper = JsonMapper.builder()
.enable(MapperFeature.INFER_RECORD_GETTERS_FROM_COMPONENTS_ONLY)
.build();The compact constructor also runs on every read, so the Employee above can't read a document without skills: List.copyOf receives a null, and the read fails with a ValueInstantiationException on both versions.
If a field is optional, use the default from Defensive copies.
The last pitfall is a security one, so stay on a current patch release.
CVE-2026-59888 affects records specifically: with a PropertyNamingStrategy, @JsonIgnore on a component is bypassed, so a client can set a value you meant to keep out of reach.
It's fixed in 2.18.8, 2.21.4 and 3.1.4, and the 2.19, 2.20 and 3.0 lines never got a fix, so a version number above 2.18.8 doesn't mean you're safe.
Records with JPA, Hibernate and Spring Data
Records don't fit every role in a persistence layer, but they fit most of the ones where people assume they can't.
Can a record be a JPA entity?
No. The Jakarta Persistence specification rules it out explicitly: "An enum, record, or interface may not be designated as an entity." It also requires a no-arg constructor, a non-final class and non-final fields. A record can add the constructor, but never the other two. An entity has an identity and a lifecycle, and it changes state over time, which is the opposite of a value.
Hibernate doesn't stop you at startup though, and that's what makes this worth a warning.
I annotated a record with @Entity, and on Hibernate 7.4.15 it bootstrapped and persisted a row with an assigned id without an error.
It only failed on reading that row back:
org.hibernate.InstantiationException: No default constructor for entity 'Customer'A test that only saves would pass.
With @GeneratedValue you'd find out sooner, as the persist fails when Hibernate tries to set the final id field.
Record embeddables
A record is an excellent @Embeddable, because an embeddable is a value without an identity of its own.
Jakarta Persistence 3.2 made this standard ("an embeddable class may be any Java record type"), and Hibernate 6.2 to 6.6 already supported it as an extension.
No EmbeddableInstantiator or any other configuration is needed on Hibernate 7.
@Embeddable
public record Address(String street, String city, @ManyToOne(fetch = FetchType.LAZY) Country country) {}@Entity
public class Customer {
@Id @GeneratedValue private Long id;
private String name;
@Embedded private Address address;
protected Customer() {}
public Customer(String name, Address address) {
this.name = name;
this.address = address;
}
public Address getAddress() {
return address;
}
}Many older articles say that a record embeddable can't hold an association, but that example does, and it stays lazy.
After loading a Customer, Hibernate.isInitialized(customer.getAddress().country()) returned false, and reading the country's name then loaded it.
A record embeddable also works as a query parameter, as in where c.address = :address.
Records as composite keys
A composite key is a value too, and it needs the correct equals and hashCode that a record gives you for free.
Both @IdClass and @EmbeddedId work, and since Jakarta Persistence 3.2 a record key class doesn't need a no-arg constructor.
public record OrderLineId(Long orderId, int position) {}
@Entity
@IdClass(OrderLineId.class)
public class OrderLine {
@Id private Long orderId;
@Id private int position;
private String product;
// Constructors and getters omitted
}@Embeddable
public record ShipmentId(String warehouse, Long number) {}
@Entity
public class Shipment {
@EmbeddedId private ShipmentId id;
private String status;
// Constructors and getters omitted
}em.find(OrderLine.class, new OrderLineId(1L, 1)) and em.find(Shipment.class, new ShipmentId("ES", 7L)) both load their entity.
Warning
Don't put relationships inside a record used as an @EmbeddedId.
The specification forbids a relationship in an embeddable used as an embedded id or a map key, so that isn't portable, even if Hibernate accepts it.
Record projections
A projection is the best job a record has in a persistence layer: a read-only view of a query, without loading entities. The portable form is a constructor expression, with the class's fully qualified name.
package com.marcnuri;
public record CustomerSummary(String name, String city) {}em.createQuery(
"select new com.marcnuri.CustomerSummary(c.name, c.address.city) from Customer c",
CustomerSummary.class
).getResultList();[CustomerSummary[name=Ada, city=Bilbao]]Hibernate doesn't need the new: if the result class has a matching constructor, select c.name, c.address.city from Customer c gives the same result.
That's a Hibernate feature: Jakarta Persistence 4.0 makes the new optional, but as of November 2026 it's still in milestones, so I'd use it only in code that won't change providers.
Spring Data JPA goes further. A repository method can return a record directly, and it selects only the record's columns.
public record CustomerContact(String name, String email) {}public interface CustomerRepository extends JpaRepository<Customer, Long> {
List<CustomerContact> findByEmailEndingWith(String suffix);
<T> List<T> findByName(String name, Class<T> type);
@Query("select c from Customer c")
List<CustomerContact> findContacts();
@Query(value = "select name, email from customer", nativeQuery = true)
List<CustomerContact> findContactsNative();
}The third method is the interesting one.
Since Spring Data JPA 3.5, a @Query that returns a DTO type is rewritten to a constructor expression, so that select c reads two columns, not the whole entity.
Each of those four methods returned CustomerContact records, with these queries:
The SQL Hibernate ran for each repository method
select c1_0.name,c1_0.email from customer c1_0 where c1_0.email like ? escape '\'
select c1_0.name,c1_0.email from customer c1_0 where c1_0.name=?
select c1_0.name,c1_0.email from customer c1_0
select name, email from customerThree details to keep in mind:
- Outside native queries, a
publicrecord can have extra constructors: Spring Data uses the canonical one, and@PersistenceCreatorselects another. A package-private record with a second constructor fails withConverterNotFoundException, after reading the whole entity. - The documentation warns against aliases in a query that will be rewritten, so write
select c.name, c.email, notselect c.name as name. Hibernate accepted the aliases in my run, but that's the provider being lenient. - A query that lists its columns maps them by position, not by name:
select c.email, c.namesilently gave meCustomerContact[name=ada@marcnuri.com, email=Ada]. Native queries aren't rewritten at all, so their column order and types must match the constructor too, and apublicrecord with two constructors of the same arity fails on them.
Builders and withers
A record has no setters, so "the same record with one change" means calling the constructor with every component. Once a record has more than a handful of components, that's tedious and easy to get wrong, particularly when two neighbors have the same type.
The solution without dependencies is a hand-written withX method, often called a wither.
public record Ticket(long id, String title, Status status, String assignee) {
public Ticket withStatus(Status status) {
return new Ticket(id, title, status, assignee);
}
public Ticket withAssignee(String assignee) {
return new Ticket(id, title, status, assignee);
}
}var ticket = new Ticket(42, "Fix the build", Status.OPEN, "marc");
System.out.println(ticket.withStatus(Status.CLOSED));Ticket[id=42, title=Fix the build, status=CLOSED, assignee=marc]That scales badly: each new component touches every wither.
Lombok generates them for you with @Builder and @With, and both work on records.
@Builder(toBuilder = true)
@With
public record Ticket(long id, @NonNull String title, @Singular List<String> labels) {
public Ticket {
labels = List.copyOf(labels);
}
}var ticket = Ticket.builder().id(42).title("Fix the build").label("ci").label("urgent").build();
System.out.println(ticket);
System.out.println(ticket.withTitle("Fix the release build"));
System.out.println(ticket.toBuilder().id(43).build());Ticket[id=42, title=Fix the build, labels=[ci, urgent]]
Ticket[id=42, title=Fix the release build, labels=[ci, urgent]]
Ticket[id=43, title=Fix the build, labels=[ci, urgent]]@NonNull also adds a null check to the constructor: new Ticket(1, null, List.of()) throws NullPointerException: title is marked non-null but is null.
The compact constructor is still needed, as Lombok's builder protects its own list, but a direct call to the constructor doesn't go through it.
Important
You may have seen ticket with { status = CLOSED; } in articles about records.
That syntax is JEP 468, and it's not a feature of Java: as of November 2026 it's a Candidate, last updated in April 2024, with no target release.
A February 2026 thread on the amber-spec-experts list describes it as "on hold".
Don't plan on it.
If a record needs a builder because it has a dozen components, it's worth asking whether some of them belong together in a smaller record.
Record vs class vs Lombok vs Kotlin data class
These four options overlap a lot, and the choice usually depends on what you already have.
| Feature | Java record | Plain class | Lombok @Value | Kotlin data class |
|---|---|---|---|---|
| Mutability | Final fields, shallow | Whatever you write | Final fields, shallow | val or var, your choice |
| Minimum Java | 16 | Any | Older JDKs too | Older JDKs too; @JvmRecord needs 16 |
| Inheritance | Can't extend or be extended | Full | Can extend a class; final unless @NonFinal | Can extend a class, can't be extended |
| Equality | All components, generated | Whatever you write | Non-static, non-transient fields, generated | Primary constructor properties, generated |
| Boilerplate | None | All of it | None, but needs a processor | None, but needs Kotlin |
| Accessors | name() | Whatever you write | getName() | getName() from Java |
| Copy with a change | Hand-written, or Lombok's @With | Hand-written | @With | Built in: copy(), every argument from Java |
| Deconstruction | Record patterns | No | No | val (a, b) = user in Kotlin only |
| Framework fit | Very good; no JPA entities, no JavaBean-only tools | Everything | Good for reads; no setters or no-arg constructor | Good, with its own Jackson module |
| Interop | Plain Java | Plain Java | Plain Java, with a processor at build time | Needs the Kotlin standard library |
My rule of thumb: for new data carriers in Java, use a record. Reach for a plain class when you need mutability, inheritance or lazily computed state, and keep Lombok for the classes a record can't replace.
Lombok and records combine well, though most of Lombok's class-level annotations are pointless on a record, and it rejects them with a compile error:
Rejected.java:6: error: @Value is only supported on a class.
Rejected.java:3: error: @Getter is only supported on a class, an enum, or a field.
Rejected.java:4: error: @Setter is only supported on a class or a field.
Rejected.java:5: error: @Data is only supported on a class.
Rejected.java:7: error: @ToString is only supported on a class or enum.
Rejected.java:8: error: @EqualsAndHashCode is only supported on a class.
Rejected.java:9: error: AllArgsConstructor is only supported on a class or an enum.@NoArgsConstructor, @RequiredArgsConstructor, @SuperBuilder, @FieldDefaults and @UtilityClass fail the same way.
The ones that compile on a record are those that add something: @Builder, @With, @WithBy, @NonNull, @FieldNameConstants and the @Log family.
A Kotlin data class isn't a Java record: from Java, isRecord() returns false, and its accessors are getName().
To make it one, add @JvmRecord, which requires a JVM 16 or later target and val properties.
@JvmRecord
data class UserRecord(val name: String, val age: Int)
data class UserData(val name: String, val age: Int)var record = new UserRecord("Ada", 36);
var data = new UserData("Ada", 36);
System.out.println(record.name() + " " + data.getName());
System.out.println(record + " " + record.getClass().isRecord());
System.out.println(data + " " + data.getClass().isRecord());Ada Ada
UserRecord(name=Ada, age=36) true
UserData(name=Ada, age=36) false@JvmRecord keeps Kotlin's own toString format and copy().
It also changes the accessor names, so adding it to an existing class isn't binary compatible.
Best practices and when not to use records
If you keep only one list from this guide, keep this one:
- Copy mutable components in the compact constructor, with
List.copyOfby default. - Avoid arrays as components.
- Validate in the compact constructor whatever must hold for every instance.
- Prefer a named static factory over a constructor that has to do real work.
- Keep records small: split one that has grown past a handful of components.
- Implement sealed interfaces with records, and leave out
defaultso the compiler checks yourswitchstatements. - Use records for what crosses a boundary: DTOs, API payloads, query results, events, composite keys and values in a map.
When not to use records
Records aren't a general replacement for classes. I wouldn't use one for:
- Entities and anything with an identity. Two customers with the same name aren't the same customer, while a record says that they are. That's also why a record can't be a JPA entity.
- Mutable state. A counter, a cache or a builder has state that changes by design.
- Hierarchies. If you need shared state from a superclass, a record can't do that. Shared behavior is fine with default methods.
- Encapsulated representations. A record's accessors expose its components, so a class that hides how it stores its data can't be a record: the representation is the API.
- JavaBean-only tools. Anything that needs a no-arg constructor and setters or
getprefixes won't work. Record support in JAXB, for one, is still an open request as of November 2026.
Common pitfalls
These are the bugs from this guide, and where each one is covered:
- Sharing a mutable collection or array, or using
unmodifiableListas if it were a copy: Defensive copies. - Expecting a constructor to run Bean Validation, or using a custom constraint without a
FIELDtarget: Validating a record. - A
getX()helper turning into a JSON property: Jackson record pitfalls. - A
@Entityrecord that saves and then fails on load: Can a record be a JPA entity?. - A
nullcomponent in a patternswitch: Exhaustive switch. - Parsing
toString, or persistinghashCode: equals, hashCode and toString.
Migrating a class to a record
Let's migrate a typical Lombok DTO.
@Value
public class CustomerDto {
String name;
String email;
List<String> tags;
}1. Find candidates.
Good ones are final, have no setters and extend nothing.
@Value classes are the obvious place to start, and searching for the package avoids matching Spring's annotation of the same name:
grep -rlE 'lombok\.(Value|\*)' src/main/java2. Check immutability and identity.
Confirm that nothing mutates the instance, and that two instances with the same data really are interchangeable.
Check the class isn't used by anything that needs a JavaBean, and look for mutable fields: @Value doesn't copy tags, so this class was already leaking its list.
3. Convert it.
The fields become components, and the mutable list gets the copy it was missing.
That also changes behavior: @Value accepted a null list, and this record rejects it.
public record CustomerDto(String name, String email, List<String> tags) {
public CustomerDto {
tags = List.copyOf(tags);
}
}4. Update the callers.
dto.getName() becomes dto.name().
This is the bulk of the work in a large codebase, but the compiler lists every call it can no longer resolve.
It won't list what finds accessors by reflection or by name: think of templates, expression languages and mapping libraries.
5. Test what the compiler can't check.
Run your serialization tests, and look for anything that depends on the old toString, such as log parsers or snapshot tests, as the format changes:
CustomerDto(name=Ada, email=ada@marcnuri.com, tags=[vip])
CustomerDto[name=Ada, email=ada@marcnuri.com, tags=[vip]]If the class is public API of a library, the conversion is a breaking change: its getters are gone, and so is any superclass.
Conclusion
A record is a class whose state is its API, and the compiler writes the rest. The syntax takes minutes to learn. What takes longer is the habit: copy what's mutable in a compact constructor, and check how each framework treats a record before you trust it.
Implement a sealed interface with records, leave out the default, and the compiler finds every switch a new case breaks.
With current versions of Jackson, Hibernate and Spring Data, records work at every layer of an application except the entities.
