Java Records: Immutable Data Classes Without the Boilerplate

Requires
Java 16+
Difficulty
Intermediate
Published
Updated
Author
records immutability data classes value objects Jackson sealed interfaces pattern matching compact constructor generic records

What are Java records?

A record is a special kind of class in Java, introduced as a preview feature in Java 14 and finalized in Java 16 (JEP 395). Records are designed for one purpose: carrying immutable data. The Java specification describes them as "a restricted form of class" whose entire state is declared in the header, with everything else generated automatically.

When you declare a record, the Java compiler generates for you:

  • A canonical constructor that accepts all components as parameters
  • A private final field for each component
  • A public accessor method for each component (named after the component, no "get" prefix)
  • equals() — two records are equal if all their components are equal
  • hashCode() — derived from all components
  • toString() — includes all component names and values

Records are implicitly final and implicitly extend java.lang.Record. They cannot be subclassed and cannot extend other classes (though they can implement interfaces). Every component is implicitly private and final.

The problem records solve

Before records, creating a simple immutable data-holding class required a mountain of ceremony. Consider a class that holds three fields — a common pattern for value objects, DTOs, and domain model components:

Java — before records
public final class Point {
    private final double x;
    private final double y;
    private final String label;

    public Point(double x, double y, String label) {
        this.x     = x;
        this.y     = y;
        this.label = label;
    }

    public double  x()     { return x; }
    public double  y()     { return y; }
    public String  label() { return label; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return Double.compare(p.x, x) == 0
            && Double.compare(p.y, y) == 0
            && Objects.equals(label, p.label);
    }

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

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + ", label=" + label + "]";
    }
}

That is 33 lines for three fields of actual information. Worse, it is brittle — add a fourth field and you must remember to update equals(), hashCode(), and toString() manually, or introduce a subtle bug.

With a record, the same class is one line:

Java 16+
public record Point(double x, double y, String label) {}

This generates the exact same class as above — canonical constructor, private final fields, accessor methods, equals(), hashCode(), and toString() — with no opportunity to introduce an inconsistency.

Basic syntax and auto-generated members

The record header lists the components — the pieces of data the record holds. Each component becomes a private final field, a public accessor method, and a parameter in the canonical constructor:

Java 16+
// Define a record
public record Person(String name, int age, String email) {}

public class Main {
    public static void main(String[] args) {

        // Construct using the canonical constructor
        Person alice = new Person("Alice", 30, "alice@example.com");

        // Accessor methods — named after the component, no "get" prefix
        System.out.println(alice.name());   // Alice
        System.out.println(alice.age());    // 30
        System.out.println(alice.email());  // alice@example.com

        // toString() — clean, readable output
        System.out.println(alice);
        // Person[name=Alice, age=30, email=alice@example.com]

        // equals() — value-based, not identity-based
        Person aliceCopy = new Person("Alice", 30, "alice@example.com");
        System.out.println(alice.equals(aliceCopy));  // true
        System.out.println(alice == aliceCopy);        // false (different objects)

        // hashCode() — consistent with equals()
        System.out.println(alice.hashCode() == aliceCopy.hashCode());  // true
    }
}

Because records are immutable by design, they are safe to share across threads, use as keys in HashMap and HashSet, and use in sets without worrying about the hash changing. This is a significant advantage over mutable classes where forgetting to override equals() and hashCode() consistently is a persistent source of bugs.

Using records in collections

Java
import java.util.*;
import java.util.stream.*;

public record Product(String name, double price, String category) {}

public class Catalog {
    public static void main(String[] args) {

        List<Product> products = List.of(
            new Product("Keyboard", 79.99, "peripherals"),
            new Product("Monitor",  399.00, "displays"),
            new Product("Mouse",    29.99, "peripherals"),
            new Product("Webcam",   89.00, "peripherals")
        );

        // Stream operations work naturally with records
        Map<String, List<Product>> byCategory = products.stream()
            .collect(Collectors.groupingBy(Product::category));

        double avgPrice = products.stream()
            .mapToDouble(Product::price)
            .average()
            .orElse(0);

        // Record works as a Map key — hashCode() is value-based
        Map<Product, Integer> stock = new HashMap<>();
        products.forEach(p -> stock.put(p, 10));

        Product lookup = new Product("Keyboard", 79.99, "peripherals");
        System.out.println(stock.get(lookup));  // 10 — found by value equality
    }
}

Compact constructors and validation

Records enforce immutability, but they do not automatically enforce business rules. A Person with a negative age or an empty name is structurally valid to the compiler but semantically wrong. The compact constructor lets you add validation and normalization logic without repeating the assignment boilerplate.

In a compact constructor, the parameter list is omitted — the components are implicitly in scope by their original names, and the implicit assignment (this.name = name, etc.) happens automatically at the end. You only write the validation or transformation logic:

Java 16+
public record Person(String name, int age, String email) {

    // Compact constructor — no parameter list, no 'this.x = x' assignments
    public Person {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name must not be blank");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Age must be between 0 and 150, got: " + age);
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Email must contain @");
        }
        // Normalization: trim and lowercase the email
        email = email.trim().toLowerCase();
        // Implicit: this.name = name; this.age = age; this.email = email;
    }
}
Test// Valid Person alice = new Person("Alice", 30, "Alice@Example.COM"); System.out.println(alice.email()); // alice@example.com ← normalized // Invalid — throws IllegalArgumentException Person bad = new Person("", 30, "alice@example.com"); // Name must not be blank Person old = new Person("Bob", 200, "bob@example.com"); // Age must be between 0 and 150

The key insight is that assigning to the local variable inside the compact constructor (e.g., email = email.trim().toLowerCase()) modifies what gets assigned to this.email. The implicit assignment happens at the end with whatever value the variable holds at that point.

Defensive copies for mutable components

If a record component holds a mutable type like List or an array, callers can mutate the original object after construction, breaking immutability. The compact constructor is the right place to make defensive copies:

Java
import java.util.List;

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

    public Team {
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("Team name required");

        // Defensive copy + make unmodifiable
        members = List.copyOf(members);  // throws NullPointerException if members is null
    }
}

public class Example {
    public static void main(String[] args) {
        var original = new java.util.ArrayList<String>();
        original.add("Alice");
        original.add("Bob");

        Team team = new Team("Backend", original);

        original.add("Carol");  // mutate original list

        // team.members() is unaffected — defensive copy was made
        System.out.println(team.members());  // [Alice, Bob]

        // team.members().add("Dave") would throw UnsupportedOperationException
    }
}

Custom methods and accessors

Records can contain any instance methods, static methods, static fields, and even overridden accessor methods. The only restriction is that they cannot declare instance fields beyond the record components — all state must come from the header.

Java
import java.math.BigDecimal;

public record Money(BigDecimal amount, String currency) {

    // Static factory methods are clean with records
    public static Money of(double amount, String currency) {
        return new Money(BigDecimal.valueOf(amount), currency);
    }

    public static Money usd(double amount) {
        return new Money(BigDecimal.valueOf(amount), "USD");
    }

    // Instance methods that derive from the components
    public Money add(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch");
        return new Money(amount.add(other.amount), currency);
    }

    public Money multiply(double factor) {
        return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
    }

    public boolean isPositive() {
        return amount.compareTo(BigDecimal.ZERO) > 0;
    }

    // Override the generated toString() for a friendlier format
    @Override
    public String toString() {
        return currency + " " + amount.toPlainString();
    }
}

public class Invoice {
    public static void main(String[] args) {
        Money price    = Money.usd(100.00);
        Money tax      = Money.usd(8.50);
        Money total    = price.add(tax);
        Money discount = total.multiply(0.9);

        System.out.println(total);     // USD 108.50
        System.out.println(discount);  // USD 97.650
        System.out.println(discount.isPositive());  // true
    }
}

Notice that all mutating operations return a new record rather than modifying the existing one. This is the immutable value object pattern — all transformations produce new values, making records completely safe to share and use in functional pipelines.

Overriding accessor methods

You can override the generated accessor for a component — useful for returning a defensive copy or formatting output:

Java
import java.util.List;

public record Playlist(String title, List<String> tracks) {

    public Playlist {
        // Make unmodifiable on the way in
        tracks = List.copyOf(tracks);
    }

    // Override the accessor to return a copy on the way out too
    @Override
    public List<String> tracks() {
        return List.copyOf(tracks);  // already unmodifiable, but explicit
    }

    public int size() {
        return tracks.size();
    }

    public Playlist withTrack(String track) {
        // Return a new record with the track appended
        var newTracks = new java.util.ArrayList<>(tracks);
        newTracks.add(track);
        return new Playlist(title, newTracks);
    }
}

Implementing interfaces

Records cannot extend classes, but they can implement any number of interfaces. This is the primary extension mechanism for records and is very commonly used with functional interfaces and domain interfaces:

Java
import java.util.Comparator;

// Domain interface
interface Identifiable {
    String id();
}

// Comparable for natural ordering
public record Employee(String id, String name, int yearsOfService)
        implements Identifiable, Comparable<Employee> {

    @Override
    public int compareTo(Employee other) {
        return Integer.compare(other.yearsOfService, this.yearsOfService);
    }
}

public class HRSystem {
    public static void main(String[] args) {
        var employees = new java.util.ArrayList<>(List.of(
            new Employee("E001", "Alice", 8),
            new Employee("E002", "Bob",   3),
            new Employee("E003", "Carol", 12)
        ));

        Collections.sort(employees);  // uses compareTo — sorts by seniority
        employees.forEach(e ->
            System.out.println(e.name() + ": " + e.yearsOfService() + " years"));
    }
}
OutputCarol: 12 years Alice: 8 years Bob: 3 years

Generic records

Records support type parameters, making them useful as reusable containers. A generic record is declared by adding type parameters after the record name:

Java
// A generic Pair of any two types
public record Pair<A, B>(A first, B second) {

    public Pair<B, A> swap() {
        return new Pair<>(second, first);
    }
}

// A Result type that holds either a value or an error
public record Result<T>(T value, String error) {

    public static <T> Result<T> success(T value) {
        return new Result<>(value, null);
    }

    public static <T> Result<T> failure(String error) {
        return new Result<>(null, error);
    }

    public boolean isSuccess() { return error == null; }
}

public class Usage {
    public static Result<Integer> divide(int a, int b) {
        if (b == 0) return Result.failure("Division by zero");
        return Result.success(a / b);
    }

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("hello", 42);
        System.out.println(p);          // Pair[first=hello, second=42]
        System.out.println(p.swap());   // Pair[first=42, second=hello]

        Result<Integer> r1 = divide(10, 2);
        Result<Integer> r2 = divide(10, 0);
        System.out.println(r1.isSuccess() + ": " + r1.value());   // true: 5
        System.out.println(r2.isSuccess() + ": " + r2.error());   // false: Division by zero
    }
}

Nesting and composition

Records compose naturally — a record can contain other records as components. This is the building block for rich domain models without mutable state:

Java
// Fine-grained value types
public record Address(String street, String city, String country, String postcode) {}
public record EmailAddress(String value) {
    public EmailAddress {
        if (!value.contains("@")) throw new IllegalArgumentException("Invalid email");
        value = value.toLowerCase().trim();
    }
}

// Composed domain object
public record Customer(String id, String name, EmailAddress email, Address address) {}

public class OrderService {
    public static void main(String[] args) {
        var customer = new Customer(
            "C001",
            "Alice Smith",
            new EmailAddress("Alice@Example.COM"),
            new Address("123 Main St", "London", "UK", "EC1A 1BB")
        );

        System.out.println(customer.email().value());         // alice@example.com
        System.out.println(customer.address().city());         // London
        System.out.println(customer.address().country());      // UK
    }
}

This composition approach — wrapping primitive values in small, validated records like EmailAddress — is called "making illegal states unrepresentable." An EmailAddress is always valid by construction, so any code that holds one never needs to re-validate it. The validation is centralized and guaranteed at object creation time.

Jackson JSON serialization

Jackson (the most widely used Java JSON library) has supported records since version 2.12 (released December 2020) without any special annotations. Jackson uses the canonical constructor for deserialization and the accessor methods for serialization:

Java — Jackson 2.12+
import com.fasterxml.jackson.databind.ObjectMapper;

public record Article(String title, String author, int year) {}

public class JsonExample {
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();

        // Serialization — record to JSON string
        Article article = new Article("Java Records Guide", "EduHT", 2025);
        String json = mapper.writeValueAsString(article);
        System.out.println(json);
        // {"title":"Java Records Guide","author":"EduHT","year":2025}

        // Deserialization — JSON string to record
        String incoming = """
            {"title":"Effective Java","author":"Bloch","year":2018}
            """;
        Article parsed = mapper.readValue(incoming, Article.class);
        System.out.println(parsed.author());  // Bloch

        // Works with generic types too
        String listJson = "[{\"title\":\"A\",\"author\":\"X\",\"year\":2020}]";
        var ref = new com.fasterxml.jackson.core.type.TypeReference<List<Article>>(){};
        List<Article> articles = mapper.readValue(listJson, ref);
        System.out.println(articles.size());  // 1
    }
}

When a record component name differs from the JSON field name, use @JsonProperty on the component:

Java
import com.fasterxml.jackson.annotation.JsonProperty;

public record ApiResponse(
    @JsonProperty("user_id")   String userId,
    @JsonProperty("first_name") String firstName,
    @JsonProperty("last_name")  String lastName
) {}

// Deserializes {"user_id":"U1","first_name":"Alice","last_name":"Smith"}
// into ApiResponse(userId="U1", firstName="Alice", lastName="Smith")

Records with sealed interfaces

Records and sealed interfaces were introduced in the same Java version cycle and are designed to work together. A sealed interface restricts which classes can implement it, creating a closed, exhaustive type hierarchy. Records are ideal as the leaves of such a hierarchy because they are implicitly final:

Java 17+
// Sealed interface — only these three classes can implement it
public sealed interface Shape
    permits Circle, Rectangle, Triangle {}

public record Circle(double radius)                       implements Shape {}
public record Rectangle(double width, double height)     implements Shape {}
public record Triangle(double base, double height)        implements Shape {}

public class Geometry {
    public static double area(Shape shape) {
        return switch (shape) {
            case Circle    c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle  t -> 0.5 * t.base() * t.height();
        };  // No default needed — the compiler knows all cases are covered
    }

    public static void main(String[] args) {
        System.out.println(area(new Circle(5)));         // 78.53...
        System.out.println(area(new Rectangle(4, 6)));    // 24.0
        System.out.println(area(new Triangle(3, 8)));     // 12.0
    }
}

The sealed interface plus records combination brings algebraic data types to Java. The compiler can verify that a switch expression covers all permitted types, so adding a new variant (e.g., Ellipse) immediately triggers a compilation error in any switch that doesn't handle it. This is exhaustiveness checking — a feature that prevents entire categories of runtime errors.

Pattern matching with records (Java 21)

Java 21 finalized record patterns — the ability to destructure a record's components directly inside a pattern match. Combined with sealed interfaces, this replaces chains of instanceof checks and casts with clean, readable deconstruction:

Java 21+
// Records used as components of a domain event hierarchy
public sealed interface OrderEvent
    permits OrderPlaced, OrderShipped, OrderDelivered, OrderCancelled {}

public record OrderPlaced   (String orderId, String customerId, double total) implements OrderEvent {}
public record OrderShipped  (String orderId, String trackingCode)              implements OrderEvent {}
public record OrderDelivered(String orderId, java.time.LocalDate deliveredOn)   implements OrderEvent {}
public record OrderCancelled(String orderId, String reason)                    implements OrderEvent {}

public class EventProcessor {

    public static String describe(OrderEvent event) {
        return switch (event) {
            // Record pattern — destructures components directly
            case OrderPlaced(var id, var customer, var total) ->
                "New order " + id + " from " + customer + ", total: " + total;

            case OrderShipped(var id, var tracking) ->
                "Order " + id + " shipped, tracking: " + tracking;

            case OrderDelivered(var id, var date) ->
                "Order " + id + " delivered on " + date;

            case OrderCancelled(var id, var reason) ->
                "Order " + id + " cancelled: " + reason;
        };  // No default needed — sealed interface is exhaustive
    }

    public static void main(String[] args) {
        var events = List.of(
            new OrderPlaced("O1", "C1", 99.99),
            new OrderShipped("O1", "TRK-001"),
            new OrderDelivered("O1", java.time.LocalDate.of(2025, 3, 15))
        );
        events.forEach(e -> System.out.println(describe(e)));
    }
}
OutputNew order O1 from C1, total: 99.99 Order O1 shipped, tracking: TRK-001 Order O1 delivered on 2025-03-15

When NOT to use records

Records are not a universal replacement for all classes. Several situations call for a traditional class instead:

SituationUse records?Reason
Immutable data carriers, DTOs, value objects Yes — ideal Exactly what records are designed for
Mutable state — fields that change after construction No All record components are final
Classes that must extend another class No Records implicitly extend java.lang.Record
JPA / Hibernate entities No JPA requires mutable state and a no-arg constructor
Classes with complex inheritance hierarchies No Records are final — they cannot be subclassed
Lazy-initialized fields (computed on first access) Caution No instance fields beyond components; workarounds are awkward
Classes with many optional fields Caution Builder pattern may be cleaner; records have no default values
Spring / CDI beans (service, repository classes) No Beans need default constructors and mutable injection fields

The right mental model: a record is a value, not an entity. A value has no identity — two Point(1.0, 2.0) instances are interchangeable and equal. An entity has identity — two Customer objects with the same name might represent different people. Domain-driven design distinguishes these concepts as value objects and entities. Records are for value objects; regular classes with identity are for entities.

If you come from Kotlin, records are Java's answer to data classes — but more principled about immutability. If you come from functional programming, records are Java's answer to product types. In both cases, the arrival of records in Java 16 removed a significant source of boilerplate and made the language meaningfully more expressive for data modeling.

Frequently Asked Questions

Can Java records extend other classes?
No. Records implicitly extend java.lang.Record and Java does not support multiple class inheritance. Records can implement any number of interfaces, but they cannot extend another class. This restriction reinforces their purpose as transparent, final data carriers.
Are Java records serializable?
Not automatically, but you can make a record implement java.io.Serializable by adding it to the implements clause. When using Jackson for JSON, records work out of the box since Jackson 2.12 without any annotations, because Jackson uses the canonical constructor for deserialization.
What is the difference between a Java record and a Kotlin data class?
Both generate equals(), hashCode(), and toString() automatically. The key differences: Java records are immutable by design (all components are final); Kotlin data classes can have mutable properties. Java records have a canonical constructor; Kotlin data classes support default parameter values and a copy() method. Neither can extend another class.
Can records have mutable fields?
The record components themselves are always final and cannot be reassigned after construction. However, if a component holds a reference to a mutable object (like a List or an array), the contents of that object can still be modified. For true deep immutability, copy mutable collections in the compact constructor using List.copyOf() or Collections.unmodifiableList().