At some point you run a small test in the Python shell. You assign x = 256, then y = 256, then type x is y — and get True. You do the same with x = 257 and y = 257 and get False. Nothing in the assignment changed. The values look identical. The only thing that shifted was a single integer, and suddenly Python's behavior flipped. That moment tends to stick with people, because it reveals that is is measuring something completely different from what most operators measure.
The is operator does not care what an object contains. It asks one question only: do these two names point at the same location in memory right now? Two variables can hold values that are byte-for-byte identical and still fail an is check, because they're housed in two separate allocations with two separate addresses. On the flip side, two names that point at the same allocation will always pass an is check regardless of what the object holds or how its __eq__ method is written.
Getting this wrong costs you in one of two directions. Using is where you should use == produces results that look correct in development and snap silently in production or on a different Python runtime. Using == where you should use is exposes your code to objects that lie about what they're equal to. Both failure modes are real, and this post covers when each one bites and how to stop it.
is maps to memory addresses, the identity versus value split, the only safe uses of is in production code, the integer and string interning traps, how is not parses, singleton enforcement, and a quick-reference table at the end.Two Names, One Box
Think of every Python object as a box sitting somewhere in memory. A variable name is a sticky note on that box. When you write a = [10, 20, 30], Python puts a box in memory containing that list, and sticks a note labeled a on it. When you write b = a, Python does not make a second box — it sticks a second note labeled b on the exact same box. Both notes point at the same allocation.
a = [10, 20, 30]
b = a # second note on the same box
c = [10, 20, 30] # a fresh box with the same contents
print(a is b) # True — both notes on the same box
print(a is c) # False — same contents, separate boxes
print(a == c) # True — value comparison passes
# The address each name resolves to:
print(hex(id(a))) # e.g. 0x7f3a1c0e4d00
print(hex(id(b))) # identical hex address
print(hex(id(c))) # different address entirely
The built-in id() function returns that address as an integer. So a is b is shorthand for the comparison id(a) == id(b). That's the entire mechanism. No hashing, no content scanning, no method calls — just two memory addresses compared as numbers.
The practical consequence of b = a is that any change made through one name shows up when you read through the other:
a = [10, 20, 30]
b = a
b.append(40)
print(a) # [10, 20, 30, 40] — the change is visible through a
That shared mutation is not a bug in b. It's the direct result of both names pointing at the same box. If you wanted a separate box with a copy of the contents, the call is b = a.copy() or b = list(a). After either of those, a is b returns False, and mutating through b leaves a untouched.
Why Bypassing __eq__ Is Sometimes the Feature
Every time you write x == y, Python calls x.__eq__(y). The class that x belongs to decides what "equal" means. Most of the time this is what you want — two Decimal("1.5") objects should compare equal. But it also means a badly written class, or a class with a surprising design, can return anything from __eq__.
from decimal import Decimal
d1 = Decimal("1.5")
d2 = Decimal("1.5")
print(d1 == d2) # True — __eq__ says the values match
print(d1 is d2) # False — two separate objects in memory
NumPy is the classic example where this trips people up. Calling == on two arrays returns a new array, not a single boolean. Drop that inside an if statement and Python throws ValueError: The truth value of an array with more than one element is ambiguous. The is check skips __eq__ entirely. It always gives you a plain True or False, regardless of what type you're looking at.
import numpy as np
arr1 = np.array([1, 2, 3])
arr2 = np.array([1, 2, 3])
# This raises ValueError in an if statement:
# if arr1 == arr2: ...
# This works fine — checks if they're the same object:
print(arr1 is arr2) # False — different allocations
There's also a deliberate use of the __eq__ bypass. Say you're writing an audit log wrapper that tracks when the exact same configuration object gets passed into two different subsystems. You want to catch the case where the identical object travels across boundaries, not just one with the same field values. is is the correct check there, and no amount of __eq__ override can interfere with it.
is always returns a plain bool. When you're dealing with objects whose __eq__ returns something other than a boolean — NumPy arrays, pandas Series, SQLAlchemy query objects — an identity check is the safe default for "are these literally the same object."The Only Correct Way to Check for None
The Python runtime maintains a single None object for the entire lifetime of the process. Every function that returns nothing, every default parameter left unspecified, every optional field that hasn't been set — they all resolve to that one object. Given that, checking whether something is None via is None is not just a style preference. It's testing the right thing.
def fetch_record(record_id: int):
# Simulating a DB miss
return None
record = fetch_record(404)
# Correct:
if record is None:
print("no record found")
# Also correct for the inverse:
if record is not None:
print(f"processing: {record}")
# Works but fragile — depends on __eq__ not being overridden:
if record == None:
print("no record found")
The fragility of == None is not hypothetical. An ORM model that overrides __eq__ to compare by primary key can produce a situation where obj == None returns False even when the object is genuinely absent — or worse, returns True when the key hasn't been set. is None does not go through __eq__. It reads the address of record, reads the address of the None singleton, compares the two numbers. That's it. PEP 8 makes this non-optional for exactly this reason.
SyntaxWarning when you write is with a literal — x is None is fine, but x is "admin" or x is 0 now prints a warning before running. The warning is Python telling you the result is unreliable.The 256 Boundary and Why It Misleads
CPython keeps a pool of pre-built integer objects covering the range -5 through 256. When your code produces an integer in that window, the interpreter hands back a reference to the pooled object rather than creating a new one. Outside that range, every assignment allocates fresh.
p = 200
q = 200
print(p is q) # True — both referencing the same pooled object
m = 300
n = 300
print(m is n) # False — two separate allocations
# The exact boundary:
a = 256
b = 256
print(a is b) # True — still inside the pool
c = 257
d = 257
print(c is d) # False — outside it, two different objects
The pool is a CPython performance decision, not a Python language rule. PyPy's JIT compiler handles integers differently. MicroPython on a microcontroller has its own constraints. If you write code that passes is checks on integers inside the pool, it works on your machine, passes CI, ships to production — and then someone runs it on a different runtime and it produces wrong comparisons silently. There's no warning, no exception, no log message. The check just returns False where you expected True.
There's also a subtler version of this trap. Two integer objects with the same value can temporarily share an address after garbage collection recycles memory. You delete one object, a new object gets allocated at the freed address, and now id(new_obj) == id(old_obj) even though they're unrelated. This cannot produce a false is result in practice — because the old object no longer exists when you're comparing — but it's a reminder that identity is a snapshot, not a permanent property. An address is valid only while the object at that address is alive.
When String Comparisons Accidentally Pass
CPython applies compile-time deduplication to string literals that resemble Python identifiers. Short strings made of letters, digits, and underscores get folded into a single shared object when the source file is compiled. Two variables assigned the same identifier-like string literal in the same module often end up at the same address.
x = "status"
y = "status"
print(x is y) # True — CPython folded these at compile time
# Strings with spaces are less predictable:
a = "pending review"
b = "pending review"
print(a is b) # True in a .py file, False if built at runtime
# Strings assembled at runtime never get this treatment:
prefix = "pend"
suffix = "ing review"
s1 = prefix + suffix
s2 = "pending review"
print(s1 is s2) # False — one was built at runtime, one was a literal
The tricky part: the REPL and a compiled script behave differently. A string that passes an is check in the interactive shell might fail the same check in a script, because the compilation context changes what gets deduplicated. You cannot predict this from the string's contents alone.
If your code genuinely needs string identity — say you're building a symbol table for a parser and want to avoid storing duplicate copies of the same token string — use sys.intern(). It opts a string into the intern table explicitly and durably, and Python then guarantees that two interned strings with the same content share an address. That's the opt-in. Without it, string identity is undefined behavior for your purposes.
import sys
raw_tokens = ["assign", "assign", "return", "assign"]
interned = [sys.intern(t) for t in raw_tokens]
# Now identity comparison is safe:
print(interned[0] is interned[1]) # True — same interned object
print(interned[0] is interned[2]) # False — different tokens
How is not Parses
Python's grammar defines is not as a single two-word operator. It's not is followed by not applied to the result. The parser recognizes the pair as a unit, same way it recognizes not in as a single membership test.
val = None
# These produce the same output:
print(not (val is None)) # False
print(val is not None) # False
# But only the second form is idiomatic Python.
# flake8 raises E714 on the first form:
# E714 test for object identity should be 'is not'
The reason linters flag not (x is y) is operator precedence. In a more complex expression, a reader has to stop and manually parse what not is negating. x is not y has no ambiguity — the operator boundary is clear from left to right. Write it that way.
Enforcing "Only One" with Identity
Some objects should exist exactly once per application run. A database connection pool, a global settings object, a shared cache. The Singleton pattern in Python is implemented through __new__, and the check inside __new__ that makes it work is an is None test on a class variable:
class DatabasePool:
_pool = None
def __new__(cls, *args, **kwargs):
if cls._pool is None:
cls._pool = super().__new__(cls)
return cls._pool
pool_a = DatabasePool()
pool_b = DatabasePool()
print(pool_a is pool_b) # True — one object, two names
print(id(pool_a) == id(pool_b)) # True — same address confirmed
After the first call, cls._pool holds a reference to the created object. Every subsequent call to DatabasePool() finds that cls._pool is None is False, skips the allocation, and returns the existing object. The guarantee is airtight because is cannot be overridden. No matter what __eq__ on DatabasePool does, the identity check in __new__ will correctly determine whether the pool has been created yet.
This also comes up in object caches and registries where you need to confirm that the object retrieved from the cache is the exact one that was stored, not a reconstructed copy. Two objects with matching field values are not the same object, and in some systems that distinction matters — locking, reference tracking, change detection. Identity gives you the answer == cannot.
Quick Reference Table
| What you're checking | Correct operator | Wrong operator | Reason |
|---|---|---|---|
None |
is None / is not None |
== None |
PEP 8; immune to __eq__ overrides |
Exact boolean True or False |
is True / is False |
== True for strict checks |
1 == True but 1 is not True |
| Integers | == |
is |
CPython pool ends at 256; not portable |
| Strings | == |
is |
Interning is compile-time and runtime-dependent |
| Lists, dicts, sets, tuples | == |
is |
Container literals are always separate objects |
| Same object in two variables | is |
== |
== answers a different question |
| Singleton guard check | is None inside __new__ |
== None |
The check must not go through __eq__ |
| NumPy array or pandas Series | is for identity; np.array_equal() for value |
== in if statement |
== returns an array, not a bool |
Frequently Asked Questions
If is and == give the same result for None, why does it matter which one I use?
They agree on None in normal circumstances, but the path they take is different. == None calls __eq__ on your object. If your object has a custom __eq__ — or if it's a proxy or wrapper class — that method could return anything. is None does not call any method. It reads the address of your object and the address of the None singleton and compares the two numbers. The outcome is always correct regardless of what the class does. That guarantee is why PEP 8 mandates is None specifically.
Can the same memory address show up for two different objects at different times?
Yes, and this is one of the stranger corners of Python's memory model. When an object is garbage collected, its memory is freed and can be reused for a new allocation. If the new object happens to be placed at the same address, id(new_obj) returns the same integer that id(old_obj) returned before the old one was deleted. This cannot cause a false is result in practice — you need both objects to be alive at the same time for the comparison to be meaningful — but it does mean that storing id() values in a long-lived cache and comparing them later is not a reliable way to track object identity. The only reliable identity token is a live reference to the object itself.
Does is work the same in CPython, PyPy, and MicroPython?
The operator works the same — it compares object addresses. What differs is which objects share addresses due to internal caching. CPython pools integers from -5 to 256 and interns identifier-like string literals. PyPy's JIT may pool a different range or deduplicate differently. MicroPython on hardware with constrained memory may not pool at all. Code that relies on is returning True for specific integer or string values is betting on one particular runtime's internal choices. The only cross-runtime guarantees are None, True, and False — those are singletons by the language specification itself, not by implementation choice.
What is the SyntaxWarning Python 3.8 added for is with literals?
Starting in Python 3.8, writing x is "text" or x is 42 produces: SyntaxWarning: "is" with a literal. Did you mean "=="?. The interpreter detects at compile time that you're comparing identity against a constant, which has no correct use case. The comparison doesn't raise an exception — it still runs — but the result is unpredictable because you have no control over whether the literal gets deduplicated with the value in x. Run this to see it yourself:
# Save this as test_warn.py and run it with: python test_warn.py
x = "hello"
if x is "hello": # SyntaxWarning fires on this line
print("same object")
Treat this warning as a hard error in code review. The fix is always to switch to ==.
Is there a performance difference between is and ==?
Measurably, yes. is is a single pointer comparison at the C level — it takes constant time regardless of what the object holds. == dispatches through Python's method resolution system, calls __eq__, and for something like a long string it then compares every character. For short strings and small integers, the difference is nanoseconds. For large objects — long lists, big strings, deep dictionaries — == is proportional to the size of the data. That said, choosing is for speed when == is the semantically correct question is a bug, not an optimization. Get the semantics right first.
How do I know if two function calls return the same cached object?
Use id() on both return values while both objects are alive, or use is directly. A common scenario is functools.lru_cache — if the same arguments are passed twice, the cache returns the exact same object it returned the first time, so result1 is result2 will be True. This matters when the cached object is mutable: both callers hold a reference to the same object, and a mutation through one call site is visible to the other. If you're passing cached return values into code that might modify them, copy before mutating.
import functools
@functools.lru_cache(maxsize=128)
def load_config(env: str) -> dict:
return {"env": env, "debug": False}
cfg1 = load_config("staging")
cfg2 = load_config("staging")
print(cfg1 is cfg2) # True — same cached dict object
cfg1["debug"] = True # mutates the cached object
print(cfg2["debug"]) # True — cfg2 sees the change