Back to Blog
Java

Java Record equals: How It Works and When to Override

java record equals: Understand how Java records generate equals() and hashCode() automatically, when to override them, and common pitfalls.

Java recordsequalshashCodeobject equalityJava 16
Diagram showing two Java record objects with matching component values being compared by equals()

When you declare a Java record, the compiler generates equals() and hashCode() implementations based on the record's components. For most use cases, this auto-generated behavior is exactly what you need. But there are cases where the default equals() doesn't match your equality semantics, and you need to override it. This article explains how java record equals works, what it compares, and when customization is necessary.

How Records Generate equals()

A record is a special kind of class whose state is defined by its components. The compiler derives the canonical constructor, accessor methods, toString(), hashCode(), and equals() from those components. For equals(), the generated method compares the runtime class of the two objects, then compares each component value using the equals() method of the component's type. For primitive components, it uses the primitive equality operator (==).

Consider this record:

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

The generated equals() behaves as if it were written like this:

@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point)) return false; Point other = (Point) o; return this.x == other.x && this.y == other.y; }

Note that the generated method first checks reference equality for a fast path, then verifies the type, and finally compares each component. This is a shallow comparison: it checks whether the values of the components are equal according to their own equals() methods, not whether the referenced objects are deeply equal.

What equals() Compares for Records

The equality logic for a record is straightforward: two records are equal if they are of the same type and all corresponding components are equal. For reference-type components, the component's equals() method is invoked. For primitive components, the values are compared directly.

This behavior is consistent with the expectation that records are transparent carriers of immutable data. If you have a record like:

public record Person(String name, int age) {}

Then new Person("Alice", 30).equals(new Person("Alice", 30)) returns true because the String equals() compares content and the int values match.

However, if a component is an array or a mutable object, the default equals() may not behave as you expect. Arrays use reference equality for equals(), so two records containing arrays with identical elements will not be equal unless they reference the same array instance. This is a common pitfall.

The Contract with hashCode()

Records also generate a hashCode() that is consistent with equals(). The generated hashCode() combines the hash codes of each component, typically using Objects.hash() or an equivalent. This ensures that if two records are equal, they have the same hash code, which is required for correct behavior in hash-based collections like HashMap and HashSet.

The auto-generated hashCode() follows the same component-based logic as equals(). If you override equals() in a record, you must also override hashCode() to maintain the contract. The compiler does not prevent you from breaking this consistency, so you have to be careful when customizing equality.

Customizing equals() in a Record

There are scenarios where the default equals() is not sufficient. For example, you might want to treat two records as equal based on a subset of components, or you might need a case-insensitive comparison for a String component. In such cases, you can override equals() and hashCode() inside the record body.

Here's an example where equality ignores case for a name component:

public record User(String username, int id) { @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User)) return false; User other = (User) o; return this.id == other.id && this.username.equalsIgnoreCase(other.username); } @Override public int hashCode() { return Objects.hash(id, username.toLowerCase()); } }

When you override equals(), you take full responsibility for the equality logic. The record's accessor methods and constructor remain unchanged, but the semantic equality is now defined by your implementation. This is useful when the record's components include a value that should not affect equality, such as a cached derived field.

Records vs Traditional Classes for equals()

Before records, you had to write equals() and hashCode() manually in classes, often using an IDE generator or a library. Records reduce that boilerplate, but they also fix the equality to be based on all components. The following table highlights the key differences:

AspectRecordTraditional Class
Default equals()Auto-generated from componentsInherited from Object (reference equality)
CustomizationCan override, but must keep hashCode() consistentCan override freely
Component changesAdding a component changes equals() behaviorNo automatic change
BoilerplateMinimalRequires manual implementation
IntentTransparent data carrierFlexible object semantics

If you need equality based on a subset of fields or with custom logic, a record still works, but you must write the code yourself. For a traditional class, you might have more flexibility, but you also have more responsibility to keep equals() and hashCode() in sync.

Common Pitfalls with Record equals()

One frequent mistake is assuming that the generated equals() performs a deep comparison. It does not. If a record contains a mutable collection or an array, the equals() result depends on the state of that object, which can change over time. This can break the immutability guarantee that records are supposed to provide.

Another pitfall is using floating-point components. The generated equals() uses Float.equals() and Double.equals(), which treat NaN as equal to itself and +0.0 as different from -0.0. This is consistent with the contract for Float.equals() and Double.equals(), but it may differ from the == operator. If your equality semantics require the == behavior, you need to override equals().

Finally, remember that records are not automatically immutable if they contain mutable components. The record only protects the reference to the component, not the object itself. When you rely on record equality, ensure that the component objects are effectively immutable or that you control their state.

Performance and Maintainability Considerations

The generated equals() and hashCode() are efficient because they use direct field access and avoid reflection. The compiler generates bytecode that is similar to what you would write manually, with no extra overhead from dynamic dispatch or proxy objects. For most applications, the cost of calling equals() on a record is negligible.

From a maintainability perspective, records reduce the amount of code you need to write and review. The equality logic is always in sync with the component list, which eliminates a common source of bugs when fields are added or removed. However, if you override equals(), you lose that automatic consistency. You must update your custom implementation whenever you change the record's components, which can be error-prone.

When performance matters in a hot path, consider that the generated equals() checks the class type before comparing components. This is a cheap operation, but if you are comparing records of the same type frequently, the instanceof check is a minor cost. If you need to optimize further, you can override equals() to use a more specific pattern, but that is rarely necessary.

When to Rely on the Default equals()

The default java record equals implementation is the right choice for most value-based data carriers. If your record represents a simple immutable data point, such as a coordinate, a configuration value, or a database row, the generated equals() and hashCode() are correct and consistent. They also work well with collections and streams, where equality is used for deduplication or lookup.

You should only override equals() when the default semantics do not match your business logic. This typically happens when you need to ignore a field, apply a custom comparison rule, or handle a component type whose default equality is not suitable. In those cases, override both equals() and hashCode() together, and document why the default behavior was insufficient.

A record's equals() is a powerful feature that simplifies Java development. Understanding exactly what it compares and when to customize it helps you write correct, maintainable code without unnecessary boilerplate.

java record equals: Practical Usage and Code Examples | RYUSLOG DEV