
Records are one of two major new features in Java 16 (the second is “Pattern Matching for instanceof”). In this article, you will learn:
- What are Java records, and why do we need them?
- How to implement and use records in Java?
- How can we extend a Java record with additional functionality?
- What is important in the context of inheritance?
- How do records work together with pattern matching?
- What should you consider regarding reflection, serialization, and deserialization of records?
- Why do you need records if you can have the constructor, getters,
equals(),hashCode(), andtoString()generated by the IDE or by Lombok?
Let’s start with an example from the times before records…
Why Do We Need Records?
Let’s say we want to create an immutable class Point with x and y coordinates and everything needed to make sense of this class. We want to instantiate Point objects, read their fields, and store them in sets or use them as keys in maps.
The result would be something like the following code:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
Point that = (Point) obj;
return this.x == that.x && this.y == that.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=%d, y=%d]".formatted(x, y);
}
}
That’s quite a bit of boilerplate code for the “a class with x and y values” requirement.
Those who wanted and were allowed to use Lombok in their projects had a clear advantage. Lombok can create constructors, getters, equals(), hashCode(), and toString() methods automatically. It reduces the code to a few lines:
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class Point {
private final int x;
private final int y;
}
That is already much more comfortable. Lombok is mature and integrates seamlessly with almost any IDE. I have been using it happily for over ten years.
Since Java 16, however, it is possible to do it even shorter:
public record Point(int x, int y) {}
Using records, the original 22 lines – or the seven lines with Lombok – become only one line! This is not only shorter, but also more secure (see sections Java Record vs. Class and Java Records vs. Lombok).
Let’s look at how exactly to write records and use them.
How to Implement and Use Records in Java?
In the previous section, we saw how to write a record with a single line of code:
public record Point(int x, int y) {}
From this one line of code, the compiler generates a class Point with:
- the final fields
int xandint y(the so-called “components” of the record), - a constructor that sets both fields (the so-called “canonical constructor”),
- the accessor methods
x()andy()for reading the fields, - an
equals()method that evaluates twoPointinstances as equal if theirxandycoordinates are equal, - a
hashCode()method that returns the same hash code for two equalPointinstances (in the example, the hash code is calculated asx * 31 + y), - a
toString()method that returns a human-readable text (in the examplePoint[x=…, y=…]).
So one line of source code becomes a complete class:
You can use Point like any regular class (in the first line of the following code example, the above mentioned, automatically generated “canonical constructor” is called):
Point p = new Point(5, 10);
int x = p.x();
int y = p.y();
You can compare two points, for example, as follows:
Point p1 = new Point(8, 4);
Point p2 = new Point(4, 3);
if (p1.equals(p2)) {
// ...
}
Java Record Constructors
In the previous section, you learned that the compiler automatically creates a constructor, the so-called canonical constructor. In this chapter, you will learn how you can overwrite this canonical constructor, how you can write so-called “compact” constructors – and any other non-canonical constructors.
The following diagram shows the three forms at a glance – the details follow in the next sections.
Overwriting a Record’s Canonical Constructor
We can implement the canonical constructor of a record ourselves:
public record Point(int x, int y) {
/** Canonical constructor as the compiler would generate it */
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
However, this only makes sense if we execute additional code before or after assigning the record fields – for example, we might want to ensure that the coordinates are not negative:
public record Point(int x, int y) {
/** Canonical constructor */
public Point(int x, int y) {
if (x < 0 || y < 0) throw new IllegalArgumentException();
this.x = x;
this.y = y;
}
}
In addition to validation, we could also transform parameters or, for example, create a defensive copy of an array.
With this form, the constructor’s signature must be exactly the same as the record’s. The following, in contrast, is not allowed:
public record Point(int x, int y) {
public Point(int a, int b) { // Other names than x and y are not allowed!
this.x = a;
this.y = b;
}
}
The compiler would reject this with the following error message:
$ javac Point.java
Point.java:4: error: invalid canonical constructor in record Point
public Point(int a, int b) {
^
(invalid parameter names in canonical constructor)
1 error
It is equally important that all fields are set (logically, they are final). If we were to set only x but not y, the compiler would abort with the following message:
$ javac Point.java
Point.java:4: error: variable y might not have been initialized
}
^
1 error
Interestingly, however, there is no need for a 1:1 mapping of the parameters to the fields. You don’t even have to use all parameters. So the following code is also valid:
public record Point(int x, int y) {
public Point(int x, int y) {
this.x = x;
this.y = x; // Assigning this.y to x here - and ignoring y
}
}
Fortunately, modern IDEs recognize this. IntelliJ, for example, warns that “‘x’ should probably not be assigned to ‘y’”.
Finally, the visibility of the canonical constructor must not be more restrictive than the visibility of the record itself. This means that a record marked as private may have a constructor marked as public – but a record declared as public may not have a private constructor – the following is, therefore, not allowed:
public record Point(int x, int y) {
private Point(int x, int y) { // private constructor not allowed for public record
this.x = x;
this.y = y;
}
}
The compiler would abort with the following error message:
$ javac Point.java
Point.java:2: error: invalid canonical constructor in record Point
private Point(int x, int y) {
^
(attempting to assign stronger access privileges; was public)
1 error
Compact Constructor
There is another more concise variant to override the canonical constructor. You can omit the parameters in the signature and the assignments completely. We call this type a “compact constructor”:
public record Point(int x, int y) {
/** Compact constructor */
public Point { // ← No parameters here
if (x < 0 || y < 0) throw new IllegalArgumentException();
// ← No assignments here
}
}
The compiler automatically inserts the assignments this.x = x and this.y = y at the end of the constructor and thus ultimately generates exactly the same bytecode from the compact constructor as from the canonical constructor shown second in the previous section.
The parameters may also be changed within the constructor, e.g., we could tacitly replace all negative values with 0:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
x = Math.max(x, 0);
y = Math.max(y, 0);
}
}
This would correspond to the following canonical constructor:
public record Point(int x, int y) {
/** Canonical constructor */
public Point(int x, int y) {
x = Math.max(x, 0);
y = Math.max(y, 0);
this.x = x;
this.y = y;
}
}
Both forms of the constructor are ultimately the same, and only either a canonical or a compact constructor may be implemented.
My recommendation is to always use a compact constructor. After all, we programmers want to express our ideas – and not write unnecessary boilerplate code.
Caution: As the record’s components are only set at the end of the constructor, you should not access the accessor methods – x() and y() in the example – within the constructor. The components are still assigned default values at this point (i.e., 0 in the case of int).
You should access the (not explicitly specified) constructor parameters instead:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
System.out.println(x()); // Prints 0 (fields are not yet assigned)
System.out.println(x); // Prints the x parameter passed to the constructor
}
}
This becomes evident when you try to access the fields within the constructor using this:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
System.out.println(this.x); // Not allowed - x is not yet initialized
}
}
This code leads to a compiler error:
$ javac Point.java
Point.java:3: error: variable x might not have been initialized
System.out.println(this.x);
^
1 error
It would have been consistent not to allow access via x() and y() in the constructor either.
Additional Constructors in Records
You can extend records with additional constructors, such as a default constructor (one without parameters) or one that sets x and y to the same value:
public record Point(int x, int y) {
/** Default constructor */
public Point() {
this(0, 0);
}
/** Custom constructor */
public Point(int value) {
this(value, value);
}
}
As shown in the previous example, you must always a) set all fields and b) do this by delegating to the canonical constructor via this(…).
Setting the values directly, as in the following code, is not allowed in these additional constructors:
public record Point(int x, int y) {
/** Default constructor */
public Point() {
this.x = 0; // Not allowed!
this.y = 0; // Not allowed!
}
/** Custom constructor */
public Point(int value) {
this.x = value; // Not allowed!
this.y = value; // Not allowed!
}
}
The compiler aborts with the following message:
$ javac Point.java
Point.java:3: error: constructor is not canonical, so it must invoke another constructor of class Point
public Point() {
^
1 error
The reason for this is that no matter which constructor is used, the parameter validations that may be implemented in the canonical or compact constructor should always be called.
Fields in Records
What about fields? The state of a record consists exclusively of its components. Additional fields are allowed in one form only: as static fields.
No Additional Instance Fields
A record must not declare any further instance fields – this is one of the most common stumbling blocks when switching from classes to records. The following code does not compile:
public record Point(int x, int y) {
private String label; // Not allowed!
}
The compiler even tells you what to do instead:
$ javac Point.java
Point.java:2: error: field declaration must be static
private String label;
^
(consider replacing field with record component)
1 error
This is not an oversight but a deliberate design decision: A record is a transparent carrier for its components. Everything that belongs to its state should also be visible in equals(), hashCode(), toString(), and – as you will see further below – in pattern matching. A hidden instance field would undermine exactly that.
Static Fields in Records
Records can, however, be extended by static fields (final and non-final). For example, we could extract the 0 from the default constructor shown above into a constant:
public record Point(int x, int y) {
private static final int ZERO = 0;
public Point() {
this(ZERO, ZERO);
}
}
We could also add a static instance counter that is incremented in the constructor:
public record Point(int x, int y) {
private static long instanceCounter = 0;
public Point {
synchronized (Point.class) {
instanceCounter++;
}
}
}
In fact, I would rather implement such a counter as AtomicLong or LongAdder – but then they would be final again and therefore not suitable as an example for a non-final static field. ;-)
Methods in Records
Just like the canonical constructor, we can also override the automatically generated accessor methods of a record. The following record contains an array component and creates a defensive copy of the array in the constructor and in the accessor in order to prevent changes to the array stored in the record:
public record ImmutableArrayHolder(int[] array) {
/* Compact constructor */
public ImmutableArrayHolder {
array = array.clone();
}
/* Accessor method */
public int[] array() {
return array.clone();
}
}
This makes the record immutable. What is still missing is value equality – because the generated equals(), hashCode(), and toString() methods treat an array like any other object: they compare references, not contents. Two ImmutableArrayHolder instances with arrays of equal content are therefore not equal (the two arrays are clones at different memory addresses). In addition, toString() returns a rather unhelpful ImmutableArrayHolder[array=[I@135fbaa4].
If you need value semantics, you have to override these three methods as well:
public record ImmutableArrayHolder(int[] array) {
// ... compact constructor and accessor as above ...
@Override
public boolean equals(Object obj) {
return obj instanceof ImmutableArrayHolder other
&& Arrays.equals(array, other.array);
}
@Override
public int hashCode() {
return Arrays.hashCode(array);
}
@Override
public String toString() {
return "ImmutableArrayHolder[array=" + Arrays.toString(array) + "]";
}
}
In most cases, it is easier to use an immutable List as a component instead of an array (e.g., via List.copyOf() in the compact constructor) – then defensive copy and value semantics come without any additional code.
Besides additional constructors and static fields, you can also define additional static and non-static methods in Java records.
The following static method returns the value of the instance counter:
public record Point(int x, int y) {
private static long instanceCounter = 0;
// ... Constructor increasing instanceCounter ...
public static synchronized long getInstanceCounter() {
return instanceCounter;
}
}
We could implement a non-static method, for example, to calculate the Euclidean distance to another point:
public record Point(int x, int y) {
public double distanceTo(Point target) {
int dx = target.x() - this.x();
int dy = target.y() - this.y();
return Math.sqrt(dx * dx + dy * dy);
}
}
We can call the method, for example, as follows:
Point p1 = new Point(17, 3);
Point p2 = new Point(18, 12);
double distance = p1.distanceTo(p2);
When implementing and invoking record methods, there is no difference from normal classes.
Records and Inheritance
Records can implement interfaces:
public interface WithXCoordinate {
int x();
}
public record Point(int x, int y) implements WithXCoordinate {}
This is also possible in combination with sealed types released in Java 17:
public sealed interface WithXCoordinate permits Point, Point3D {
int x();
}
public record Point(int x, int y) implements WithXCoordinate {}
public record Point3D(int x, int y, int z) implements WithXCoordinate {}
Records cannot, however, inherit from classes. So the following is not allowed:
public class TaggedElement {
private String tag;
}
public record Point(int x, int y) extends TaggedElement {} // Not allowed!
This is because records already inherit from the java.lang.Record class – and they are supposed to be immutable. And that they would not be if they inherited from a mutable class.
Records are implicitly final, so you can’t inherit from them either. The following code is also invalid:
public record Point(int x, int y) {}
public class TaggedPoint extends Point { // Not allowed!
private String tag;
TaggedPoint(int x, int y, String tag) {
super(x, y);
this.tag = tag;
}
}
Records and Pattern Matching
Records and pattern matching were designed together – a record is a transparent carrier of its components, and a record pattern takes it apart along exactly those components. Since Java 21, record patterns are a final feature.
Instead of checking a Point via instanceof, casting it, and then calling x() and y(), you can bind the components directly in the pattern:
Object obj = new Point(3, 4);
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at " + x + "/" + y);
}
The same works in a switch – optionally combined with a guard condition (when):
String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == y -> "on the diagonal: %d/%d".formatted(x, y);
case Point(int x, int y) -> "Point at %d/%d".formatted(x, y);
default -> "something else";
};
}
Record patterns show their real strength in combination with sealed interfaces – like the WithXCoordinate from the previous section: The compiler then knows that only Point and Point3D are possible and checks whether your switch covers all cases. You can find nested patterns, patterns over generic records, and further examples in the Record Patterns section of the Java 21 article.
Where Records May Be Declared
Records are not limited to files of their own. Compared to regular classes, you should know some peculiarities – I will explain these in the following sections.
Local Records
Records may also be defined locally (i.e., within methods). This can be especially helpful if you want to store intermediate results consisting of several related variables.
In the following example, we define, within the findFurthestPoint() method, the local record PointWithDistance: a combination of a Point and a double value representing the distance of the point to an origin point.
With the help of the local record, we fill a list of points and their distances to the current point. From this list, we then determine the PointWithDistance with the greatest distance – to then extract the corresponding Point from it.
public Point findFurthestPoint(Point origin, Point... points) {
record PointWithDistance(Point point, double distance) {}
List<PointWithDistance> pointsWithDistance = new ArrayList<>();
for (Point point : points) {
double distance = origin.distanceTo(point);
pointsWithDistance.add(new PointWithDistance(point, distance));
}
PointWithDistance furthestPointWithDistance = Collections.max(
pointsWithDistance,
Comparator.comparing(PointWithDistance::distance));
return furthestPointWithDistance.point();
}
Records within Inner Classes
Records may also be defined within inner classes:
class OuterClass {
// ...
class InnerClass {
record InnerClassRecord(String foo, int bar) {}
// ...
}
}
This possibility is worth mentioning in that it was only made possible with the final release of records by JDK Enhancement Proposal 395.
Generic Records
Just like classes, records can have type parameters. This makes them an excellent substitute for the tuples that Java lacks:
public record Pair<A, B>(A first, B second) {}
Using it is no different from using a generic class:
Pair<String, Integer> pair = new Pair<>("answer", 42);
String first = pair.first();
int second = pair.second();
A generic record Pair<A, B> is handy for small intermediate results. For anything with domain meaning, you should still prefer a named record – PointWithDistance says more than Pair<Point, Double>.
Varargs Components
The last component of a record may be a varargs parameter:
public record Polyline(String name, Point... points) {}
The canonical constructor then accepts any number of points; the component itself is an array, and the accessor points() returns a Point[]:
Polyline triangle =
new Polyline("triangle", new Point(0, 0), new Point(4, 0), new Point(0, 3));
int numberOfPoints = triangle.points().length; // 3
Since this is an array, the same restrictions apply as described in the section Methods in Records: equals() and hashCode() compare the array by reference only – so two Polyline instances with the same name and the same points are not equal.
Annotations on Record Components
An annotation that you write on a record component does not end up in just one place. The compiler propagates it – depending on its @Target – to all elements it derives from the component:
public record Point(@Positive int x, @Positive int y) {}
If @Positive is declared for FIELD, METHOD, and PARAMETER, then the field x carries the annotation, as do the accessor method x() and the parameter x of the canonical constructor. If the annotation additionally allows the target RECORD_COMPONENT, it is also visible via reflection on the component itself (more on that in a moment).
This is exactly what frameworks like Bean Validation or Jackson rely on: They find their annotations in the place where they would look for them in a regular class.
One detail you should know: Propagation only applies to the implicitly generated elements. If you implement the accessor method x() yourself, it does not carry the component’s annotation – you have to repeat it there explicitly.
Records at Runtime
Reflection and deserialization access an object’s fields directly at runtime – bypassing constructors and methods. For records, both behave differently than for classes. Let’s look at them now.
Records and Reflection
First, the good news: Records are particularly convenient to read via reflection. Class knows whether it represents a record and provides its components including name, type, and accessor method:
Point point = new Point(5, 10);
System.out.println(Point.class.isRecord()); // true
for (RecordComponent component : Point.class.getRecordComponents()) {
System.out.println(component.getName() + " : " + component.getType()
+ " = " + component.getAccessor().invoke(point));
}
The code outputs the following:
true
x : int = 5
y : int = 10
Serialization libraries, for example, build on this API to process records generically – without having to rely on field names or getter conventions.
And now the difference to classes: final fields of regular classes can be changed via reflection. In the following code, Point is the class from the beginning of this article:
Point point = new Point(10, 5);
System.out.println("point = " + point);
Field xField = Point.class.getDeclaredField("x");
xField.setAccessible(true);
System.out.println("point.x = " + xField.get(point));
xField.set(point, 55);
System.out.println("point = " + point);
Up to and including Java 25, the code outputs the following:
point = Point[x=10, y=5]
point.x = 10
point = Point[x=55, y=5]
That means that we have read and changed the actually private and final x field of the Point class via reflection! (How far you can push this is shown in the article Java Deep Reflection: How to Hack Integer and String – where, in the end, 2 + 3 = 6.)
Since Java 26, this still works – but no longer silently. With JEP 500 (“Prepare to Make Final Mean Final”), the JVM issues a warning on the first reflective write access to a final field (output abbreviated):
point = Point[x=10, y=5]
point.x = 10
WARNING: Final field x in class eu.happycoders.records.Point has been mutated reflectively by class eu.happycoders.records.ReflectionDemo in unnamed module ...
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
point = Point[x=55, y=5]
In a future Java version, the JVM will abort the access with an exception by default; anyone who still needs it will have to enable it explicitly via --enable-final-field-mutation.
If we call the same code with the Point record, we get – in every Java version since 16 – the following output:
point = Point[x=10, y=5]
point.x = 10
Exception in thread "main" java.lang.IllegalAccessException:
Can not set final int field eu.happycoders.records.Point.x to java.lang.Integer
We can thus also read private fields from records via reflection (and thus, for example, bypass an accessor that creates a defensive copy of a mutable component, such as an array).
However, unlike classes, records have been protected from changes via reflection from the very beginning. JEP 500 explicitly states as its goal to align final fields of regular classes with this behavior. In other words: Since Java 16, records have anticipated what is the future for all final fields.
At a glance:
Deserializing Records
Records have a particularity when deserializing them. I will show this with the following example.
Let’s first extend the Point constructor with a parameter validation. Let’s say we want to exclude negative values. We use the compact notation for overriding the canonical constructor. We also make the class serializable:
public record Point(int x, int y) implements Serializable {
@Serial private static final long serialVersionUID = -1482007299343243215L;
public Point {
if (x < 0) throw new IllegalArgumentException("x must be >= 0");
if (y < 0) throw new IllegalArgumentException("y must be >= 0");
}
}
To see the difference in deserialization, we create an analogous regular class PointClass:
public final class PointClass implements Serializable {
@Serial private static final long serialVersionUID = 8411630734446201523L;
private final int x;
private final int y;
public PointClass(int x, int y) {
if (x < 0) throw new IllegalArgumentException("x must be >= 0");
if (y < 0) throw new IllegalArgumentException("y must be >= 0");
this.x = x;
this.y = y;
}
// ... getters, equals(), hashCode(), toString() ...
}
We temporarily comment out the parameter validation and serialize an invalid Point record and an invalid PointClass class to a file each, using the following code:
PointClass pc = new PointClass(-5, 5); // Parameter validation temporarily commented
try (FileOutputStream fileOut = new FileOutputStream("point-class.bin");
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(pc);
}
Point p = new Point(-5, 5); // Parameter validation temporarily commented
try (FileOutputStream fileOut = new FileOutputStream("point-record.bin");
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(p);
}
After that, we uncomment the parameter validation and try to deserialize the serialized objects:
try (FileInputStream fileIn = new FileInputStream("point-class.bin");
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
PointClass pointClass = (PointClass) objectIn.readObject();
System.out.println("pointClass = " + pointClass);
}
try (FileInputStream fileIn = new FileInputStream("point-record.bin");
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
Point point = (Point) objectIn.readObject();
System.out.println("point = " + point);
}
The result reveals another difference between records and classes:
pointClass = PointClass{x=-5, y=5}
Exception in thread "main" java.io.InvalidObjectException: x must be >= 0
at ...
Caused by: java.lang.IllegalArgumentException: x must be >= 0
at eu.happycoders.records.Point.<init>(Point.java:10)
The faulty class can be deserialized without any problems – the faulty record, however, cannot.
The reason: When deserializing a class, the values read from the ObjectInputStream are written directly into the fields of the class. In the case of a record, on the other hand, the canonical constructor is called – and any parameter validations it contains are executed.
The two paths compared:
Java Record vs. Class
I am often asked why you need records when you can simply have an IDE generate the constructor, getters, equals(), hashCode(), and toString().
Firstly, in my opinion, it is not the task of an IDE to compensate for the shortcomings of a programming language. A programming language should be so sophisticated that we can express our ideas with as little code as possible – the compiler should do the rest. Not everyone uses the same IDE, and different IDEs generate different source code.
Records have other concrete advantages:
- Record components are truly immutable – and have been since Java 16. In regular classes, final fields could be changed via reflection; only since Java 26 does the JVM warn about it, and only a future version will block it by default (see section Records and Reflection). Records have had this guarantee from the start.
- It is not possible to create invalid records via deserialization, as the canonical constructor of the record is called during deserialization (in contrast to classes).
- For the
equals(),hashCode(), andtoString()methods, the compiler generates a special bytecode that calls implementations of these methods in the JVM. This means that these methods can be further optimized in future Java versions without having to recompile existing code. - Records work closely with other language features, such as record patterns finalized in Java 21 (see section Records and Pattern Matching).
Java Records vs. Lombok
Similarly, I am often asked about the advantages of records over Lombok.
In my opinion, we should not hand over responsibility for something that a language should be able to do to a library. Because there are risks involved: What if the library is no longer maintained? What if it is not adapted to new Java versions or even completely discontinued?
In addition, the same concrete disadvantages apply as with regular classes: Final fields of Lombok-annotated classes can be changed via reflection (without any warning up to Java 25, see above); invalid instances can be created via deserialization; equals(), hashCode(), and toString() cannot be optimized by the JVM; and record patterns do not work with Lombok-annotated classes either.
Outlook: Withers and Value Objects
There are two developments around records that you should keep an eye on.
Withers: Since records are immutable, you create modified variants by deriving a new record from the old one. Today, you write so-called wither methods by hand for this:
public record Point(int x, int y) {
public Point withX(int newX) {
return new Point(newX, y);
}
}
For records with many components, this quickly turns into the kind of boilerplate that records were supposed to eliminate. JDK Enhancement Proposal 468 (“Derived Record Creation”) proposes a dedicated syntax for this: point with { x = 10; }. However, the JEP is still in Candidate status; there is no target version. You can read how withers work today and what the planned syntax looks like in the article Withers in Java: Derived Record Creation Expressions.
Value Objects: With JEP 401 (“Value Objects”, planned as a preview feature for Java 28), a record declared as value record Point(int x, int y) {} becomes an object without identity – the JVM may then, for example, inline it into arrays and other objects. Records are the natural candidate for this, as they already bring the most important prerequisite: immutability.
Summary
Records provide a compact notation to define classes with only final fields. Records automatically include a constructor that sets all final fields (the canonical constructor), read access methods for all fields (the accessor methods), as well as equals(), hashCode(), and toString() methods optimized by the JVM.
Records can be extended by additional constructors, static fields, and static as well as non-static methods – but not by additional instance fields. The canonical constructor can be overridden.
Records can implement interfaces (including sealed ones) but cannot extend classes, nor can they be inherited from. They may be generic, may be declared locally within methods and within inner classes, and with record patterns, they can be deconstructed directly into their components in instanceof and switch.
When deserializing records, their canonical constructor is invoked – and any parameter validations it may contain. Via reflection, record components can be read but not modified – a guarantee that Java 26 is gradually extending to all final fields with JEP 500.
Records were released as preview features in Java 14 and Java 15 and were declared production-ready in Java 16 through JDK Enhancement Proposal 395. Records were developed as part of Project Amber, which also includes switch expressions, text blocks, pattern matching, and sealed classes.
Did you take something away from this article? With a review on my ProvenExpert profile, you help other developers assess whether these articles are worth reading – and you help me understand which content is most useful to you.
The HappyCoders newsletter keeps you up to date on new Java features and articles – click here to sign up.




