I still see experienced Python developers lose time to one tiny operator: is. The bug usually looks harmless: a conditional that “obviously” should be true, or a cache that “randomly” misses, or a test suite that passes locally and fails in CI. The root cause is almost always the same: mixing up identity (two references pointing at the exact same object) with equality (two objects having the same value).
If you’ve ever written status is "ok", count is 0, or value is [], you’ve stepped onto a trapdoor that may or may not open depending on runtime details. At the same time, if you avoid is entirely, you miss one of the most precise tools Python gives you for reasoning about aliasing, sentinels, and singletons.
I’ll walk you through how is actually behaves, when it’s the right choice, when it’s a foot-gun, and how I use it in modern Python codebases (with today’s linters, type checkers, and pattern matching). You’ll leave with concrete rules you can apply immediately, plus runnable examples you can keep around as a sanity check.
Identity vs Equality: Two Questions, Two Operators
When you write comparisons in Python, you’re usually answering one of two different questions:
1) “Do these two things represent the same value?”
- Use
==.
2) “Are these two names pointing to the same object in memory (the same instance)?”
- Use
is.
A simple analogy I use: imagine two identical-looking house keys.
==asks: “Do these keys have the same cut pattern?”isasks: “Is this literally the same physical key in my hand?”
Here’s a small script you can run that makes the difference concrete:
# identityvsequality.py
def show(label: str, a, b) -> None:
print(f"\n{label}")
print("a == b:", a == b)
print("a is b:", a is b)
print("id(a):", id(a))
print("id(b):", id(b))
def main() -> None:
firstnamesa = ["Ada", "Grace", "Linus"]
firstnamesb = ["Ada", "Grace", "Linus"]
show("Two separate lists with the same contents", firstnamesa, firstnamesb)
firstnamesc = firstnamesa
show("Two references to the same list", firstnamesa, firstnamesc)
if name == "main":
main()
Typical output looks like this:
- Two separate lists:
a == bisTrue, buta is bisFalse - Two references:
a == bisTrueanda is bisTrue
That’s the mental model you should keep in your head: == asks about value, is asks about object identity.
Why this distinction exists at all
Python names are references. When you write:
x = [1, 2, 3]
y = x
You didn’t “copy a list.” You made y refer to the same list object. Identity is about that reference relationship.
Equality is a protocol. When you write a == b, Python may call a.eq(b) (or fall back to b.eq(a)), and the result can be computed however the type chooses. For lists, it compares elements; for dicts, it compares key/value pairs; for your own classes, it’s whatever you implement.
Identity is not a protocol. is can’t be customized. That’s a huge reason it’s so reliable when you truly want it.
What is Actually Checks (and Why id() Helps)
At runtime, a is b is an identity check. Conceptually it answers: “Do a and b refer to the exact same object?” It does not call eq. It does not compare contents. It does not walk lists or compare dictionaries.
This has two important consequences:
isis typically constant-time.isis only meaningful when identity is what you care about (aliasing, singletons, sentinels, specific instances).
id() is your microscope
Python exposes id(obj) as a stable identifier for the object for its lifetime. In the most common implementation (CPython), id() is effectively the object’s memory address. You should treat it as “address-like,” not as a value you rely on across processes.
I often reach for id() when I’m debugging aliasing bugs:
# alias_debugging.py
def main() -> None:
config = {"features": ["search", "billing"]}
features_a = config["features"]
features_b = config["features"]
print("Same list?", featuresa is featuresb)
print("id(featuresa):", id(featuresa))
print("id(featuresb):", id(featuresb))
# Mutating through one reference affects the other because it‘s the same object.
features_a.append("recommendations")
print("featuresb after append:", featuresb)
if name == "main":
main()
If you ever see “spooky action at a distance” where a mutation seems to occur “somewhere else,” is plus id() will usually tell you whether you’re dealing with shared references.
#### One important id() gotcha
id() is unique only while the object exists. Once an object is garbage-collected, Python may reuse that memory address for a new object. So:
- It’s great for debugging within one run.
- It’s not a stable identifier you store in a database or compare across time.
is not is just as important
You’ll see identity checks written both ways:
if value is None:
...
if value is not None:
...
I recommend this style instead of negating:
# Prefer this
if value is not None:
...
Over this
if not (value is None):
...
It reads cleanly and plays nicely with linters and type narrowing.
Comparison chaining: a subtle readability hazard
Python allows chained comparisons:
if 0 < x < 10:
...
It also allows chaining with identity/equality:
# This is legal, but easy to misread.
if a is b is c:
...
That checks whether all three references point to the same object.
More dangerous is mixing operators:
# Also legal, but extremely easy to misread.
if a is b == c:
...
That means (a is b) and (b == c). If you ever see a line like this in code review, I’d rewrite it into two explicit comparisons.
Where is Is the Right Tool (And I’d Use It Without Hesitation)
There are a few cases where is is not just acceptable—it’s the cleanest, least ambiguous way to express your intent.
1) Checking for None
None is a singleton: there is only one None object in a Python process. When you write value is None, you’re asking exactly the right question.
# none_checks.py
def formatdisplayname(display_name: str | None) -> str:
if display_name is None:
return "Anonymous"
return display_name.strip() or "Anonymous"
This is also a sweet spot for modern type checkers: is None typically narrows the type from str | None to str in the non-None branch.
#### Why not == None?
Because == is overridable. A type can implement a surprising eq method that returns True for None in weird situations. That’s rare, but it’s exactly the kind of rare behavior that turns into a production incident.
Also, comparing to None with == is widely treated as a style error; modern tooling nudges you toward is None because it is both clearer and safer.
2) Checking other singletons: True, False, NotImplemented, Ellipsis
Some objects are designed to be unique (or treated as special sentinels). You’ll see identity checks in places like:
# singletons.py
def compare(a, b):
# A comparison method might return NotImplemented to signal "try the other operand".
result = a.lt(b)
if result is NotImplemented:
return "fallback"
return result
Or with Ellipsis (...) in APIs and typing-related code.
One note: for booleans, I still treat is True / is False as a smell in application logic. Most of the time, you want truthiness (if flag:) or an explicit equality check when the value is not guaranteed to be boolean. Linters often flag == True / == False comparisons, and identity checks can be confusing unless you’re intentionally checking for the literal singleton.
That said, there are legitimate identity checks with booleans, usually when you are modeling a tri-state:
# tri_state.py
enabled can be True (force on), False (force off), or None (use default)
def resolve_enabled(enabled: bool | None, default: bool) -> bool:
if enabled is None:
return default
return enabled
3) Custom sentinel objects (my go-to pattern)
When None is a valid value, I use a sentinel object to mean “missing.” This avoids ambiguous checks and keeps APIs honest.
# sentinels.py
from future import annotations
MISSING = object() # Unique per process; identity checks are the whole point.
def get_setting(settings: dict[str, object], key: str, default=MISSING):
value = settings.get(key, MISSING)
if value is MISSING:
if default is MISSING:
raise KeyError(f"Missing required setting: {key}")
return default
return value
def main() -> None:
settings = {"region": None}
print(get_setting(settings, "region")) # None is a real value here
print(get_setting(settings, "timeout", 30)) # Uses default
try:
print(getsetting(settings, "apikey")) # Missing and no default
except KeyError as exc:
print("Error:", exc)
if name == "main":
main()
This pattern scales nicely in 2026-era codebases because it communicates intent clearly to humans, linters, and AI-assisted refactor tools.
#### Sentinel design tips I actually use
I’ve seen sentinel usage go wrong in a few predictable ways. Here’s what I do in real projects:
- I keep sentinel names uppercase (
MISSING,UNSET,DEFAULT) and treat them as constants. - I don’t expose the raw
object()sentinel across module boundaries unless the module is the official owner. - If the sentinel is public API, I often wrap it in a tiny class for better repr/debugging:
# sentinel_repr.py
class _Missing:
def repr(self) -> str:
return "MISSING"
MISSING = _Missing()
Functionally it’s the same: identity checks still work. But logs become dramatically easier to read.
4) Enums and “one instance per member” objects
Enum members are singletons within an Enum type. Identity checks work and are often clearer when you truly mean “this exact state.”
# enums_identity.py
from enum import Enum
class JobState(Enum):
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
def main() -> None:
state = JobState.RUNNING
if state is JobState.RUNNING:
print("Job is running")
if name == "main":
main()
In application code, I’m fine with either is or == for Enums. If you’re in a large codebase, pick one convention and enforce it with linting so you don’t mix styles.
5) Intentional identity: caching and memoization boundaries
Identity checks are occasionally useful when you are optimizing object creation, caching immutable objects, or enforcing “this must be the instance.”
A simple example is a small flyweight cache:
# flyweight_cache.py
class Token:
slots = ("kind", "value")
def init(self, kind: str, value: str) -> None:
self.kind = kind
self.value = value
def repr(self) -> str:
return f"Token({self.kind!r}, {self.value!r})"
tokencache: dict[tuple[str, str], Token] = {}
def get_token(kind: str, value: str) -> Token:
key = (kind, value)
tok = tokencache.get(key)
if tok is not None:
return tok
tok = Token(kind, value)
tokencache[key] = tok
return tok
def main() -> None:
a = get_token("IDENT", "x")
b = get_token("IDENT", "x")
print(a == b) # False (no eq defined)
print(a is b) # True (same instance returned from cache)
if name == "main":
main()
Here, identity is the point: you want the cache to unify identical keys into the same object.
The Classic Traps: When is “Works”… Until It Doesn’t
Most is bugs come from one underlying mistake: using identity to compare values.
Here are the traps I see most often.
1) Comparing numbers with is
You might run this and see True:
# iswithints.py
def main() -> None:
x = 10
y = 10
print("x is y:", x is y)
print("x == y:", x == y)
if name == "main":
main()
Why does it print True sometimes? Because many Python runtimes cache certain small integers for efficiency, and the compiler/runtime may reuse the same object.
Here’s the rule I actually follow:
- If you care about the numeric value, use
==. - If you care whether two names refer to the exact same numeric object (almost never meaningful in app code), use
is.
And here’s the part that bites teams: caching and compiler behavior are not guaranteed to behave the same across Python implementations, builds, and execution contexts.
#### A more realistic example: boundaries and “random” failures
Identity bugs with ints often show up at boundaries:
# boundaryidentitybug.py
def ishttpok(status_code: int) -> bool:
# BUG: identity comparison to a value
return status_code is 200
def main() -> None:
# Constructed at runtime, not necessarily the same object as a literal.
code = int("200")
print(ishttpok(code))
if name == "main":
main()
The fix is boring and correct:
return status_code == 200
2) Comparing strings with is
This is a common bug in request handlers and CLI parsing:
# iswithstrings.py
def main() -> None:
status_a = "ok"
status_b = "".join(["o", "k"]) # Constructed at runtime
print("statusa == statusb:", statusa == statusb)
print("statusa is statusb:", statusa is statusb)
if name == "main":
main()
The equality is reliably True. The identity may be True or False depending on interning decisions and compiler/runtime details. If your logic depends on identity here, you’ve built a bug that can hide for months.
#### Why this is especially dangerous in web apps
A lot of “status-ish” values are created at runtime:
- JSON parsing creates new string objects.
- Environment variables come from the OS.
- Database drivers build strings dynamically.
So a literal like "ok" in your code is often not the same object as "ok" coming from the outside world, even if the characters match.
3) Comparing containers with is
Lists, dicts, and sets should almost never be compared with is for content checks:
# iswithlists.py
def main() -> None:
requested_scopes = ["read", "write"]
allowed_scopes = ["read", "write"]
print(requestedscopes is allowedscopes) # Identity: almost always False
print(requestedscopes == allowedscopes) # Value: True when contents match
if name == "main":
main()
And there’s a second trap here: even when is happens to be True, that might mean you’ve accidentally shared a mutable object (which could be a bug).
4) “But it worked on my machine” (interning and constant folding)
A subtle source of confusion: the compiler can pre-create constants and reuse them, which makes identity checks “accidentally true.” For example, two equal literals in the same code object may be the same object.
I treat any code that relies on identity of literals (numbers, strings, tuples) as incorrect unless it’s explicitly documented for a very special reason.
If you want a dependable guideline you can teach your team:
- Use
isonly forNone, sentinel objects, and cases where identity is the point. - Use
==for values.
5) Floating point corner case: NaN
NaN (“not a number”) has the special property that it is not equal to itself:
x = float("nan")
print(x == x) # False
That doesn’t mean you should start using identity checks for NaN. Identity would only be true if you’re comparing the same reference, which is not what you usually mean.
If you need to check for NaN, use math.isnan(x).
Using is to Catch Real Bugs: Aliasing, Mutability, and Shared State
When I’m debugging a Python system, is is often the quickest way to answer: “Are these two paths manipulating the same object?” That question matters because mutability changes the shape of the problem.
Shared list bug in a service layer
A classic example is accidental sharing:
# shared_references.py
def main() -> None:
tagsbyuser: dict[str, list[str]] = {}
default_tags: list[str] = []
# Two users mistakenly share the same list instance.
tagsbyuser["alice"] = default_tags
tagsbyuser["bob"] = default_tags
print("Same list object?", tagsbyuser["alice"] is tagsbyuser["bob"])
tagsbyuser["alice"].append("beta_tester")
# Surprise: bob also got the tag.
print("alice tags:", tagsbyuser["alice"])
print("bob tags:", tagsbyuser["bob"])
if name == "main":
main()
The fix is to create a new list per user:
# sharedreferencesfixed.py
def main() -> None:
tagsbyuser: dict[str, list[str]] = {}
tagsbyuser["alice"] = []
tagsbyuser["bob"] = []
print("Same list object?", tagsbyuser["alice"] is tagsbyuser["bob"])
if name == "main":
main()
Default argument gotcha (and how identity checks help prove it)
Another real-world foot-gun is mutable default arguments. This isn’t caused by is, but is makes it obvious what’s happening.
# defaultargumentbug.py
def add_metric(name: str, metrics: list[str] = []) -> list[str]:
# The default list is created once at function definition time.
metrics.append(name)
return metrics
def main() -> None:
first = addmetric("latencyms")
second = addmetric("errorrate")
print("first:", first)
print("second:", second)
print("Same object?", first is second)
if name == "main":
main()
The modern fix is a None default plus an identity check:
# defaultargumentfixed.py
def add_metric(name: str, metrics: list[str] | None = None) -> list[str]:
if metrics is None:
metrics = [] # New list per call
metrics.append(name)
return metrics
def main() -> None:
first = addmetric("latencyms")
second = addmetric("errorrate")
print("first:", first)
print("second:", second)
print("Same object?", first is second)
if name == "main":
main()
This is one of the healthiest uses of is: it makes object lifecycle and aliasing explicit.
Diagnosing "why did my dict change?" bugs
A pattern I see in services is pulling nested data out of dicts and then mutating it, forgetting that it’s shared.
# nestedmutationdebug.py
def main() -> None:
payload = {
"user": {"id": "u_123", "roles": ["member"]},
"request": {"roles": ["member"]},
}
rolesfromuser = payload["user"]["roles"]
rolesfromrequest = payload["request"]["roles"]
print("Same roles list?", rolesfromuser is rolesfromrequest)
rolesfromrequest.append("admin")
print(payload)
if name == "main":
main()
If that prints True, you have a serious bug: two logical domains share the same mutable list.
Identity checks and defensive copying
Once you find shared references, the fix is often a copy boundary:
- Shallow copy for a one-level container:
new = old.copy()ornew = list(old) - Deep copy for nested structures:
copy.deepcopy(...)(use carefully)
Identity checks help you verify the boundary:
import copy
x = {"a": [1, 2]}
y = copy.deepcopy(x)
print(x is y) # False
print(x["a"] is y["a"]) # False
Performance Notes and Modern Tooling (2026): Write Clear Identity Checks, Let Tools Guard You
Identity checks are generally fast and predictable: they don’t need to examine the contents of a container. Equality checks can be cheap (two short ints) or expensive (two large nested structures). In real applications, though, readability and correctness matter more than shaving microseconds off a branch.
Where performance does matter is when you’re accidentally doing a deep equality check in a hot loop. If you truly mean “same object,” is avoids work.
A practical rule for hot paths
- If you are comparing to a sentinel (
Noneor a custom sentinel), preferis. - If you are comparing data values, prefer
==. - If you’re not sure, pick
==and write a test. Identity-based logic tends to be more fragile unless it’s about a singleton.
Linters and type checkers make the right thing the easy thing
In modern Python projects, I typically enable:
- A fast linter that flags suspicious comparisons (for example, comparisons to
None,True,Falseusing==). - A type checker that understands narrowing based on
is None.
The net effect is that the “good” identity checks become the default style, and the risky ones get caught in CI.
Traditional vs Modern patterns (what I recommend now)
Traditional pattern I still see
Why it’s better
—
—
if value == None:
if value is None: Clear intent; typical lint + type narrowing support
def f(items=[]): ...
def f(items=None): if items is None: items=[] Avoid shared mutable defaults
None Overload None for both
MISSING = object() and is MISSING Removes ambiguity; makes API honest
if status is "ok":
if status == "ok": Identity is not stable for values
if flag is True:
if flag: (when boolean) or if flag == True (rare) Reads naturally; avoids odd identity### A note on equality performance: don’t prematurely optimize
I see people reach for is because it’s “faster,” especially with strings.
My experience:
- If you’re comparing strings,
==is almost always plenty fast. - If you need to optimize, you usually get more from reducing the number of comparisons, normalizing data once, or using a better data structure.
If performance truly matters, measure it. Otherwise, choose correctness and clarity.
Pattern Matching: How is Shows Up in match (and where it doesn’t)
Modern Python’s structural pattern matching gives you a new way to express “special values” and “special cases.” It’s not a replacement for is, but it overlaps.
Matching None reads like an identity check
When you write:
# match_none.py
def normalize(name: str | None) -> str:
match name:
case None:
return "Anonymous"
case _:
return name.strip() or "Anonymous"
That case None: is effectively the “special singleton” check, in the same spirit as if name is None:.
Matching sentinels: keep it explicit
If you use a sentinel like MISSING = object(), pattern matching can become confusing because object() is not a literal pattern you can write in a case.
My approach:
- Use
if x is MISSING:for sentinel checks. - Use
matchfor shape/structure checks (tuples, dicts, class patterns).
You can match against named constants in certain ways, but I still find the explicit is branch easier to read and less surprising.
Use match for structure, use is for identity
This is my mental rule:
matchanswers: “Does this value have this shape?”isanswers: “Is this the exact object?”
Practical Scenarios: Using is in Real APIs Without Regrets
Knowing the rule is one thing; applying it cleanly in APIs is another. Here are a few patterns I reach for repeatedly.
Scenario 1: Optional parameter vs “use default”
Suppose you have an API like render(timeout=...) where:
timeout=Nonemeans “no timeout”- leaving it unspecified means “use global default”
That’s a perfect sentinel use-case:
# optionalvsdefault.py
class _UseDefault:
def repr(self) -> str:
return "USE_DEFAULT"
USEDEFAULT = UseDefault()
def render(*, timeout: float
None UseDefault = USEDEFAULT) -> str:
if timeout is USE_DEFAULT:
timeout = 5.0
# timeout is now float | None
return f"timeout={timeout!r}"
def main() -> None:
print(render())
print(render(timeout=None))
print(render(timeout=0.1))
if name == "main":
main()
This makes call sites honest:
render()means “default behavior”render(timeout=None)means “explicitly no timeout”
Scenario 2: Distinguishing “not provided” in dict updates
If you accept partial updates (patch semantics), you often need three states for a field:
- not present in update
- present with value
- present with explicit
None(clear the value)
Sentinel plus is is the simplest way I know:
# patch_semantics.py
MISSING = object()
def apply_patch(user: dict[str, object], patch: dict[str, object]) -> dict[str, object]:
# patch may or may not include "nickname"
nickname = patch.get("nickname", MISSING)
if nickname is not MISSING:
# Explicitly update even if nickname is None
user["nickname"] = nickname
return user
Scenario 3: Fast-pathing by identity when safe
Sometimes you want to avoid expensive work if a value is literally the same object.
A classic example is an immutable “normalize” that returns the original object if it’s already normalized:
# normalizeidentityfast_path.py
def normalize_whitespace(s: str) -> str:
normalized = " ".join(s.split())
if normalized == s:
# We cannot reliably return the same object unless we know how s was created.
# But returning s is fine because strings are immutable.
return s
return normalized
Note that this uses ==, not is. For immutable types, you can safely return the original value based on equality, because there is no mutation risk. But you should not compare strings with is here.
Common Review Rules I Enforce Around is
If you want team-wide consistency, here are the review rules I actually enforce (or encode in lint config):
1) None checks must use is None / is not None.
2) Sentinels must be checked by identity (is MISSING), not equality.
3) Never use is to compare:
- numbers (
0,1,200, etc.) - strings (
"ok","", etc.) - bytes
- containers (
[],{},set())
4) Avoid is True and is False unless you are explicitly modeling three states and the type is guaranteed to be bool | None (or similar).
5) If identity matters, say why. One short comment is enough:
# Identity check: MISSING is a sentinel distinct from None.
if value is MISSING:
...
Expansion Strategy
Add new sections or deepen existing ones with:
- Deeper code examples: More complete, real-world implementations
- Edge cases: What breaks and how to handle it
- Practical scenarios: When to use vs when NOT to use
- Performance considerations: Before/after comparisons (use ranges, not exact numbers)
- Common pitfalls: Mistakes developers make and how to avoid them
- Alternative approaches: Different ways to solve the same problem
Here’s how I’d turn those bullets into an actual learning loop (this is what I do when mentoring teams):
1) Write a tiny “identity lab” script and run it under your usual Python version.
- Try ints, strings, tuples, lists, dicts.
- Construct values both as literals and at runtime.
- Print both
==andis, plusid().
2) Pick one production bug pattern and make a minimal reproduction.
- Mutable default arguments.
- Shared state across requests.
- Incorrect status checks (
isvs==).
3) Add one linter rule and one type checker rule to CI.
- The goal isn’t “more rules.” It’s preventing the exact bug class you just reproduced.
4) Document your sentinel convention.
- What are sentinel names?
- Where do sentinels live?
- Do you allow public sentinels or keep them internal?
5) Add one code review checklist item: “No is against literals.”
- This single rule catches a surprising amount of real bugs.
If Relevant to Topic
- Modern tooling and AI-assisted workflows (for infrastructure/framework topics)
- Comparison tables for Traditional vs Modern approaches
- Production considerations: deployment, monitoring, scaling
Here’s how is ties into those in practice:
Modern tooling
If your tooling supports it, take advantage of automatic narrowing:
def f(x: str | None) -> str:
if x is None:
return "(missing)"
# Many type checkers treat x as str here.
return x.upper()
The identity check is not just correctness; it improves the quality of static analysis and autocomplete.
AI-assisted workflows
When refactoring with AI assistance or large-scale automated edits, identity checks are safer than clever truthiness tricks. For example:
if value is None:is hard to misinterpret.if not value:can silently include empty strings, empty lists, and zero.
I try to write the most unambiguous check for the next human (or tool) that touches the code.
Production considerations
Identity bugs tend to look like nondeterminism:
- Different Python builds/versions.
- Different execution contexts (REPL vs module vs optimized builds).
- Different code paths constructing “the same” values.
That’s why I treat is against literals as a production risk even if it “works” today.
Quick Rules You Can Apply Immediately
If you remember nothing else, remember this:
- Use
is/is notforNone. - Use
is/is notfor sentinels you create withobject()(or a sentinel class). - Use
isfor identity-based state like Enum members when it matches your team convention. - Use
==for strings, numbers, and container contents. - If you’re tempted to write
x is 0orx is "ok", stop and write==.
And if you’re debugging:
- Use
isplusid()to confirm whether two references are actually the same object.
That’s it. is is a scalpel: extremely sharp, incredibly useful, and not something you want to swing around like a hammer.


