== tests value equality — "do these represent the same data?" — by
calling the object's __eq__. is tests identity — "are these the
exact same object in memory?" — which is effectively an id() comparison. They
frequently agree, but conceptually they ask completely different questions.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — equal contents
a is b # False — two distinct list objects
c = a
c is a # True — same object, just another name
Use == whenever you care about the value, which is almost always. Reserve
is for identity checks against singletons. Rule of thumb: if you're
comparing data, use ==; if you're checking "is this literally that object," use
is.
id(obj) returns a unique integer identity for an object that's constant
for the object's lifetime. In CPython it's the object's memory
address, though that's an implementation detail. Two names with the same id
refer to the same object, and is is essentially an id comparison.
a = [1, 2]
b = a
id(a) == id(b) # True — same object
a is b # True — equivalent check
c = [1, 2]
id(a) == id(c) # False — equal value, different object
id is handy for understanding aliasing — why mutating through one name
shows up through another. Don't rely on the actual numeric value (it can be
reused after an object is garbage-collected); use it only to reason about
sameness.
CPython pre-creates and caches integers from −5 to 256 as singletons at
startup. Any time a value in that range is needed, the same cached object is
reused — so equal small ints share identity and is returns True. Outside that
window, equal integers are typically distinct objects.
a = 256
b = 256
a is b # True — both point at the cached 256
c = 257
d = 257
c is d # often False — separate objects
c == d # True — always compare values with ==
This is purely a memory/performance optimization and a CPython
implementation detail — not a language guarantee. It's the single biggest reason
is "appears" to work on numbers. Rule of thumb: never use is to compare ints,
use ==.
Interning stores a single shared copy of a string so identical strings
can be the same object, saving memory and making equality checks a fast
pointer comparison. CPython auto-interns short, identifier-like string
literals (and compile-time constants); other strings may not be. You can force it
with sys.intern.
a = "hello"
b = "hello"
a is b # True — auto-interned literal
c = "hello world!"
d = "hello world!"
c is d # often False — not auto-interned
import sys
e = sys.intern("hello world!")
f = sys.intern("hello world!")
e is f # True — explicitly interned
sys.intern is useful when you compare the same strings repeatedly (parsing,
tokenizing, dict keys) and want the speed/memory win. Like int caching, which
strings auto-intern is an implementation detail — never rely on it for
correctness.
is sometimes returns True for equal values only because CPython caches or
interns those particular objects — small ints (−5..256) and many short string
literals share one object. It's an accident of optimization, not equality, so it
breaks the moment you leave the cached range.
x = 100
x is 100 # True — cached small int (deceiving!)
y = 1000
y is 1000 # often False — outside the cache
y == 1000 # True — the correct comparison
The danger is that code passes during testing with small values and then fails in
production with larger ones. Treat any is-on-a-value that works as a lucky
coincidence. Rule of thumb: if swapping is for == would change behavior on
some input, you should have used ==.
Use is to test identity against singletons — objects of which there is
exactly one — most commonly None, but also True, False, and your own
sentinel objects. For singletons is is correct, fast, and can't be fooled by
a custom __eq__.
if x is None: # idiomatic, recommended by PEP 8
...
_MISSING = object() # unique sentinel
def get(d, key, default=_MISSING):
val = d.get(key, _MISSING)
if val is _MISSING: # distinguishes "absent" from "value is None"
return default
return val
A sentinel like object() is ideal precisely because is checks identity — no
other object can ever match it. Rule of thumb: is/is not for None and
sentinels; ==/!= for everything else.
Dicts/sets find items by hash first, then == (not is). Two objects that are
== and have equal hashes are treated as the same key. Identity only matters as
an optimization: containers may short-circuit x is key before calling __eq__.
a = (1, 2)
b = (1, 2)
a is b # False — distinct objects
d = {a: "x"}
d[b] # 'x' — found by hash + ==, identity irrelevant
# nan is the exception: nan != nan, but `in` checks identity first
n = float("nan")
n in [n] # True — same object short-circuits the == check
Rule of thumb: dict/set membership uses hash + ==, so equal-and-hashable objects
are interchangeable keys; the nan-in-list quirk comes from the identity shortcut.
No — id() is guaranteed constant for the object's lifetime. But once an object
is garbage-collected, CPython may reuse that id for a new object, so comparing
ids of objects with non-overlapping lifetimes is meaningless.
id(object()) # some address
id(object()) # MAY be the same! first object was already freed
x = object(); old = id(x)
id(x) == old # True — stable while x is alive
Rule of thumb: id is stable per live object but can be recycled after collection —
never store an id to identify an object across its lifetime; keep a real reference.
They are true singletons — the interpreter guarantees exactly one instance of
each. So is None, is True, is False are always correct and can't be subverted
by a custom __eq__. PEP 8 mandates is for these.
x is None # the canonical, recommended check
x is True # exact identity (rarely needed; usually just `if x:`)
class Tricky:
def __eq__(self, other): return True
Tricky() == None # True — misleading!
Tricky() is None # False — identity can't be fooled
Rule of thumb: always use is/is not with None (and the bool singletons); ==
can be overridden and lie, is cannot.
The compiler stores immutable literals in co_consts and may share one object
for identical constants within the same code unit (constant folding/deduplication).
This makes is accidentally True — a compile-time artifact, not a guarantee.
a = (1, 2, 3)
b = (1, 2, 3)
a is b # may be True at module level (shared constant), False elsewhere
x = "long string"; y = "long string"
x is y # may be True if folded into one constant
Rule of thumb: literal sharing depends on compilation context — never rely on is
for tuples/strings; compare with ==.
Use sys.intern when you process many duplicate strings and compare them
repeatedly — parsers, tokenizers, large dict keys, dedup of column values. Interning
turns equality into a fast pointer check and saves memory by sharing one copy.
import sys
# intern repeated field names while parsing millions of records:
key = sys.intern(raw_key)
record[key] = value
# later comparisons of interned keys are O(1) identity checks
Rule of thumb: intern high-duplication, frequently-compared strings for memory/speed wins; don't bother for unique or short-lived strings.
Equality should be reflexive: x == x must be True (except deliberate cases
like nan). Containers rely on this — they often check x is key as a fast path
before ==, so a broken __eq__ that fails on itself causes lookups to misbehave.
class Bad:
def __eq__(self, other): return False # not reflexive!
b = Bad()
b == b # False — violates expectations
b in [b] # still True — saved only by the `is` short-circuit
Rule of thumb: keep __eq__ reflexive and consistent with __hash__; the identity
short-circuit in containers assumes an object equals itself.
Assignment (b = a) creates an alias (b is a). A copy (a[:], list(a),
copy.copy) makes a new object (b is not a) but shares nested references
(shallow). copy.deepcopy makes new objects all the way down.
import copy
a = [[1], [2]]
b = a # alias
c = a[:] # shallow copy
d = copy.deepcopy(a)
b is a # True
c is a # False — new outer list
c[0] is a[0] # True — shared inner list (shallow)
d[0] is a[0] # False — fully independent
Rule of thumb: = aliases, shallow copy duplicates the outer object but shares
innards, deepcopy duplicates everything — choose by how deep your independence must be.
Beyond ints −5..256, CPython caches the empty tuple (), the empty string/
bytes, single-character latin-1 strings, and shares None/True/False/...
(Ellipsis). These are reused, so is may return True — again an implementation
detail.
() is () # True — single empty-tuple singleton
"" is "" # True — empty string cached
bool(1) is True # True — bool singletons
... is Ellipsis # True
[] is [] # False — empty lists are NOT cached (mutable)
Rule of thumb: immutable empties/singletons are often shared (so is may pass), but
mutable empties ([], {}) are always fresh — still compare values with ==.
Print id() of suspected variables to confirm whether two names point at the same
object. If mutating one unexpectedly changes another, matching ids prove they're
aliases (often from a missing copy).
def add_item(item, basket=[]): # mutable default bug
basket.append(item)
return basket
r1 = add_item("a")
r2 = add_item("b")
id(r1) == id(r2) # True — same list! reveals the shared-default bug
Rule of thumb: equal id()s on values you expected to be independent flag an
aliasing/shared-reference bug — fix by copying or using a fresh object.
More Memory & Internals interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.