Blog

Java Records: Data Classes Without the Boilerplate

A Java record declares a small immutable data class in one line, and the compiler writes its constructor, accessors, equals, hashCode and toString. Learn what records give you, what they refuse, and where they fall short.

A record is a class whose whole job is to carry a few values. You list the values once, and Java writes the constructor, the accessors, equals, hashCode and toString for you. Most small data classes in modern Java code are records now.

This post covers what a record generates, compact constructors for validation, what records refuse to do, what they still allow, the shallow immutability trap, “wither” methods, when a record is the wrong choice, and why records make good map keys. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

The boilerplate problem

A class that just holds two values takes a surprising amount of code in plain Java. The part on classes and objects built an immutable Money class by hand. It had two private final fields, a constructor, and a toString. Even that wasn’t finished. To be useful it also needed accessor methods to read the fields, plus equals and hashCode so two Money objects holding 12.50 EUR count as equal. That’s around 40 lines for two values, and every line is a place for a typo.

Here’s the same thing as a record:

record Money(long cents, String currency) {}

void main() {
    var price = new Money(1250, "EUR");
    var samePrice = new Money(1250, "EUR");
    var other = new Money(999, "EUR");

    IO.println(price);
    IO.println(price.cents() + " " + price.currency());
    IO.println("equals:   " + price.equals(samePrice));
    IO.println("hashCode: " + (price.hashCode() == samePrice.hashCode()));
    IO.println("==:       " + (price == samePrice));
    IO.println("other:    " + price.equals(other));
}

It prints:

Money[cents=1250, currency=EUR]
1250 EUR
equals:   true
hashCode: true
==:       false
other:    false

The part in brackets, (long cents, String currency), is the record’s header, and each entry is a component. From that one line, the compiler wrote:

  • A private final field for each component.
  • A canonical constructor, Money(long cents, String currency), which takes the components in order and stores them.
  • An accessor for each component. It’s named cents(), not getCents(). Records don’t follow the old JavaBeans naming.
  • toString, which prints the record’s name and every component.
  • equals and hashCode, based on the component values.

price and samePrice are two separate objects, so == is false. But equals compares the values, and the values match, so it’s true. Their hash codes match too, as they must.

Records became a final feature in Java 16. We checked: compiling a record with javac --release 15 fails with records are not supported in -source 15, and the same file compiles with --release 16.

The accessors are real methods

You can list a record’s components at run time, in the order the header declares them:

record Money(long cents, String currency) {}

void main() {
    IO.println(Money.class.isRecord());
    for (var component : Money.class.getRecordComponents()) {
        IO.println(component.getType() + " " + component.getName());
    }
    IO.println(java.lang.reflect.Modifier.isFinal(Money.class.getModifiers()));
}

It prints:

true
long cents
class java.lang.String currency
true

The last line says the record class is final. Nothing can extend a record, which comes back later in this post.

Explain it like I’m ten

A record is a filled-in form. The questions are printed on the form: “name”, “age”, “favourite colour”. When you make a record, you fill in every answer at once, in pen.

Anyone can read the answers. Nobody can rub one out and write a new one. If you want a different answer, you fill in a new form.

And two forms with the same answers are the same form, as far as anyone checking is concerned. It doesn’t matter that they’re two separate pieces of paper.

The precise version

A record declaration record R(T1 c1, T2 c2) {} creates a final class that extends java.lang.Record. For each component it declares a private final field and a public accessor method with the component’s name. It gets a canonical constructor with the same parameter list as the header. It also gets equals, hashCode and toString, all computed from the components.

equals returns true when the other object is the same record class and each pair of components is equal. Primitive components are compared by value, and reference components with Objects.equals. hashCode combines the components’ hash codes, so equal records always have equal hash codes. You can write any of these members yourself, and then the compiler uses yours.

Where the analogy breaks: a form’s answers are written on the paper, but a record’s reference components aren’t. A component of type List holds a reference to a list that lives somewhere else, and that list can still change. The section on shallow immutability shows exactly how.

Compact constructors validate and normalise

A record’s canonical constructor can be written in a short form, called a compact constructor, that has no parameter list. You write only the checks, and the compiler assigns the fields at the end:

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        if (currency.length() != 3) {
            throw new IllegalArgumentException("bad currency code: " + currency);
        }
        currency = currency.toUpperCase();
    }
}

void main() {
    IO.println(new Money(1250, "eur"));
    IO.println(new Money(-5, "EUR"));
}

It prints, then stops:

Money[cents=1250, currency=EUR]
Exception in thread "main" java.lang.IllegalArgumentException: negative amount: -5

Inside the compact constructor, cents and currency are the constructor’s parameters, not the fields. The line currency = currency.toUpperCase() changes the parameter. When the body finishes, Java copies each parameter into its field. So "eur" was stored as "EUR", and -5 never made it into a Money at all.

That’s why you can’t write this.cents = cents in a compact constructor. We tried, and the build failed with cannot assign a value to final variable cents. The compiler does that assignment itself, after your code, and a final field can only be assigned once.

Extra constructors must delegate

A record can have other constructors, as long as each one ends up calling the canonical constructor with this(...):

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        currency = currency.toUpperCase();
    }

    Money(long cents) {
        this(cents, "EUR");
    }

    Money(String text) {
        String[] parts = text.split(" ");
        this(Long.parseLong(parts[0]), parts[1]);
    }
}

void main() {
    IO.println(new Money(300));
    IO.println(new Money("450 usd"));
}

It prints:

Money[cents=300, currency=EUR]
Money[cents=450, currency=USD]

Both extra constructors went through the compact constructor, so "usd" was upper-cased and the negative check still applies. There’s one place where the rules live.

The Money(String text) constructor runs two statements before this(...). That’s allowed since Java 25’s flexible constructor bodies, which the part on classes and objects covers. If an extra constructor tries to set the fields itself instead of calling this(...), javac refuses with constructor is not canonical, so it must invoke another constructor of class Money.

What records can’t do

A record trades flexibility for guarantees, and the compiler enforces the trade. Each rule below is there so that the header stays the complete description of the record’s data.

No extra instance fields

All of a record’s state is in its header. You can’t add a hidden field on the side:

record Money(long cents, String currency) {
    private int timesPrinted;
}

void main() {
    IO.println(new Money(1250, "EUR"));
}

The build fails with:

Main.java:2: error: field declaration must be static
    private int timesPrinted;
                ^

javac adds a hint underneath: (consider replacing field with record component). If equals and toString are generated from the header, a field outside the header would be data they silently ignore.

No extends

A record already extends java.lang.Record, so it can’t extend anything else. The error you get is a surprise:

class Amount {
    long cents;
}

record Money(long cents, String currency) extends Amount {}

void main() {
    IO.println(new Money(1250, "EUR"));
}

The build fails with:

Main.java:5: error: '{' expected
record Money(long cents, String currency) extends Amount {}
                                         ^

That’s a syntax error, not a friendly explanation. The grammar for a record simply has no place for extends, so the parser expects the body to start right after the header. If you see '{' expected pointing at a record, check for an extends. It works the other way too: a class that tries to extend Money fails with cannot inherit from final Money.

No setters

Every field is final, so a method that assigns one doesn’t compile:

record Money(long cents, String currency) {
    void setCents(long cents) {
        this.cents = cents;
    }
}

void main() {
    var price = new Money(1250, "EUR");
    price.setCents(0);
}

The build fails with:

Main.java:3: error: cannot assign a value to final variable cents
        this.cents = cents;
            ^

If you want a different amount, you make a different Money. The section on withers shows the usual way to do that.

What records can do

Apart from those limits, a record is a normal class. It can have static fields and methods, instance methods, and it can implement interfaces:

interface Priced {
    Money price();
}

record Money(long cents, String currency) implements Comparable<Money> {
    static final String DEFAULT_CURRENCY = "EUR";

    static Money zero() {
        return new Money(0, DEFAULT_CURRENCY);
    }

    static Money euros(long whole, long cents) {
        return new Money(whole * 100 + cents, "EUR");
    }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(cents + other.cents, currency);
    }

    @Override
    public int compareTo(Money other) {
        return Long.compare(cents, other.cents);
    }

    @Override
    public String toString() {
        return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
    }
}

record Item(String name, Money price) implements Priced {}

void main() {
    var items = List.of(
        new Item("tea", Money.euros(3, 0)),
        new Item("cake", Money.euros(4, 50)),
        new Item("water", Money.euros(1, 20)));

    var total = Money.zero();
    for (Priced item : items) {
        total = total.plus(item.price());
    }
    IO.println("total: " + total);

    var cheapest = items.stream().map(Item::price).min(Money::compareTo).orElseThrow();
    IO.println("cheapest: " + cheapest);
    IO.println(items.get(0));
}

It prints:

total: 8.70 EUR
cheapest: 1.20 EUR
Item[name=tea, price=3.00 EUR]

A few things are worth noticing:

  • Money.zero() and Money.euros(...) are static factories. They call new Money(...) from a static method. In a compact source file, the part on classes and objects hit a compile error doing that with a plain class, because the class was nested inside the hidden Main class. Records are implicitly static when nested, so the factory just works.
  • Item implements Priced without writing price(). The interface asks for a method called price() that returns Money, and the record’s generated accessor is exactly that method.
  • We replaced toString. Money prints as 3.00 EUR now, and Item‘s generated toString used it for its price component.
  • Inside the record, other.currency reads the field directly. You can also call other.currency(). Both work.

Local records and generic records

You can declare a record inside a method, which is handy for a short-lived group of values. A record can also take type parameters:

record Pair<A, B>(A first, B second) {
    <C> Pair<A, C> withSecond(C newSecond) {
        return new Pair<>(first, newSecond);
    }
}

void main() {
    record Score(String player, int points) {}

    var scores = List.of(new Score("Ana", 15), new Score("Ben", 22), new Score("Caro", 9));
    var best = scores.stream().max((a, b) -> Integer.compare(a.points(), b.points())).orElseThrow();
    IO.println("best: " + best);

    var pair = new Pair<>("Ana", 15);
    Pair<String, String> labelled = pair.withSecond("fifteen");
    IO.println(pair + " " + pair.first().length());
    IO.println(labelled);
    IO.println(new Pair<>("x", 1).equals(new Pair<>("x", 1)));
}

It prints:

best: Score[player=Ben, points=22]
Pair[first=Ana, second=15] 3
Pair[first=Ana, second=fifteen]
true

Score exists only inside main, and nothing outside can see it. A local record is also implicitly static, so it can’t read the method’s local variables. We tried reading a local currency from a method inside a local record, and javac said non-static variable currency cannot be referenced from a static context. Pass the value in as a component instead.

Pair<A, B> works like any generic class. pair.first() returns a String, so .length() needs no cast and prints 3. withSecond returns a Pair with a different second type, and the compiler tracks that too.

Records also make natural leaves for sealed interfaces, and a switch can take them apart with record patterns. The part on sealed types and pattern matching covers that.

Shallow immutability: a record can hold a list that changes

A record’s fields are final, but final only fixes the reference, not the object it points at. Here’s a bug that looks impossible in an “immutable” record:

record Order(String id, List<String> items) {}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);
    IO.println("placed:  " + order);

    basket.add("cake");
    IO.println("later:   " + order);

    order.items().clear();
    IO.println("cleared: " + order);
}

It prints:

placed:  Order[id=A1, items=[tea]]
later:   Order[id=A1, items=[tea, cake]]
cleared: Order[id=A1, items=[]]

The order changed twice, and no line of code touched order directly. The record’s items field points at the same ArrayList as basket. Adding to basket changed the order. And order.items() handed out that same list, so anyone who reads the order can empty it.

That’s shallow immutability. The record can’t be pointed at a different list, but the list itself is as mutable as ever. The same goes for arrays, StringBuilder, and any mutable class you put in a component.

Fix it with List.copyOf in the compact constructor

Copy the list when the record is made, into a list nobody can change:

record Order(String id, List<String> items) {
    Order {
        items = List.copyOf(items);
    }
}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);

    basket.add("cake");
    IO.println("after changing basket: " + order);

    order.items().add("biscuits");
    IO.println("never printed");
}

It prints, then stops:

after changing basket: Order[id=A1, items=[tea]]
Exception in thread "main" java.lang.UnsupportedOperationException

Two things happened. List.copyOf made a new list, so later changes to basket no longer reach the order. And that new list is unmodifiable, so order.items().add(...) throws instead of changing it. The exception has no message, which can be confusing the first time you see it.

List.copyOf also rejects null elements: we passed a list containing null and got a NullPointerException from the constructor. Usually that’s what you want from an order. Set.copyOf and Map.copyOf do the same for sets and maps.

Withers: changing one component

A record has no setters, so “change the amount” means “make a new record with a different amount”. Java doesn’t write that method for you, so the usual pattern is to write a with method by hand:

record Money(long cents, String currency) {
    Money withCents(long newCents) {
        return new Money(newCents, currency);
    }

    Money withCurrency(String newCurrency) {
        return new Money(cents, newCurrency);
    }
}

void main() {
    var price = new Money(1250, "EUR");
    var discounted = price.withCents(1000);
    var inDollars = discounted.withCurrency("USD");

    IO.println(price);
    IO.println(discounted);
    IO.println(inDollars);
}

It prints:

Money[cents=1250, currency=EUR]
Money[cents=1000, currency=EUR]
Money[cents=1000, currency=USD]

price is untouched. Each wither calls the canonical constructor, so the compact constructor’s checks run again for the new value. You can’t sneak a negative amount in through a wither.

Some languages have a built-in syntax for this. Java 25 doesn’t. We tried the proposed price with { cents = 0; } form, with and without --enable-preview, and javac rejected it both times with not a statement. For now, write the withers you need, and only those.

When not to use a record

A record is the right tool when two objects with the same values should count as the same thing. Some objects aren’t like that.

An entity has an identity that stays the same while its data changes. A bank account is the classic case. Account 42 is still account 42 after a deposit, and two different accounts that both hold 100 EUR aren’t the same account. A record gets both of those wrong: it can’t change, and it says equal values mean equal objects. Write a class, like BankAccount in the part on classes and objects, with private fields and methods that guard the rules.

The other misfit is a mutable bean. Frameworks such as JPA (Java’s standard for storing objects in a database) expect a no-argument constructor, setters, and fields that can be filled in after the object exists. A record has none of those. Use records for the data you pass around, such as request bodies, query results and messages, and keep the framework’s classes for what the framework manages.

Records as map keys and in sets

A good map key has to keep its equals and hashCode stable, and records give you exactly that. A HashMap finds a key by its hash code, then confirms the match with equals. So a lookup works with any object that’s equal to the stored key, not just the same object.

Here’s a seating plan keyed by a record, next to the same map keyed by a plain class that doesn’t override equals:

record Seat(char row, int number) {}

class PlainSeat {
    final char row;
    final int number;

    PlainSeat(char row, int number) {
        this.row = row;
        this.number = number;
    }
}

void main() {
    var bookings = new HashMap<Seat, String>();
    bookings.put(new Seat('B', 7), "Ana");
    bookings.put(new Seat('B', 8), "Ben");
    IO.println("record key B7:  " + bookings.get(new Seat('B', 7)));
    IO.println("contains B8:    " + bookings.containsKey(new Seat('B', 8)));

    var plainBookings = new HashMap<PlainSeat, String>();
    plainBookings.put(new PlainSeat('B', 7), "Ana");
    IO.println("plain key B7:   " + plainBookings.get(new PlainSeat('B', 7)));

    var taken = new HashSet<Seat>();
    taken.add(new Seat('C', 1));
    taken.add(new Seat('C', 1));
    IO.println("seats in set:   " + taken.size());
}

It prints:

record key B7:  Ana
contains B8:    true
plain key B7:   null
seats in set:   1

new Seat('B', 7) in the lookup is a brand-new object, but it’s equal to the key stored earlier, so the map finds Ana. The PlainSeat lookup returns null, because a class without its own equals only matches the very same object. Adding Seat('C', 1) twice to a set keeps one, for the same reason.

Two cautions. First, a record key is only as stable as its components. If a key holds a mutable List and the list changes, its hash code changes, and the map can no longer find it. The List.copyOf fix above prevents that. Second, an array component compares by reference, not by contents, because arrays don’t override equals. We checked: two records holding separate String[] {"a"} arrays aren’t equal, and their toString shows [Ljava.lang.String;@ and a hash instead of the contents. Use a List in a record you plan to compare.

What to remember

  • record Money(long cents, String currency) {} gives you private final fields, a canonical constructor, accessors named cents() and currency(), and equals, hashCode and toString based on the values. Records are final since Java 16.
  • Put validation and normalisation in a compact constructor. Assign to the parameters, and the compiler stores them in the fields. Extra constructors must call this(...).
  • A record can’t declare instance fields outside its header, can’t use extends, and can’t assign its fields after construction.
  • A record can have static factories, instance methods, interfaces, type parameters, and can be declared locally inside a method.
  • Records are shallowly immutable. Copy mutable components with List.copyOf in the compact constructor.
  • To change a component, write a withX method that returns a new record. Java 25 has no built-in syntax for it.
  • Use records for values, and classes for entities whose state changes. Value equality makes records reliable map keys and set elements.

A record is for data that is fully described by its values, and Java writes the rest.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.