Skip to content

Java · Modern Java

Java Records Explained — Immutable Data Classes Without the Boilerplate

7 min read Updated 2026-06-20 Share:

Practice Records interview questions

The boilerplate problem records solve

Before Java 16, a plain data-holding class required dozens of lines of repetitive code:

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) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return x == other.x && y == other.y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

With records (finalized in Java 16, JEP 395):

record Point(int x, int y) {}

One line. The compiler generates everything above automatically.

What the compiler generates

The identifiers in the record header are components. For each component the compiler creates:

  • A private final field.
  • A public accessor method named after the component (x(), not getX()).
  • A canonical constructor that accepts all components.
  • equals(), hashCode(), and toString() based on all components.
record Person(String name, int age) {}

Person p = new Person("Alice", 30);
p.name();       // "Alice"
p.age();        // 30
p.toString();   // "Person[name=Alice, age=30]"
p.equals(new Person("Alice", 30)); // true

Immutability — shallow, not deep

Record fields are private final, so a reference to a component cannot be reassigned after construction. But if a component's type is mutable, the object it refers to can still change:

record Scores(List<Integer> values) {}

var s = new Scores(new ArrayList<>(List.of(1, 2)));
s.values().add(99); // mutates the list — perfectly legal

For true deep immutability, defensively copy mutable components in the canonical constructor:

record Scores(List<Integer> values) {
    Scores {
        values = List.copyOf(values); // unmodifiable snapshot
    }
}

Rule of thumb: records give you shallow immutability by default. Wrap or copy mutable components yourself when you need a value object that can't be modified from outside.

The canonical and compact constructor

The canonical constructor has the same parameter list as the header. You can override it for validation or normalisation. The compact form (no parameter list, no explicit field assignments) is the preferred style — the compiler appends this.x = x; this.y = y; automatically after your body:

record Range(int min, int max) {
    Range {   // compact canonical constructor
        if (min > max)
            throw new IllegalArgumentException("min=%d > max=%d".formatted(min, max));
        // compiler inserts: this.min = min; this.max = max;
    }
}

You can also add alternative constructors as long as they delegate to the canonical one as their first statement:

record Point(double x, double y) {
    Point() { this(0.0, 0.0); }
}

A key safety property: deserialization always invokes the canonical constructor, so validation in the compact constructor is enforced even when a Point is deserialized from bytes — unlike regular classes where Java deserialization bypasses constructors entirely.

Records vs regular classes — restrictions

Records are implicitly final (cannot be subclassed) and implicitly extend java.lang.Record. Beyond the generated members, you cannot add:

  • Additional non-static instance fields (only the components exist).
  • Superclass declarations (records can't extend any class).
  • abstract modifier.

You can add:

  • Static fields and static methods.
  • Additional instance methods.
  • Interface implementations (implements Comparable<T>, implements Serializable, etc.).
  • Annotations on components.
record Temperature(double celsius) implements Comparable<Temperature> {
    static final double ABS_ZERO = -273.15;

    static Temperature ofFahrenheit(double f) {
        return new Temperature((f - 32) * 5.0 / 9.0);
    }

    double toFahrenheit() { return celsius * 9.0 / 5.0 + 32; }

    @Override
    public int compareTo(Temperature other) {
        return Double.compare(celsius, other.celsius);
    }
}

Records vs Lombok

Lombok's @Value and @Data were the pre-records workaround. The key differences:

AspectJava RecordLombok @Value
Accessor stylex()getX() (JavaBean)
InheritanceCannot extend or be subclassedNormal
Build toolNone — JDK featureRequires annotation processor
Jackson compatNative since Jackson 2.12Works with any version
BuildersNo built-in builder@Builder available

When to prefer records: new code on Java 16+, no need for getX() naming, no need for mutable builders.

When to prefer Lombok: legacy codebase with frameworks expecting getX(), need for @Builder, or targeting Java < 16.

Jackson integration

Jackson supports records natively since 2.12. Serialization uses the component accessors; deserialization uses the canonical constructor:

record User(String name, int age) {}

var mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new User("Alice", 30));
// {"name":"Alice","age":30}

User u = mapper.readValue(json, User.class);
// User[name=Alice, age=30]

For older Jackson, add -parameters to the compiler flags so parameter names are preserved in bytecode, and include jackson-module-parameter-names.

Generic records

Type parameters work exactly as on regular classes:

record Pair<A, B>(A first, B second) {}
record Result<T>(T value, String message) {}

var p = new Pair<String, Integer>("hello", 42);
p.first();  // "hello"
p.second(); // 42

Generic records replace generic tuple classes with a concise, self-documenting declaration.

Records as Map keys and Set elements

The generated equals() and hashCode() are based on all components, making records correct HashMap/HashSet keys immediately:

record Point(int x, int y) {}

var labels = new HashMap<Point, String>();
labels.put(new Point(0, 0), "origin");
System.out.println(labels.get(new Point(0, 0))); // "origin"

With a regular class you'd have to implement equals()/hashCode() manually or rely on IDE generation (which can drift from the fields over time).

Local records

Records can be declared inside a method — scoped to that method, invisible outside:

List<String> topEmails(List<User> users) {
    record Ranked(User user, int score) {}  // local record

    return users.stream()
        .map(u -> new Ranked(u, score(u)))
        .sorted(Comparator.comparingInt(Ranked::score).reversed())
        .limit(10)
        .map(r -> r.user().email())
        .toList();
}

Local records are implicitly static. They're ideal for naming intermediate pipeline values without polluting the top-level namespace.

Records with sealed interfaces and pattern matching

Records compose naturally with sealed interfaces (Java 17) and switch pattern matching (Java 21) to model algebraic data types:

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius)          implements Shape {}
record Rectangle(double w, double h)  implements Shape {}
record Triangle(double base, double h) implements Shape {}

double area(Shape s) {
    return switch (s) {
        case Circle(double r)             -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
        case Triangle(double b, double h)  -> 0.5 * b * h;
    };
}

The switch is exhaustive (compiler-checked because Shape is sealed) and the components are destructured inline — no instanceof casts, no accessor calls.

This pattern (sealed interface + record variants + exhaustive switch) is the modern Java replacement for the Visitor design pattern.

Recap

Records (Java 16) eliminate data-class boilerplate by generating equals(), hashCode(), toString(), and component accessors from a one-line header. Fields are private final giving shallow immutability — copy mutable components in the compact constructor for deep immutability. Validation in the compact constructor is enforced on deserialization too, unlike regular classes. Records are final, can't extend classes, and can't add instance fields beyond components, but they freely implement interfaces and carry static members and instance methods. They work with Jackson 2.12+ out of the box, make safe Map keys without manual equals()/hashCode(), and compose with sealed interfaces and switch pattern matching as a clean alternative to the Visitor pattern.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel