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 equalhashCode()— derived from all componentstoString()— 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:
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:
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:
// 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
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:
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;
}
}
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:
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.
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:
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:
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"));
}
}
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:
// 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:
// 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:
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:
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:
// 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:
// 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)));
}
}
When NOT to use records
Records are not a universal replacement for all classes. Several situations call for a traditional class instead:
| Situation | Use 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.