Identity vs Equality - Object Semantics and Memory in CPython
Reading time: ~24 minutes | Level: Foundation → Engineering
Here is a question that trips up developers who have been writing Python for years:
a = 1000
b = 1000
print(a == b) # True
print(a is b) # ??? - what do you expect here?
x = 256
y = 256
print(x == y) # True
print(x is y) # ??? - same question, different answer
Both pairs have identical values. The first pair produces False for is. The second pair produces True for is. Same operator, same values, different results - because is is not about value, it is about whether two names point to the exact same object in RAM.
The deeper issue: if you have been using is to compare numbers or strings in production, your code may have been accidentally correct for years thanks to CPython's integer cache - and will fail unpredictably when the numbers cross the cache boundary.
Understanding is versus == is not just a style question. It is a correctness question with production consequences.
What You Will Learn
- The precise semantics of
is(identity) and==(equality) and why they differ - How
id()maps to memory addresses in CPython - The
__eq__and__ne__protocols: how Python determines equality for any object - Why
None,True, andFalseare singletons and whyisis correct for them - The CPython integer cache (−5 to 256): what it is, why it exists, why you must not rely on it
- String interning: which strings are automatically interned, how
sys.intern()works - The NaN equality anomaly: why
float('nan') != float('nan') - Deep equality with nested structures and
copy.deepcopy() - The full set of production pitfalls and how to avoid them
Prerequisites
- Variables and assignment in Python
- Basic understanding of objects and classes
- Familiarity with Python's built-in types
- Basic knowledge of functions and methods
The Fundamental Distinction
These two operators answer completely different questions:
Consider two identical keys cut from the same lock. == asks "do both keys open the same door?" The answer is yes. is asks "are these literally the same key?" The answer is no - they are two separate physical objects.
# Two separate lists with identical content
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True - same content, equality holds
print(a is b) # False - different objects in memory
# Two names pointing to the same list
c = [1, 2, 3]
d = c # d is NOT a copy - it's an alias
print(c == d) # True - same content
print(c is d) # True - same object (d is an alias for c)
# The proof: modify through one name, see it through the other
c.append(4)
print(d) # [1, 2, 3, 4] - d reflects the change because c is d
The last example reveals why identity matters at a practical level. If c is d, then modifying c is the same as modifying d. If c is not d (only c == d), they can diverge independently.
How is Works in CPython
In CPython, is is implemented as a simple pointer comparison. Every Python object lives at a memory address. id(obj) returns that address as an integer. a is b is exactly equivalent to id(a) == id(b).
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(id(a)) # e.g. 140234567890
print(id(b)) # same as id(a)
print(id(c)) # different address
print(id(a) == id(b)) # True - same address
print(a is b) # True - same result, different syntax
print(id(a) == id(c)) # False - different addresses
print(a is c) # False - same result
This identity is stable for the lifetime of the object. An object's id() never changes while the object is alive. Two different objects can never have the same id() at the same time - though they can have the same id() at different times if one has been garbage collected and the other allocated at the same address (more on this pitfall later).
The CPython implementation of is is literally a->ob_refcnt pointer comparison in C. It is one of the fastest possible operations in the language - a single machine instruction. This makes it tempting to use is for performance, but correctness must always come first.
The __eq__ Protocol: How == Works
When Python evaluates a == b, it calls a.__eq__(b). If that returns NotImplemented, Python tries the reflected operation b.__eq__(a). If both return NotImplemented, Python falls back to identity comparison (same as is).
The default __eq__ inherited from object is identity - object.__eq__(self, other) returns self is other. This means that for any class that does not define __eq__, two instances are only equal if they are the same object.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# Default __eq__ is identity
p1 = Point(3, 4)
p2 = Point(3, 4)
print(p1 == p2) # False - different objects, default __eq__ is identity
print(p1 is p2) # False - different objects
# Now define __eq__ to use value semantics
class PointV2:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, PointV2):
return NotImplemented
return self.x == other.x and self.y == other.y
p3 = PointV2(3, 4)
p4 = PointV2(3, 4)
print(p3 == p4) # True - __eq__ compares values
print(p3 is p4) # False - still different objects
Notice the isinstance guard and the NotImplemented return. This is the correct pattern for __eq__. Returning NotImplemented (not False) tells Python "I cannot handle this comparison, ask the other side." Returning False would silently suppress a potential comparison the other type could have handled.
__ne__: The != Operator
In Python 3, __ne__ is automatically the inverse of __eq__ by default. If you define __eq__, you do not need to also define __ne__ - Python 3 will invert __eq__'s result automatically.
class PointV2:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, PointV2):
return NotImplemented
return self.x == other.x and self.y == other.y
# No __ne__ needed - Python 3 derives it automatically
p1 = PointV2(1, 2)
p2 = PointV2(3, 4)
p3 = PointV2(1, 2)
print(p1 != p2) # True
print(p1 != p3) # False
In Python 2, you had to define both __eq__ and __ne__ explicitly because the auto-derivation did not exist. Code ported from Python 2 may still have both defined. In Python 3, defining only __eq__ is the correct approach unless you have unusual asymmetric semantics.
None, True, and False as Singletons
This is the most important practical rule about is: the only values for which is is the correct and recommended operator are None, True, and False. PEP 8 explicitly states this.
Why? Because None, True, and False are singletons - there is exactly one instance of each in a Python runtime. This is not an optimization or implementation detail; it is a language guarantee. The Python Language Reference specifies that None is the sole instance of NoneType, and True/False are the sole instances of bool.
# The correct way to check for None - PEP 8 mandated
if value is None:
print("Value is absent")
if value is not None:
print("Value is present")
# WRONG - do not use == for None
if value == None: # Works accidentally, but wrong
pass
# The reason == None is dangerous
class Tricky:
def __eq__(self, other):
return True # Equal to everything, including None!
t = Tricky()
print(t == None) # True - misleading
print(t is None) # False - correct
The == None form calls __eq__, which can be overridden to return anything. The is None form bypasses __eq__ entirely and checks memory address. Since there is exactly one None, is None can never be fooled by a custom __eq__.
# True and False as singletons
print(True is True) # True - guaranteed
print(False is False) # True - guaranteed
# But: if x is True is different from if x
x = 1
print(x == True) # True - 1 == True (bool is subclass of int, True == 1)
print(x is True) # False - 1 is not the boolean singleton True
print(bool(x)) # True - x is truthy
if x: # Checks truthiness - x=1 passes
pass
if x is True: # Checks for the exact singleton True - x=1 FAILS
pass
The distinction between if x: and if x is True: matters when you need to distinguish between "x is truthy" (any non-falsy value) and "x is literally the boolean True." In most code, if x: is what you want. if x is True: is for when you specifically require a boolean True and not just any truthy value - for example, in config validation where 1 and True should be treated differently.
The CPython Integer Cache: −5 to 256
CPython pre-allocates integer objects for all values from −5 to 256 inclusive. These objects are created at interpreter startup and live for the lifetime of the process. When you assign x = 42, Python does not create a new integer object - it hands you a reference to the pre-existing 42 object.
# Cached range - both names point to the same object
a = 100
b = 100
print(a is b) # True (within cache)
a = 256
b = 256
print(a is b) # True (last cached value)
# Outside cache - new objects created each time
a = 257
b = 257
print(a is b) # False (usually - but see note below)
a = -5
b = -5
print(a is b) # True (first cached negative)
a = -6
b = -6
print(a is b) # False (outside cache)
The cache bounds NSMALLNEGINTS = 5 and NSMALLPOSINTS = 257 are defined in CPython's Objects/longobject.c. The total cache holds 262 integer objects (−5 through 256 inclusive).
There is a subtle exception: when CPython compiles code in a single code object (a function body or module), it may reuse the same integer object for multiple occurrences of the same literal, even outside the cache range. This is compile-time constant folding, not the integer cache. So a = 257; b = 257 in the same function may produce a is b == True in the interactive REPL but False when loaded from a compiled .pyc. Never rely on integer identity in production code.
Why this cache range? Integers from −5 to 256 cover the vast majority of small integers used in real programs - loop counters, array indices, return codes. Pre-allocating them eliminates allocation and deallocation overhead for the most common case.
String Interning: When Strings Share Identity
Python automatically interns strings that look like identifiers - strings composed only of ASCII letters, digits, and underscores. The rationale: these strings are commonly used as dictionary keys, attribute names, and variable names. Interning means repeated dictionary lookups can compare by identity (pointer comparison) instead of value (character by character).
# Identifier-like strings are usually interned
a = "hello"
b = "hello"
print(a is b) # True - interned automatically
a = "hello_world"
b = "hello_world"
print(a is b) # True - underscore is allowed
# Strings with spaces or other characters may NOT be interned
a = "hello world"
b = "hello world"
print(a is b) # False (in most contexts) - space breaks interning
# Strings built at runtime are NOT automatically interned
a = "hel" + "lo"
b = "".join(["h", "e", "l", "l", "o"])
print(a is b) # False - runtime-constructed strings are not interned
# (though compile-time constant folding may intern "hel"+"lo")
The compile-time interning behavior is subtle:
# In a single module/function, the compiler folds constants
a = "hel" + "lo" # Compile-time folding: this becomes "hello" at compile time
b = "hello"
print(a is b) # True in CPython - both refer to the interned "hello"
# But at runtime:
def make_string(s1, s2):
return s1 + s2
c = make_string("hel", "lo") # Runtime concatenation
d = "hello"
print(c is d) # False - runtime result is a new object
Manual Interning with sys.intern()
You can force a string into the intern table:
import sys
# Force interning of runtime-created strings
a = sys.intern("hello world") # intern despite the space
b = sys.intern("hello world") # second call returns the same object
print(a is b) # True - both now reference the interned object
The intern table uses weak references - if all regular references to an interned string are dropped, the intern table's weak reference does not prevent garbage collection. The string is removed from the intern table when collected.
When to use sys.intern():
# Use case: high-frequency dictionary key lookups with repeated strings
# Scenario: parsing a large log file with thousands of repeated field names
import sys
FIELD_NAMES = [sys.intern(name) for name in
["timestamp", "level", "service", "message", "trace_id"]]
def parse_log_line(line):
parts = line.split("|")
# Intern field names so dictionary key lookup can use identity comparison
return {sys.intern(k.strip()): v.strip()
for k, v in zip(FIELD_NAMES, parts)}
In CPython's dictionary implementation, if both the key being looked up and the stored key are interned strings with the same identity, CPython can short-circuit the full hash + equality check and use identity. For programs doing millions of repeated dictionary key lookups with the same string keys, this can provide measurable performance gains.
sys.intern() is primarily useful in three scenarios: (1) parsers that create many strings from the same vocabulary, (2) long-running servers that repeatedly use the same field names as dict keys, and (3) compiler/interpreter implementations building symbol tables. For typical application code, the benefit is negligible.
Deep Equality and Nested Structures
== for containers delegates recursively to the elements:
# List equality: element-by-element recursion
a = [1, [2, 3], {"x": 4}]
b = [1, [2, 3], {"x": 4}]
print(a == b) # True - recursive element comparison
print(a is b) # False - different list objects
# The nested lists are also separate objects
print(a[1] == b[1]) # True
print(a[1] is b[1]) # False
# copy.deepcopy creates a full recursive copy
import copy
c = copy.deepcopy(a)
print(a == c) # True - same values
print(a is c) # False - different top-level objects
print(a[1] is c[1]) # False - even nested objects are different
# copy.copy creates a shallow copy
d = copy.copy(a)
print(a == d) # True
print(a is d) # False - different top-level list
print(a[1] is d[1]) # True - nested list is SHARED (shallow copy!)
The distinction between shallow and deep copy is a direct consequence of understanding identity vs equality. A shallow copy creates a new top-level container but the elements have the same identity as the originals. A deep copy creates new objects at every level.
The NaN Anomaly: When == Is Never True
IEEE 754 floating-point defines NaN (Not a Number) as not equal to anything, including itself. Python implements this correctly:
nan = float('nan')
import math
nan2 = math.nan
print(nan == nan) # False - NaN is never equal to itself
print(nan is nan) # True - same object in memory
print(nan != nan) # True - the reliable way to detect NaN
# Checking for NaN
import math
x = float('nan')
print(math.isnan(x)) # True - correct way
print(x != x) # True - also works (NaN is the only value where this holds)
print(x == float('nan')) # False - does not work!
This is not a Python bug - it is correct IEEE 754 behavior. But it is a frequent source of surprise and a classic interview question.
# Practical impact: NaN in collections
data = [1.0, float('nan'), 3.0]
print(float('nan') in data) # False! 'in' uses ==, NaN != NaN
# Safe NaN filtering
import math
clean = [x for x in data if not math.isnan(x)]
print(clean) # [1.0, 3.0]
The id() Reuse Pitfall
Because id() returns the memory address of an object, and because CPython recycles memory addresses after objects are garbage collected, two different objects can have the same id() at different times:
a = [1, 2, 3]
addr = id(a)
print(addr)
del a # Object is garbage collected, memory freed
b = [4, 5, 6] # New object may be allocated at the same address
print(id(b)) # May equal addr - CPython recycles freed memory
# This means: id equality at different times does NOT mean same object
# This is called the "zombie id" pitfall
The practical implication: caching id() values to track object identity across time is unsafe. Use weakref if you need to track object identity across potential garbage collection.
Production Pitfalls Summary
# PITFALL 1: Using 'is' for string comparison
name = input("Enter name: ")
if name is "admin": # WRONG - may fail depending on interning
give_admin_access()
if name == "admin": # CORRECT
give_admin_access()
# PITFALL 2: Relying on integer cache in production
user_count = get_user_count() # returns int from database
max_users = get_max_users() # returns int from config
if user_count is max_users: # WRONG - works for small numbers, breaks at 257
print("At capacity")
if user_count == max_users: # CORRECT
print("At capacity")
# PITFALL 3: Using == to check for None
def process(value=None):
if value == None: # WRONG - can be fooled by __eq__
return default()
if value is None: # CORRECT - cannot be overridden
return default()
# PITFALL 4: Using 'is' for True/False when truthiness is what you want
flag = 1
if flag is True: # WRONG if you mean "truthy" - 1 is not True
do_thing()
if flag: # CORRECT if you mean "truthy"
do_thing()
# PITFALL 5: NaN comparison
result = compute_value()
if result == float('nan'): # WRONG - always False
handle_nan()
if math.isnan(result): # CORRECT
handle_nan()
Interview Questions
Q1: What is the difference between is and == in Python?
A: is tests object identity - it returns True if and only if both operands refer to the exact same object in memory, which in CPython means they have the same id() (same memory address). == tests value equality - it invokes the __eq__ protocol, which by default checks identity but can be overridden to compare by value. Two distinct objects can be == without being is. Two names can be is (aliasing the same object) which necessarily implies ==. Identity implies equality (for well-behaved __eq__), but equality does not imply identity.
Q2: Why does PEP 8 recommend if x is None: instead of if x == None:?
A: None is a singleton - there is exactly one None object in a Python process. Since is checks identity (memory address), x is None is guaranteed to work correctly regardless of what type x is or how x.__eq__ is implemented. In contrast, x == None calls x.__eq__(None), which can be overridden by a custom class to return True even when x is not None. Using is None is both semantically clearer (it says "I want to know if this is literally the None object") and safer (it cannot be fooled by __eq__ overrides).
Q3: Explain the CPython integer cache. What are the bounds and why do they exist?
A: CPython pre-allocates integer objects for all values from −5 to 256 inclusive (defined by NSMALLNEGINTS = 5 and NSMALLPOSINTS = 257 in Objects/longobject.c). These 262 objects are created at interpreter startup and reused for the lifetime of the process. When code requests an integer in this range, CPython returns a pointer to the pre-allocated object rather than allocating a new one. This means a = 100; b = 100; a is b is True - both names point to the same cached object. The bounds cover the most common integers in real programs (loop counters, array indices, small constants), eliminating allocation overhead for frequent operations. Outside this range, each assignment creates a new object, so a = 257; b = 257; a is b is typically False. This is a CPython implementation detail, not a language guarantee - PyPy and other implementations may cache different ranges.
Q4: What strings does CPython automatically intern, and how does sys.intern() differ?
A: CPython automatically interns strings that look like Python identifiers - strings containing only ASCII letters, digits, and underscores, and that are encountered as string literals in source code. This covers variable names, attribute names, and simple string literals. Strings with spaces, punctuation, or other characters are generally not automatically interned, nor are strings constructed at runtime through concatenation or join(). sys.intern(s) explicitly adds a string to the intern table regardless of its content, ensuring that future calls to sys.intern() with an equal string return the exact same object. The intern table holds weak references, so interned strings can still be garbage collected if no regular references exist. sys.intern() is useful for performance-critical code with high-frequency dictionary lookups using repeated string keys.
Q5: Why is float('nan') != float('nan')? How do you correctly check for NaN?
A: IEEE 754 - the floating-point standard that Python follows - defines NaN (Not a Number) as unordered with respect to every value, including itself. Any comparison involving NaN returns False except !=, which returns True. This is not a Python bug; it is mathematically correct behavior: NaN represents an undefined or indeterminate result, and two undefined results should not be considered equal. The correct way to check for NaN is math.isnan(x) or, portably, x != x (the only value for which this is true). Never use x == float('nan') - it always returns False.
Q6: What happens to id() values after an object is garbage collected?
A: CPython recycles memory. When an object is garbage collected (its reference count drops to zero), CPython can immediately reclaim the memory and allocate a new object at the same address. If a new object is allocated at that address, id(new_obj) will equal the saved id() of the dead object. This means that saving id() values to track object identity across time - a "zombie id" - is unsafe. If you need to hold a reference to an object without preventing garbage collection, use weakref.ref(obj). A weak reference tracks the object without incrementing its reference count; it returns None automatically when the object has been collected.
Graded Practice Challenges
Level 1 - Predict the Output
What does this code print? Explain each line.
a = 255
b = 255
c = 256
d = 256
e = 257
f = 257
print(a is b)
print(c is d)
print(e is f)
x = None
y = None
print(x is y)
p = True
q = True
print(p is q)
Show Answer
True - 255 is in the cache (−5 to 256), both names point to same object
True - 256 is the last cached value, still the same object
False - 257 is outside the cache, two separate objects created (usually)
True - None is a singleton, there is only one None object
True - True is a singleton, there is only one True object
Key insight: the boundary is at 256/257. Both a is b (255) and c is d (256) return True because 256 is still inside the cache. e is f (257) returns False because 257 falls outside the cache. None and True are guaranteed singletons by the language spec, not just CPython optimization.
Note: e is f returning False is the typical result in CPython, but compile-time constant folding in a single function may make it True in some contexts. This unpredictability is exactly why you should never rely on integer identity.
Level 2 - Debug the Code
The following code is part of a permission-checking system. It has a subtle bug related to identity vs equality. Find the bug, explain why it occurs, and write the corrected code.
class Permission:
def __init__(self, name):
self.name = name
def __eq__(self, other):
if isinstance(other, Permission):
return self.name == other.name
return NotImplemented
ADMIN = Permission("admin")
READ = Permission("read")
WRITE = Permission("write")
def check_access(user_permission, required):
if user_permission is required:
return True
return False
# Simulate: user has admin permission loaded from database
user_perm = Permission("admin") # New object, same value as ADMIN
result = check_access(user_perm, ADMIN)
print(f"Access granted: {result}") # Expected: True, Actual: ?
Show Answer
The bug: check_access uses is to compare permissions. user_perm is a new Permission object with the same name as ADMIN, but it is a different object in memory. user_perm is ADMIN is False even though user_perm == ADMIN is True.
Why it happens: The developer correctly defined __eq__ on Permission to compare by name, but then used is in check_access, which bypasses __eq__ and compares object identity (memory address). Objects created from a database will always be new object instances, never the pre-allocated constants.
Corrected code:
def check_access(user_permission, required):
if user_permission == required: # Use == to invoke __eq__
return True
return False
# Now works correctly
result = check_access(user_perm, ADMIN)
print(f"Access granted: {result}") # True
General rule: Use is only for None, True, and False. Use == for any value comparison, even when you believe the objects should be the same due to caching or interning - you cannot guarantee that without controlling all object creation paths.
Level 3 - Design Challenge
Design a CachedRecord class that implements value-based equality while also supporting a use case where identity can be used for fast path optimization in a cache lookup. The class should:
- Be comparable by value (
==) using a uniquerecord_idfield - Implement
__hash__correctly so records can be used as dictionary keys or in sets - Include a class-level registry that maps
record_idto the canonical object instance - Implement a
get_or_create(record_id, **data)class method that returns the existing canonical instance if one exists (allowingisidentity checks to work for cached objects), or creates and registers a new one - Demonstrate that two records obtained via
get_or_createwith the samerecord_idpass both==andis
Write the class and a demonstration script.
Show Answer
import weakref
class CachedRecord:
"""
A record class where instances with the same record_id can be obtained
by identity (is) when retrieved through the canonical cache, or by
equality (==) when created independently.
"""
_registry: dict = {} # record_id -> weakref to canonical instance
def __init__(self, record_id: int, name: str, value: float):
self.record_id = record_id
self.name = name
self.value = value
@classmethod
def get_or_create(cls, record_id: int, **data) -> "CachedRecord":
"""Return the canonical instance for record_id, or create one."""
if record_id in cls._registry:
existing = cls._registry[record_id]() # dereference weakref
if existing is not None:
return existing # canonical instance still alive
# Create new canonical instance
instance = cls(record_id, **data)
cls._registry[record_id] = weakref.ref(instance)
return instance
def __eq__(self, other) -> bool:
if not isinstance(other, CachedRecord):
return NotImplemented
return self.record_id == other.record_id # equality by record_id
def __hash__(self) -> int:
return hash(self.record_id) # must be consistent with __eq__
def __repr__(self) -> str:
return f"CachedRecord(id={self.record_id}, name={self.name!r})"
# Demonstration
# Via get_or_create - returns canonical instances
r1 = CachedRecord.get_or_create(42, name="Alice", value=3.14)
r2 = CachedRecord.get_or_create(42, name="Alice", value=3.14) # same id
print(f"r1: {r1}")
print(f"r2: {r2}")
print(f"r1 == r2: {r1 == r2}") # True - value equality
print(f"r1 is r2: {r1 is r2}") # True - same canonical object!
# Created independently - equality works but identity does not
r3 = CachedRecord(42, name="Alice", value=3.14)
print(f"\nr3 == r1: {r3 == r1}") # True - same record_id
print(f"r3 is r1: {r3 is r1}") # False - different objects
# Works as dict key and in sets
cache = {r1: "cached data"}
print(f"\nLookup by r2: {cache[r2]}") # "cached data" - same hash and ==
print(f"Lookup by r3: {cache[r3]}") # "cached data" - same hash and ==
record_set = {r1, r2, r3}
print(f"Set size: {len(record_set)}") # 1 - all three are "equal"
Key design decisions:
__eq__usesrecord_idfor value semantics - records with the same ID are equal__hash__is consistent with__eq__(ifa == bthenhash(a) == hash(b))- The registry uses
weakrefso the cache does not prevent garbage collection get_or_createenablesisto work for canonical instances while==works for all instances- This pattern (Flyweight or Identity Map) is common in ORM implementations
Quick Reference Cheatsheet
| Operation | Operator | What it checks | Can be overridden |
|---|---|---|---|
| Identity | is | Same memory address (same object) | No |
| Non-identity | is not | Different memory addresses | No |
| Equality | == | Value equivalence via __eq__ | Yes |
| Inequality | != | Value non-equivalence via __ne__ | Yes (auto-derived from __eq__ in Python 3) |
| Get address | id(x) | Memory address as integer | N/A |
| Value | Correct test | Why |
|---|---|---|
None | x is None | Singleton; is cannot be overridden |
True | x is True or if x: | Depends on intent (exact vs truthy) |
False | x is False or if not x: | Depends on intent |
| Numbers | x == 0, x == 42 | Never use is for numbers |
| Strings | x == "hello" | Never use is for strings |
| NaN | math.isnan(x) | == always returns False for NaN |
| Situation | Use |
|---|---|
| Checking for absence | if x is None: |
| Value comparison | if x == value: |
| Same container (aliasing) | if a is b: (intentional aliasing check) |
| Deep equality | a == b (recursive for built-in containers) |
Key Takeaways
iscompares object identity (same memory address);==compares value via the__eq__protocol - they answer fundamentally different questionsisis implemented as pointer comparison in CPython:a is bis exactlyid(a) == id(b)- The default
__eq__inherited fromobjectis identity - custom classes must define__eq__to get value semantics None,True, andFalseare guaranteed singletons -is None,is True,is Falseare the PEP 8-mandated correct formsx == Noneis dangerous because__eq__can be overridden;x is Nonecannot be fooled- CPython caches integers from −5 to 256;
a is bfor integers in this range may returnTrue, but this is an implementation detail never to be relied upon - String interning automatically applies to identifier-like strings;
sys.intern()can extend this to arbitrary strings for performance float('nan') != float('nan')- NaN is never equal to itself; usemath.isnan()to detect NaN- Saving
id()across time is unsafe because CPython reuses memory addresses after garbage collection - Every production codebase should use
==for all value comparisons and reserveisexclusively forNone,True, andFalsechecks
