1. Introduction
When you’re just starting to program, you want code to simply “work”: enter a number → get a result. But as soon as your own models appear (for example, Expense in our mini expense tracker), you start comparing objects, storing them in collections, and printing them for debugging. And then you suddenly find out that without proper equals/hashCode/toString the program can behave like a cat pretending it doesn’t know you.
Let’s imagine our console expense-tracking program. We add expenses to a list, build category reports, and sometimes want to prevent duplicates. But what is a “duplicate”? Two objects with the same fields? Or “this is literally the exact same object”? And when we do println(expense), we want to see something readable, not a mysterious memory address like “the object exists somewhere, but I won’t tell you where”.
data class exists precisely for these scenarios: data models almost always need to be easy to print and correct to compare. Kotlin takes care of generating the standard methods, including equals(), hashCode(), and toString().
2. Quick recap: == and ===
Before “blaming data class for all sins,” it’s important to clearly distinguish the two comparison operators. == in Kotlin is structural equality (by meaning/value), which in fact calls equals(). === is referential equality; it checks that it’s the exact same object (in a sense, “the same box,” not “the same contents of two boxes”). With data you typically want ==; otherwise you compare not the meaning, but “is this the same instance”.
Let’s look at a minimal example (and we’ll also stick to the future model of our application):
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val a = Expense(1, "Coffee", 250)
val b = Expense(1, "Coffee", 250)
println(a == b) // true
println(a === b) // false
}
The logic is: the expenses are the same by fields, so == is true. But these are different objects created by two constructor calls, so === is false.
3. What a data class generates and which fields participate
A very common beginner trap: “I wrote a data class, so now everything inside the object participates in comparisons and printing.” No. Kotlin does it predictably: it uses only the properties (val/var) declared in the primary constructor. From them it derives equals/hashCode/toString (and not only them, but today we focus specifically on these three).
This is convenient because the primary constructor becomes the “data passport”: whatever is listed there is considered the model’s state.
An example of “how we want it” for expense tracking:
data class Expense(
val id: Int,
val title: String,
val amountCents: Long,
val category: String
)
Such an object is convenient to compare by all four fields, convenient to print, and convenient to use in collections.
Conversely, if you move a property into the class body, it does not participate in the automatically generated equals/hashCode/toString. This is the official data class rule: properties in the class body are excluded from generation.
4. toString() for debugging and logs
When a program doesn’t work, a developer’s first two reactions are: “why?” and “where is my println?”. And this is where toString() suddenly becomes very useful: you want to see the object as a whole, and readable. For a data class, toString() is generated in a format like Expense(id=1, title=Coffee, amountCents=250, category=Food)—it directly shows the class name and the values of the constructor fields.
Let’s add printing of an expense:
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val e = Expense(1, "Coffee", 250)
println(e) // Expense(id=1, title=Coffee, amountCents=250)
}
Notice an important nuance: toString() is a diagnostic representation, not “nice user-facing output.” A user most likely wants “Coffee — $2.50,” not “Expense(amountCents=250).” So in our app we’ll use toString() as a debugging/logging tool, and for user output we’ll make a separate formatting function (but without fanaticism—we’re still in the console world).
A small practical habit: when debugging business logic, printing println(expense) is like a flashlight in a dark basement. It doesn’t make the basement pretty, but it helps you avoid falling into a hole.
5. equals() and the meaning of object “sameness”
Data models are often compared precisely by their contents. For a data class, Kotlin generates equals() so that the comparison goes by the values of the primary-constructor properties.
So a check like this becomes natural:
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val a = Expense(1, "Coffee", 250)
val b = Expense(1, "Coffee", 250)
val c = Expense(2, "Coffee", 250)
println(a == b) // true
println(a == c) // false
}
Within our application, this gives a clear meaning: if two expenses have the same fields, they are the same as data.
But there is an important design point here: equality is a meaning contract. You must decide which fields truly define “sameness.” For example, if id is a unique identifier, you can consider two expenses equal only when their id values are equal (and the other fields are just “record data”). But if you want to detect duplicates by content, then id might get in the way. Today we won’t “break our brains” with architectural debates—just note: a data class compares by all constructor fields, so choose those fields consciously.
6. hashCode() and why Set and Map break without it
Comparing with equals() is only half the story. In Set and Map collections, hashCode() plays a key role. For a data class, Kotlin generates the equals()/hashCode() pair consistently—that is, so objects that are equal by equals have the same hashCode.
Why is this so important? Because Set and Map work roughly like this: “first use the hash to find a bucket, then use equals() to narrow it down.” If hashCode and equals “each live their own life,” the collection will start losing elements or failing to find keys.
A very simplified lookup scheme in a HashMap looks like this:
flowchart TD
A["Key (key)"] --> B["hashCode()"]
B --> C["bucket"]
C --> D["equals() with candidates"]
D --> E["found value / not found"]
Now let’s show the benefit in practice. Suppose we want to store expenses by id:
data class ExpenseId(val value: Int)
fun main() {
val m = mapOf(ExpenseId(1) to "Coffee", ExpenseId(2) to "Taxi")
println(m[ExpenseId(1)]) // Coffee
}
Why does this work? Because ExpenseId(1) is “equal” to another ExpenseId(1) by equals, and they have the same hashCode. That’s exactly why we can create a new key object and still successfully find the old value.
7. Properties in the data class body and “invisible” fields
This is a classic mistake: you add a field, expect it to participate in comparisons and printing, but it’s “as if invisible.” The reason is almost always the same: the field is declared not in the primary constructor, but in the class body. Then it is excluded from equals/hashCode/toString (and other generated functions), because Kotlin only takes constructor properties into account.
Let’s look at an example that looks reasonable but behaves unexpectedly:
data class User(val name: String) {
var age: Int = 0
}
fun main() {
val a = User("Ann").also { it.age = 10 }
val b = User("Ann").also { it.age = 99 }
println(a == b) // true
println(a) // User(name=Ann)
}
If you read this as a human, you want to say: “but they’re different, the age is different.” But from the data class point of view, age is not part of the data because it’s not in the primary constructor. Kotlin does this intentionally and predictably: you decide what goes into the “data passport” and what is auxiliary state.
A practical rule for our expense-tracking app: if a field affects uniqueness, comparison, usage in Map/Set, and is generally part of the model’s data—keep it in the primary constructor. If it’s a cache, a temporary UI flag (yes, there is “UI” even in a console—don’t laugh), or technical state—you can move it into the class body, but then don’t be surprised by the consequences.
8. Practice in the app: Set, Map, and key stability
Anti-duplicates and Set
In early versions of the project we often stored records in a MutableList and did search/deletion manually. That’s fine for a start. But once you use classes and especially a data class, you want a bit more order: for example, preventing insertion of a full duplicate if the user accidentally repeats a command.
Let’s say we have a simple function to add an expense to a list, but we want to check for duplicates. For demonstration, a Set is enough, because it stores unique elements (by equals/hashCode).
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val unique = mutableSetOf<Expense>()
unique.add(Expense(1, "Coffee", 250))
unique.add(Expense(1, "Coffee", 250))
println(unique.size) // 1
}
From the Set point of view, the second element is a duplicate because it equals the first by equals() and has the same hashCode(). For data models this is usually exactly what the programmer expects (and the user too: “why do I need two identical coffees at the same moment?”).
But here the design question surfaces again: what should count as a duplicate—“the same content” or “the same id”? In a real app this depends on your logic. Today we care about something else: a data class makes collection behavior predictable, as long as you chose the constructor fields correctly.
Map keys and “why can’t it find it?!”
The next practical scenario: you build an index or fast access. For example, you want to find an expense by id. While you used a List, you searched with a loop or find. But a Map can access by key almost instantly—if the keys compare correctly.
Let’s show it with a small example (without complicating it with architecture):
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val byId = mapOf(
1 to Expense(1, "Coffee", 250),
2 to Expense(2, "Taxi", 1500)
)
println(byId[2]) // Expense(id=2, title=Taxi, amountCents=1500)
}
And if we want the key to be not an Int but our own separate type (this is often done to avoid mixing different identifiers), then a data class is perfect:
data class ExpenseId(val value: Int)
fun main() {
val byId = mapOf(ExpenseId(1) to "Coffee")
println(byId[ExpenseId(1)]) // Coffee
}
And this is where you can see again why equals/hashCode are critical: we created a new ExpenseId(1) and still found the value. This is the basic “magic” of Map, and it works because of the equality contract that a data class generates automatically.
Pitfall: mutable keys in a Map and elements in a Set
Now comes the moment when many people first understand why experienced developers love val more than var. And no, it’s not about morals. It’s about the fact that hashCode must be stable while an object sits in a HashSet or is used as a key in a HashMap.
If the fields that participate in equals/hashCode can be changed (var), you can put an object into a Set, then change it, and… the Set starts behaving strangely: the object seems to be there, but contains may return false. This is not a “Kotlin bug”—it’s a consequence: the object ended up in a bucket under the old hash, but after the change it should be under a new one.
A bad example (bad as in “for demonstration,” not as in “follow this”):
data class Key(var id: Int)
fun main() {
val s = hashSetOf(Key(1))
val k = s.first()
k.id = 2
println(s.contains(Key(2))) // false (very likely)
}
There’s no guarantee you’ll see the exact same result across all implementations, but the idea remains: changing key fields after putting the object into a HashSet/HashMap is like changing the apartment number on the door when the courier is already on the staircase. The courier (the collection) honestly came to the address, and the address “suddenly updated.”
For our app, the practical rule is simple: if an object will be an element of a Set or a key of a Map, then the fields that affect equals/hashCode are best made val and not changed. This makes the program predictable and saves hours of debugging that would otherwise be spent talking to the void: “why can’t it find the key, I literally just added it.”
Can you override equals/hashCode/toString manually?
Sometimes you think: “What if I need a different print format or different equality?” In Kotlin this is possible: you can write your own implementations of equals(), hashCode(), or toString() right in the body of a data class. But then the compiler will not generate these methods for you—it will use yours. This is described as part of the data class generation rules: if there are explicit implementations (or final ones in a superclass), they are not generated automatically.
At the course level (and especially at the level of “I’m not very confident at programming yet”), it’s better to stick to the principle: if a data class fits—use auto-generation. If it doesn’t fit—that’s a signal that the data model may be designed incorrectly, or you are trying to use one model to solve tasks that are too different.
You may sometimes want to change toString(), but remember: we agreed that toString() is a debugging tool. If you make it “pretty for the user,” you can lose diagnostic value. A bit later, when we improve the design of domain models, you’ll learn to separate “logging/debugging” and “user output” even more cleanly.
9. Typical mistakes
Mistake #1: expecting that fields in the data class body participate in equality and printing.
This is one of the most common reasons for “magical behavior”: you added a var age or var cachedTotal, and then you’re surprised that println(obj) doesn’t show it, and obj1 == obj2 ignores it. A data class uses only primary-constructor properties for auto-generation, and this is done on purpose so that the “data structure” is clearly visible in the class header.
Mistake #2: using === to compare data models.
Referential equality is rarely useful and usually only in special cases. For models like Expense, User, Product, you almost always want ==, because the meaning of “sameness” is “same fields,” not “the same reference.” If you accidentally compare with ===, you’ll get false even for “identical” expenses and you’ll start building logic on the wrong check.
Mistake #3: making key fields var and mutating the object after adding it to a HashSet/HashMap.
This is the case when the program doesn’t just crash—it starts gaslighting you: “the element is not there,” even though you added it. The reason is that HashSet and HashMap rely on hashCode stability. If a field involved in hash computation changes, the object would have “moved” to another bucket, but the collection doesn’t know that. That’s why for keys and unique elements people almost always choose val.
Mistake #4: treating toString() as user-facing output.
toString() on a data class is indeed very convenient and readable, but it is intended for diagnostics: debugging, logs, state checks. If you start showing it to the user as an “interface,” you’ll quickly run into the fact that the format is not friendly (and can change when you rename fields). It’s better to have a separate formatting function for display, and keep toString() for the developer—that is, for you, when you fix a bug at night and quietly promise yourself “tomorrow I’ll rewrite it nicely.”