cswork / libraryOpen practice workspace

Articles

Python Dataclass Interview Questions: Mutable Defaults, Equality, and Frozen Objects

Prepare for Python dataclass interviews with mutable defaults, equality, frozen objects, hash stability, and a tested cache-key design with clear trade-offs.

The most useful Python dataclass interview question is not “What methods does the decorator generate?” It is “Can this object safely represent a value in the surrounding program?” Mutable defaults, generated equality, and frozen=True all affect that answer, but none replaces a deliberate identity contract.

For backend and Python candidates, this article works through an original cache-key review and executable counterexamples. Official Python documentation establishes dataclass behavior; design recommendations are identified as choices for the exercise. No employer's question frequency or candidate experience is inferred.

What does a dataclass promise, and what does it leave to you?

A dataclass is an ordinary Python class whose annotated fields guide generated methods such as initialization, representation, and equality. It reduces repetitive code; it does not automatically validate field types, enforce business invariants, or make nested objects immutable. Python dataclass documentation

In an interview, separate three decisions: which values belong to the record, which determine equality, and who may change the reachable state. A request object and a cache key may contain similar information but require different answers. The request can accumulate diagnostics; the key should remain a stable description of the response being cached.

Type annotations communicate intent to readers and type checkers. Passing a list where the annotation says tuple is not automatically rejected by the generated initializer. If the boundary accepts untrusted or loosely typed values, add validation or normalization deliberately. PEP 557 discusses dataclasses' role and their distinction from richer validation systems. PEP 557

The simplest opening answer is therefore: “I will inspect the fields that define identity and test their mutability before using this object in a dictionary.” That statement connects the decorator to observable program behavior.

Why is default_factory necessary but not sufficient?

A default factory is a zero-argument callable invoked when a field needs its default value. field(default_factory=list) creates a list for that construction. Current Python rejects unhashable field defaults as an approximation for detecting problematic mutable defaults; that check is not a complete immutability analysis. Dataclass default rules

Work this original example before discussing caches:

from dataclasses import dataclass, field

@dataclass
class Request:
    labels: list[str] = field(default_factory=list)

first = Request()
second = Request()
first.labels.append("urgent")
assert second.labels == []
assert first.labels is not second.labels

Now change the construction rather than the class:

shared = []
first = Request(shared)
second = Request(shared)
first.labels.append("urgent")
assert second.labels == ["urgent"]
assert first.labels is second.labels

The factory never runs for an explicitly supplied argument. Both instances receive the same list, and the generated initializer does not copy it. This is why “we used default_factory” is not enough to claim independent ownership.

A factory can itself return shared state. Replacing list with lambda: global_labels recreates aliasing through a different route. Ask what the callable returns, not just whether the syntax contains the word factory.

For this request model, one possible contract is “the caller transfers ownership.” Another is “the constructor takes a defensive copy.” A shallow copy is adequate for a list of immutable strings, but a list of mutable objects requires a more precise policy. Do not recommend deep copying everything without considering identity, resource handles, and cost.

How does generated equality change API behavior?

By default, dataclass equality compares participating fields and requires the two objects to have the same concrete type. field(compare=False) excludes a field from that generated comparison. Explain the field's semantic role before excluding it. Generated equality behavior

Our exercise distinguishes a request's business identity from its tracing metadata:

@dataclass
class Lookup:
    tenant: str
    query: str
    trace_id: str = field(compare=False)

a = Lookup("north", "books", "trace-1")
b = Lookup("north", "books", "trace-2")
assert a == b

That equality is sensible only if trace identifiers truly have no effect on the lookup result. Excluding tenant would be a much more serious decision: requests for different customers could collapse into one logical identity. A shorter key is not necessarily a correct key.

Also inspect inheritance. A subclass with the same visible values is not automatically equal under the generated same-type comparison. If the interviewer wants cross-type equality, clarify that requirement rather than assuming dataclasses behave like tuples. A tuple containing the same field values is not the same value model either.

Ordering is a separate opt-in design choice. For a request key, there may be no meaningful business ordering. Enabling order=True to make one sort call convenient can expose an accidental field-order policy as public behavior. Prefer an explicit sort key when the application needs one particular presentation order.

Does frozen=True freeze the whole object graph?

No. Frozen dataclasses restrict ordinary assignment and deletion through their generated machinery. They do not recursively freeze objects referenced by their fields. Python's data model also distinguishes an object's identity from the mutable state of objects it references. Python object model

Predict each line of this original example:

from dataclasses import FrozenInstanceError

@dataclass(frozen=True)
class FrozenRequest:
    tenant: str
    labels: list[str] = field(default_factory=list)

request = FrozenRequest("north")
request.labels.append("urgent")
assert request.labels == ["urgent"]

try:
    request.tenant = "south"
except FrozenInstanceError:
    pass
else:
    raise AssertionError("assignment should fail")

Appending mutates the list; it does not assign a new value to the labels attribute. This distinction is the center of the exercise. Saying “frozen objects cannot change” would hide the behavior the interviewer is asking you to explain.

Do not use frozen as a security boundary. Python exposes mechanisms that can bypass ordinary assignment restrictions, and cooperating application code is expected to respect the value model. The useful question is whether normal callers can accidentally violate the intended contract, not whether the decorator creates tamper-proof storage.

When will Python generate a hash, and can it still fail?

With default equality enabled, a non-frozen dataclass is normally unhashable. With equality and frozen enabled, a hash is normally generated. Disabling generated equality leaves inherited hash behavior in place. unsafe_hash=True requests generation under circumstances that require additional care; it is not a deep-freezing operation. Dataclass hash rules

In the FrozenRequest example, call hash(request). It fails because a participating field is a list. A generated hash method cannot make an unhashable field hashable. This is a concrete counterexample to “frozen means suitable for a set.”

Python requires equal hashable objects to have equal hashes, and a key's hash must remain stable during its lifetime. Hash collisions between unequal objects are allowed; changing a key's identity after insertion is the dangerous case. Hash contract

For a follow-up, create a mutable dataclass with unsafe_hash=True and a string field. Insert it into a dictionary, then replace that string. The computed hash can change because the generated method uses the new field value. Do not promise a particular failed lookup in every run: collisions and table details can affect observations. The violated contract is already sufficient to reject the design.

Redesign the cache key around response identity

Our original service returns search results based on tenant, query text, and selected tags. Assume tag order and duplicates do not affect results, while query spelling does. Those assumptions determine normalization; change them if the service's semantics differ.

@dataclass(frozen=True, slots=True)
class SearchKey:
    tenant: str
    query: str
    tags: tuple[str, ...]

    @classmethod
    def from_request(cls, tenant, query, tags):
        if not isinstance(tenant, str) or not tenant:
            raise ValueError("tenant must be a nonempty string")
        if not isinstance(query, str):
            raise TypeError("query must be a string")
        if isinstance(tags, str):
            raise TypeError("tags must be an iterable of strings, not one string")
        values = tuple(tags)
        if not all(isinstance(tag, str) for tag in values):
            raise TypeError("tags must contain strings")
        return cls(tenant, query, tuple(sorted(set(values))))

The factory materializes the input iterable once, checks its elements, and stores immutable strings inside a tuple. Sorting and deduplication are our chosen set-like tag semantics, not a generic recipe for all cache keys. If tag order controls ranking, sorting would silently merge different requests.

Test the intended identity explicitly:

source = ["sale", "new", "sale"]
key = SearchKey.from_request("north", "Books", source)
cache = {key: "result"}
source.append("clearance")
assert key.tags == ("new", "sale")
assert cache[SearchKey.from_request(
    "north", "Books", ["new", "sale"]
)] == "result"
assert key != SearchKey.from_request(
    "south", "Books", ["new", "sale"]
)

These assertions catch aliasing, inconsistent normalization, and tenant omission. Add one for case-sensitive queries because our contract preserves query spelling. Avoid claiming that the dataclass validates direct calls to SearchKey(...): callers can bypass the factory. If every construction path must be checked, put invariant checks in __post_init__ or expose a stricter public construction boundary.

slots=True constrains attribute storage; it does not validate field values or make nested state immutable. Here it expresses that the key has a fixed set of fields. Any memory claim would require measurement on the target Python version and workload.

Where should diagnostics and cached computations live?

A trace identifier, debug counter, or last-access timestamp usually describes an operation rather than response identity. For our cache, keep those values outside SearchKey, in the request context or cache entry. This avoids making equality and hashing depend on diagnostic state.

Excluding a mutable field with compare=False can be appropriate for some models, but explain what consumers will observe. Two objects may compare equal while displaying different diagnostics. If code later uses that excluded field to compute the cached answer, the equality model becomes incorrect even if the hash remains stable.

Likewise, repr=False limits generated representation output; it does not encrypt a value or guarantee that all logging paths omit it. If a model carries secrets, reason about serialization, exception messages, and explicit logging separately. Do not turn a formatting flag into a security claim.

A useful review habit is to follow every field from construction to equality, hashing, display, and response computation. The flags should agree with those uses. When they do not, changing the data model is often clearer than accumulating exceptions in generated methods.

What should a focused dataclass test suite prove?

Test semantic boundaries instead of testing that the standard library generates an initializer. Our cache-key suite should establish independent defaults, explicit-input alias behavior, stable normalized identity, tenant separation, rejected invalid elements, and the intended query-case rule.

Add a regression when requirements change. If tags become ordered priorities, replace set-like normalization and update the equality assertions. If a new locale changes the response, add it to the key before enabling localized caching. These are data-model changes with observable consequences, even if the decorator line stays identical.

Explain the trade-off in your interview answer: immutable canonical keys cost construction work, but make cache identity easier to reason about. For tiny request keys that can be a good bargain. For very large collections, measure normalization cost and consider a different explicit representation rather than hiding repeated sorting inside every lookup.

Sources and Further Reading