03 — Operators & Expressions
Arithmetic — Floor Division and Modulo Are Not C-Style
# Python's // and % follow MATHEMATICAL flooring, not C/Java truncation.
# This matters in pagination, hashing, ring buffers, and modular arithmetic.
# ── Truncation (C/Java/Rust) vs Flooring (Python) ──
# C: -7 / 2 = -3 (truncates toward zero)
# Python: -7 // 2 = -4 (floors toward -∞)
# Production: ring buffer index computation
BUFFER_SIZE = 256
def ring_index_c_style(offset):
"""WRONG — C-style truncation produces negative indices for negative offsets."""
return int(offset / BUFFER_SIZE) % BUFFER_SIZE # truncation, not flooring
def ring_index_python(offset):
"""CORRECT — Python's % always returns a value with the sign of the DIVISOR."""
return offset % BUFFER_SIZE # always 0..255, even for negative offsets
print(ring_index_c_style(-1)) # 255 — accidentally works here, but breaks at other values
print(ring_index_python(-1)) # 255 — guaranteed correct: -1 % 256 = 255
print(ring_index_python(-257)) # 255 — correct: -257 % 256 = 255
# ── The divmod() builtin — quotient + remainder in one operation ──
# More efficient than computing // and % separately (one division operation)
quotient, remainder = divmod(-17, 5)
print(quotient, remainder) # -4, 3 — q*floors*, r takes sign of divisor
# Verify: quotient * divisor + remainder == dividend
assert quotient * 5 + remainder == -17
# ── Production: parsing binary protocols with bitwise operators ──
# TCP header flag extraction — real-world bit manipulation, not toy examples
def parse_tcp_flags(flags_byte: int) -> dict:
"""Extract TCP header flags from a single byte — bit masking in production code."""
return {
"FIN": bool(flags_byte & 0x01), # bit 0 — AND isolates single bit, bool() normalizes
"SYN": bool(flags_byte & 0x02), # bit 1
"RST": bool(flags_byte & 0x04), # bit 2
"PSH": bool(flags_byte & 0x08), # bit 3
"ACK": bool(flags_byte & 0x10), # bit 4
"URG": bool(flags_byte & 0x20), # bit 5
"ECE": bool(flags_byte & 0x40), # bit 6
"CWR": bool(flags_byte & 0x80), # bit 7
}
# SYN+ACK = 0x02 | 0x10 = 0x12 = 18
flags = 0x12
parsed = parse_tcp_flags(flags)
print(parsed) # {'FIN': False, 'SYN': True, 'RST': False, 'PSH': False, 'ACK': True, ...}
# ── Packed bitfield construction (e.g., for protocol headers) ──
def build_tcp_flags(fin=False, syn=False, rst=False, psh=False, ack=False, urg=False):
"""Pack boolean flags into a single byte using bit shifts and OR."""
flags = 0
flags |= (fin << 0) # shift each flag to its bit position, OR into result
flags |= (syn << 1)
flags |= (rst << 2)
flags |= (psh << 3)
flags |= (ack << 4)
flags |= (urg << 5)
return flags
print(build_tcp_flags(syn=True, ack=True)) # 18 = 0x12
Comparison Operators
print(3 < 5) # True
print(3 <= 3) # True
print(3 == 3.0) # True — value equality across numeric types
print(3 != "3") # True — different types, never equal
print([1, 2] == [1, 2]) # True — lists compare by value, element-wise
print([1, 2] is [1, 2]) # False — two distinct list objects
is vs == — Identity vs Equality
This is the single most important distinction in this chapter.
==calls__eq__and asks: "do these objects have the same value?"isasks: "are these literally the same object in memory (sameid())?"
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True — same contents
print(a is b) # False — two distinct list objects with equal contents
print(a is c) # True — c is literally a, same object
print(id(a), id(b), id(c)) # a and c share an id; b is different
Rule of thumb: use == for value comparisons (the overwhelming majority of cases). Use is only for:
- Comparing against
None:if x is None - Comparing against other singletons:
if x is Ellipsis,if x is NotImplemented - Deliberate identity checks (e.g., "is this the exact cached instance", sentinel objects)
_SENTINEL = object() # a unique, unforgeable sentinel
def get(d, key, default=_SENTINEL):
if key in d:
return d[key]
if default is _SENTINEL:
raise KeyError(key)
return default
Using a private object() instance as a sentinel (rather than None) lets a function distinguish "no default was passed" from "the caller explicitly passed None as the default" — a pattern impossible to express correctly with is None checks alone.
Chained Comparisons
Python allows mathematical-style chaining, which is evaluated left-to-right with implicit and, and — critically — each subexpression is evaluated only once.
x = 5
print(1 < x < 10) # True — equivalent to (1 < x) and (x < 10)
print(10 > x > 1) # True
print(1 < x < 10 < 3) # False — chains can mix any comparison operators
# The single-evaluation guarantee matters with side effects:
def noisy(n):
print(f"checked {n}")
return n
print(0 < noisy(5) < 10)
# prints "checked 5" ONCE — not twice, unlike a naive `0 < noisy(5) and noisy(5) < 10`
The gotcha: chained comparisons with == create surprising boolean logic
# A beginner might write this expecting "is x equal to 1 OR 2?"
x = 3
print(x == 1 or 2) # TRUTHY — this is (x == 1) or (2), and 2 is truthy!
print(bool(x == 1 or 2)) # True — ALWAYS true, regardless of x, because `2` is truthy
# What they meant:
print(x == 1 or x == 2) # False — correct
print(x in (1, 2)) # False — idiomatic
Logical Operators — and/or Return Operands, Not Booleans
Unlike C-family languages, and/or in Python don't coerce to bool — they short-circuit and return one of the original operands.
print(3 and 5) # 5 — both truthy, returns the LAST evaluated operand
print(0 and 5) # 0 — first is falsy, short-circuits, returns it
print(3 or 5) # 3 — first is truthy, short-circuits, returns it
print(None or "default") # "default" — classic default-value idiom
print([] or {}) # {} — both falsy-ish, returns the last one evaluated
This enables the extremely common default-value idiom:
def greet(name=None):
name = name or "stranger"
return f"Hello, {name}!"
Gotcha: this idiom breaks when a legitimate, intentionally-falsy value should be accepted:
def set_volume(level=None):
level = level or 50 # BUG: set_volume(0) becomes 50, not 0!
return level
print(set_volume(0)) # 50 — wrong! Caller explicitly wanted silence.
# Correct: check for None explicitly, don't rely on truthiness
def set_volume_fixed(level=None):
level = 50 if level is None else level
return level
print(set_volume_fixed(0)) # 0 — correct
Bitwise Operators
print(5 & 3) # 1 — AND
print(5 | 3) # 7 — OR
print(5 ^ 3) # 6 — XOR
print(~5) # -6 — NOT (two's complement: ~x == -x - 1)
print(5 << 2) # 20 — left shift (multiply by 2**2)
print(5 >> 1) # 2 — right shift (floor-divide by 2**1)
# Sets also overload these for set algebra (see chapter 08)
print({1, 2, 3} & {2, 3, 4}) # {2, 3} — intersection
print({1, 2, 3} | {2, 3, 4}) # {1, 2, 3, 4} — union
Augmented Assignment (+=, -=, etc.)
x = 5
x += 3 # x = x + 3
print(x) # 8
The gotcha: += on a list is an in-place mutation, but reassignment inside a function is not
def append_wrong(lst):
lst = lst + [4] # creates a NEW list, rebinds the LOCAL name `lst`
# the caller's list is untouched
return lst
def append_right(lst):
lst += [4] # for lists, += calls __iadd__ -> mutates IN PLACE
return lst # (also rebinds locally, but the object itself changed)
original = [1, 2, 3]
result = append_wrong(original)
print(original) # [1, 2, 3] — unchanged
print(result) # [1, 2, 3, 4]
original2 = [1, 2, 3]
result2 = append_right(original2)
print(original2) # [1, 2, 3, 4] — MUTATED, because list defines __iadd__
print(result2) # [1, 2, 3, 4]
The underlying mechanism: lst = lst + [4] calls __add__, which returns a brand-new list, and rebinding only affects the local name. lst += [4] calls __iadd__ if it exists (lists define it, tuples don't) which mutates the object in place and returns self — so the caller's original object is affected too. For tuples (immutable, no __iadd__), += silently falls back to __add__ and just rebinds, exactly like the list + case — no mutation is possible because tuples can't be mutated.
t = (1, 2, 3)
t += (4,) # falls back to t = t + (4,) — creates a new tuple
print(t) # (1, 2, 3, 4)
The genuinely infamous one: += inside a tuple element
t = ([1, 2], 3)
t[0] += [4]
# TypeError: 'tuple' object does not support item assignment
# ...BUT the list was mutated anyway before the error!
print(t) # ([1, 2, 4], 3)
Why: t[0] += [4] desugars to t[0] = t[0].__iadd__([4]). The __iadd__ call succeeds (lists are mutable, so t[0] — the list — is mutated to [1, 2, 4] in place and returns itself). But then Python tries to execute t[0] = <result> — assigning into the tuple's slot — which raises TypeError because tuples don't support item assignment. The mutation already happened before the failed assignment step, leaving the tuple in a state that looks "impossible" if you don't know the two-step desugaring.
The Walrus Operator := (Assignment Expressions)
Introduced in Python 3.8 (PEP 572), := lets you assign a value as part of a larger expression, avoiding redundant computation or a separate statement.
# Before 3.8 — compute twice or add a throwaway statement
data = get_data()
if data:
process(data)
# With walrus — assign and test in one expression
if (data := get_data()):
process(data)
Real-world use: avoiding repeated expensive calls in a loop
import re
pattern = re.compile(r"(\d+)")
lines = ["order 42 shipped", "no numbers here", "order 108 shipped"]
# Without walrus — the match object is computed, discarded, computed again
results = []
for line in lines:
if pattern.search(line):
results.append(pattern.search(line).group(1)) # searches TWICE
# With walrus — search once, bind it, reuse it
results = []
for line in lines:
if (match := pattern.search(line)):
results.append(match.group(1))
print(results) # ['42', '108']
Walrus inside comprehensions
# Filter and transform using a value that's expensive to compute, without
# calling the expensive function twice per element
def expensive_check(n):
return n * n
numbers = [1, 2, 3, 4, 5, 6]
squared_evens = [y for n in numbers if (y := expensive_check(n)) % 2 == 0]
print(squared_evens) # [4, 16, 36]
Gotcha: the walrus operator creates a binding that leaks into the enclosing scope — unlike the loop variable of a comprehension (which is scoped to the comprehension in Python 3), a walrus target is NOT scoped to the comprehension.
result = [y := x * 2 for x in range(3)]
print(y) # 4 — leaked out of the comprehension into the enclosing scope!
print(x) # NameError — the comprehension's own loop variable `x` did NOT leak
Operator Precedence (Abbreviated)
From highest to lowest (a small but high-value subset):
| Precedence | Operators |
|---|---|
| Highest | ** (exponentiation, right-associative) |
unary +x, -x, ~x | |
*, /, //, % | |
+, - | |
<<, >> | |
& | |
^ | |
| | |
comparisons: <, <=, >, >=, !=, ==, in, not in, is, is not | |
not x | |
and | |
| Lowest | or |
# ** is right-associative — surprising if you expect left-to-right
print(2 ** 3 ** 2) # 512, NOT 64! Evaluated as 2 ** (3 ** 2) = 2 ** 9
# Unary minus binds tighter than ** on the LEFT but not cleanly on both sides
print(-2 ** 2) # -4, NOT 4 — this is -(2 ** 2), because ** binds tighter than unary -
print((-2) ** 2) # 4 — parenthesize to get what you probably meant
💡 Tips & Tricks
math.isclose()for float comparisons — Never compare floats with==;math.isclose(a, b, rel_tol=1e-9)handles the inherent binary rounding error correctly, with configurable relative/absolute tolerance.divmod()for quotient and remainder together —divmod(17, 5)returns(3, 2)in one call, avoiding two separate//and%operations when you need both (common in time/unit conversion code).- Chained comparisons replace verbose range checks —
if 0 <= age < 120:is both more readable and marginally faster thanif age >= 0 and age < 120:since the shared subexpression is evaluated once. - The walrus operator shines in
whileloops reading streams —while (chunk := file.read(8192)):is the idiomatic replacement for the oldchunk = file.read(8192); while chunk: ...; chunk = file.read(8192)duplicated-read anti-pattern. operatormodule gives you operators as functions —from operator import add, itemgetter, attrgetterlets you pass+, item access, or attribute access as first-class callables tosorted(key=...),reduce, ormapwithout writing alambda.
⚠️ Edge Cases & Gotchas
or/andreturn operands, not booleans, and the truthy-default idiom silently breaks on legitimate falsy values —x = value or defaulttreats0,"",[], andFalseas "absent," which is wrong whenever those are valid inputs (see theset_volume(0)example above). Usex = default if value is None else valuewheneverNonespecifically (not any falsy value) means "absent."- Floor division floors toward negative infinity, not toward zero —
-7 // 2 == -4, not-3. Code ported from C/Java that assumes truncation-toward-zero semantics for negative operands will be off by one. **is right-associative; unary minus has lower precedence than**—2 ** 3 ** 2is512(right-associative), and-2 ** 2is-4(unary minus applies after exponentiation). Both surprise programmers coming from languages with different precedence tables — parenthesize when in doubt.a += bmutates in place for mutable types with__iadd__(list) but rebinds for immutable types (tuple, str, int) — the same syntax has different aliasing consequences depending entirely on the type ofa, which is invisible at the call site without knowing the type.- Chained
iscomparisons acrossand/ordon't chain like<—a is b is cDOES chain ((a is b) and (b is c)), but mixingiswithordoesn't do what a beginner porting mathematical-notation intuition expects, per thex == 1 or 2example —or/andnever implicitly distribute across a value the way chained comparisons do.
🧠 Spot the Bug
What does this print?
def get_cache_entry(cache, key):
value = cache.get(key) or "MISS"
return value
cache = {"count": 0, "name": "widget"}
print(get_cache_entry(cache, "count"))
print(get_cache_entry(cache, "name"))
print(get_cache_entry(cache, "missing"))
Answer
Prints MISS, widget, MISS. The bug: cache.get("count") correctly returns 0 (a legitimate cached value), but 0 or "MISS" evaluates to "MISS" because 0 is falsy — the function can never distinguish "the cached value is genuinely 0" from "the key is absent." Only the second call behaves as intended, because "widget" happens to be truthy.
The fix uses a sentinel or explicit None check against a dict's actual absence signal:
def get_cache_entry(cache, key):
value = cache.get(key)
return "MISS" if value is None else value
This still has a narrower edge case if None is itself a valid cached value — in that case use key in cache or cache.get(key, _SENTINEL) is _SENTINEL instead.
The lesson: or as a "use this if the left side is missing" idiom conflates falsy with absent — they are only the same thing if you've proven the valid value space never includes 0, "", [], or False.
Key Takeaways
/always returns afloat(true division);//floors toward negative infinity, it does not truncate toward zero.- Use
==for value comparisons andisonly forNone/singleton/sentinel identity checks — never for general value comparison, even on ints or strings. and/orshort-circuit and return one of the original operands, not a coerced boolean — this powers (and can silently break) thex = value or defaultidiom.+=mutates in place for types with__iadd__(likelist) but rebinds for immutable types (liketuple) — the same syntax, different aliasing behavior depending on type.- The walrus operator (
:=) assigns as part of an expression and is invaluable for avoiding duplicate computation inif/while/comprehensions, but its binding leaks into the enclosing scope.