OneBug All write-ups

Python ·

Python is vs == on integers: True at 256, False at 1000

is compares identity, not value. CPython caches ints from -5 to 256, so small numbers pass an is-check by luck and bigger ones fail it. Use ==.

Test yourself before reading - the write-up below gives it away.

Spot the bug Python
1
# Did the request land on the configured port?
2
def same_port(configured, active):
3
    return configured is active
4
 
5
print(same_port(int("80"), 80))
6
print(same_port(int("8080"), 8080))
7
# expected: True, True
8
# actual:   True, False  (!)

Two equal ports, one failing check

same_port(int("80"), 80) prints True. same_port(int("8080"), 8080) prints False. Same function, values that compare equal, opposite answers.

# Did the request land on the configured port?
def same_port(configured, active):
    return configured is active

print(same_port(int("80"), 80))
print(same_port(int("8080"), 8080))
# expected: True, True
# actual:   True, False  (!)

Why does is return False for equal integers?

is tests object identity - whether two names point at the same object in memory - never value equality. CPython keeps one shared object for every integer from -5 to 256, so int("80") returns the cached 80 and the check passes by coincidence. int("8080") builds a fresh object, and is says no to equal values. The fix: compare numbers with ==.

The small-integer cache, and why 256 is the magic number

CPython preallocates the integers -5 through 256 at startup and hands out the same object every time one of those values comes up - the docs state it plainly in the C-API notes on integer objects: "the current implementation keeps an array of integer objects for all integers between -5 and 256". Any expression that produces 80 - parsing it, adding 79+1, slicing it out of a struct - yields the identical object, so is agrees with == across that whole range. At 257 the guarantee ends. Each new 8080 is its own object, identity diverges from equality, and line 3 - the is - is where the bug lives. The int() calls on lines 5-6 look like the suspicious part and are doing honest work.

There is a second layer that makes this bug genuinely slippery to reproduce: the compiler folds equal constants inside one code object. Test it in a script and you get a surprise in the other direction:

$ python -c "print(1000 is 1000)"
<string>:1: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
True

True - because both literals were folded into a single constant. The same line typed as two separate REPL statements can print False. So the failure depends not just on the value but on where each integer object came from, which is why an is-comparison can survive a long time on numbers that "should" have failed it. Values that arrive from parsing, arithmetic, or I/O - like production IDs - are the ones that finally break it.

Green tests, then a cache that never hits

A session service checks if session.user_id is cached.user_id before reusing a connection. The test fixtures create users 1 through 10 - all deep inside the cached range, so every assertion passes and the branch looks correct in review too, because is reads like English. In production, user IDs are seven digits. The identity check fails for every request, the service treats each one as a cache miss, and nothing errors - the only symptom is a hit rate near zero and a database working ten times harder than capacity planning said it would.

The fix

def same_port(configured, active):
    return configured == active

== asks the question the code meant to ask. Keep is for what it is designed for: None and other true singletons (is None, is True in rare flag-object protocols), where identity is the actual contract. Since Python 3.8 the compiler backs this up - is against a literal raises the SyntaxWarning shown above, a warning worth promoting to an error in CI (python -W error::SyntaxWarning).

How to find is-comparisons on values in your codebase

  • grep -rnE 'is (not )?-?[0-9]' --include='*.py' . catches literal comparisons; variable-to-variable cases need the linters below.
  • flake8 flags is against str/bytes/int literals as F632; pylint calls it literal-comparison / R0123.
  • Runtime check while debugging: if a == b and a is not b, you are looking at two equal objects - which is the normal case, not a bug.

Related bugs

Java has this exact trap wearing different syntax: the Integer boxing cache runs from -128 to 127, and == on boxed values works until it doesn't. For another Python operation that does something adjacent to what its name says, see str.strip(".txt"). The rest live at the bug write-ups hub.

Port 80 will keep passing that check forever. That is the problem.