Identity and equality answer different questions. is asks whether two references point at the same object. == asks whether the operands agree with an equality protocol that may run arbitrary Python code.
Core answer
Use identity for sentinels and singleton checks such as value is None. Use equality for value contracts. Then decide whether aliasing is acceptable before mutating an object shared through multiple names.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass@dataclass(frozen=True, slots=True)class AccountId: raw: strdef same_account(left: AccountId, right: AccountId) -> bool: return left == rightfirst = AccountId("ACCT-42")second = AccountId("ACCT-42")alias = firstprint(same_account(first, second))print(first is second)print(first is alias)Why this design exists
Python names are references to objects, not value slots that copy every assignment. Identity gives the runtime and user code a cheap way to ask whether two references share one object. Equality is overloadable because domains need value semantics: two money records, normalized paths, or account identifiers may compare equal without being one allocation.
The separation keeps sentinel APIs reliable. A user-defined __eq__ can be slow, surprising, or intentionally broad; it cannot change what is means.
Mechanics and CPython internals
At the CPython level, variables hold references to PyObject instances. Assignment increments reference relationships; it does not clone the object. Reference counting and cyclic GC decide lifetime, while equality dispatch follows the data model and can call __eq__ on one or both operands. CPython may intern or cache some objects, but those optimizations are not a portable basis for business logic.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass, field@dataclass(slots=True)class RetryState: order_id: str attempts: list[str] = field(default_factory=list)def record_retry(state: RetryState, reason: str) -> None: state.attempts.append(reason)primary = RetryState("ORD-9")shared = primaryrecord_retry(shared, "timeout")print(primary.attempts)print(primary is shared)print(primary == shared)Complexity and tradeoffs
Identity comparison is constant-time pointer comparison. Equality cost is whatever the operands implement: a frozen identifier may compare in O(1), while a nested list comparison can scan until a mismatch or consume O(n) work. Value semantics improve API clarity; identity semantics expose aliasing and sentinel state without delegating to user code.
Idiomatic patterns and refactoring
Prefer explicit sentinel identity when None is a valid domain value. That keeps the function signature honest and avoids equality dispatch on user input.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom typing import Final@dataclass(frozen=True, slots=True)class PricingRule: name: str discount: float | None_MISSING: Final = object()def discount_or_default(rule: PricingRule, value: object = _MISSING) -> float | None: if value is _MISSING: return rule.discount if value is None or isinstance(value, float): return value raise TypeError("discount override must be float or None")rule = PricingRule("new-customer", None)print(discount_or_default(rule))print(discount_or_default(rule, 0.10))Common mistakes and edge cases
Do not use is for strings, integers, or dataclass values because an interpreter cache made a local experiment look stable. Do not assume equality is side-effect free or cheap for arbitrary user types. Do not shallow-copy a container and then expect nested mutable state to stop aliasing.
When to use / When NOT to use
Use is for identity questions, singletons, and sentinels. Use == when a type advertises a value contract and the code wants that contract.
Do not turn identity into a performance micro-optimization for normal value comparison, and do not use equality when the protocol itself is the thing you need to avoid.