A logo showing the text blog.marcnuri.com
Español
Home»Backend Development»Java Records Complete Guide: Patterns, JPA, Jackson and Pitfalls

Recent Posts

  • Fabric8 Kubernetes Client 8.0 is now available!
  • Fabric8 Kubernetes Client 7.9 is now available!
  • Eclipse JKube 1.20 is now available!
  • Project, Agent, Person: The Missing Axis of AI Coding Tools
  • Fabric8 Kubernetes Client 7.8 is now available!

Categories

  • Artificial Intelligence
  • Backend Development
  • Cloud Native
  • Engineering Insights
  • Frontend Development
  • JavaScript
  • Legacy
  • Operations
  • Personal
  • Pet projects
  • Quality Engineering
  • Tools

Archives

  • September 2026
  • July 2026
  • June 2026
  • May 2026
  • April 2026
  • March 2026
  • February 2026
  • January 2026
  • December 2025
  • October 2025
  • September 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • August 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • July 2022
  • June 2022
  • May 2022
  • March 2022
  • February 2022
  • January 2022
  • December 2021
  • November 2021
  • October 2021
  • September 2021
  • August 2021
  • July 2021
  • April 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020
  • June 2020
  • May 2020
  • March 2020
  • February 2020
  • January 2020
  • December 2019
  • November 2019
  • October 2019
  • September 2019
  • July 2019
  • March 2019
  • November 2018
  • July 2018
  • June 2018
  • May 2018
  • April 2018
  • March 2018
  • February 2018
  • December 2017
  • October 2017
  • August 2017
  • July 2017
  • January 2017
  • May 2016
  • December 2015
  • November 2015
  • December 2014
  • November 2014
  • October 2014
  • March 2014
  • August 2012
  • February 2011
  • January 2010
  • November 2008
  • June 2008
  • May 2008
  • April 2008
  • January 2008
  • November 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007

Java Records Complete Guide: Patterns, JPA, Jackson and Pitfalls

2021-04-10 in Backend Development tagged Java / Java 17 / Java 21 by Marc Nuri | Last updated: 2026-11-07
Versión en Español

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.

Point.java
public record Point(int x, int y) {}
Using a record
var point = new Point(1, 2);
System.out.println(point);
System.out.println(point.x());
Output
Point[x=1, y=2]
1

Records 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.

Point.java, without records
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.

JavaReleasedJEPWhat changed
14March 2020JEP 359Records, first preview
15September 2020JEP 384Second preview: canonical constructor access rules, no field assignment in a compact constructor
16March 2021JEP 395Records final; inner classes may declare member records
17September 2021JEP 409Sealed classes final
19September 2022JEP 405Record patterns, first preview
20March 2023JEP 432Record patterns, second preview
21September 2023JEP 440, JEP 441Record patterns and pattern matching for switch final
22March 2024JEP 456Unnamed variables and patterns (_) final
25September 2025JEP 513Flexible 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.

bash
javac Point.java
javap -p Point
Output
Compiled 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.
Anatomy of a Java record: the one-line declaration of the Point record, with arrows to what javac generates from it: final class Point, extends java.lang.Record, private final fields x and y, the constructor Point(int, int), the accessors x() and y(), and equals, hashCode and toString
Anatomy of a Java record: the one-line declaration of the Point record, with arrows to what javac generates from it: final class Point, extends java.lang.Record, private final fields x and y, the constructor Point(int, int), the accessors x() and y(), and equals, hashCode and toString

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.

Point.java
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:

Output
Point.java:2: error: invalid canonical constructor in record Point
  Point(int x, int y) {
  ^
  (attempting to assign stronger access privileges; was public)
1 error

The 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.

Name.java
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:

Output
Name.java:3: error: cannot assign a value to final variable value
    this.value = value.strip();
        ^
1 error

Validating a record

⁠

The compact constructor is also the natural place for validation, because a constructor that throws means an invalid instance never exists.

Range.java
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.

User.java
public record User(@NotBlank String name, @Email String email) {}
Validating a User
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);
}
Output
User[name= , email=not-an-email]
email: must be a well-formed email address
name: must not be blank

The 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.

Team.java
public record Team(String name, List<String> members) {}
Changing a record from the outside
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);
Output
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.

Team.java
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:

Team.java
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.

Scores.java
public record Scores(int[] values) {}
Array components
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})));
Output
[99, 2, 3]
false

Fixing it properly takes everything the record was supposed to save you from.

Scores.java
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.

Money.java
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);
  }
}
Using the factories
System.out.println(Money.of("19.99", "EUR"));
System.out.println(Money.zero("USD"));
Output
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.

Range.java
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:

Interval.java, before Java 25
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(...).

Interval.java, on Java 25
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.

Point.java
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:

Output
Point.java:2: error: field declaration must be static
  private int z;
              ^
  (consider replacing field with record component)
1 error

There 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:

Output
Point.java:3: error: '{' expected
public record Point(int x, int y) extends Base {}
                                 ^
1 error

Nothing 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.

Point.java
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.

Measure.java
public record Measure(double value) {}
Comparing doubles
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);
Output
true
false
false
true

The 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.

Pair.java
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:

Output
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.

Version.java
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);
  }
}
Sorting versions
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);
Output
[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[].

A local record
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());
Output
[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.

Temperature.java
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");
  }
}
Output
Temperature[celsius=21]
java.io.InvalidObjectException: below absolute zero: -300
java.io.InvalidClassException: Plain; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

The 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.

Shape.java
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.

Deconstructing with instanceof
Object value = new Point(3, 4);
if (value instanceof Point(int x, int y)) {
  System.out.println(x + y);
}
Output
7

Using 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).

Calculating an area
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);
  };
}
Calling it
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))));
Output
12.57
12.0

Nested 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.

Patterns with guards
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";
  };
}
Calling it
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))));
Output
circle at the origin
circle of radius 2
empty rectangle

Exhaustive 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.

Skipping components with _
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:

Output
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.

Employee.java
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.

Round trip with Jackson 2
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));
Output
{"full_name":"Ada Lovelace","skills":["math","engines"]}
Employee[fullName=Ada Lovelace, skills=[math, engines]]
true

Jackson 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:

Creating the mapper on Jackson 3
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.

Property order on Jackson 3
public record Ordered(String zeta, String alpha) {}

public class Plain {
  public String zeta = "z";
  public String alpha = "a";
}
Output
{"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.

Person.java
public record Person(String firstName, String lastName) {
  public String getFullName() {
    return firstName + " " + lastName;
  }
}
Output
{"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:

Output
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:

Only components are properties
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:

Output
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.

Address.java
@Embeddable
public record Address(String street, String city, @ManyToOne(fetch = FetchType.LAZY) Country country) {}
Customer.java
@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.

@IdClass
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
}
@EmbeddedId
@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.

CustomerSummary.java
package com.marcnuri;

public record CustomerSummary(String name, String city) {}
A constructor expression
em.createQuery(
  "select new com.marcnuri.CustomerSummary(c.name, c.address.city) from Customer c",
  CustomerSummary.class
).getResultList();
Output
[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.

CustomerContact.java
public record CustomerContact(String name, String email) {}
CustomerRepository.java
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
SQL log
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 customer

Three details to keep in mind:

  • Outside native queries, a public record can have extra constructors: Spring Data uses the canonical one, and @PersistenceCreator selects another. A package-private record with a second constructor fails with ConverterNotFoundException, after reading the whole entity.
  • The documentation warns against aliases in a query that will be rewritten, so write select c.name, c.email, not select 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.name silently gave me CustomerContact[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 a public record 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.

Ticket.java
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);
  }
}
Using a wither
var ticket = new Ticket(42, "Fix the build", Status.OPEN, "marc");
System.out.println(ticket.withStatus(Status.CLOSED));
Output
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.

Ticket.java
@Builder(toBuilder = true)
@With
public record Ticket(long id, @NonNull String title, @Singular List<String> labels) {
  public Ticket {
    labels = List.copyOf(labels);
  }
}
Using Lombok
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());
Output
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.

FeatureJava recordPlain classLombok @ValueKotlin data class
MutabilityFinal fields, shallowWhatever you writeFinal fields, shallowval or var, your choice
Minimum Java16AnyOlder JDKs tooOlder JDKs too; @JvmRecord needs 16
InheritanceCan't extend or be extendedFullCan extend a class; final unless @NonFinalCan extend a class, can't be extended
EqualityAll components, generatedWhatever you writeNon-static, non-transient fields, generatedPrimary constructor properties, generated
BoilerplateNoneAll of itNone, but needs a processorNone, but needs Kotlin
Accessorsname()Whatever you writegetName()getName() from Java
Copy with a changeHand-written, or Lombok's @WithHand-written@WithBuilt in: copy(), every argument from Java
DeconstructionRecord patternsNoNoval (a, b) = user in Kotlin only
Framework fitVery good; no JPA entities, no JavaBean-only toolsEverythingGood for reads; no setters or no-arg constructorGood, with its own Jackson module
InteropPlain JavaPlain JavaPlain Java, with a processor at build timeNeeds 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:

Output
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.

Models.kt
@JvmRecord
data class UserRecord(val name: String, val age: Int)

data class UserData(val name: String, val age: Int)
Calling them from Java
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());
Output
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.copyOf by 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 default so the compiler checks your switch statements.
  • 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 get prefixes 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 unmodifiableList as if it were a copy: Defensive copies.
  • Expecting a constructor to run Bean Validation, or using a custom constraint without a FIELD target: Validating a record.
  • A getX() helper turning into a JSON property: Jackson record pitfalls.
  • A @Entity record that saves and then fails on load: Can a record be a JPA entity?.
  • A null component in a pattern switch: Exhaustive switch.
  • Parsing toString, or persisting hashCode: equals, hashCode and toString.

Migrating a class to a record

⁠

Let's migrate a typical Lombok DTO.

CustomerDto.java, before
@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:

bash
grep -rlE 'lombok\.(Value|\*)' src/main/java

2. 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.

CustomerDto.java, after
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:

Output
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.

You might also like

⁠
  • Java Virtual Threads Complete Guide (Project Loom)
  • Pattern Matching for instanceof in Java
  • Java Streams: Convert List into Map
  • How to generate a serialVersionUID in IntelliJ IDEA
Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Post navigation
Trigger Kubernetes CronJob manually from Java using YAKCQuarkus 2 + Kubernetes Maven Plugin + GraalVM integration
© 2007 - 2026 Marc Nuri