The Quiet Terminal

← Beginner

None, is, and ==: a short story about identity

2026-07-08 · 3 min read

I spent a long time using 0 and "" as placeholders — if a variable held zero, I figured nothing had happened yet. Then I wrote a search loop that remembers the position of the first error in a log, and I couldn't tell "error on line 1" from "no error at all". None is Python's value for exactly that situation: a real value that means nothing yet.

first_error = None                    # nothing found yet

for i, line in enumerate(log_lines):
    if "ERROR" in line:
        first_error = i
        break

if first_error is None:
    print("clean run")
else:
    print("first error on line", first_error + 1)

None is not zero and not an empty string — both of those are real values that can occur honestly. None means this hasn't been set yet, and nothing in your data can imitate it by accident.

== and is ask different questions

a = [1, 2, 3]
b = [1, 2, 3]

a == b     # True  — same contents
a is b     # False — two separate lists that happen to match

Two identical shopping lists are still two pieces of paper. == compares what's written on them; is asks whether they're the same sheet of paper. (One wrinkle: Python caches small integers and some short strings, so is can say True where you expected False. That's a reason to keep is for None and not stretch it further.)

Why "is None" is the idiom

None is a singleton — there is exactly one None in a running program — so identity is the precise question, and it can't be fooled:

if first_error is None:
    print("clean run")

is can't be customized; it always asks the same literal question. == can be: a class defines its own __eq__, and I once used a library whose objects cheerfully reported themselves equal to anything you asked, None included. That was the afternoon my if x == None checks started lying to me. is None means the same thing in every Python file ever written; == None means whatever some class decided it means.

One gotcha: defaults that remember

The other place None quietly does hero work is function defaults. This looked fine to me for months:

def add_item(item, cart=[]):
    cart.append(item)
    return cart

print(add_item("apple"))    # ['apple']
print(add_item("pear"))     # ['apple', 'pear']  — where did apple come from?

Default values are created once, when the function is defined — not on every call. So every call that omits cart shares the same list. The standard fix is the None pattern itself:

def add_item(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

cart=None means "nothing handed in yet", and the function builds its own list when it hears that. Notice which check the fix uses: is None, because identity is the one question None always answers truthfully.

So the short story: == for values, is for identity, and is None whenever the question is "has this happened yet?". None isn't a hole in your data — it's an honest value for the space before the data. Once I stopped fearing it, half of my flag variables and sentinel hacks disappeared.