Skip to content
elephantoo

Records

Lesson 18 of 43 12 min read

Concise immutable data carriers: components, compact constructors, methods and limitations.


A huge share of the classes in real programs just carry data: a Point, a Money amount, a row from a database, the JSON body of a request. Writing them by hand means a constructor, private final fields, getters, equals, hashCode and toString, around 40 lines of boilerplate that is easy to get subtly wrong.

A record (standard since Java 16) is a special kind of class that declares all of that in a single line.

Your first record#

RecordBasics.java
public class RecordBasics {
    public static void main(String[] args) {
        Point a = new Point(3, 4);
        Point b = new Point(3, 4);

        System.out.println(a);                 // generated toString
        System.out.println(a.x() + ", " + a.y());   // generated accessors
        System.out.println(a.equals(b));       // generated equals: compares components
        System.out.println(a == b);            // still two different objects
        System.out.println(a.hashCode() == b.hashCode());
    }
}

record Point(int x, int y) { }
Output
Point[x=3, y=4]
3, 4
true
false
true

That one line, record Point(int x, int y) { }, gives you:

  • a private final field for each component (x and y),
  • a canonical constructor Point(int x, int y) that assigns them,
  • an accessor method per component, named x() and y() (not getX()),
  • equals and hashCode based on all components,
  • a readable toString like Point[x=3, y=4].

It is roughly equivalent to this hand-written class:

Java
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 o) {
        return o instanceof Point p && x == p.x && y == p.y;
    }
    @Override public int hashCode() { return java.util.Objects.hash(x, y); }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

Records are immutable#

There are no setters, and the fields are final. To "change" a record, create a new one, usually with a small wither method:

Wither.java
public class Wither {
    public static void main(String[] args) {
        Book b1 = new Book("Clean Code", "Robert Martin", 450);
        Book b2 = b1.withPrice(399);
        System.out.println(b1);
        System.out.println(b2);
    }
}

record Book(String title, String author, int price) {
    Book withPrice(int newPrice) {
        return new Book(title, author, newPrice);
    }
}
Output
Book[title=Clean Code, author=Robert Martin, price=450]
Book[title=Clean Code, author=Robert Martin, price=399]

Records are shallowly immutable. If a component is a mutable object, like an ArrayList, the list itself can still be changed. Store a defensive copy with List.copyOf(...) in the constructor (shown below).

Validation with a compact constructor#

To check or normalise the arguments, write a compact constructor: the record name with no parameter list. Your code runs first, then the fields are assigned automatically from the (possibly reassigned) parameters:

CompactCtor.java
import java.util.List;

public class CompactCtor {
    public static void main(String[] args) {
        Email e = new Email("  Asha@Example.COM ");
        System.out.println(e.address());

        Range r = new Range(1, 10);
        System.out.println(r + " length " + r.length());

        try {
            new Range(10, 1);
        } catch (IllegalArgumentException ex) {
            System.out.println("Rejected: " + ex.getMessage());
        }

        Team t = new Team("Core", List.of("Asha", "Ben"));
        try {
            t.members().add("Mallory");
        } catch (UnsupportedOperationException ex) {
            System.out.println("Team members are read-only");
        }
    }
}

record Email(String address) {
    Email {                                            // compact constructor
        if (address == null || !address.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + address);
        }
        address = address.strip().toLowerCase();       // reassign the PARAMETER
    }                                                  // fields are assigned here, automatically
}

record Range(int start, int end) {
    Range {
        if (start > end) {
            throw new IllegalArgumentException(start + " > " + end);
        }
    }

    int length() { return end - start; }
}

record Team(String name, List<String> members) {
    Team {
        members = List.copyOf(members);                // defensive, unmodifiable copy
    }
}
Output
asha@example.com
Range[start=1, end=10] length 9
Rejected: 10 > 1
Team members are read-only

In a compact constructor you must not write this.address = address;; the compiler does that for you (and reports an error if you try).

Adding more to a record#

A record body can contain most things a class can:

RecordExtras.java
public class RecordExtras {
    public static void main(String[] args) {
        Money price = Money.of(499.99);
        Money total = price.plus(Money.of(100.01));
        System.out.println(total);
        System.out.println(total.compareTo(price) > 0);
        System.out.println(Money.ZERO);
        System.out.println(new Temperature(36.6).isFever());
        System.out.println(new Temperature("38.2C"));
    }
}

record Money(long paise) implements Comparable<Money> {
    static final Money ZERO = new Money(0);           // static fields are fine

    static Money of(double rupees) {                   // static factory
        return new Money(Math.round(rupees * 100));
    }

    Money plus(Money other) {                          // instance methods are fine
        return new Money(paise + other.paise);
    }

    @Override
    public int compareTo(Money other) {                // records can implement interfaces
        return Long.compare(paise, other.paise);
    }

    @Override
    public String toString() {                         // you may override generated methods
        return String.format("Rs %d.%02d", paise / 100, paise % 100);
    }
}

record Temperature(double celsius) {
    Temperature(String text) {                          // extra constructors must delegate
        this(Double.parseDouble(text.replace("C", "")));
    }

    boolean isFever() { return celsius >= 38.0; }
}
Output
Rs 600.00
true
Rs 0.00
false
Temperature[celsius=38.2]

Allowed in a record:

  • static fields, static methods and static nested types,
  • instance methods, including overriding toString, equals, hashCode or an accessor,
  • implementing interfaces,
  • extra constructors, as long as they call this(...) (eventually the canonical constructor).

Limitations#

  • No extra instance fields. The state is exactly the components. private int cache; inside a record is a compile error.
  • Records are implicitly final and cannot be extended.
  • Records cannot extend a class. Every record already extends java.lang.Record. (Implementing interfaces is fine.)
  • Component fields are final, so there are no setters.
  • Some frameworks (notably JPA/Hibernate entities) need mutable classes with no-arg constructors, so records don't suit entities. They are perfect for DTOs, API responses, keys and value objects, and Jackson and Spring handle records fine.

Local records and records as keys#

You can declare a record inside a method for a quick temporary type. And because equals/hashCode come for free, records make excellent map keys:

RecordKeys.java
import java.util.HashMap;
import java.util.Map;

public class RecordKeys {
    public static void main(String[] args) {
        record Seat(char row, int number) { }        // local record

        Map<Seat, String> bookings = new HashMap<>();
        bookings.put(new Seat('C', 12), "Asha");
        bookings.put(new Seat('C', 13), "Ben");

        System.out.println(bookings.get(new Seat('C', 12)));   // equal key, different object
        System.out.println(bookings.containsKey(new Seat('A', 1)));
    }
}
Output
Asha
false

With a normal class and no equals/hashCode, that lookup would return null. (The Maps and equals()/hashCode() lessons explain why.)

Records and pattern matching#

Since Java 21, you can deconstruct a record directly in instanceof and switch, pulling out its components in one step:

RecordPatterns.java
public class RecordPatterns {
    public static void main(String[] args) {
        Object o = new Point(5, 0);
        if (o instanceof Point(int x, int y) && y == 0) {
            System.out.println("On the x-axis at " + x);
        }
    }
}

record Point(int x, int y) { }
Output
On the x-axis at 5

The Modern Java lesson covers record patterns with sealed types and switch.

Record or class?#

Choose a record when...Choose a normal class when...
the type is mainly datathe object has changing state over time
identity doesn't matter, only the valuesidentity matters (two accounts with equal fields are still different accounts)
you want immutability and value equalityyou need inheritance or extra hidden fields

Common mistakes#

  • Calling getX() on a record; the accessor is x().
  • Assigning this.field = ... inside a compact constructor.
  • Adding an instance field to a record.
  • Assuming a record holding a List is deeply immutable; copy it with List.copyOf.
  • Using records for JPA entities.

What's next#

Records model data with a fixed shape. Enums model a fixed set of values, like days of the week or order statuses, with full type safety.

Check your understanding

Quick quiz

0/3 answered
  1. 1.For record Point(int x, int y) {}, how do you read the x value of p?

  2. 2.Which of these is NOT allowed in a record?

  3. 3.What does a compact constructor record Age(int value) { Age { if (value < 0) throw new IllegalArgumentException(); } } do?

Finished reading?

Mark this lesson complete to track your progress.