A data class defines a set of values through its primary-constructor properties, and the compiler generates equality, hashing, string, destructuring, and copying members.
copy() is shallow and body properties are excluded from generated members; val also doesn’t make nested objects immutable.
Put all state that belongs to the value semantics in the primary constructor, and keep the whole object graph used by equality and hashing stable.
What it is and why it exists
A data class is a class marked with data, intended for objects described mainly by a set of values. From its primary-constructor properties, the compiler generates the usual equals(), hashCode(), toString(), componentN(), and copy() members, avoiding boilerplate whose parts can easily drift out of sync.
Data classes solve more than a code-length problem. They place the object’s value semantics at the entry to its declaration: properties in the primary constructor participate by default in comparison, hashing, display, destructuring, and copying. When you review a model, this boundary tells you when two instances represent the same value.
You encounter data classes in API data-transfer objects, immutable state snapshots, configuration values, event payloads, and small domain values. A data class isn’t automatically a database entity or immutable. It fits when the object should compare by content and its state can be expressed stably as constructor properties.
An ordinary class inherits identity-based behavior from Any.equals() by default. A data class instead supplies structural equality : a == b calls equals(), and two distinct instances can produce true when their participating properties are equal. Use === when you need to ask whether two references point to the same instance.
One practical test is whether rebuilding an instance from the same public values should make it interchangeable with the original. If so, a data class usually fits. When lifecycle, resource ownership, or independent identity matters more than property content, an ordinary class is generally more accurate.
How it works
The primary constructor sets the boundary
A data class primary constructor must have at least one parameter, and every parameter must be declared with val or var. A data class can’t be abstract, open, sealed, or inner. It can implement interfaces and extend an ordinary or sealed class that permits inheritance.
The compiler derives members only from properties in the primary constructor. A property declared in the class body is still instance state, but it is excluded by default from equality, hashing, string output, destructuring, and copying. Moving a property into or out of the primary constructor changes the object’s public semantics, not just its layout.
| Declaration site | equals() and hashCode() | toString() | componentN() | copy() |
|---|---|---|---|---|
| Primary-constructor property | Included | Included | Generated in declaration order | Copied as a parameter |
| Class-body property | Excluded | Excluded | Not generated | Initialization runs again |
Members generated by the compiler
For data class Parcel(val id: String, val zone: Int), the compiler generates equals() and hashCode() from id and zone. Equal instances must have the same hash code, allowing them to act as values in a HashSet or as keys in a HashMap.
The generated toString() includes the class name and constructor properties in a form such as Parcel(id=..., zone=...). It is useful for ordinary diagnostic data but isn’t a secure audit format. If the constructor holds a token, password, or personal data, logging the instance writes those values too.
The compiler also generates operator functions named component1(), component2(), and so on, following property declaration order. val (id, zone) = parcel calls the first two component functions, so the local variable names don’t control the mapping. Reversed names still compile but receive the wrong values.
Each copy() parameter defaults to the corresponding property on the current instance, and the function invokes the constructor to make a new instance. A named argument replaces only that item. Unspecified references still point to their original objects, so copy() is a shallow copy ; it doesn’t recursively copy nested objects.
Explicit members and inheritance
You can explicitly implement equals(), hashCode(), or toString() in a data class. Overriding equality without preserving the hashing contract breaks hash-based collections, so design and test equals() and hashCode() as a pair.
You can’t explicitly implement the data class copy() or componentN() members managed by the compiler. If a supertype supplies open componentN() functions with compatible return types, the generated functions override them. An incompatible signature or a final member makes the declaration fail to compile.
A data class is final, but final doesn’t mean it can’t have a superclass. It may extend an open superclass; other classes simply can’t extend the data class in turn. If a superclass marks equals(), hashCode(), or toString() as final, the data class keeps that implementation instead of generating an override for the member.
Construction validation and derived state
A data class can validate constructor arguments in an init block, and it can declare ordinary methods and computed properties. Because copy() invokes construction again, both replaced and retained arguments go through the current validation rules together. Invalid values fail with the same exception whether they come from a direct constructor call or a copy.
A value computed purely from constructor properties usually doesn’t need another constructor slot. For example, fullName can be a read-only property calculated from givenName and familyName. It needs no separate role in equality because equal inputs already guarantee an equal result.
Treat caches more carefully. Leaving a mutable cache in the body keeps it outside value semantics, but every copy() receives a newly initialized cache. If the cache owns external resources or needs explicit closing, a data class is usually the wrong owner.
Constructor defaults belong to the creation API and also contribute to how copy() works. Before adding or changing one, check new construction and copying separately. New instances use the declared default, whereas a copy defaults to the receiving instance’s current property.
Examples
Value equality, identity, and destructuring
The two Delivery instances are created separately but contain the same constructor values. Structural equality compares content, referential equality compares the instances themselves, and destructuring reads properties in declaration order.
data class Delivery(
val id: String,
val city: String,
val priority: Int = 0,
)
fun main() {
val first = Delivery("D-17", "Paris", priority = 2)
val sameValue = Delivery("D-17", "Paris", priority = 2)
println(first)
println(first == sameValue)
println(first === sameValue)
val (id, city, priority) = first
println("$id -> $city (priority $priority)")
}Delivery(id=D-17, city=Paris, priority=2)
true
false
D-17 -> Paris (priority 2)first == sameValue calls the generated equals(), so its result is true. first === sameValue checks object identity. The two constructor calls make distinct instances, so that result is false.
The variables on the final line correspond in order to component1(), component2(), and component3(). If you only need id, writing first.id is usually more resistant than destructuring all three properties when property order might change later.
Expressing state changes with copy()
Data classes often represent state snapshots. This code replaces both the order status and the item list. items + "keyboard" produces a new list, so the old order doesn’t observe the added item.
enum class OrderStatus { CREATED, PAID }
data class Order(
val id: String,
val status: OrderStatus,
val items: List<String>,
)
fun main() {
val created = Order(
id = "O-204",
status = OrderStatus.CREATED,
items = listOf("monitor"),
)
val paid = created.copy(
status = OrderStatus.PAID,
items = created.items + "keyboard",
)
println(created)
println(paid)
println(created === paid)
}Order(id=O-204, status=CREATED, items=[monitor])
Order(id=O-204, status=PAID, items=[monitor, keyboard])
falsecopy() always constructs a new outer Order; even an argument-free call doesn’t return the original instance. Isolation here comes from explicitly creating a new list, not from copy() recursively copying anything.
List is a read-only collection interface, which means only that this reference exposes no mutation operations. If another MutableList reference changes the backing object, the List view still observes the change. When you need a stable snapshot, copy at the ownership boundary and constrain element mutability too.
Observing a shared reference after a shallow copy
Once a data class contains a mutable list, an argument-free copy() makes both outer objects share that same list. Mutating the list through the copy also changes what the original instance observes.
data class Team(
val name: String,
val members: MutableList<String>,
)
fun main() {
val original = Team(
name = "platform",
members = mutableListOf("Mina", "Noah"),
)
val sharedCopy = original.copy()
sharedCopy.members += "Omar"
val isolatedCopy = original.copy(
members = original.members.toMutableList(),
)
isolatedCopy.members += "Priya"
println(original.members)
println(sharedCopy.members)
println(isolatedCopy.members)
}[Mina, Noah, Omar]
[Mina, Noah, Omar]
[Mina, Noah, Omar, Priya]sharedCopy and original hold the same members reference, which is why their output lines match. toMutableList() copies only the list structure. If the elements themselves are mutable, both lists can still share those element objects.
In production models, prefer exposing collections that consumers don’t need to mutate, and make ownership explicit when accepting an external mutable collection. If a mutable copy is genuinely required, define the copy policy at each layer rather than treating copy() as a general deep-copy operation.
Body properties stay outside value semantics
The body property displayName doesn’t participate in generated members. Two customers compare equal when their id values match even if their display names differ. A copied instance also runs the body initializer again.
data class Customer(val id: String) {
var displayName: String = "anonymous"
}
fun main() {
val first = Customer("C-8").apply {
displayName = "Ada"
}
val second = Customer("C-8").apply {
displayName = "Grace"
}
val copied = first.copy()
println(first == second)
println(first)
println(first.displayName)
println(copied.displayName)
}true
Customer(id=C-8)
Ada
anonymousThis design is sound only if displayName deliberately sits outside customer value semantics. If the business treats it as part of the state snapshot, move it into the primary constructor so equality, string output, and copy() use the same boundary.
Body properties can hold caches, derived values, or deliberately excluded runtime state, but they easily create the illusion of a complete copy that silently drops a field. Document the reason for each exclusion and pin the convention down with equality and copy tests.
Pitfalls
Fix: Define the object’s equality first, then put every piece of state in that definition into the primary constructor. Reserve body properties for caches, derived values, or state that intentionally belongs to an instance rather than its value, and test the exclusion.
Fix: Make collection ownership explicit at model boundaries, copy a collection when a snapshot is required, and inspect whether its elements are mutable too. Prefer read-only types for state models, but don’t treat a read-only type as proof of immutability.
Fix: Use a stable, immutable dedicated key for HashMap keys and HashSet elements. If the model permits editing, remove the old key before the change and insert the new value afterward. Creating a new instance is usually clearer.
Fix: Prefer List for value collections. When an array is required, override both equals() and hashCode() using matching contentEquals() and contentHashCode() operations. Nested arrays require their corresponding deep operations.
Fix: Don’t depend on general toString() output for models containing secrets. Use a logging structure with an allowlist of fields or an explicit representation that masks sensitive values, and test that raw values never appear in the output.
Fix: Prefer named property access across API boundaries. Destructure only when the positional form is short and stable, and search for every destructuring call site before reordering the properties of a public data class.
The boundary of value semantics
Equality and hashing form one contract
Generated equals() compares the primary-constructor properties, and generated hashCode() uses that same set. A concrete hash value isn’t a business identifier to persist across platforms or releases. Depend only on the contract that equal objects have equal hash codes.
Hash-based collections typically use a hash code to locate a candidate position and equality to confirm a key. When a participating key property changes, the collection doesn’t automatically relocate an existing entry. That makes a data class with var or nested mutable values more prone to exposing this bug than an ordinary state holder.
Before customizing equality, write down its normalization rules. Whether email addresses ignore case or orders compare only by ID is a domain decision the data modifier can’t make. When equality depends on one stable identity, an ordinary class or dedicated key type is often clearer than overriding a data class’s default semantics.
Copying invokes the construction boundary
Conceptually, copy() gives each primary-constructor parameter a this.property default and calls the primary constructor. Replaced parameters receive new arguments; unreplaced references pass through unchanged. Initialization blocks, property initializers, and constructor-time validation run again for the new instance.
Consequently, body properties aren’t transferred from the old instance; they are initialized again. Nested references don’t automatically receive their own copy() calls either. To update nested immutable state, call copy() explicitly at each level. To duplicate a mutable object graph, define ownership and a copying rule for every layer.
Default parameters keep call sites focused on changes, but they can also hide newly added properties. When a data class gains a constructor parameter with a default, old copy() calls preserve that property’s value from the current instance. That is usually right, but serialization, persistence, and state migration can require a different default policy.
Component order is a source interface
Each componentN() corresponds to one primary-constructor property, with its number determined by declaration order. Names in a destructuring declaration are only new local names; they aren’t matched to property names. When _ skips an item, Kotlin doesn’t call that component function.
Reordering properties in a public data model changes positional arguments and destructuring results. Named constructor arguments protect a caller from the first change but not the second. Keeping destructuring local and short reduces this positional coupling.
An array is not a collection value
Kotlin arrays are specialized objects whose ordinary equals() doesn’t recursively compare elements. Generated data class equality delegates to each property’s own equality, so it doesn’t add content semantics to an array. contentEquals() compares one level by corresponding index, while contentDeepEquals() handles nested arrays.
Hashing must use a rule at the same depth. Pair contentEquals() with contentHashCode(), or pair contentDeepEquals() with contentDeepHashCode(). Changing comparison without hashing can place equal objects in different hash positions and violate the contract collections rely on.
A data class isn’t an immutability declaration
data generates members and val fixes a property reference; neither controls methods on the referenced object. A data class containing only val properties can still hold a MutableList, a mutable service object, or an external buffer. True immutability requires no observable mutation path from the root object through any reachable state.
A read-only collection interface can reduce the current caller’s capabilities but can’t prevent another alias from changing the backing collection. A stable snapshot requires independent data at the boundary and no leaked mutable alias. If the elements are mutable objects, copying only the collection structure is still insufficient.
Choosing a neighboring representation
A data class isn’t the default answer for every small class. Decide whether the object needs value, identity, or singleton semantics before choosing its declaration.
| Requirement | Typical choice | Main reason |
|---|---|---|
| Several properties jointly define one value | Data class | Generates the complete set of value members |
| Lifecycle or stable ID distinguishes an object | Ordinary class | Keeps mutable fields out of equality by default |
| One underlying value needs a distinct static type | Value class | Constrains wrapping semantics and representation cost |
| A payload-free singleton in a closed state set | data object | Gives a singleton stable data-style output |
DTOs often fit data classes because they usually represent value snapshots at a boundary. Transfer fields, domain equality, and persistence identity aren’t necessarily the same, however. Don’t assume every field belongs to one value definition merely because a database row or JSON object has fields.
A long-lived service with extensive internal state usually fits an ordinary class. Its connection pools, caches, and collaborators aren’t data to compare property by property. Declaring the service as a data class creates misleading equality and log output.
When a type has one underlying value, compare a data class with a value class. Value classes have distinct boxing and identity rules and are useful for separating meanings that share an underlying type. Data classes fit values that need several components, destructuring, or an ordinary object representation.
Compare a payload-free closed state with data object rather than inventing a data class with no meaningful payload. State branches that carry data can remain data classes and combine with a sealed class or interface; the hierarchy itself belongs to the sealed-types topic.
Evolving a public data class
A public data class primary constructor simultaneously affects construction, equality, hashing, output, component order, and copying. Its change surface is larger than that of a private plain carrier. A review can’t stop after checking whether constructor calls still compile.
Appending a property with a default often lets existing named calls compile, but it changes equality, hashing, and string output. Snapshot tests, cache keys, and log processing can all change. A default solves call compatibility, not semantic compatibility.
Reordering same-typed properties is particularly dangerous. Positional arguments and destructuring can keep compiling while sending values to the wrong names. Public APIs should encourage named arguments and treat destructuring as a separate call form to search for.
Removing a property removes a component function and renumbers every later component. Even if all project source is recompiled, callers can still have faulty positional logic. Cross-module binary compatibility requires dedicated tooling and can’t be inferred from passing source tests.
Moving a property from the primary constructor to the body isn’t a behavior-preserving refactor. It removes that property from five generated-member categories at once. Moving a body property into the constructor expands equality and log output instead, potentially exposing state that was previously excluded.
Record the old contract before evolving the type: which instances compare equal, which fields can appear in logs, which references a copy should share, and which callers destructure it. Retesting the same contract table after the change separates intentional changes from regressions.
Testing generated semantics
A data class contains little handwritten code, but its generated behavior still needs tests. The goal isn’t to prove the compiler generates members. It is to prove your chosen constructor boundary matches the domain contract.
| Test axis | Minimal counterexample |
|---|---|
| Equality | Two independent instances that differ in one property |
| Hash stability | Every permitted mutation after collection insertion |
| Copy isolation | Mutation of each nested mutable value through the copy |
| Body exclusion | Different body properties followed by comparison and copying |
| Output safety | A sentinel secret checked against every log representation |
Equality tests need both positive and negative cases. Merely asserting that two identical instances are equal can’t reveal a required property accidentally left in the body. Change each value-defining property one at a time to verify that the boundary is complete.
Hash tests should perform real HashSet or HashMap operations instead of just comparing two hashCode() results. Insert first, perform every state change the model permits, then look up and remove the key to expose stability failures.
Copy tests should begin with reference aliases. For every list, array, or nested object, decide whether the copy should share it, copy its container, or recursively copy its elements, then verify the decision by mutating both sides.
Output tests can place a unique sentinel string in a sensitive field and check toString() plus structured logging output. If a later edit adds a log call or changes the field representation, the test then fails with the specific leaked value.
Further reading
These are the maintained source pages behind Kotlin’s official language guide. The equality page also links to the standard-library array content operations used in the pitfall above.
5 questions · 1 predict-the-output · 1 spot-the-bug