PythonIdentity & Membership Operators

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 ==

Python
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

Python
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.
Never use is for value equality
Whether `a is b` is `True` for equal-looking integers or strings depends on CPython's internal caching, not on the language specification. Relying on it will produce code that appears to work in testing and then breaks in production, or on a different Python build. Use `is` / `is not` *only* to compare against singletons — almost always `None`, and occasionally `True` / `False` — and use `==` / `!=` for everything else.

The one correct use of is

Python
value = None

if value is None:
    print("no value provided")

# Not this:
if value == None:  # works, but is not the idiomatic or recommended form
    pass
in / 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

Python
# 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)      # True
Performance: 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

set

O(1) average

Hash table — jumps to the bucket for the value

dict (keys)

O(1) average

Hash table — same mechanism as set

list

O(n)

Linear scan — compares against each element in order

tuple

O(n)

Same linear scan as list, just on an immutable sequence

Same logic, very different cost at scale

Python
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
Rule of thumb
If you are going to run membership checks (`in`) repeatedly against the same collection, and order/duplicates do not matter, convert it to a `set` first with `set(my_list)`. The one-time conversion cost usually pays for itself very quickly once you are checking membership more than a handful of times.