When should you use a record instead of a class?

Asked constantlyintermediate1–10 yrs11 min readJava 14Java 16Java 21LTS

When the type IS its data. A record is a semantic claim, not a boilerplate saving — you are declaring that two instances with the same components are the same thing, and that the components are the whole state. If that is not true, a record is the wrong shape however much typing it would save.

The Answer

  • Use a record when the type is its data: two instances with equal components are the same thing, and the components are the whole state.
  • You get a canonical constructor, an accessor per component, and equals, hashCode and toString — all derived from the component list, so they cannot drift when a component is added.
  • Records are implicitly final and extend java.lang.Record, so they cannot extend anything else. They can implement interfaces.
  • The fields are private final. A record can have static fields and methods, but no additional instance fields.
  • The saving in typing is a side effect. The reason to reach for one is the claim — and the reason not to is when that claim is false.
  • The trap: a record is only as immutable as its components. record Team(String name, List<String> members) is freely mutable by anyone holding the list.

Understand It

What javac actually generates

Compiled and run on this buildEdit and run
describeRecord(Team.class);
Output
Team  final=true  extends Record
  component name : java.lang.String
  component members : java.util.List<java.lang.String>
  field     name : private=true final=true
  field     members : private=true final=true

Three things fall out of that. The class is final, so a record can never be extended — subclassing a value type would break equality's symmetry, which is a real problem the design chose to prevent rather than document. It extends java.lang.Record, which uses up the one superclass slot. And the fields are private final, which is the shallow half of immutability.

The accessor is members(), not getMembers(). That is deliberate: a record is not a JavaBean, and the naming says so.

The claim, not the boilerplate

This is the part that decides real design questions, and it is why "use a record to avoid writing getters" is bad advice.

Declaring a record says three things at once:

  1. These components are the whole state. There is nothing else to compare, hash or print.
  2. Equality is by value. Two Money(4200, "INR") instances are the same money. If your Order has an id and two orders with the same id are the same order regardless of the other fields, a record is wrong — the generated equals will compare everything.
  3. The representation is public. The component names are part of your API, readable via getRecordComponents(), and serialisation frameworks bind to them. You cannot rename one without a breaking change, and you cannot hide one.

Point 2 is the one that catches people. A JPA entity has identity semantics — it is the row, not the values — and a record's value equality is exactly wrong for it. This is the same argument as entity-at-the-boundary: records make excellent DTOs and poor entities.

Record equality is not ==, and floating point proves it

Here is something almost nobody knows, and it is the kind of thing an interviewer uses to separate people who have read about records from people who have used them.

Compiled and run on this buildEdit and run
System.out.println("Double.NaN == Double.NaN   : " + (Double.NaN == Double.NaN));
System.out.println("record NaN .equals         : "
        + new Point(Double.NaN, 0).equals(new Point(Double.NaN, 0)));

System.out.println("0.0 == -0.0                : " + (0.0 == -0.0));
System.out.println("record 0.0 vs -0.0 .equals : "
        + new Point(0.0, 0).equals(new Point(-0.0, 0)));
Output
Double.NaN == Double.NaN   : false
record NaN .equals         : true
0.0 == -0.0                : true
record 0.0 vs -0.0 .equals : false

Both lines are the opposite of ==. The generated equals compares floating-point components the way Double.compare does — on their bit patterns — rather than with ==.

That is not a quirk, it is a requirement. equals has to be reflexive: x.equals(x) must be true, and with == semantics a record holding NaN would not equal itself, which would break every HashMap it was ever used as a key in. Getting reflexivity right forces 0.0 and -0.0 apart as the price.

The practical consequence: if you have a record with a double component and you are comparing computed values, equals will not do what you expect around zero. Use BigDecimal for money — see double-is-not-money — and think twice before making a computed double part of a record's identity.

Validation goes in the compact constructor

Compiled and run on this buildEdit and run
Money fee = new Money(4200, "inr");
System.out.println("normalised : " + fee);

try {
    new Money(-1, "INR");
} catch (IllegalArgumentException e) {
    System.out.println("rejected   : " + e.getMessage());
}
Output
normalised : Money[paise=4200, currency=INR]
rejected   : negative amount: -1

The compact form — Money { … }, no parameter list — runs before the fields are assigned, and assigning to a parameter inside it changes what gets stored. That is why currency = currency.toUpperCase(...) works and why you never write this.currency = currency in one.

This is the answer to "can you validate a record?" and it is worth being precise: you can validate and normalise, and the invariant holds for every instance ever created, because the canonical constructor is the only way in.

A record is not deeply immutable

The single most common mistake, and it is one line to prove:

Compiled and run on this buildEdit and run
List<String> members = new ArrayList<>(List.of("ravi"));
Team team = new Team("payments", members);

members.add("anita");
System.out.println("via the caller's list : " + team);

team.members().add("sneaky");
System.out.println("via the accessor      : " + team);
Output
via the caller's list : Team[name=payments, members=[ravi, anita]]
via the accessor      : Team[name=payments, members=[ravi, anita, sneaky]]

The members field is final. That guarantees the reference never changes, and says nothing at all about the list. Both directions are open: the caller kept a reference, and the accessor hands one out.

Worse than the mutation is what it does to the record's core claim. equals and hashCode are computed from the components, so mutating the list changes the record's hash — and a record used as a HashMap key becomes unreachable exactly as described in hashmap-internals.

The fix is a compact constructor that copies in, plus an accessor that copies out. That is the subject of immutability-in-practice, and it is not automatic for records any more than for classes.

Reference

What you get, and what you cannot have

Record
equals, hashCode, toStringgenerated from the components
Accessorsx(), not getX()
Canonical constructorgenerated; overridable in compact or full form
Extendsalways java.lang.Record — you get no other superclass
Extended bynothing; implicitly final
Implements interfacesyes, any number
Instance fieldsonly the components
Static fields and methodsyes, no restriction
Instance methodsyes, including overriding an accessor
Declared wheretop level, nested, or local Java 16+since 16
Genericyes — record Pair<A, B>(A first, B second)

The three constructor forms

// 1. Compact — the one you want almost always. Validate and normalise;
//    the field assignment is generated after this body runs.
record Money(long paise, String currency) {
    Money {
        if (paise < 0) throw new IllegalArgumentException("negative: " + paise);
        currency = currency.toUpperCase(Locale.ROOT);   // reassigns the PARAMETER
    }
}

// 2. Canonical — full parameter list. Needed when you must assign something
//    other than the parameter, such as a defensive copy.
record Team(String name, List<String> members) {
    Team(String name, List<String> members) {
        this.name = name;
        this.members = List.copyOf(members);            // copy IN
    }
}

// 3. Additional — must delegate to the canonical one. Use for defaults.
record Money(long paise, String currency) {
    Money(long paise) {
        this(paise, "INR");
    }
}

Making one genuinely immutable

record Team(String name, List<String> members) {
    // Copy on the way in, so the caller's list cannot reach the record.
    Team {
        members = List.copyOf(members);      // also rejects nulls, deliberately
    }

    // And on the way out, in case the component is a type copyOf cannot
    // freeze. For a List this second half is unnecessary — List.copyOf
    // already produced an immutable list — which is the point: copy in is
    // what matters, copy out is what you need when the component itself is
    // mutable (Date, byte[], a mutable domain object).
    @Override
    public List<String> members() {
        return members;                      // already immutable
    }
}

// A byte[] component cannot be frozen at all. clone() on both sides is the
// only option, and equals/hashCode compare array IDENTITY, not contents —
// so a record with an array component is nearly always a design mistake.
record Payload(byte[] bytes) {
    Payload { bytes = bytes.clone(); }
    @Override public byte[] bytes() { return bytes.clone(); }
}

Record or class — the decision

Is the type ITS DATA, with nothing else to compare?
  no  -> class

Are two instances with equal components the SAME THING?
  no  -> class. Identity semantics (entities, anything with a surrogate id
         where the other fields may differ) are not value semantics.

Do you need to extend something, or be extended?
  yes -> class. A record's one superclass slot is already spent.

Do you need mutable state, or a field that is not part of equality?
  yes -> class. Every component is in equals, hashCode and toString.

Is the component list part of your public API and stable?
  no  -> class. Component names are visible to reflection and to every
         serialisation library, and renaming one is a breaking change.

otherwise -> record

Scenarios

Your JPA entity would be so much shorter as a record. It would, and it would also be wrong twice over. An entity has identity semantics — the same row is the same entity even when the fields differ — while a record compares everything. And Hibernate needs a no-arg constructor and non-final fields to proxy and dirty-check, neither of which a record has. Records are excellent as the DTO you map the entity to, which is the boundary argument anyway.

A record with sixteen components. It compiles, and the length is not the real problem — the pressure it creates is. Callers must pass all sixteen positionally, so a builder appears, and then the builder can construct invalid states the compact constructor was meant to prevent. Long before sixteen, look for components that group: record Address(...) inside record Customer(String name, Address address, ...). Nesting records is cheap and record patterns deconstruct them in one line.

Someone wants a field excluded from equality. A createdAt timestamp, a cached value, a correlation id. You can override equals and hashCode in a record, and you should not: the override is now inconsistent with the generated toString and with every reader's expectation, and the next person to add a component will not know to update it. That requirement is the type telling you it is not its data. Use a class, or move the excluded field out of the type entirely.

Records across a serialisation boundary. Records serialise well and deserialise through the canonical constructor, which means your validation runs on the way in — unlike ordinary Java serialization, which bypasses constructors entirely and is a large part of why it is a security liability. That is a genuine and underrated advantage. The cost is that component names are the wire format, so renaming one breaks every consumer, and adding one breaks old readers unless the format tolerates unknown fields. Treat the component list as published API from the first commit.

Interviewer's Next Move

1. "What can a record not do?" Extend a class — its superclass is always java.lang.Record. Be extended, since it is implicitly final. Declare instance fields beyond its components. And have a mutable component safely, because final only freezes the reference.

2. "Can you validate a record's components?" Yes, in the compact constructor — Money { … } with no parameter list. It runs before the fields are assigned, so you can both reject and normalise, and assigning to a parameter there is what gets stored. Because the canonical constructor is the only way to create one, the invariant holds for every instance, including ones produced by deserialisation.

3. "Is a record immutable?" Shallowly. The fields are private final, so the references never change. If a component is a mutable type the record is mutable through it, in both directions — the caller may keep the list they passed in, and the accessor hands it back out. List.copyOf in a compact constructor is the fix, and it is not automatic.

4. "Does record Point(double x, double y) equality behave like ==?" No, and in both directions. NaN equals NaN in a record while NaN == NaN is false, and 0.0 does not equal -0.0 while 0.0 == -0.0 is true. The generated equals uses Double.compare semantics because equals must be reflexive — a record holding NaN has to equal itself or it breaks every hash-based collection.

5. "When would you use a class instead, other than for mutability?" When the type has identity rather than value semantics — anything with a surrogate id where two instances can differ in other fields and still be the same thing. When a field must be excluded from equality. When you need to extend or be extended. And when the component list is not something you are willing to publish, since the names are visible to reflection and bind to every serialisation library.

Code traps

record Config(Map<String, String> settings) { }

Map<String, String> map = new HashMap<>();
map.put("mode", "fast");
Config config = new Config(map);
Set<Config> configs = new HashSet<>();
configs.add(config);

map.put("mode", "slow");
System.out.println(configs.contains(config));
Answer

false — and config is still in the set, still counted by size(), and now unreachable by lookup. The generated hashCode is derived from the components, so mutating the map changed the record's hash after it had been filed in a bucket chosen by the old one. This is the mutable-key problem from hashmap-internals, and records make it easier to hit because they look immutable.

record Version(int major, int minor) implements Comparable<Version> {
    public int compareTo(Version other) {
        return major != other.major ? major - other.major : minor - other.minor;
    }
}
Answer

The record part is fine — records may implement interfaces, and this is a good use of one. The bug is major - other.major: subtraction overflows for large or negative values, so the comparator is not transitive and TimSort can throw "Comparison method violates its general contract!". Use Integer.compare(major, other.major). See comparison-contract.

Common wrong answers

Said in interviewsReality
"Records are for reducing boilerplate."They are a semantic claim that the type is its data. The typing saved is a side effect.
"Records are immutable."Shallowly. A mutable component makes the whole record mutable, in both directions.
"You can't put logic in a record."You can add methods, override accessors, implement interfaces, and validate in the compact constructor.
"Records can extend a class."Never — java.lang.Record is always the superclass.
"A record is a good JPA entity."Value equality is wrong for entities, and Hibernate needs a no-arg constructor and non-final fields.
"record Point(double x) equality works like ==."The opposite, twice: NaN equals itself and 0.0 does not equal -0.0.
"Adding a component is backwards compatible."The component list is your serialisation format and part of your API.

Check Yourself

Q1. What is the actual question you should ask before choosing a record?

Answer"Is this type its data?" — meaning the components are the whole state and two instances with equal components are the same thing. If the type has an identity separate from its values, or a field that must not count toward equality, a record is the wrong shape no matter how much typing it saves.

Q2. record Team(String name, List<String> members). Is a Team immutable, and what makes it so or not?

AnswerNo. The members field is final, which freezes the reference and nothing else. The caller who passed the list still holds it, and members() hands it back to anyone. List.copyOf(members) in a compact constructor closes both directions — and if the component were a type copyOf cannot freeze, such as byte[], you would need to clone on both sides.

Q3. Why does a record holding Double.NaN equal another record holding Double.NaN, when NaN == NaN is false?

AnswerBecause equals must be reflexive — an object has to equal itself, or it breaks every hash-based collection. The generated equals therefore compares double components on their bit patterns, the way Double.compare does, rather than with ==. The price of that is that 0.0 and -0.0 compare unequal even though 0.0 == -0.0.


Practice

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 14

    Records arrive as a preview feature, with a canonical constructor, accessors, equals, hashCode and toString all generated from the component list.

    Before Java 14: You wrote all of it by hand, or generated it from the IDE — and the generated equals silently went stale the moment somebody added a field.

  2. Java 16

    Records go final, and local records become legal, so a method can declare a throwaway carrier type without polluting the package.

    Before Java 16: A short-lived pair or triple meant a top-level class, an inner class, or abusing Map.Entry and Object[].

  3. Java 21LTS

    Record patterns let a switch or instanceof deconstruct a record in the pattern itself, which is what makes records and sealed types worth pairing.

    Before Java 21: You matched the type and then called each accessor by hand, so the compiler could not check you had covered every case.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Questions that lead here

  • Is Java pass-by-value or pass-by-reference?

    Java is always pass-by-value, with no exceptions. For an object the value copied is the reference, which is why a method can mutate your object but can never swap or replace it.

    Asked constantlyjunior0–6 yrs8 min readLanguage basics
  • What is the contract between hashCode() and equals()?

    Equal objects must return the same hash code. Unequal objects are free to collide — that is legal and normal, not a bug. Break the first rule and hash-based collections lose your data silently.

    Asked constantlyintermediate1–8 yrs8 min readOop
  • How do you make a class genuinely immutable?

    final fields are not enough. final freezes the reference, never the object it points at — so a class with a final List is fully mutable through both the constructor argument and the getter. Genuine immutability needs a defensive copy on the way IN and on the way OUT, and Collections.unmodifiableList gives you neither because it is a view of a list somebody else can still change.

    Asked constantlyintermediate1–10 yrs11 min readOop

Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.