Skip to main content
Java · lesson 32 of 66
Open the Java page →
Java intermediate Lesson 32 of 66

Records in Java

Learn Java records by running them: generated accessors and equals, compact constructors, custom methods, sealed hierarchies, and record patterns.

By SkillByExample · Updated

A record is a concise, immutable data carrier (Java 16+). You declare the components once in the header and the compiler generates the constructor, accessors, equals, hashCode, and toString. Every example is a single file run with the JDK 21 source launcher (java File.java).

What a record generates

public class RecordDemo {
    record Point(int x, int y) {}

    public static void main(String[] args) {
        Point p1 = new Point(3, 4);
        Point p2 = new Point(3, 4);
        Point p3 = new Point(1, 2);

        System.out.println(p1.x() + " " + p1.y());
        System.out.println(p1);
        System.out.println(p1.equals(p2) + " " + p1.equals(p3));
        System.out.println(p1.hashCode() == p2.hashCode());
        System.out.println(p1 == p2);
    }
}
$ java RecordDemo.java
3 4
Point[x=3, y=4]
true false
true
false

p1.equals(p2) is true even though they are two separate objects — the generated equals compares components, not identity, which p1 == p2 still reports as false. Accessors are named after the components (x(), not getX()), and toString uses a fixed Name[field=value] format. The equivalent hand-written class needs a constructor, two getters and three overrides.

Compact constructors: validation and normalisation

A compact constructor has no parameter list. Its body runs before the fields are assigned, so it can reject arguments or reassign the parameters.

public class CompactCtorDemo {
    record Range(int min, int max) {
        Range {
            if (min > max)
                throw new IllegalArgumentException("min %d > max %d".formatted(min, max));
        }
    }

    record Email(String address) {
        Email {
            if (address == null || !address.contains("@"))
                throw new IllegalArgumentException("Invalid email: " + address);
            address = address.strip().toLowerCase();   // reassign the parameter
        }
    }

    public static void main(String[] args) {
        System.out.println(new Range(1, 10));
        try {
            new Range(10, 1);
        } catch (IllegalArgumentException e) {
            System.out.println("Caught: " + e.getMessage());
        }
        System.out.println(new Email("  Alice@Example.COM  ").address());
    }
}
$ java CompactCtorDemo.java
Range[min=1, max=10]
Caught: min 10 > max 1
alice@example.com

Email reassigns its own parameter, and the implicit field assignment that follows the compact body picks up the normalised value. Assigning this.address directly would be a compile error — in a compact constructor the fields are written for you, after your code runs.

Final fields are not deep immutability

import java.util.ArrayList;
import java.util.List;

public class ShallowDemo {
    record Team(String name, List<String> members) {}
    record SafeTeam(String name, List<String> members) {
        SafeTeam { members = List.copyOf(members); }
    }

    public static void main(String[] args) {
        List<String> people = new ArrayList<>(List.of("Ada"));
        Team team = new Team("core", people);
        SafeTeam safe = new SafeTeam("core", people);

        people.add("Linus");
        System.out.println(team);
        System.out.println(safe);
        try {
            safe.members().add("Grace");
        } catch (UnsupportedOperationException e) {
            System.out.println("SafeTeam is unmodifiable");
        }
    }
}
$ java ShallowDemo.java
Team[name=core, members=[Ada, Linus]]
SafeTeam[name=core, members=[Ada]]
SafeTeam is unmodifiable

Team stores a reference to the caller’s list, so adding Linus afterwards changed the record’s contents. SafeTeam took an immutable copy in its compact constructor, so it kept the original member list and refuses modification through its accessor.

Adding methods: derived values and withers

public class PointMethodsDemo {
    record Point(double x, double y) {
        static final Point ORIGIN = new Point(0, 0);

        double distanceTo(Point other) {
            return Math.hypot(x - other.x, y - other.y);
        }

        Point withX(double newX) { return new Point(newX, y); }

        static Point polar(double r, double theta) {
            return new Point(r * Math.cos(theta), r * Math.sin(theta));
        }
    }

    public static void main(String[] args) {
        Point p = new Point(3, 4);
        System.out.println(p.distanceTo(Point.ORIGIN));
        Point moved = p.withX(6);
        System.out.println(moved + " " + p);
        Point polar = Point.polar(5, Math.PI / 4);
        System.out.printf("%.2f, %.2f%n", polar.x(), polar.y());
    }
}
$ java PointMethodsDemo.java
5.0
Point[x=6.0, y=4.0] Point[x=3.0, y=4.0]
3.54, 3.54

withX cannot mutate p — its fields are final — so it returns a new Point, and p is unchanged on the same output line. This “wither” pattern gives update-like ergonomics on an immutable type. Static fields and static factories like polar are allowed; extra instance fields are not.

Records implementing interfaces

import java.util.Comparator;
import java.util.List;

public class ShapeRecordsDemo {
    interface Shape { double area(); }

    record Circle(double radius) implements Shape {
        public double area() { return Math.PI * radius * radius; }
    }

    record Rectangle(double width, double height) implements Shape {
        public double area() { return width * height; }
    }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Circle(5), new Rectangle(4, 6), new Circle(3));
        shapes.stream()
              .sorted(Comparator.comparingDouble(Shape::area).reversed())
              .forEach(s -> System.out.printf("%s -> area=%.2f%n", s, s.area()));
    }
}
$ java ShapeRecordsDemo.java
Circle[radius=5.0] -> area=78.54
Circle[radius=3.0] -> area=28.27
Rectangle[width=4.0, height=6.0] -> area=24.00

Records cannot extend a class but can implement interfaces, so they take part in ordinary polymorphism. The %s calls each record’s generated toString, which is why the output shows Circle[radius=5.0] rather than a class name and hash. Interface methods implemented in a record must be public, as in any class.

Records in sealed hierarchies with record patterns

public class SealedResultDemo {
    sealed interface Result<T> permits Ok, Err {}
    record Ok<T>(T value) implements Result<T> {}
    record Err<T>(String error, int code) implements Result<T> {}

    static <T> String handle(Result<T> result) {
        return switch (result) {
            case Ok<T>(var value)            -> "Success: " + value;
            case Err<T>(var error, var code) -> "Error " + code + ": " + error;
        };
    }

    public static void main(String[] args) {
        System.out.println(handle(new Ok<>(42)));
        System.out.println(handle(new Err<>("timeout", 504)));
    }
}
$ java SealedResultDemo.java
Success: 42
Error 504: timeout

sealed ... permits Ok, Err tells the compiler these are the only implementations, so the switch needs no default and stays exhaustive. The record patterns Ok<T>(var value) (Java 21) test the type and pull out the components in one step — no casts, no accessor calls.

Records as map keys and set members

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class CoordinateDemo {
    record Coordinate(int row, int col) {}

    public static void main(String[] args) {
        Map<Coordinate, String> grid = new HashMap<>();
        grid.put(new Coordinate(0, 0), "start");
        grid.put(new Coordinate(3, 4), "end");
        System.out.println(grid.get(new Coordinate(0, 0)));

        Set<Coordinate> visited = new HashSet<>();
        visited.add(new Coordinate(1, 1));
        visited.add(new Coordinate(1, 1));
        System.out.println(visited.contains(new Coordinate(1, 1)) + " size=" + visited.size());
    }
}
$ java CoordinateDemo.java
start
true size=1

The lookup key is a different object from the one passed to put; it matches only because the generated equals and hashCode agree on row and col. Adding an equal coordinate twice leaves the set at size 1. A plain class without both overrides would fail the lookup and store duplicates.

When to use records vs classes

SituationUse
Pure data carrier, value object, DTOrecord
Map key or set elementrecord
Needs to extend a non-record classclass
Mutable state or a complex lifecycleclass
JPA entity (needs no-arg constructor + setters)class

Practice

  1. Add a translate(int dRow, int dCol) method to Coordinate that returns a shifted copy, and confirm new Coordinate(0, 0).translate(3, 4) equals new Coordinate(3, 4).
Solution
public class Translate {
    record Coordinate(int row, int col) {
        Coordinate translate(int dRow, int dCol) {
            return new Coordinate(row + dRow, col + dCol);
        }
    }

    public static void main(String[] args) {
        Coordinate moved = new Coordinate(0, 0).translate(3, 4);
        System.out.println(moved + " " + moved.equals(new Coordinate(3, 4)));
    }
}
$ java Translate.java
Coordinate[row=3, col=4] true
  1. Give Range a boolean contains(int n) method (inclusive on both ends) and a second constructor Range(int max) that delegates to this(0, max).
Solution
public class RangeExtra {
    record Range(int min, int max) {
        Range {
            if (min > max) throw new IllegalArgumentException("min > max");
        }
        Range(int max) { this(0, max); }
        boolean contains(int n) { return n >= min && n <= max; }
    }

    public static void main(String[] args) {
        Range r = new Range(5);
        System.out.println(r + " " + r.contains(5) + " " + r.contains(6));
    }
}
$ java RangeExtra.java
Range[min=0, max=5] true false

Any non-canonical constructor must start by calling another constructor with this(...), so the compact constructor’s validation always runs.

  1. Add a third permitted type Pending<T>() to Result and see the compiler reject handle; then add a case for it.
Solution
public class ResultPending {
    sealed interface Result<T> permits Ok, Err, Pending {}
    record Ok<T>(T value) implements Result<T> {}
    record Err<T>(String error) implements Result<T> {}
    record Pending<T>() implements Result<T> {}

    static <T> String handle(Result<T> result) {
        return switch (result) {
            case Ok<T>(var value)   -> "Success: " + value;
            case Err<T>(var error)  -> "Error: " + error;
            case Pending<T> p       -> "Still waiting";
        };
    }

    public static void main(String[] args) {
        System.out.println(handle(new Pending<>()));
    }
}
$ java ResultPending.java
Still waiting

Without the Pending case the compiler reports the switch expression does not cover all possible input values.

  1. Make a Playlist(String name, List<String> songs) record that is safe against the caller mutating the list they passed in, and show it.
Solution
import java.util.ArrayList;
import java.util.List;

public class PlaylistDemo {
    record Playlist(String name, List<String> songs) {
        Playlist { songs = List.copyOf(songs); }
    }

    public static void main(String[] args) {
        List<String> songs = new ArrayList<>(List.of("Intro"));
        Playlist p = new Playlist("mix", songs);
        songs.clear();
        System.out.println(p);
    }
}
$ java PlaylistDemo.java
Playlist[name=mix, songs=[Intro]]

Frequently Asked Questions

Are Java records the same as Kotlin data classes?
They are similar: both generate equals, hashCode, toString and accessors from a list of components. The main difference is that Java record components are always final, while Kotlin data classes can declare mutable var properties. Records also get a canonical constructor that you can customise with a compact constructor for validation.
Can a record extend another class?
No. Every record implicitly extends java.lang.Record, and Java allows only one superclass, so a record cannot extend anything else. Records can implement any number of interfaces, which is why they fit naturally into sealed interface hierarchies.
Can I add methods and fields to a record?
You can add instance methods, static methods, static fields, nested types and extra constructors that delegate to the canonical one. You cannot add instance fields beyond the components declared in the record header, because the record's state is defined entirely by those components.
Are Java records deeply immutable?
No. Record fields are final, so a component cannot be reassigned, but if a component is a mutable object such as an ArrayList, its contents can still change. Copy mutable inputs in the compact constructor, for example with List.copyOf, to make the record effectively immutable.