Java 17 Records

July 22, 20264 min readUpdated 8/20/2026

A record is a class whose job is to carry data. You declare the components; the compiler writes the constructor, the accessors, equals, hashCode and toString. One line replaces about sixty.

The sixty lines it replaces

// The old way — and this is the SHORT version, with toString omitted
final class PointOld {
    private final int x;
    private final int y;

    PointOld(int x, int y) {
        this.x = x;
        this.y = y;
    }

    int getX() { return x; }
    int getY() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PointOld other)) return false;
        return x == other.x && y == other.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }
}

// The same thing
record Point(int x, int y) { }

Every one of those hand-written members is a place a bug can hide — an equals that forgets a field, a hashCode that disagrees with it. The generated versions cannot drift, because adding a component regenerates all of them.

Using one

record Point(int x, int y) { }

class Demo {
    void run() {
        Point p = new Point(3, 4);

        System.out.println(p.x());              // 3 — accessor is x(), NOT getX()
        System.out.println(p);                  // Point[x=3, y=4] — a real toString

        Point same = new Point(3, 4);
        System.out.println(p.equals(same));     // true — value equality, generated
        System.out.println(p == same);          // false — still two objects

        Set<Point> points = new HashSet<>();
        points.add(p);
        System.out.println(points.contains(same));   // true — hashCode agrees with equals
    }
}

Note the accessor name: x(), not getX(). Records deliberately break the JavaBean convention. Most frameworks — Jackson, Spring, JPA to a point — understand records now, but it is worth knowing when something reflective cannot find your getter.

That HashSet line is the practical payoff. As Collections showed, a class without equals and hashCode goes into a hash-based collection and never comes back out. A record cannot have that bug.

Validation with a compact constructor

Records are not just dumb holders. A compact constructor lets you validate or normalise before the fields are assigned:

Here is a real one, from the console bank app this site uses for examples:

public record User(long id, String email, String password, String fullName, LocalDateTime createdAt) {

    public User {
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("A user must have an email");
        }
        email = email.trim().toLowerCase(); // Normalise once, here, so sign-in never worries about it.
    }

    public String firstName() {
        return fullName.split(" ")[0];
    }

    public boolean passwordMatches(String attempt) {
        return password.equals(attempt);
    }
}

Two things to notice. There is no parameter list and no this.email = email; — the compiler adds the assignments after your block runs. And assigning to the parameter inside the block changes what actually gets stored, which is how email.trim().toLowerCase() guarantees every User in the system has a normalised email. Sign-in never has to think about casing, because no un-normalised User can exist.

Note also that a record is free to have ordinary methods. firstName() and passwordMatches() are derived behaviour, not stored components — a record is a data carrier, not a data-only class.

This makes a record a genuinely good domain type: an Order that exists is an Order whose invariants hold.

What records cannot do

The restrictions are deliberate, and each one follows from "this is a transparent carrier of its components":

  • Components are final. There are no setters and there cannot be.
  • No extending. A record cannot extend a class and cannot be extended — it is implicitly final. It can implement interfaces.
  • No extra instance fields. Everything a record holds is in its component list. Static fields are allowed.
interface Shape { double area(); }

record Circle(double radius) implements Shape {      // interfaces: yes
    @Override public double area() { return Math.PI * radius * radius; }
}

record Config(String name) {
    static final String DEFAULT = "none";            // static field: fine
    // private int cached;                           // instance field: will not compile
}

Shallow immutability — the trap

A record's components cannot be reassigned. That does not make the objects they point at immutable, which is the same distinction final has in static and final:

record Team(String name, List<String> members) { }

class Demo {
    void run() {
        List<String> members = new ArrayList<>(List.of("Ana"));
        Team team = new Team("Ops", members);

        members.add("Bo");                          // modifying the list from outside
        System.out.println(team.members());         // [Ana, Bo] — the record "changed"
    }
}

Defend against it in the compact constructor by copying:

record Team(String name, List<String> members) {
    Team {
        members = List.copyOf(members);             // immutable snapshot, taken on construction
    }
}

List.copyOf both copies and makes the result unmodifiable, so neither the caller nor anyone holding the accessor's result can change it afterwards.

When to use one

Use a record for DTOs and API request or response bodies, for value objects (money, coordinates, an identifier), for a method that needs to return two things, for keys in a Map or Set, and for test data.

Use a class when the object has mutable state, when it needs to extend something, when it has behaviour rather than data, or when it has to hide its internal representation — a record is transparent by design, and its component list is part of its public API forever.

A short local record declared inside a method is also allowed, and it is often the tidiest way to group intermediate values in a stream pipeline without polluting the package with a type nobody else needs:

class Report {
    void summarise(List<String> names) {
        record Scored(String name, int length) { }        // visible only in this method

        names.stream()
                .map(n -> new Scored(n, n.length()))
                .sorted(Comparator.comparingInt(Scored::length))
                .forEach(s -> System.out.println(s.name() + " " + s.length()));
    }
}

Next

Records pair with the other Java 17 addition for modelling data. Sealed Classes is next — saying these are the only kinds, and getting the compiler to hold you to it.