Java Records
A record is a special kind of Java class designed to model immutable data with a minimum of boilerplate. Instead of hand-writing a constructor, private final fields, accessor methods, equals(), hashCode(), and toString(), you declare the fields once in a header and the compiler generates all of that for you. Records were finalized in Java 16 (after preview rounds in Java 14 and 15) and are now the standard way to represent simple, transparent data carriers such as coordinates, DTOs, and API responses.
Overview / How Records Work
A record is a restricted form of class whose entire purpose is to be a transparent, immutable holder of data. When you write record Point(int x, int y) {}, the compiler treats x and y as components. From that single declaration, javac generates:
- A private
finalfield for each component. - A public canonical constructor that takes all components in order and assigns them to the fields.
- A public accessor method for each component, named exactly like the component (so
x(), notgetX()). - An
equals()method that returns true when all components are equal. - A
hashCode()method consistent with thatequals(). - A
toString()method that prints the record name and each component, e.g.Point[x=3, y=4].
Because the whole point of a record is immutability and transparency, the language enforces some restrictions: a record is implicitly final (it cannot be subclassed), it cannot extend any other class (it implicitly extends the abstract class java.lang.Record), and it cannot declare additional non-static instance fields beyond the components. It can implement interfaces, declare static fields and methods, declare instance methods, add extra (non-canonical) constructors, and override any of the generated methods if you need custom behavior.
Syntax
record RecordName(Type component1, Type component2, ...) {
// optional: compact constructor for validation/normalization
// optional: additional constructors, static fields, methods
}
| Part | Meaning |
|---|---|
record |
Keyword that declares a record type (a restricted class). |
RecordName |
The type name, following normal class-naming conventions. |
(Type component1, ...) |
The header: each entry becomes a private final field plus a public accessor method of the same name. |
Body { } |
Optional; may contain a compact constructor, extra constructors, static members, and instance methods. |
Examples
Example 1: A basic record
public class Main {
record Point(int x, int y) {}
public static void main(String[] args) {
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
System.out.println(p1);
System.out.println("x = " + p1.x() + ", y = " + p1.y());
System.out.println("p1 equals p2: " + p1.equals(p2));
System.out.println("p1 == p2: " + (p1 == p2));
System.out.println("hashCodes match: " + (p1.hashCode() == p2.hashCode()));
}
}
Output:
Point[x=3, y=4]
x = 3, y = 4
p1 equals p2: true
p1 == p2: false
hashCodes match: true
Notice that p1 and p2 are two different objects (== is false, since they occupy different memory locations), yet equals() returns true because the generated implementation compares component values, not references. This is exactly the behavior you would want from a value-like coordinate type, and you got it for free.
Example 2: Validating input with a compact constructor
public class Main {
record Range(int min, int max) {
Range {
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
}
int length() {
return max - min;
}
}
public static void main(String[] args) {
Range r = new Range(2, 8);
System.out.println(r);
System.out.println("Length: " + r.length());
try {
Range bad = new Range(10, 1);
} catch (IllegalArgumentException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
Output:
Range[min=2, max=8]
Length: 6
Caught: min must be <= max
The block Range { ... } is a compact constructor. It has no parameter list of its own (it implicitly reuses the header's parameters) and no explicit field assignment — after the compact constructor body runs, the compiler automatically assigns the (possibly modified) parameter values to the fields. This makes it the ideal place to validate or normalize input without repeating the parameter list. Records can also declare ordinary methods, like length() here, alongside the generated ones.
Example 3: Implementing an interface and adding a static factory
public class Main {
interface Shape {
double area();
}
record Circle(double radius) implements Shape {
static final double PI_APPROX = 3.14159;
static Circle unitCircle() {
return new Circle(1.0);
}
@Override
public double area() {
return PI_APPROX * radius * radius;
}
}
public static void main(String[] args) {
Circle c = new Circle(2.0);
Shape s = c;
System.out.println(c);
System.out.println("Area: " + s.area());
Circle unit = Circle.unitCircle();
System.out.println("Unit circle area: " + unit.area());
}
}
Output:
Circle[radius=2.0]
Area: 12.56636
Unit circle area: 3.14159
Since a record can't extend a class, interfaces are how you give it shared behavior or let it be treated polymorphically. This example also shows that records support static fields (PI_APPROX) and static factory methods (unitCircle()), which are common patterns for offering convenient, named ways to construct instances.
Under the Hood
When javac compiles a record, it produces a class file that extends java.lang.Record (an abstract class with abstract equals, hashCode, and toString methods) and is marked final. For each component the compiler emits a private final field with the same name and type. The canonical constructor — whether you wrote a compact one or not — ends up assigning every parameter to its matching field, in declaration order.
The generated equals(), hashCode(), and toString() are not written out as ordinary bytecode the way a hand-written class's methods would be. Instead, javac emits a call to a special JDK bootstrap method (java.lang.runtime.ObjectMethods::bootstrap) via invokedynamic, passing the list of component names. At first invocation, the JVM links this to efficient method handles that read every component and combine them — this is why adding or removing a component automatically updates all three methods consistently, with no chance of them drifting out of sync (a common bug in hand-written classes).
Accessor methods are ordinary public instance methods that simply return the corresponding field — no get prefix, by design, since the accessor's job is just to expose the value, not perform an action. Because the fields are final and there are no setters, once a record instance is constructed its component values can never change through the record's own API (though a mutable object stored inside a component, like an array or an ArrayList, can still be mutated through its own methods — see the next section).
Common Mistakes
Mistake 1: Trying to add a mutable instance field in the body
Records only allow static fields in the body — any attempt to declare an additional instance field fails to compile, because it would break the transparency guarantee that a record's state is fully described by its components.
record Account(String owner, double balance) {
private double bonus; // ERROR: instance fields are not allowed in records
}
Fix it by either turning the extra value into a real component, or — if it's derived rather than stored — expose it as a computed method or a static configuration value:
public class Main {
record Account(String owner, double balance) {
static double BONUS_RATE = 0.05; // static fields are allowed
double bonusAmount() {
return balance * BONUS_RATE;
}
}
public static void main(String[] args) {
Account acc = new Account("Sam", 1000.0);
System.out.println("Bonus: " + acc.bonusAmount());
}
}
Output:
Bonus: 50.0
Mistake 2: Using arrays as components
Arrays don't override equals(), hashCode(), or toString() — they use reference identity. Since a record's generated methods simply delegate to each component's own equals/hashCode/toString, an array component silently breaks the value-equality behavior you'd expect from a record.
public class Main {
record Grades(String student, int[] scores) {}
public static void main(String[] args) {
Grades g1 = new Grades("Ana", new int[]{90, 85, 77});
Grades g2 = new Grades("Ana", new int[]{90, 85, 77});
System.out.println("g1 equals g2: " + g1.equals(g2));
System.out.println("Same array reference: " + (g1.scores() == g2.scores()));
}
}
Output:
g1 equals g2: false
Same array reference: false
Even though both arrays contain identical values, equals() returns false because it compares array references, not contents. The fix is to use an immutable collection type instead, which does implement value-based equality:
import java.util.List;
public class Main {
record Grades(String student, List scores) {}
public static void main(String[] args) {
Grades g1 = new Grades("Ana", List.of(90, 85, 77));
Grades g2 = new Grades("Ana", List.of(90, 85, 77));
System.out.println(g1);
System.out.println("g1 equals g2: " + g1.equals(g2));
}
}
Output:
Grades[student=Ana, scores=[90, 85, 77]]
g1 equals g2: true
Best Practices
- Use records for simple, immutable data — coordinates, DTOs, tuples, API request/response bodies — not for entities that have identity or need to change over time.
- Validate and normalize input inside a compact constructor so invalid instances can never be constructed.
- Prefer immutable component types (
List.of(...),String, other records, or primitives) over arrays or mutable collections so the record stays deeply immutable. - Don't fight the design by adding setters or wrapping components in mutable containers — if you need mutability, use a regular class instead.
- Use interfaces to share behavior across records, since records cannot extend another class.
- Keep the canonical/compact constructor lightweight; put heavier logic in separate static factory methods.
- Remember accessor methods are named after the component (
x()), not JavaBean-style (getX()) — adjust code that relies on reflection-based bean conventions accordingly.
Practice Exercises
- Write a record
Employee(String name, String department, double salary)with a compact constructor that throws anIllegalArgumentExceptionifsalaryis negative. Construct one valid employee and print it, then try an invalid salary inside a try/catch and print the caught message. - Write a record
Temperature(double celsius)with an instance methodtoFahrenheit()that returnscelsius * 9 / 5 + 32. Create twoTemperatureobjects with the same value and print whether they areequals()to each other. - Write a record
Inventory(String product, List<String> tags)usingList.of(...)for the tags, and print two equal instances to confirm they reportequalsas true — contrast this in your head with what would happen iftagswere a plain array instead.
Summary
- A record is a concise, restricted class for modeling immutable data; the header's components drive auto-generated fields, a canonical constructor, accessors,
equals(),hashCode(), andtoString(). - Records are implicitly
final, extendjava.lang.Record, and cannot declare extra instance fields, but they can implement interfaces and declare static members and methods. - A compact constructor lets you validate or normalize component values without restating the parameter list or field assignments.
- Generated
equals/hashCode/toStringare produced via aninvokedynamicbootstrap over the component list, so they always stay consistent with the record's declared state. - Avoid array-typed components since arrays use reference-based equality; prefer immutable collections like
List.of(...)instead. - Records shine for transparent, value-like data — reach for ordinary classes when you need mutability or identity semantics.
