Identity & Membership Operators
Python has two small but frequently confused families of operators: identity operators (is / is not), which ask "are these the exact same object in memory?", and membership operators (in / not in), which ask "does this container hold this value?". They read like plain English, but each hides an important technical detail worth understanding.
is / is not — identity, not equality
is compares object identity: it checks whether two names refer to the exact same object in memory, which is what id() returns. == compares value equality: it checks whether two objects contain the same data, even if they are different objects.
is vs ==
a = [1, 2, 3] b = [1, 2, 3] c = a print(a == b) # True -- same contents print(a is b) # False -- two different list objects print(a is c) # True -- c is literally the same object as a print(id(a), id(b), id(c))
The small-integer and string caching gotcha
CPython, the reference implementation of Python, caches small integers (typically -5 to 256) and some short strings as a memory optimization. This means two variables holding the "same" small integer often are the same object — but this is an implementation detail, not a language guarantee, and it stops working once the numbers get bigger.
Small-int caching is not something to rely on
a = 256 b = 256 print(a is b) # True on CPython -- both point at the cached int object 256 x = 1000 y = 1000 print(x is y) # False (usually) -- two separate int objects were created # Even the "True" case above is an implementation detail. # It can differ between Python versions, implementations (PyPy, etc.), # and even between an interactive shell and a script file.
The one correct use of is
value = None
if value is None:
print("no value provided")
# Not this:
if value == None: # works, but is not the idiomatic or recommended form
passin / not in — membership testing
in checks whether a value exists inside a container, and not in checks the opposite. The exact meaning of "exists inside" depends on the container: for strings it checks substrings, for lists/tuples it checks elements, for dicts it checks keys (not values), and for sets it checks elements.
Membership across different containers
# Strings: substring check
sentence = "Python is fun"
print("fun" in sentence) # True
print("Java" in sentence) # False
# Lists: element check
fruits = ["apple", "banana", "cherry"]
print("banana" in fruits) # True
print("grape" not in fruits) # True
# Dicts: checks KEYS by default, not values
scores = {"alice": 90, "bob": 85}
print("alice" in scores) # True -- key lookup
print(90 in scores) # False -- 90 is a value, not a key
print(90 in scores.values()) # True -- explicitly check values
# Sets: element check
unique_ids = {101, 102, 103}
print(102 in unique_ids) # TruePerformance: set/dict vs. list/tuple
Membership testing is not equally fast on every container. Sets and dicts are built on hash tables, so in can jump almost directly to the answer — on average, checking membership is O(1), meaning it takes roughly the same amount of time no matter how large the collection is. Lists and tuples have no such index: Python has to scan element by element until it finds a match (or reaches the end), which is O(n) — the larger the list, the longer the worst case takes.
Container | Membership check | Why |
|---|---|---|
| O(1) average | Hash table — jumps to the bucket for the value |
| O(1) average | Hash table — same mechanism as |
| O(n) | Linear scan — compares against each element in order |
| O(n) | Same linear scan as |
Same logic, very different cost at scale
ids_list = list(range(1_000_000)) ids_set = set(ids_list) # Both give the correct answer, but on a list this walks up to # a million elements; on a set it's an (almost) instant hash lookup print(999_999 in ids_list) # True -- slow: worst case scans the whole list print(999_999 in ids_set) # True -- fast: O(1) average, regardless of size