Java: Records

24 Jul 2026 11 min read
1
Records are a special kind of Java class designed to model immutable data.

They automatically generate common boilerplate code such as constructors, accessor methods, equals(), hashCode(), and toString(), allowing developers to focus on the data itself rather than repetitive implementation details.

Once a record object is created, its state cannot be modified.

Records were introduced as a preview feature in Java 14 and became a standard language feature in Java 16. A record is declared using the record keyword followed by its components.
public record Employee(
        int id,
        String name,
        double salary
) {
}
For each record component, the Java compiler creates a private final field to store the value and a corresponding accessor method to retrieve it.

It also generates a constructor that initializes all components, along with implementations of equals(), hashCode(), and toString().

The generated equals() and hashCode() methods compare and compute values using all record components, while toString() returns a readable string representation of the record.

Creating a record object looks exactly like creating a normal class.
Employee employee = new Employee( 101, "John", 85000 );
Unlike traditional JavaBeans, records do not generate getter methods beginning with get. Instead, the accessor method has exactly the same name as the component.
System.out.println( employee.id() ); 
System.out.println( employee.name() ); 
System.out.println( employee.salary() );
Output:
101 
John 
85000.0
The generated toString() method provides a readable representation of the record.
System.out.println(employee);
Output:
Employee[ id=101, name=John, salary=85000.0 ]
Java records are well suited for representing immutable data.

They are commonly used as DTOs (Data Transfer Objects) to transfer data between application layers, as request and response objects in REST APIs, and as message payloads for systems such as Apache Kafka and RabbitMQ.

Records are also ideal for creating immutable configuration objects and simple domain value objects, where the primary purpose is to hold data rather than implement complex business logic.

How Records Work Internally

Although records appear to be a new language construct, the Java compiler ultimately transforms them into normal final classes.

When the compiler encounters a record declaration, it generates a final class that extends java.lang.Record (which itself extends java.lang.Object) and automatically generates the required fields, constructor, accessor methods, and implementations of equals(), hashCode(), and toString().

For example,
public record Employee(
        int id,
        String name
) {
}
is conceptually similar to the following generated class.
public final class Employee extends Record {

    private final int id;
    private final String name;

    public Employee(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int id() {
        return id;
    }

    public String name() {
        return name;
    }

    @Override
    public boolean equals(Object o) {
        // generated
    }

    @Override
    public int hashCode() {
        // generated
    }

    @Override
    public String toString() {
        // generated
    }

}
The actual compiler-generated implementation is more sophisticated, but conceptually this is how a record behaves.

Constructors

Canonical Constructor

Every record automatically receives a constructor whose parameters exactly match the declared components. This constructor is known as the canonical constructor.

The compiler generates this constructor automatically unless the developer explicitly provides one. For the following record,
public record Employee(
        int id,
        String name,
        double salary
) {
}
the compiler conceptually generates:
public Employee(
        int id,
        String name,
        double salary
) {
    this.id = id;
    this.name = name;
    this.salary = salary;
}
Creating a record invokes this constructor.
Employee employee = new Employee( 101, "John", 85000 );
The canonical constructor can also be written explicitly.
public record Employee(
        int id,
        String name,
        double salary
) {
    public Employee(
            int id,
            String name,
            double salary
    ) {
        this.id = id;
        this.name = name;
        this.salary = salary;
    }
}

Compact Constructor

Writing the full canonical constructor can become repetitive when only validation logic is needed. To simplify this, records support a compact constructor.

A compact constructor omits the parameter list and field assignments. The compiler automatically inserts them after the constructor body. Instead of writing:
public record Employee(
        int id,
        String name,
        double salary
) {
    public Employee(
            int id,
            String name,
            double salary
    ) {
        if (salary < 0) {
            throw new IllegalArgumentException(
                    "Salary cannot be negative."
            );
        }
        this.id = id;
        this.name = name;
        this.salary = salary;
    }

}
the same logic can be written more concisely.
public record Employee(
        int id,
        String name,
        double salary
) {
    public Employee {
        if (salary < 0) {
            throw new IllegalArgumentException(
                    "Salary cannot be negative."
            );
        }
    }
}

Custom Constructors

Although records automatically generate a canonical constructor, developers can define additional constructors whenever multiple ways of creating a record are required.

Unlike normal classes, every custom constructor must eventually delegate to the canonical constructor using this(...).

Direct assignment to record components is not allowed because they are final and managed by the compiler. Consider the following record.
public record Employee(
        int id,
        String name,
        double salary
) {
    public Employee(
            int id,
            String name
    ) {
        this(id, name, 0.0);
    }
}
Now the record can be created in two different ways.
Employee employee1 = new Employee(
        101,
        "John",
        85000
);

Employee employee2 = new Employee(
        102,
        "Alice"
);
Output:
Employee[id=101, name=John, salary=85000.0] 
Employee[id=102, name=Alice, salary=0.0]
This approach allows multiple construction options while ensuring that all record components are initialized consistently.

Adding Methods to Records

Although records are primarily intended to store immutable data, they are not limited to holding fields. Records can define instance methods, static methods, and helper methods just like normal classes.

The only restriction is that instance methods cannot modify the record's state because all components are immutable. For example, a record can include business logic.
public record Employee(
        int id,
        String name,
        double salary
) {
    public boolean isHighEarner() {
        return salary > 100000;
    }
}
The method can be invoked normally.
Employee employee = new Employee( 101, "John", 120000 ); 
System.out.println(employee.isHighEarner());
Output:
true
Records can also override compiler-generated methods when custom behavior is required.
public record Employee(
        int id,
        String name
) {
    @Override
    public String toString() {
        return name + " (" + id + ")";
    }
}
Output:
John (101)

Static Members in Records

Records may also contain static fields, static methods, and nested types, just like ordinary classes.

Static members belong to the record itself rather than to individual record objects. A static field and utility method can be declared as follows.
public record Employee(
        int id,
        String name
) {
    public static final String COMPANY = "OpenAI";
}
The static method is invoked without creating a record object.
System.out.println(Employee.COMPANY);
Output:
OpenAI
Static factory methods are also common.
public record Employee(
        int id,
        String name
) {
    public static Employee unknown() {
        return new Employee(0, "Unknown");
    }
}
Usage:
Employee employee = Employee.unknown();
System.out.println(employee);
Output:
Employee[id=0, name=Unknown]
Static members provide utility functionality without affecting the immutable nature of record instances.

Nested Records

A record can be declared inside another class, interface, enum, or even another record. Such records are known as nested records.

Unlike nested classes, nested records are implicitly static.

They do not maintain a reference to an enclosing instance, making them lightweight and suitable for grouping closely related immutable data.
public class Company {
    public record Employee(
            int id,
            String name
    ) {
    }
}
The nested record is instantiated using its enclosing type.
Company.Employee employee = new Company.Employee(
        101,
        "John"
);
System.out.println(employee);
Output:
Employee[id=101, name=John]
Nested records are commonly used to organize helper data structures without creating additional top-level classes.

Generic Records

Like ordinary classes, records can declare type parameters. Generic records allow the same record definition to work with multiple data types while maintaining compile-time type safety.

A simple generic record is shown below.
public record Pair<T>(
        T first,
        T second
) {
}
It can be instantiated with different types.
Pair<String> names = new Pair<>(
        "John",
        "Alice"
);
Pair<Integer> numbers = new Pair<>(
        10,
        20
);
Accessing the components works exactly like any other record.
System.out.println( names.first() ); 
System.out.println( numbers.second() );
Output:
John 
20
Multiple type parameters are also supported.
public record Entry<K, V>(
        K key,
        V value
) {
}
Usage:
Entry<Integer, String> employee = new Entry<>( 101, "John" );

Records with Interfaces

Although records cannot extend ordinary classes, they can implement one or more interfaces.

This allows records to participate in polymorphism while preserving their immutable nature. Consider the following interface.
public interface Printable {
    void print();
}
A record can implement it.
public record Employee(
        int id,
        String name
) implements Printable {
    @Override
    public void print() {
        System.out.println(id + " - " + name);
    }
}
Usage:
Printable employee = new Employee( 101, "John" ); 
employee.print();
Output:
101 - John
Records can implement multiple interfaces just like normal classes. This capability allows records to integrate seamlessly into existing object-oriented designs.

Records and Inheritance (Restrictions)

One of the most important characteristics of records is that they have several inheritance restrictions. These restrictions ensure that records remain simple immutable data carriers.

Every record is implicitly final and automatically extends java.lang.Record. Consequently, a record cannot extend another class, and no class can extend a record.

A record cannot extend another class because it implicitly extends Record, and it also cannot be extended since records are implicitly final.

However, records can implement interfaces, allowing them to participate in polymorphic designs.

They can also define additional instance methods, contain static fields and static methods, and declare nested types such as classes, interfaces, enums, or other records when needed.
public record Employee(
        int id,
        String name,
        double salary
) {
    public static final String COMPANY = "OpenAI";

    public boolean isHighEarner() {
        return salary >= 100000;
    }

    public static String company() {
        return COMPANY;
    }

    public record Address(
            String city,
            String country
    ) {
    }

    public enum Department {
        ENGINEERING,
        HR,
        SALES
    }

    public interface Printable {
        void print();
    }

    public static class Utils {
        public static String format(Employee employee) {
            return employee.name() + " (" + employee.id() + ")";
        }
    }
}

Records and Serialization

Records automatically support Java serialization when they implement the Serializable interface.

The record can be serialized using the same APIs as ordinary classes.
Employee employee = new Employee(
        101,
        "John"
);
ObjectOutputStream out = new ObjectOutputStream(
        new FileOutputStream("employee.ser")
);
out.writeObject(employee);
Deserialization recreates the record by invoking its canonical constructor.
ObjectInputStream in = new ObjectInputStream(
        new FileInputStream("employee.ser")
);
Employee employee = (Employee) in.readObject();
The serialization process is conceptually identical to ordinary serializable classes.

Because records naturally represent immutable value objects, they are an excellent fit for serialization, messaging, caching, and distributed applications.

Reflection with Records

Java provides dedicated reflection APIs for working with records. Starting with Java 16, the Reflection API can determine whether a class is a record and inspect its individual components.

The isRecord() method determines whether a class is a record, while getRecordComponents() returns metadata for all of its components.

Each RecordComponent exposes additional information, including getName() to retrieve the component name, getType() to determine its data type, and getAccessor() to obtain the automatically generated accessor method associated with the component.

Consider the following record.
public record Employee(
        int id,
        String name,
        double salary
) {
}
We can determine whether the class is a record.
Class<Employee> clazz = Employee.class;
System.out.println(clazz.isRecord());
Output:
true
The record components can then be inspected.
RecordComponent[] components = clazz.getRecordComponents();
for (RecordComponent component : components) {
    System.out.println(component.getName()  +  " : " + component.getType().getSimpleName());
}
Output:
id : int 
name : String 
salary : double
Records vs POJOs vs Lombok

Before records were introduced, immutable data classes were typically implemented either as traditional POJOs or with libraries such as Lombok to reduce boilerplate. Records provide the same functionality directly within the Java language, eliminating the need for generated code in many situations.

Traditional POJOs require developers to manually write constructors, getters, and methods such as equals(), hashCode(), and toString().

Lombok significantly reduces this boilerplate through annotations but relies on an annotation processor during compilation. In contrast, records are a built-in Java language feature that require minimal code, are immutable by default, and do not require any external libraries or annotation processing.

Records are generally the preferred choice whenever a class simply represents immutable data. Traditional classes remain more suitable when mutable state, inheritance, or complex object behavior is required.

Final Notes

Records represent one of the most significant improvements to the Java language in recent years. By eliminating boilerplate code and embracing immutability, they allow developers to express data models in a concise, readable, and reliable way.

Although records are implemented as ordinary classes under the hood, the compiler automatically generates constructors, accessor methods, equals(), hashCode(), and toString(), making them ideal for immutable data carriers.

Records integrate naturally with generics, interfaces, serialization, reflection, and modern Java frameworks, making them suitable for REST APIs, messaging systems, configuration objects, and many other enterprise applications.
Nagesh Chauhan

Nagesh Chauhan

Principal Software Engineer • Java • Python • Distributed Systems • AI/ML

Principal Software Engineer with 14+ years of experience designing and delivering large-scale distributed systems, cloud-native applications, and AI-powered platforms.

Passionate about solving complex engineering problems using strong data structures and algorithms, along with expertise in Java, Spring Boot, Python, System Design, Microservices, Cloud, Kafka, Elasticsearch, and Generative AI.

Share this Article

💬 Comments

Join the Discussion