Why Your Kotlin Data Class’s equals() Ignores the Parent Class

A bookmarks app merges a cached list with a freshly synced one and calls .distinct(). Two bookmarks with different server IDs but the same title and URL collapse into one. A record disappears. Bookmark is a data class. Its server ID lives on a base class. What’s wrong?

open class RemoteEntity {
    var remoteId: String = ""
        private set
    var syncedAt: Long = System.currentTimeMillis()
        private set

    fun markSynced(id: String) {
        remoteId = id
        syncedAt = System.currentTimeMillis()
    }
}

data class Bookmark(
    val title: String,
    val url: String
) : RemoteEntity()
val cachedCopy = Bookmark(title = "Kotlin Docs", url = "https://kotlinlang.org").apply { markSynced("rec_101") }
val freshCopy  = Bookmark(title = "Kotlin Docs", url = "https://kotlinlang.org").apply { markSynced("rec_204") }

cachedCopy == freshCopy                       // true
cachedCopy.remoteId == freshCopy.remoteId     // false

The Kotlin data class equals check ignores a parent class’s properties. The compiler generates equals(), hashCode(), toString() and copy() from the data class’s own primary constructor only. Bookmark‘s constructor has title and url. remoteId lives on RemoteEntity, so the generated equals() never sees it. Two bookmarks with different IDs but the same title and URL compare equal. They collapse into one in a Set or after .distinct(). This is documented, intentional design, not a compiler bug. The fix is to override equals() and hashCode() by hand, including remoteId. There’s a second trap in that fix. Both functions must use exactly the same fields. A hashCode() that includes a field equals() ignores breaks hash-based collections.

Bug 1: the Kotlin data class equals check stops at its own constructor

Kotlin’s data classes documentation states the scope. The compiler only uses properties defined in the primary constructor for the generated functions. That rule is usually read as being about properties in the class body. It also excludes everything on a superclass.

The design goes back to KEEP-0031, the proposal that let data classes extend other classes. It’s marked stable since Kotlin 1.1. It covers the base class’s primary constructor. Those properties don’t take part in componentN and the other special functions. In April 2025, a developer filed KEEP issue 419. It called this an incorrect equals() for data classes with inheritance. The issue is closed. In an interview, “it’s a compiler bug” is the wrong answer.

The damage shows up when merging lists.

val merged = (cachedBookmarks + freshBookmarks).distinct()
// two bookmarks with different remoteId but the same title/url
// collapse into a single entry, and one of them is gone

Bug 2: the obvious fix hashes a field equals() doesn’t check

A common first fix is to override equals() and hashCode() by hand. The mistake shows up in hashCode().

// Common wrong answer: fixes equals(), breaks the equals/hashCode contract
data class Bookmark(
    val title: String,
    val url: String
) : RemoteEntity() {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Bookmark) return false
        return remoteId == other.remoteId && title == other.title && url == other.url
    }

    override fun hashCode(): Int {
        return Objects.hash(remoteId, title, url, syncedAt)
    }
}

syncedAt ends up in hashCode() because it’s on the object. More fields feels safer. But equals() never checks it. Kotlin’s Any.hashCode() contract requires the hash to stay the same as long as nothing used in equals() changes. Here, changing syncedAt changes the hash even though equality didn’t change.

val bookmark = Bookmark(title = "Kotlin Docs", url = "https://kotlinlang.org").apply { markSynced("rec_101") }
val cache = hashSetOf(bookmark)

bookmark.markSynced("rec_101")   // re-synced later; same id, syncedAt changed

cache.contains(bookmark)         // false

The object is still in cache. HashSet can’t find it, because contains() recomputes the hash to pick a bucket. The new hash points to a different bucket from the one the object was filed in.

The fix

data class Bookmark(
    val title: String,
    val url: String
) : RemoteEntity() {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Bookmark) return false
        return remoteId == other.remoteId && title == other.title && url == other.url
    }

    override fun hashCode(): Int = Objects.hash(remoteId, title, url)
}

hashCode() now uses exactly the fields equals() checks. syncedAt stays on the object for display and sync logic, with no say in identity. One caution remains. remoteId is still mutable through markSynced(). So don’t put a bookmark in a hash-based collection until it has its final ID. If you can, pass the ID into the constructor and make it a val. Then the generated functions handle it and the problem disappears.

The rule to remember

A data class’s generated functions only see its own primary constructor. If you override equals() and hashCode(), build both from the same fields. Keep those fields stable while the object is in a hash-based collection.

  • Properties on a superclass, or in the data class body, are invisible to the generated functions.
  • This is documented Kotlin design from KEEP-0031, not a bug awaiting a fix.
  • Override one of equals() and hashCode(). Then you own both.

How to answer this in an interview

  1. Say it’s a scope question. Which properties does the generated equals() see once a superclass is involved?
  2. Name both failures. Superclass fields are excluded. The hand-written hashCode() also uses a field equals() ignores.
  3. State the fix as “same field list in both functions,” not “hash more fields for safety.”

Common wrong answers:

  • Calling it a compiler bug. It’s documented behavior.
  • Overriding equals() only and assuming the generated hashCode() will match. It’s still scoped to the primary constructor.
  • Treating extra fields in hashCode() as harmless. Every field there must also be checked by equals().

Related: the Compose null check that didn’t save you. There, too, a Kotlin rule decides the outcome. GrindLoop’s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.

Failed the interview? Not the next one.

1 thought on “Why Your Kotlin Data Class’s equals() Ignores the Parent Class”

Leave a Comment