skip to content

Hashability and Default Arguments

Which built-ins can change in place decides which can be dict keys, which are safe to share, and where aliasing bugs start. The def f(x=[]) trap is one of the most-asked Python questions there is.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Which of Python's built-in types are immutable, and which can be changed in place?

level: juniorimportance: must knowfreq 78%

answer

  1. Two families of built-in types
  2. Does the object change, or the name?
  3. str methods hand back a new object
  4. list, dict, set, bytearray change in place
  5. tuple and frozenset are the frozen ones

basics

~20 s

int, float, bool, str, bytes, tuple and frozenset are immutable: every apparent change builds a new object. list, dict, set and bytearray are mutable and can be modified in place through any name that refers to them.

solid answer

~40 s

Python's built-ins split into two families. **Immutable**: `int`, `float`, `bool`, `complex`, `str`, `bytes`, `tuple`, `frozenset`, `range` and `None`. **Mutable**: `list`, `dict`, `set` and `bytearray`. Immutable means the object's value is fixed for its whole lifetime, so `s.upper()` or `s.replace(...)` returns a *new* string and leaves the original untouched; you only see a change if you rebind the name. Mutable objects are edited through the object itself, so `lst.append(x)` is visible through every name that refers to that same list. Two consequences drive most interview follow-ups: only immutable-by-construction objects are usefully hashable, so only they make good dict keys and set members; and immutability in Python is *shallow* — a tuple fixes which objects it holds, not what those objects contain, so a tuple can hold a list that still grows.

code

python · 10 lines
python
s = "bid"
print(s.upper(), s)
nums = [1, 2, 3]
nums.append(4)
print(nums)
t = (1, 2, 3)
try:
    t[0] = 9
except TypeError as exc:
    print("tuple:", exc)

go deeper

for a junior

Be ready to sort the built-ins into the two families on the spot, and to say that s.upper() hands back a new string rather than editing s. This is a warm-up question; a confident, specific list is what the interviewer wants.

for a middle

Explain the mechanics rather than the list: an immutable object's value is fixed for its lifetime, so an apparent change allocates a new object and rebinds the name. Say why frozenset and bytes exist alongside set and bytearray.

for a senior

Show where the distinction costs real money: aliasing across module boundaries, mutable module-level configuration that any importer can edit, and string building in a hot loop where repeated concatenation should be str.join.

for a principal

Own the convention for the codebase: which of your own value objects are frozen, when a read-only view such as types.MappingProxyType beats defensive copying, and how you stop mutable state from leaking across module or service boundaries.

## The distinction An object is **immutable** when its value cannot change after construction. An object is **mutable** when operations exist that change it in place, leaving the same object identity behind. The word applies to *objects*, never to names: a name is only a binding, and rebinding a name that points at an immutable object is always allowed. | Immutable built-ins | Mutable built-ins | |---|---| | `int`, `float`, `complex`, `bool` | `list` | | `str`, `bytes`, `range` | `dict` | | `tuple`, `frozenset` | `set` | | `NoneType`, `type(...)` function objects' code | `bytearray` | `frozenset` exists precisely so there is an immutable counterpart to `set`; `bytes` is the immutable counterpart to `bytearray`; `tuple` is the immutable counterpart to `list`. ## Immutable does not mean constant The most common beginner slip is reading immutability as "this name can never change". It cannot mean that, because assignment rebinds names freely: ```python s = "bid" s = s.upper() # legal: s now points at a different str object ``` Nothing was mutated. `str.upper` built a new string and the assignment pointed `s` at it. The original `"bid"` object is unchanged and, if nothing else refers to it, is simply collected. This is why every `str` method that looks like an edit — `replace`, `strip`, `lower`, `join`, `format` — *returns* a value that you must capture. Code that calls `text.strip()` and then keeps using `text` is one of the most frequent real bugs at this level. A mutable object behaves the opposite way: ```python bids = [120, 340] same = bids bids.append(500) print(same) # [120, 340, 500] - one object, two names ``` This aliasing is the source of most "why did my other variable change?" questions. Nothing was copied; two names simply refer to one list. ## Immutability is shallow A tuple freezes *which* objects it holds, not the contents of those objects: ```python row = ("campaign-7", [120, 340]) row[1].append(500) # fine - the list inside is still mutable row[1] = [] # TypeError - the tuple's own slots are fixed ``` So "a tuple is a read-only list" is only half true. The tuple's slots are read-only; the objects in those slots keep whatever mutability they had. The same holds for a `frozenset`, which sidesteps the issue by only accepting hashable members in the first place. ## Why the distinction matters in practice **Sharing.** An immutable object can be shared across threads, cached, or handed to callers with no defensive copy, because no caller can change it under you. A module-level `TIMEOUTS = {"bid": 0.2}` is a dict any importer can edit; the same data as a tuple of pairs, or wrapped in a read-only view via `types.MappingProxyType`, cannot be edited by accident. **Hashability.** The interpreter refuses to hash `list`, `dict` and `set`, so they cannot be dict keys or set members. `tuple`, `frozenset`, `str`, `bytes` and the numeric types can be. That is not an arbitrary rule: a key whose value could change would be filed under one hash and then looked up under another. **Function defaults.** Because a default value is created once, when the `def` statement runs, a mutable default is shared by every call that omits the argument — the single most-asked Python trap there is. An immutable default such as `0`, `""`, `()` or `None` has no such problem. **Building strings.** Since every concatenation of `str` allocates a new object, growing a string in a loop with `+=` does quadratic work. Collect the pieces in a list and finish with `"".join(parts)`, or accumulate into a `bytearray` when you are working with bytes. ## Checking it at the prompt There is no `is_immutable()` builtin. In practice you either know the type or you try the thing: attempting item assignment on a `tuple` or `str` raises `TypeError`, and `hash(obj)` raising `TypeError` is a strong signal that the object is mutable. Your own classes are mutable by default; freezing them means writing an immutable value type — for example a `dataclasses.dataclass(frozen=True)` or a `collections.namedtuple` — rather than relying on convention.

  • If tuples are immutable, how can the contents of a tuple still change?
    Immutability in Python is shallow. A tuple fixes which objects sit in its slots; it says nothing about those objects. `row = ("c7", [120])` will not let you replace `row[1]`, but `row[1].append(340)` works, because the list itself is still mutable. That is also why such a tuple cannot be hashed.
  • Is `bytes` mutable, and what do you use when you need to build up binary data?
    `bytes` is immutable, the binary counterpart of `str`. When you need to accumulate or patch binary data in place, use `bytearray`, which supports item assignment, `extend` and slice assignment. Convert with `bytes(buf)` when you want an immutable, hashable result to hand out.
  • How do you make an object of your own class immutable?
    There is no keyword for it. The practical options are a `collections.namedtuple` or a `dataclasses.dataclass(frozen=True)`, both of which reject attribute assignment after construction and give you a value type that hashes on its fields. Hand-rolling it means overriding `__setattr__`, which is rarely worth the noise.

An immutable object is a printed receipt: to correct it you print a new one. A mutable object is a whiteboard: everyone looking at it sees the edit you just made.

saying these in an interview costs you the question

  • Says str.replace edits the string in place
  • Claims immutable means the name cannot be rebound
  • Calls tuple deeply immutable, so its contents can never change
  • Thinks frozenset is mutable because set is
  • Believes int is mutable because a counter can be incremented
  • Assumes assigning one list to another name copies it

context

open as a page

Why does `def record_bid(price, log=[])` keep values from earlier calls?

level: middleimportance: must knowfreq 80%

basics

~20 s

The empty list is built once, when the def statement executes at import time, and stored on the function object. Every call that omits log mutates that same list. Default to None and build a fresh list inside the body.

open as a page

Why can a tuple be used as a dict key when a list cannot?

level: middleimportance: should knowfreq 62%

basics

~20 s

Dict keys and set members must be hashable, and Python only makes objects hashable when their value is fixed. list, dict and set set __hash__ to None, so hash([1, 2]) raises TypeError; tuple, frozenset, str and the numeric types hash fine.

open as a page

A scorer wrapped in functools.lru_cache raises TypeError: unhashable type: 'dict' — why, and how do you fix it?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

The cache keys entries by the call's arguments, so every argument must be hashable, and a dict is not. Pass a canonical immutable form instead — a frozenset of the mapping's items, or a sorted tuple of them — and unpack it inside.

open as a page