{"id":187,"date":"2026-09-24T15:47:00","date_gmt":"2026-09-24T13:47:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=187"},"modified":"2026-09-27T01:20:41","modified_gmt":"2026-09-26T23:20:41","slug":"kotlin-data-class-equals-ignores-parent-class","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/kotlin-data-class-equals-ignores-parent-class\/","title":{"rendered":"Why Your Kotlin Data Class&#8217;s equals() Ignores the Parent Class"},"content":{"rendered":"<blockquote><p>A bookmarks app merges a cached list with a freshly synced one and calls <code>.distinct()<\/code>. Two bookmarks with different server IDs but the same title and URL collapse into one. A record disappears. <code>Bookmark<\/code> is a data class. Its server ID lives on a base class. What&#8217;s wrong?<\/p><\/blockquote>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">open class RemoteEntity {\n    var remoteId: String = \"\"\n        private set\n    var syncedAt: Long = System.currentTimeMillis()\n        private set\n\n    fun markSynced(id: String) {\n        remoteId = id\n        syncedAt = System.currentTimeMillis()\n    }\n}\n\ndata class Bookmark(\n    val title: String,\n    val url: String\n) : RemoteEntity()<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val cachedCopy = Bookmark(title = \"Kotlin Docs\", url = \"https:\/\/kotlinlang.org\").apply { markSynced(\"rec_101\") }\nval freshCopy  = Bookmark(title = \"Kotlin Docs\", url = \"https:\/\/kotlinlang.org\").apply { markSynced(\"rec_204\") }\n\ncachedCopy == freshCopy                       \/\/ true\ncachedCopy.remoteId == freshCopy.remoteId     \/\/ false<\/pre>\n\n\n<p>The Kotlin data class equals check ignores a parent class&#8217;s properties. The compiler generates <code>equals()<\/code>, <code>hashCode()<\/code>, <code>toString()<\/code> and <code>copy()<\/code> from the data class&#8217;s own primary constructor only. <code>Bookmark<\/code>&#8216;s constructor has <code>title<\/code> and <code>url<\/code>. <code>remoteId<\/code> lives on <code>RemoteEntity<\/code>, so the generated <code>equals()<\/code> never sees it. Two bookmarks with different IDs but the same title and URL compare equal. They collapse into one in a <code>Set<\/code> or after <code>.distinct()<\/code>. This is documented, intentional design, not a compiler bug. The fix is to override <code>equals()<\/code> and <code>hashCode()<\/code> by hand, including <code>remoteId<\/code>. There&#8217;s a second trap in that fix. Both functions must use exactly the same fields. A <code>hashCode()<\/code> that includes a field <code>equals()<\/code> ignores breaks hash-based collections.<\/p>\n\n<h2 class=\"wp-block-heading\">Bug 1: the Kotlin data class equals check stops at its own constructor<\/h2>\n\n<p>Kotlin&#8217;s <a href=\"https:\/\/kotlinlang.org\/docs\/data-classes.html\" target=\"_blank\" rel=\"noopener\">data classes documentation<\/a> 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.<\/p>\n\n<p>The design goes back to <a href=\"https:\/\/github.com\/Kotlin\/KEEP\/blob\/main\/proposals\/KEEP-0031-data-class-inheritance.md\" target=\"_blank\" rel=\"noopener\">KEEP-0031<\/a>, the proposal that let data classes extend other classes. It&#8217;s marked stable since Kotlin 1.1. It covers the base class&#8217;s primary constructor. Those properties don&#8217;t take part in <code>componentN<\/code> and the other special functions. In April 2025, a developer filed <a href=\"https:\/\/github.com\/Kotlin\/KEEP\/issues\/419\" target=\"_blank\" rel=\"noopener\">KEEP issue 419<\/a>. It called this an incorrect <code>equals()<\/code> for data classes with inheritance. The issue is closed. In an interview, &#8220;it&#8217;s a compiler bug&#8221; is the wrong answer.<\/p>\n\n<p>The damage shows up when merging lists.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val merged = (cachedBookmarks + freshBookmarks).distinct()\n\/\/ two bookmarks with different remoteId but the same title\/url\n\/\/ collapse into a single entry, and one of them is gone<\/pre>\n\n\n<h2 class=\"wp-block-heading\">Bug 2: the obvious fix hashes a field equals() doesn&#8217;t check<\/h2>\n\n<p>A common first fix is to override <code>equals()<\/code> and <code>hashCode()<\/code> by hand. The mistake shows up in <code>hashCode()<\/code>.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ Common wrong answer: fixes equals(), breaks the equals\/hashCode contract\ndata class Bookmark(\n    val title: String,\n    val url: String\n) : RemoteEntity() {\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (other !is Bookmark) return false\n        return remoteId == other.remoteId &amp;&amp; title == other.title &amp;&amp; url == other.url\n    }\n\n    override fun hashCode(): Int {\n        return Objects.hash(remoteId, title, url, syncedAt)\n    }\n}<\/pre>\n\n\n<p><code>syncedAt<\/code> ends up in <code>hashCode()<\/code> because it&#8217;s on the object. More fields feels safer. But <code>equals()<\/code> never checks it. Kotlin&#8217;s <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin\/-any\/hash-code.html\" target=\"_blank\" rel=\"noopener\"><code>Any.hashCode()<\/code> contract<\/a> requires the hash to stay the same as long as nothing used in <code>equals()<\/code> changes. Here, changing <code>syncedAt<\/code> changes the hash even though equality didn&#8217;t change.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val bookmark = Bookmark(title = \"Kotlin Docs\", url = \"https:\/\/kotlinlang.org\").apply { markSynced(\"rec_101\") }\nval cache = hashSetOf(bookmark)\n\nbookmark.markSynced(\"rec_101\")   \/\/ re-synced later; same id, syncedAt changed\n\ncache.contains(bookmark)         \/\/ false<\/pre>\n\n\n<p>The object is still in <code>cache<\/code>. <code>HashSet<\/code> can&#8217;t find it, because <code>contains()<\/code> recomputes the hash to pick a bucket. The new hash points to a different bucket from the one the object was filed in.<\/p>\n\n<h2 class=\"wp-block-heading\">The fix<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">data class Bookmark(\n    val title: String,\n    val url: String\n) : RemoteEntity() {\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (other !is Bookmark) return false\n        return remoteId == other.remoteId &amp;&amp; title == other.title &amp;&amp; url == other.url\n    }\n\n    override fun hashCode(): Int = Objects.hash(remoteId, title, url)\n}<\/pre>\n\n\n<p><code>hashCode()<\/code> now uses exactly the fields <code>equals()<\/code> checks. <code>syncedAt<\/code> stays on the object for display and sync logic, with no say in identity. One caution remains. <code>remoteId<\/code> is still mutable through <code>markSynced()<\/code>. So don&#8217;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 <code>val<\/code>. Then the generated functions handle it and the problem disappears.<\/p>\n\n<h2 class=\"wp-block-heading\">The rule to remember<\/h2>\n\n<p><strong>A data class&#8217;s generated functions only see its own primary constructor. If you override <code>equals()<\/code> and <code>hashCode()<\/code>, build both from the same fields. Keep those fields stable while the object is in a hash-based collection.<\/strong><\/p>\n\n<ul>\n<li>Properties on a superclass, or in the data class body, are invisible to the generated functions.<\/li>\n<li>This is documented Kotlin design from KEEP-0031, not a bug awaiting a fix.<\/li>\n<li>Override one of <code>equals()<\/code> and <code>hashCode()<\/code>. Then you own both.<\/li>\n<\/ul>\n\n<h2 class=\"wp-block-heading\">How to answer this in an interview<\/h2>\n\n<ol>\n<li>Say it&#8217;s a scope question. Which properties does the generated <code>equals()<\/code> see once a superclass is involved?<\/li>\n<li>Name both failures. Superclass fields are excluded. The hand-written <code>hashCode()<\/code> also uses a field <code>equals()<\/code> ignores.<\/li>\n<li>State the fix as &#8220;same field list in both functions,&#8221; not &#8220;hash more fields for safety.&#8221;<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>Calling it a compiler bug. It&#8217;s documented behavior.<\/li>\n<li>Overriding <code>equals()<\/code> only and assuming the generated <code>hashCode()<\/code> will match. It&#8217;s still scoped to the primary constructor.<\/li>\n<li>Treating extra fields in <code>hashCode()<\/code> as harmless. Every field there must also be checked by <code>equals()<\/code>.<\/li>\n<\/ul>\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<p><em>Related: <a href=\"https:\/\/grindloop.ai\/blog\/compose-null-check-crash\/\">the Compose null check that didn&#8217;t save you<\/a>. There, too, a Kotlin rule decides the outcome. GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>The Kotlin data class equals check ignores a parent class&#8217;s properties, by design. Why, the hashCode trap in the obvious fix, a correct override and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":210,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Kotlin Data Class equals() Ignores the Parent Class","rank_math_description":"The Kotlin data class equals check ignores a parent class's properties, by design. Why, the hashCode trap in the obvious fix, and a correct override.","rank_math_focus_keyword":"kotlin data class equals","footnotes":""},"categories":[9,4],"tags":[15,65,66,16,12,67,45],"class_list":["post-187","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-oop-solid","tag-android","tag-data-class","tag-equals-hashcode","tag-interview-prep","tag-kotlin","tag-oop-solid","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/187","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=187"}],"version-history":[{"count":12,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/187\/revisions"}],"predecessor-version":[{"id":691,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/187\/revisions\/691"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/210"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=187"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=187"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=187"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}