◈ python mapVol 1 · Ch 04/14
Volume 1 Python, from the metal up · chapter 04

04Operators & expressions

In Chapter 3 we pinned names onto objects. In this one we make those objects do something. Here's the plan. Every scrap of code you write is an expression, a question Python answers by boiling it down to one value, and operators are the verbs that ask it. We'll add two song lengths and watch 200 + 245 collapse to 445. We'll split that total with /, //, and % to turn raw seconds into 7:25. We'll tell == apart from is, find the side of and Python never bothers to read, and flip raw bits with & | ^ ~ << >>. And the whole way through we keep asking the one thing that actually matters: when a line has more than one operator, who binds first? By the end you'll read any expression the exact way Python does, precedence in hand, with no magic left anywhere in it.

iolinked · chapter 04 — the checkpoints6 steps
$ sections covered in Operators & expressions
01An expression is a question; operators are the verbs
02One slash, two slashes, and the leftover
03Two kinds of equal: == and is
04Truthiness, and the logic that stops early
05Down to the bits: & | ^ ~ << >>
06Who binds first: precedence

01An expression is a question; operators are the verbs

Let's start with the smallest useful thing you can ask. You've got two song lengths, 200 and 245, and you want the total. Type 200 + 245, press Enter, and watch what Python does with it. It does exactly one thing: it answers. It keeps chewing until a single value is left standing, here 445. That scrap of code that boils down to one value has a name. We'll call it an expression, and every expression alive shares that one job: collapse to a single answer. 200 + 245 is an expression. So is total // 60. So is a bare 445, because a value sitting alone is the smallest expression there is. The symbol doing the work is the operator, the verb of the sentence, and the values it grips are its operands. So 200 + 245 isn't really arithmetic homework. It's a tiny question, how long are these two songs, back to back?, and + is the verb that answers it.

THE ONE IDEA TO CARRY FORWARD
Every expression, however sprawling, collapses to exactly one value. 2 is already an expression; so is 200 + 245; so is a page-long formula. "Running" an expression means only this: keep applying operators to operands until a single value survives. That survivor is the answer.
Anatomy of one expressionfigure
the question: “how long are these two songs, back to back?” 200 + 245 445 operand operator · the verb operand the value one expression
Fig 1 — Anatomy of an expression: two operands, one operator, a single value as the answer. Every expression in Python, no matter how tangled, has this shape once you zoom in.

Python ships seven arithmetic operators — + - * / // % ** — and most are old friends in a keyboard costume. Multiplication's × is typed * because no keyboard has a times sign. Division's ÷ becomes / for the same reason. ** is the power operator: 7 ** 3 means 7·7·7 = 343, not 7·3. That leaves two genuine strangers, // and %. Both are flavours of division, odd enough that they get the whole next section to themselves. Point every one of the seven at the same pair of numbers and you get seven different answers. Drag the second number below and watch them diverge.

One pair, seven verbs▸ drag me
one pair — 7 and b — asked seven different ways + - * / // % ** 7 + 3 7 - 3 7 * 3 7 / 3 7 // 3 7 % 3 7 ** 3 10 4 21 2.333… 2 1 343 with two whole numbers, only / is forced to a decimal — every other result type follows the operands
3
Drag b from 1 to 9. Watch // and % shift as b grows past 7 — once the divisor is bigger than 7, the whole-part drops to 0 and the leftover becomes all of 7 — and notice / is the only card that ever shows a decimal point.
Fig 2 — One pair, seven verbs. Slide b and every card re-answers at once — proof that an operator is just a question waiting on its operands. Inside this widget's regime — a = 7 and a positive whole b — only / ever shows a decimal. But that is not a law about the operators: the result type is decided by the operands. Feed any card a float and its answer floats too (7 % 2.0 is 1.0), and even ** floats on a negative power (7 ** -1 is 0.142…). That lonely dot under / is the crack the next section pries open.
Wait — when I write total = blinding + titanium, does + change blinding or titanium? Neither. An operator reads its operands and hands back a brand-new third value; the two originals sit there untouched, ready to be asked again. A verb like + is a question about its operands, never a command to them.

Enough talk. Watch Python answer a stack of these questions in a row. Each print below hands a finished expression to the screen. Read the comment on each line as the answer Python works out.

songs.pypython
# our playlist, in seconds — chapter 0's two anchors
blinding = 200                 # "Blinding Lights" — The Weeknd
titanium = 245                 # "Titanium" — David Guetta ft. Sia
total = blinding + titanium    # one + verb, two operands
print(total)                   # 445  — the whole playlist, once
print(total * 2)               # 890  — played twice, end to end
print(7 ** 3)                 # 343  — ** is power: 7·7·7, not 7·3

plays = 0
plays += 1                     # augmented assignment: read, add, rebind
print(plays)                   # 1

That last line hides the section's one real subtlety. += is augmented assignment, and for a number it means precisely three things: read plays, add 1, rebind plays to the result. And rebind is the exact word. An int is a photo, not a whiteboard (chapter 3), so += can never edit the 0 object itself. Python computes 0 + 1 and slides the name onto the answer. Here that answer isn't even freshly built: it's the 1 already waiting on chapter 3's small-integer shelf. Past the shelf, imagine 256 ticking to 257, the result would be constructed anew. Either way plays lands on a different object than it started on: new value, new id(). That is the whole truth for numbers, and hold onto it, because it is about to matter. Point two names at one list and += does the opposite. It edits that very object in place and id() never moves, so a change made through one name silently shows up under the other. That single divergence is the seed of a whole class of aliasing bugs you'll hunt in chapter 6. The deeper cut below shows the split.

SYNTAX · the arithmetic verbs & their augmented twinsseven binary verbs, two unary signs, and an op= shorthand for every one of them
a + b a - b a * b — add, subtract, multiply a / b a // b a % b — exact ratio, whole blocks, the leftover a ** b -a +a — power, and the two unary signs name += b name -= b name *= b — augmented: read, apply, rebind name //= b name %= b name **= b — every binary verb has one of these
Type 1binary — two operands go in, one brand-new value comes out. Neither operand is touched.
Type 2unary — one operand. -a flips the sign; +a hands a straight back, unchanged.
Type 3augmented — plays += 1 means read, add, rebind. For a number it is exactly plays = plays + 1.
Type 4mixed types promote. An int touched by a float answers in float: 7 + 2.0 is 9.0.
Type 5the same verb, other types. "-" * 20 repeats a string and [200] + [245] joins lists — each type answers + its own way.
you type
blinding, titanium = 200, 245
print(blinding + titanium, titanium - blinding)
print(blinding * 2, blinding / 3)
print(titanium // 60, titanium % 60, 2 ** 10)

plays = 0
plays += 1                  # read 0, add 1, rebind
plays *= 5                  # every binary verb has an augmented twin
print(plays, -plays)

print("-" * 20)             # the same * verb, a different type
print(7 + 2.0, type(7 + 2.0))
you see
445 45
400 66.66666666666667
4 5 1024
5 -5
--------------------
9.0 <class 'float'>
where beginners trip
  • 200 + "245" raises TypeError. Python will not guess whether you meant arithmetic or glue — convert one side first.
  • 7 / 2 is 3.5 and 6 / 3 is 2.0. One slash always hands back a float, even on a clean division.
  • Python has no ++. ++plays parses as +(+plays) and quietly does nothing at all; the increment you want is plays += 1.
  • plays =+ 1 binds plays to 1; plays += 1 adds to it. Two transposed characters, two different programs.
  • an augmented form needs the name to exist already. total += 1 on a fresh name raises NameError, because there is nothing to read first.
What plays += 1 actually movesfigure
plays += 1 — same name, different object, different id() plays before now · += 1 0 id ≈ 0x…9e0 1 id ≈ 0x…a10 both numbers live on chapter 3's small-integer shelf — pre-built, shared, and frozen
Fig 3 — plays += 1 never touches the object 0; it moves the name plays onto a different object (the shelf's 1), so id(plays) changes. The two ids shown are illustrative — run it yourself and they will differ, but they will differ.

✗ The myth

plays += 1 is faster than plays = plays + 1 because it bumps the number in place instead of building a new one.

✓ The reality

For an int there is nothing to bump — the object 0 is frozen (chapter 3). += computes 0 + 1 and re-aims the name at a different object; here it even lands on the pre-built 1 from the small-integer shelf. Same work, same result — += is simply fewer keystrokes.

↺ reframe
+= is not "add into." Read it as three moves compressed into one keystroke: read the name's current value, add, then rebind the name onto the answer. The number never moves — the name does.
EXPRESSIONS ANSWER, STATEMENTS ACT
blinding + titanium is an expression — it produces 445. But plays = 0 is a statement: it does something (binds a name) yet hands back no value, which is why x = (plays = 0) is a syntax error, not a clever trick. The lone exception is the walrus :=: it lets an assignment also hand back the value it just stored. That earns its keep in exactly one situation — when you need a value and want to remember it in the same breath, so you never compute it twice. You have not hit that situation yet; it arrives with loops and comprehensions in chapter 5, where while (chunk := read()): both tests the chunk and keeps it. File it away — (n := 5) + 1 is 6 and n is now 5 — and reach for it there, not here.
The deeper cut — is += always just x = x + …?

For frozen values — int, float, str, tuple — yes, exactly: read, add, rebind, land on a new object. But calling that "the rule" is a labelled simplification, and here is the refinement. When the left side is a mutable object like a list, += is allowed to change that very object in place, and it does. Watch the identities:

inplace.pypython
L = [200, 245]
before = id(L)
L += [230]         # grows the SAME list — before == id(L)
M = [200, 245]
M = M + [230]      # builds a NEW list — id(M) changes

So L += [230] and L = L + [230] are not the same statement. The first keeps the list's identity. The second replaces it. Under the hood, += first asks the object "can you extend yourself?" It looks for a method named __iadd__. A method is just a function that rides on the object's type, the machinery you'll build in chapter 12. A list carries one and mutates in place. An int does not, so Python quietly falls back to plain x = x + 1. We meet mutation properly in chapter 6. For numbers, "read, add, rebind" remains the whole truth.

Two of those seven verbs — // and % — are still just "divide," yet Python keeps them apart from / on purpose. One slash, two slashes, and the leftover: three ways to split a number, each answering a different question. →

SYNTAX · the walrus :=bind a name and hand the value on — the one assignment that is also an expression
(name := expression) — bind the name, then hand the value onward if (n := len(title)) > 12: — test it and keep it, in one breath while (chunk := read()): — the loop-and-keep shape (chapter 5) name = expression — the ordinary =, which hands back NOTHING
Type 1plain — (n := 5) binds n and the expression's own value is 5.
Type 2test-and-keep — you need a value for the condition and again in the body, and you refuse to compute it twice.
Type 3read-and-loop — while (chunk := read()): gets the chunk, tests it, and keeps it. This is the shape it was invented for.
Type 4the parentheses are near-compulsory. A bare n := 5 on its own line is a SyntaxError — the walrus only lives inside a bigger expression.
Type 5showing off — any line where a plain assignment above it would have read better. That is most lines.
you type
print((n := 5) + 1)         # bind n AND hand the 5 straight back
print(n)                    # the name survives the expression

title = "Blinding Lights"
print("long" if (size := len(title)) > 12 else "short", size)

length = len(title)         # the plain two-line version
print("long" if length > 12 else "short", length)
you see
6
5
long 15
long 15
where beginners trip
  • n := 5 alone on a line is a SyntaxError. Wrap it: (n := 5).
  • := can only target a plain name. song.plays := 1 and d["k"] := 1 are both refused.
  • inside a comprehension the walrus name leaks out to the surrounding scope on purpose — that is the feature, and it surprises people once (chapter 10).
  • reaching for := to save one line often costs the next reader two. Use it when it removes a repeated computation, not to be clever.
  • := binds a name, == asks a question. if (x := 5): is always truthy; if x == 5: is the test you probably meant.
Wait — put blinding + titanium on a line all by itself and Python still does the work — it computes 445 and then throws it straight in the bin, because no name reached out to catch it. That's a bare expression statement: the verb runs, the answer evaporates. The only reason print(…) seems different is that printing is a side-effect on the way out, not the value handed back to you.
THE OPERATOR THAT ENDED AN ERA
The walrus := from this section is the most divisive syntax Python ever shipped. The battle over its proposal (PEP 572) was so bruising that days after it was accepted, in July 2018, Guido van Rossum — Python's creator — stepped down as "Benevolent Dictator For Life," the final-say role he had held for nearly three decades, saying he was tired of the fight. One three-character operator helped end the buck-stops-here era of the language.
ONE VERB, MANY MEANINGS
+ is not hard-wired to numbers. 'Blind' + 'ing' glues strings into 'Blinding'; [200] + [245] joins lists into [200, 245]. The operator simply asks its left operand, "do you know how to add?" — and each type answers in its own way. It's also why 200 + '245' raises TypeError: an int and a str can never agree on what + should mean, so Python refuses to guess.
Trace3 step the machine — idea · code · memory move together
What plays += 1 actually moves
For a number, += is not “add in place.” It is three moves — read the value, add, then rebind the name onto a different object. The number 0 is never touched; only what the name points at changes, so id() changes.
int · immutable+= · read·add·rebindid() changes
The name-tag and the small-integer shelfint · frozenbind
plays
small-integer shelf · pre-built, frozen objects
int
0
int
1
READ
ADD
REBIND
In plain words
Under the hood
BINDALU · the operation
Program · rebind.py
variables · type
Memory · registers + shelf
registers — the running state
small-int shelf frozen objects · which one plays is on
what's happening
same name → different object → different id()
beat 1 / 1

02One slash, two slashes, and the leftover

"Blinding Lights" runs 200 seconds. Your music app shows 3:20. Those are the same fact wearing two outfits — and the wardrobe change from one to the other is exactly the gap between Python's three ways to divide. Learn these three symbols and you can turn any raw count into minutes-and-seconds, pages, rows, change owed, the hour on a clock. They look nearly identical. They answer three genuinely different questions.

A single slash, /, is true division. It answers "exactly how many times?" And it has one unbreakable habit: it always hands back a float, a number carrying a decimal point. Not sometimes. Not only-when-it-doesn't-divide-evenly. Even 6 / 3 comes back as 2.0, never a bare 2. One slash, always a float. Burn that in, because it surprises everyone exactly once.

Two slashes, //, is floor division. It answers "how many whole times?" Do the division, then round down to a whole number. 200 // 60 is 3: three complete minutes have gone by, and it shrugs off the sliver left dangling.

The percent sign, %, is the remainder (say it aloud as "mod"). It answers "what's left once the whole times are taken out?" 200 % 60 is 20: twenty spare seconds that couldn't fill another whole minute.

// and % are a matched pair, two halves of one act. One counts the full blocks. The other keeps the crumbs. Together they account for every second, spilling nothing: three minutes of sixty, plus twenty over, is two hundred again. That completeness isn't luck. In a moment you'll see Python guarantee it.

THE ONE IDEA TO CARRY FORWARD
One division, asked three ways. / wants the exact ratio (and always answers in floats). // wants the count of whole blocks. % wants the leftover. The last two are inseparable: // and % together lose nothing.
200 seconds, split three waysfigure
"Blinding Lights" = 200 s 3:20 60 s 60 s 60 s 20 s 3 blocks × 60 = 180 s + 20 s 200 // 60 = 3 the FULL blocks · whole minutes 200 % 60 = 20 the LEFTOVER · spare seconds 200 / 60 = 3.3333… the EXACT ratio · always a float
Fig 1 — Three symbols, three questions. // counts whole blocks, % keeps the leftover, / gives the exact ratio. The green blocks plus the gold sliver are the whole 200 s — between them, // and % lose nothing.

Watch Python say all three out loud. Nothing here is magic; each line just asks one of those three questions and prints the answer:

divisions.pypython
total = 200                     # "Blinding Lights", in seconds
print(total / 60)               # 3.3333333333333335  — exact ratio, a float
print(6 / 3)                    # 2.0  — still a float, even when it divides clean
print(total // 60)              # 3   — whole minutes only
print(total % 60)               # 20  — the seconds left over
print(divmod(total, 60))        # (3, 20)  — both answers in one call
print(f"{total // 60}:{total % 60:02d}")   # 3:20
print(f"{245 // 60}:{245 % 60:02d}")       # 4:05  — :02d pads the 5 into 05

Decoder for those last two lines: an f"…" string fills each {…} hole with the value of the expression inside it, so {total // 60} becomes 3. The :02d tacked onto the second hole is a formatting instruction: "print this as a decimal integer, at least 2 digits wide, padding with a 0." That's what promotes a lonely 5 into 05, so "Titanium" reads as a proper 4:05 and not a broken 4:5.

TWO ANSWERS, ONE CALL
// and % almost always travel together, so Python ships them as a combo: divmod(200, 60) returns the pair (3, 20) — quotient and remainder computed in a single step. The whole part and the leftover, handed back at once.
Drag a song's length — watch // and % carve it up▸ drag me
200 // 60 = 3 full minutes 200 % 60 = 20 s left over 3:20 each green block = 60 s (one full minute) · amber bar = the % remainder
200 s → 3:20
Try 245 ("Titanium") — it lands on 4:05. Push it to 600 and the remainder vanishes: a clean 10:00. The green blocks are //; the amber sliver is %; drop either and seconds go missing.
Fig 2 — // counts the whole green minutes, % is the amber remainder, and the two always re-add to the number you dialed in. That's the completeness the last section promised.
% IS THE "WRAP-AROUND" OPERATOR
Anything that cycles is a job for %. hour % 12 keeps a clock in range; n % 2 is 0 for even numbers and 1 for odd; i % len(playlist) loops a track index back to the top after the last song. Whenever a value should roll over at some limit, reach for the remainder.
Wait — if 6 / 3 is 2.0, is there any pair of numbers where a single slash gives back a plain integer? No — not one. / is defined to produce a float every single time, so the type of the answer never depends on the numbers you feed it. That predictability is the whole point: the code downstream always knows exactly what shape it's getting, and never has to guess.

So far, so tidy. But watch what "round down" really means the instant a number turns negative, because this is the trapdoor half of all programmers step through. "Round down" does not mean "chop the decimals off." It means round toward negative infinity, always leftward on the number line, toward the smaller value. Slide the dividend below zero and watch // step down to the next multiple, never up toward zero.

Slide a below zero — see // floor toward −∞▸ drag me
a ÷ 3 · the green ring is the multiple of 3 at or below a; the gold gap up to a is a % 3 −9 −6 −3 0 3 6 9 a −7 // 3 = −3 −7 % 3 = 2 (the gap up to a · never negative) 3 × (−3) + 2 = −7 ✓
a = −7
The green ring always snaps to the multiple of 3 at or below a — leftward, toward −∞. Truncation would jump the other way (to −6 for −7); Python never does. The gold gap is a % 3, and for a positive divisor it stays 0, 1, or 2 — never negative.
Fig 3 — // rounds down the line, not toward zero — so -7 // 3 is -3, and the remainder -7 % 3 is the non-negative gold gap of 2. The two always rebuild a.
↻ reframe
// and % were never two operations — they're one operation glimpsed through two windows. There is a single act, "divide and keep the remainder," that yields a quotient and a leftover in the same breath; divmod(a, b) is that act named directly. Writing a // b and a % b on separate lines just performs the one act twice, throwing away half the answer each time.

✗ The myth

a // b is just a / b with the digits after the decimal point chopped off.

✓ The reality

True for positives, wrong for negatives. Chopping -2.33 toward zero gives -2; flooring it gives -3. Python floors — -7 // 3 is -3, not -2. Same symbol, a different rule than C or JavaScript follow.

The deeper cut — the contract that ties all three together

Why does Python floor toward negative infinity instead of truncating toward zero the way C and JavaScript do? To keep one promise it makes for every pair of numbers, the invariant the figures above were quietly obeying:

a == b * (a // b) + a % b  —  rearranged, a % b == a - b * (a // b).

Because // floors, the remainder is forced to carry the divisor's sign and to sit in the half-open range [0, b) for a positive b. So -7 % 3 is 2, not -1. Flip the divisor's sign and 7 % -3 is -2. C truncates instead, so its -7 % 3 is -1: identical symbols, a different contract underneath. Worth knowing before you port a formula between languages and quietly get the wrong sign.

A second, sharper edge: on huge whole numbers, // stays exact while / can quietly lie. A single slash must route its answer through a float, and a float only has room for about 16 significant digits. So (10**18 + 7) // 1 is exactly 1000000000000000007, but (10**18 + 7) / 1 rounds off to 1e+18. The last digits simply evaporate. When integers get big and every digit matters, reach for //, never /.

And a reassurance: // and % aren't integer-only. Hand them a float and they cooperate: 5.5 // 2 is 2.0 and 5.5 % 2 is 1.5. The result is a float whenever either operand is, and the floor-and-remainder logic is exactly the same.

THE ONE THING THAT CRASHES
All three choke on a zero divisor. 1 / 0, 1 // 0, and 1 % 0 each raise ZeroDivisionError and halt your program on the spot — no "infinity," no silent 0 slipped in behind your back. If a divisor could ever be zero (an empty playlist, a count that hasn't loaded yet), you guard it before you divide.

Numbers answered with numbers. The next family of operators answers every question with just one of two words — and forces you to meet two subtly different notions of "equal": == and is. →

% HAS A SECRET SECOND JOB
On numbers, a % b is the remainder. On a string, the very same % is the old formatting operator: '%d:%02d' % (7, 25) builds '7:25', padding the 25 for you — the identical job an f-string does. One symbol, two unrelated powers, chosen entirely by the type sitting on its left.
Wait — exactly when does a single slash start lying? At 2**539,007,199,254,740,992. Past that point a float runs out of room to tell consecutive whole numbers apart, so (2**53 + 1) / 1 collapses back to 2**53, the + 1 simply gone. // never blinks: (2**53 + 1) // 1 stays exact forever. When integers get big and every digit counts, / is the one you can't trust.
// FLOORS — IT DOESN'T CHOP
// isn't integer-only and it isn't "delete the decimals." It always steps left on the number line. 5.5 // 2 is 2.0 (yes — a float), and -5.5 // 2 is -3.0, not -2.0. Truncation would round toward zero; flooring rounds toward −∞. On positives you'd never spot the difference — which is exactly what makes the negative case a trapdoor.
Wait — because Python floors toward −∞, a remainder with a positive divisor is never negative — even for negative inputs. -1 % 12 is 11, not -1: the clock wrapping backwards from 12 round to 11. That's why tracks[-1 % len(tracks)] lands cleanly on the last song instead of crashing. In C the same line gives -1 and the index walks off the edge; Python's flooring makes wrap-around just work.

03Two kinds of equal: == and is

The comparison operators — == != < > <= >= — are a panel of yes/no judges. Each one asks a single question and always answers with a bool: True or False, and never anything in the middle. Feed the panel 200 < 245 and it returns True. Feed it 245 == 245 and it returns True. There is no "sort of," no "close enough." The verdict is one bit, the smallest answer a machine can give.

And Python is a strict judge: 7 == "7" is False. It will not quietly turn the string "7" into the number 7 to force a match. An int and a str are simply different kinds of thing, so the honest answer is "no, not equal." For the same reason 1 != "1" is True. Other languages fudge this. Python refuses to, and that refusal will save you from a whole genus of silent bug.

What about comparing two strings — is "Avicii" "less than" "Daft Punk"? Yes, and it's decided the way a dictionary is ordered: character by character, by code point, the plain number behind each letter. ord('A') is 65, and ord('D') is 68. Python lines the two words up and looks at the very first pair, A versus D, 65 versus 68. The moment they differ, the match is settled. 65 < 68, so "Avicii" < "Daft Punk" is True, and not one of the remaining letters is ever read. By the same rule "Apple" > "Orange" is False, because the first characters, A (65) and O (79), decide it on the spot.

SYNTAX · the yes/no familysix comparisons, the chain, in and is — every one answers with a bool
a == b a != b — same value? different value? a < b a <= b a > b a >= b — order lo <= x <= hi — a CHAIN: one expression, x read once x in seq x not in seq — membership: is it in there at all? x is None x is not None — identity: the very same object
Type 1value tests — == and != work on any two objects. Worst case the honest answer is "no".
Type 2order tests — < <= > >= need types that agree on order. 7 < "7" raises TypeError rather than guess.
Type 3the chain — 180 <= seconds <= 240 is one expression meaning 180 <= seconds and seconds <= 240, with seconds read exactly once.
Type 4membership — in asks a container. On a str it tests substrings; on a dict it tests keys (chapter 7).
Type 5identity — is asks "the same object?" Reserve it for the singletons: is None, is not None.
you type
seconds = 200
titles = ["Blinding Lights", "Titanium"]

print(seconds == 200, seconds != 245, seconds < 245)
print(180 <= seconds <= 240)              # one chain, seconds read once
print("Titanium" in titles, "Levels" not in titles)
print("ani" in "Titanium")                # a str tests substrings
encore = None
print(encore is None, encore is not None)
you see
True True True
True
True True
True
True False
where beginners trip
  • = binds, == asks. Writing seconds = 200 where a test belongs is caught as a SyntaxError — Python spots the typo for you.
  • 180 <= seconds <= 240 is not (180 <= seconds) <= 240. Python chains it; most languages cannot.
  • x is 200 can answer True by caching accident and False for 1000. Modern Python raises a SyntaxWarning; use ==.
  • "ani" in "Titanium" is a substring test, but 200 in [200, 245] tests whole items. The container decides what "in" means.
  • a chain short-circuits like and. In 0 <= x < 10, if the first half fails the second is never evaluated at all.
One code point settles the whole wordfigure
"Avicii" A v i c ord 65 "Daft Punk" D a f t ord 68 65 < 68 → decided here the rest of the letters are never even read → True
Fig 1 — String order is dictionary order, resolved one code point at a time. The first pair that differs delivers the whole verdict; everything after it is dead weight the comparison never touches.

So far, so intuitive: == is asking "do these two things carry the same value?" But Python has a second, sharper question that looks almost the same and means something entirely different: is. And is is not a fancier ==. Where == asks "same value?", is asks "same object?" It means literally the same thing in memory, the same identity id() handed you back in chapter 3. Two things can carry identical values yet be two separate objects. That gap is the whole lesson.

equal.pypython
# three playlists, timed in seconds
a = [200, 245, 187]     # one list, built here
b = [200, 245, 187]     # a second list, same contents
c = a                   # NOT a new list — a second name for the first

print(a == b)           # True  — identical contents
print(a is b)           # False — two objects, two addresses
print(a is c)           # True  — one object wearing two labels
Same value, different objectsfigure
a c b id 0x1f…a10 [200, 245, 187] id 0x1f…b58 [200, 245, 187] a == b → True a is b → False a is c → True
Fig 2 — == walks into each box and reads the contents; is only checks which box you're pointing at. b matches a in value but lives at its own address; c is just a second label stuck on a's object.

Here is where is turns treacherous, and where an interactive earns its keep. For small, common integers, CPython quietly keeps a single shared copy: one pre-built 200 that every 200 in your program points at (chapter 3's singleton trick). So two independently-computed 200s can turn out to be the same object, and is says True, by pure caching accident. Push the number past the cached zone and the accident evaporates. Now they're two objects and is says False, even though == never wavered. Slide a song's length across the boundary and watch is betray you.

Same number — one object, or two?▸ drag me
x y id 0x7f…e8 256 s id 0x7f…e8 256 s one shared cached object x == y → True x is y → True
256 s
Two names, both set to the same length, computed independently. Slide up: at 200 ("Blinding Lights") and 245 ("Titanium") they share one cached object, so is answers True — by luck. Cross 256 and they split into two objects; is flips to False while == never budges.
Fig 3 — == reads the value and says True the whole way. is only reflects an accident of where CPython stored the number — which is exactly why you must never trust it on numbers. (Addresses are illustrative.)
⚠️
Never use is on numbers or strings
CPython pre-builds the integers −5 to 256 as shared singletons, so x is 200 can come back True by caching accident — and False for 1000. It's an implementation detail, not a language promise. Modern Python even warns you: write x is 200 and it raises SyntaxWarning: "is" with 'int' literal. Did you mean "=="?. Reserve is for one honest job — comparing against a genuine singleton: x is None.
🔗
Chained comparison reads like maths
Python lets you write 180 <= seconds <= 240 to ask "is this song between three and four minutes?" It means 180 <= seconds and seconds <= 240, and — crucially — seconds is evaluated exactly once. Most languages can't do this; Python reads a range the way you'd write it on paper.
Wait — if == checks value, why is 0.1 + 0.2 == 0.3 False? Because floats are base-2 approximations (chapter 2's bytes striking again): the sum lands at 0.30000000000000004, a hair off 0.3. == is being scrupulously honest — the two values genuinely differ. It was the inputs that were never exact.
↺ reframe
Stop reading == and is as "equal" and "really equal." They answer different questions. == asks about the contents — do these carry the same value? is asks about identity — are these the very same object, one thing with two names? Two twins have == faces and are is-different people. Confusing the two isn't a small slip; it's asking the wrong question and mistaking the answer for the one you wanted.

✗ The myth

is is just a faster, stricter == — a performance trick for when you're sure the values match.

✓ The reality

They compute different facts. is is a one-instruction address check that ignores contents entirely; == reads and compares the values. is being "faster" is exactly why it's dangerous: it answers instantly and answers the wrong question.

The deeper cut — what == actually does, and why 7 == "7" is False

Earlier I said an int and a str are "different kinds of thing," so they're not equal. True enough to trust, but here's the real machinery. == isn't a hard-wired byte compare. It's a negotiation. Python asks the left object first: (7).__eq__("7"). The int looks at the str, doesn't know how to compare itself to one, and returns a special signal, NotImplemented, meaning "not my department." Python then asks the right object: ("7").__eq__(7), and the str also returns NotImplemented. When both decline, Python falls back to identity: are they literally the same object? They aren't, so the final answer is False. Nothing was ever coerced. Two objects just failed to agree they were equal.

This is also why == is something you can teach your own types. Define __eq__ on a Song class and song_a == song_b runs your rule for sameness. is can't be redefined. It always means "same object," full stop.

One last honesty note on is: strings get a caching trick too, called interning. Two identical string literals in your source often become one shared object, so "Titanium" is "Titanium" may be True. But build the same text at runtime and it's a fresh object, so is turns False. Same accident, same lesson: for value, always ==, and save is for None.

== forces every value into a clean True or False. But Python will happily judge values that are neither — and the instant it knows the verdict, it stops reading. →

SYNTAX · the conditional expressionx if cond else y — a decision that produces a value instead of running a block
value_if_true if condition else value_if_false — the shape itself: value, test, other value label = "long" if seconds > 240 else "standard" — the everyday use: pick one of two print("s" if n != 1 else "") — inline, inside a call or an f-string a if c1 else b if c2 else d — chained: the first true condition wins
Type 1the whole point — it picks one of two values, so it can sit anywhere a value can: after =, inside a call, inside an f-string.
Type 2the condition is judged by truthiness (next section), so x if name else "guest" reads an empty string as false.
Type 3chained — each else opens a fresh question, left to right, and the first true one wins. Two levels is plenty; three is a wall.
Type 4lazy, like and/or — only the branch you land on is evaluated. The other side is never touched, crash or not.
Type 5the wrong tool — branches that do things rather than produce a value. Those want the if statement of chapter 5.
you type
seconds = 245
label = "long" if seconds > 240 else "standard"
print(label)

n = 1
print(f"{n} song{'s' if n != 1 else ''} queued")

grade = "A" if 180 <= seconds <= 240 else "B" if 120 <= seconds <= 300 else "C"
print(grade)
you see
long
1 song queued
B
where beginners trip
  • the order is value-first: "long" if c else "short". Read it aloud as "long, if c, otherwise short" and it stops feeling backwards.
  • else is not optional. There is no one-armed ternary, because every expression must end up holding a value.
  • it binds looser than everything above it, so a if c else b + 1 groups as a if c else (b + 1).
  • inside an f-string the inner quotes must differ from the outer ones: f"{'s' if n != 1 else ''}".
  • a ternary cannot hold a statement. x = (print("hi") if c else 0) runs, but it stores None — a sure sign you wanted an if.
Wait — is anything not equal to itself? Yes — exactly one thing. float('nan') ("not a number") is defined so that nan == nan is False: a value that fails its own equality test. And it's why nan is nan is still Trueis checks the object, not the value, so the two questions split clean apart right here.
A BOOLEAN IS SECRETLY A NUMBER
True == 1 and False == 0 are both True — because bool is a subclass of int. So True + True + True is 3, ['off', 'on'][True] indexes to 'on', and sum([True, False, True]) counts the Trues as 2. A yes/no is a 1/0 wearing a mask — which is why "how many passed?" is often just summing a list of comparisons.
Wait — 'Zebra' < 'apple' is True?! Yes — comparison reads code points, and every uppercase letter has a smaller one than every lowercase letter: ord('Z') is 90, ord('a') is 97. So a naïve sort files all Capitalized words ahead of lowercase ones — "Zebra" before "apple." It isn't a bug; it's the exact code-point rule from this section, followed without mercy.
== ALWAYS ANSWERS; < CAN REFUSE
7 == '7' quietly returns False — different types, not equal, no drama. But 7 < '7' raises TypeError. Equality is total: any two objects can be asked, and worst case the answer is "no." Ordering is not: Python won't invent a rule for whether an int is "less than" a str, so it stops you rather than guess.
Trace3 step the machine — idea · code · memory move together
How Python decides 'Titanium' < 'Titans'
Strings sort like a dictionary: compare one code point at a time, left to right (here 'i' is 105, 's' is 115, so 'i' comes first). The first column that differs settles the whole verdict — every later letter is never read. And if the columns never differ but one word runs out first, the shorter string wins: 'Titan' < 'Titans', because a missing letter counts as less than any real one. Press Next to watch the scan.
lexicographicdecides at i = 5left → right
The idea — two words, code point by code pointstr · code pointsinit
 
a
b
In plain words
Under the hood
SETALU · the operation
Program · strcmp.py
variables · type
Memory · registers + verdict
registers — the running state
verdict bool · a < b — the settled result
first differing code point wins — the rest is dead weight
what's happening
beat 1 / 1

04Truthiness, and the logic that stops early

Type if playlist: and Python does something quietly generous. It does not demand a True or a False. It hands the question to the list itself and lets it answer. Every value in Python can stand in for a yes-or-no, and bool(x) reveals the verdict it would give. Why would a language bother judging every value this way? Because the real questions you ask rarely arrive as tidy booleans. They arrive as "is the queue empty?" or "did the user actually type a name?" Python lets the queue and the string answer for themselves, so you never have to write len(name) > 0 when if name: says the same thing more plainly.

The rule that decides it fits on one line: empty or zero is falsy, and everything else is truthy. That is the whole law. The complete falsy lineup is short enough to memorise — False, None, 0, 0.0, "", [], {}, set() — eight values, each one either a kind of nothing or a kind of zero. Every non-empty string, every non-zero number, every collection with even a single thing inside: truthy.

THE ONE IDEA TO CARRY FORWARD
A value's truthiness is not about its meaning — it's about whether it is empty or zero. Python never reads what a string says or what a number represents; it only asks "is there anything here?" Get that one distinction right and every surprise in this section dissolves.
The falsy eight — everything else is truefigure
FALSY — the empty and the zero (all eight) False None 0 0.0 "" [] {} set() TRUTHY — everything else (a few of infinitely many) 42 -1 "0" " " [0] "False" "0" and "False" are non-empty strings, so both are truthy. The only falsy string is the empty one, "".
Fig 04a — The falsy eight. If a value is not one of these — not empty, not a zero — Python treats it as true. Notice the trap on the bottom row: "0", " ", and "False" all hold something, so all three are truthy.
NOW REWRITE IT YOURSELFtwo ifs that want to be one-liners — and one that must be left exactly as it is
You have not written an if statement yet — that is chapter 5 — but you can already read one. First: take a four-line if seconds > 240: label = "long" else: label = "standard" and collapse it into a single line with the ternary. Second: do the same for a blank-title default, then shorten it again using or — two shapes, one meaning. Third, the honest one: here is an if whose branch prints two separate lines. Try to squeeze that into a ternary and notice what breaks. The rule you are testing is simple. A ternary chooses a value; an if chooses an action. When the branches do work rather than produce something, the one-liner is not shorter code — it is a disguise.
show the solution
# THE ORIGINAL -- four lines to choose between two words
seconds = 245

if seconds > 240:
    label = "long"
else:
    label = "standard"
print(label)                             # long

# THE REWRITE -- the same decision, as one expression
label = "long" if seconds > 240 else "standard"
print(label)                             # long
# WHY: the ternary IS an expression, so it can sit on the right of an =.
#      Read it aloud: "long, if seconds > 240, otherwise standard."

# THE DEFAULT -- the same shape, twice shorter each time
title = ""

if title:
    shown = title
else:
    shown = "Untitled"
print(shown)                             # Untitled

shown = title if title else "Untitled"   # the ternary version
shown = title or "Untitled"              # or does it in three words
print(shown)                             # Untitled

# AND THE ONE YOU MUST NOT REWRITE ----------------------------
downloaded, explicit = False, True

if not downloaded:                       # two side effects on this branch
    print("fetching", seconds, "seconds of audio")
    print("marking explicit:", explicit)
else:
    print("already on the device")
# WHY: a ternary must pick a VALUE. These branches do work instead --
#      two statements on one side, none of them producing anything.

# Squeeze it in anyway and you get a line nobody wants to inherit:
print("fetching") if not downloaded else print("already on the device")
# It runs, it prints, and its own value is None -- an if-statement
# wearing an expression's coat. Leave it as the if.

Three operators braid these verdicts together. not flips one: not x is True exactly when x is falsy. a and b is truthy only when both are; a or b when at least one is. But the headline of this whole section isn't what they compute — it's how. and and or are lazy. Python reads them strictly left to right and stops the instant the answer is settled. This is short-circuit evaluation: in False and anything, the anything is never touched, because a single falsy operand already doomed the and. In True or anything, same story — one truthy operand already saved the or.

SYNTAX · and · or · notthree words that stop early — and hand back an operand, not a tidy bool
a and b — a falsy? hand back a. Otherwise hand back b. a or b — a truthy? hand back a. Otherwise hand back b. not a — the only one that always returns a real bool name = value or default — the default idiom: fill in the blank ok = cheap_test and risky_test — the guard idiom: the safe test goes first
Type 1the verdict use — with two comparisons on either side you get a plain True/False, which is why nobody notices the rest.
Type 2the value use — 245 and 200 is 200, and type(245 and 200) is int. No bool was ever built.
Type 3the default — name = typed or "guest". A falsy left side steps aside and lets the default through, untouched.
Type 4the guard — count != 0 and total / count. The cheap test on the left decides whether the dangerous half is ever reached.
Type 5not stands alone — one operand, always a genuine bool, and it binds looser than == (see the precedence section).
you type
typed = ""
print(typed or "guest")           # "" is falsy, so or hands back the right side
print("Ajai" or "guest")          # truthy left, handed back untouched
print(0 and 1 / 0)                # falsy left settles it: the division never runs
print(245 and 200)                # both truthy: and hands back the LAST one
print(type(245 and 200))
print(not "", not "0")            # only not always returns a real bool
you see
guest
Ajai
0
200
<class 'int'>
True False
where beginners trip
  • count = user_count or 10 silently replaces a real 0 with 10. If zero is a valid answer, write user_count if user_count is not None else 10.
  • & and | are not spellings of and and or. They evaluate both sides, so the guard trick silently stops guarding.
  • if x == True: is noise, and it is wrong for truthy non-bools like [200]. Write if x:.
  • not 200 == 245 reads as not (200 == 245). not waits for the comparison to finish, then flips the verdict.
  • "stops early" is not an optimisation you can ignore. In 0 and 1 / 0 the division never runs — and code you rely on could be skipped the same way.
Wait — when the right side is "never touched," how thoroughly is it skipped? If that side were a function call that prints a line, or writes to a database, would the print still happen? No — genuinely, nothing on the skipped side runs. Python doesn't quietly evaluate it and hide the result; it jumps clean over the code. No computation, no memory, no side effect. The skipped half might as well not be there.

That "jump clean over it" is not trivia. It's a guard you'll reach for constantly. Put a cheap, safe test on the left and let it decide whether the expensive or dangerous test on the right ever gets to run:

guard.pypython
# the two canon tracks: "Blinding Lights" 200 s + "Titanium" 245 s
total, count = 445, 2
print(count != 0 and total / count > 180)   # True  — the average is 222.5 s

count = 0                                # now the playlist is empty
print(count != 0 and total / count > 180)   # False — total / count NEVER runs

When count is 0, the left side count != 0 is False, so the whole and is already decided. That means total / count, the division-by-zero that would crash your program, is never reached. The dangerous half sits right there in the source and simply doesn't execute. Reverse the two halves and the identical program explodes with ZeroDivisionError. Order is the whole design. Drag the slider below and watch the left operand act as a gate for the right one.

The guard, live — drag the count and watch the right half switch on and off▸ drag me
count != 0 and total / count > 180 (total = 445 s) count = 2 count != 0 True and ✗ skips → the risky half 445 / 2 > 180 222.5 → True result: True both halves ran, both were truthy
2
Start at count = 2 (both canon songs). Slide to 0: the left goes False, the right greys out — the division never happens, no crash. Slide up to 3: the gate is open, the division runs, but now the average has dropped below 180 s, so the right half votes False on its own. Same result word, very different reason.
Fig 04b — and tests left first. A False on the left settles everything and the right operand is skipped whole (count = 0). A True on the left opens the gate and lets the right operand cast its own vote.
⚠ MOST BEGINNERS THINK…the test that is true for every value you feed it
English lets me say "if the title is Blinding Lights or Levels", so title == "Blinding Lights" or "Levels" asks whether the title is either of those two. It reads exactly like the sentence, and it runs without a murmur of complaint.
TYPE THIS — 10 SECONDS
>>> title = "Titanium"   # a title that is NEITHER of the two we test for
>>> title == "Blinding Lights" or "Levels"
>>> bool(title == "Blinding Lights" or "Levels")
>>> title in ("Blinding Lights", "Levels")
'Levels'
True
False
Look at line two: the answer is not even a bool. or binds looser than ==, so Python grouped it as (title == "Blinding Lights") or ("Levels"), and or hands back an operand — here the non-empty string "Levels", which is truthy. That test is therefore True for every title on earth. When you mean "either of these", write the membership test: title in ("Blinding Lights", "Levels").
TYPE THIS · save it, then run itdefaults.py — blanks filled in by or, and the zero it eats
# defaults.py  --  what the user left blank, filled in by `or`

# three answers exactly as input() hands them back: always strings
typed_name   = ""        # they just pressed Enter
typed_repeat = "3"       # they typed 3
typed_note   = "0"       # careful: a real answer that only LOOKS empty

name   = typed_name or "guest"          # "" is falsy -> the default wins
repeat = int(typed_repeat or "1")       # the default is a string too
note   = typed_note or "(none)"         # "0" is one character, so it stays

print("welcome,", name)
print("playing the set", repeat, "times")
print("note:", note)

# the guard: the cheap test on the left protects the risky one on the right
total, count = 445, 0
average = count != 0 and total / count
print("average:", average)

# and the trap `or` sets for you: it cannot tell blank from a real zero
plays = 0
print("plays:", plays or "never played")                     # wrong for a real 0
print("plays:", plays if plays is not None else "unknown")   # says what you mean
welcome, guest
playing the set 3 times
note: 0
average: False
plays: never played
plays: 0
Save it as defaults.py and run it from the terminal. The three typed_ names are staged answers — exactly what input() hands back, which is always a string, even when the user typed a number. Read the first three lines as one idiom: typed_name or "guest" asks the left side to prove it holds something, and steps aside when it does not. Watch typed_note closely, because it is the whole lesson in one variable. The user answered "0", a one-character string, so it is truthy and survives. Then look at the last pair of lines, where the same idiom quietly lies: the real number 0 is falsy, so plays or "never played" throws away a perfectly good answer. The fix says what you actually mean — plays if plays is not None else "unknown". Two things to try. Change typed_repeat to "" and watch the default "1" take over before int() ever sees it. Then set count = 2 in the guard and see average switch from False to a real number — the right half only runs once the left one lets it.
↺ the thing people get backwards
Most people are sure bool("0") and bool("False") return False — they read the string and believe it. They're both True. Truthiness never reads the meaning of a string, only whether it is empty. "0" is one character long, "False" is five; both hold something, so both are truthy. The lone falsy string is "". If you ever parse text and check if answer: to mean "did they say yes," you've just accepted "no" as a yes — because "no" is truthy too.
The rule is bigger than eight values
Those eight are just the everyday faces of a general law. Any zero of any numeric type is falsy — 0j, Decimal(0), Fraction(0) all vote False — and any empty container is falsy, including (), b"", and range(0). Even your own classes get a say: define __bool__ (or fall back to __len__) and an empty Playlist([]) can report itself falsy so if playlist: just works.

✗ The myth

Short-circuiting is a behind-the-scenes speed trick — Python still evaluates both sides, it just returns the answer faster. Harmless either way.

✓ The reality

The skipped operand is never executed at all. If it would have printed, called an API, or crashed on a divide-by-zero, none of that happens. Short-circuiting isn't an optimisation you can ignore — it's a control-flow tool you write on purpose, ordering cheap-and-safe before expensive-and-risky.

The deeper cut — and and or don't actually hand you a bool

Here's the refinement to the tidy story above. Strictly, and and or do not return True/False. They return one of their operands, exactly as it was, unconverted. a or b yields a if a is truthy, otherwise b. a and b yields a if a is falsy, otherwise b. With plain boolean operands the result happens to be a bool, which is why you never noticed. 3 and 4 quietly returns the integer 4, and type(3 and 4) is int, not bool. This powers a classic idiom for a default value: name = title or "Untitled". If title is the empty string "" (falsy), you get "Untitled". If title is "Blinding Lights", you get that title back untouched. Only not is the honest one that always returns a genuine bool: not "" is exactly True, type and all.

and, or and not weigh a value as a single whole — one verdict for the entire thing. But chapter 0 promised operators that reach inside a number and grip its switches one at a time. Down to the bits, with & | ^ ~ << >>

NOW WRITE IT YOURSELFthe leap-year rule — three clauses, and, or, one expression
Here is the rule, in words: a year is a leap year if it divides by 4, except century years, which must divide by 400. So 2024 is one, 1900 is not, and 2000 is. Your job: say that in a single expression built from %, ==, !=, and and or — no if, no temporary names. Print it for 2024, 1900, 2000 and 2023 and check all four. Two hints worth taking. year % 4 == 0 is how you ask "does 4 divide it?", because % hands back nothing left over when it does. And the word except in the rule is doing the work of an or: not a century, or a 400-year century. When your four answers match, open the solution — there is a note there about the parentheses that is worth more than the exercise.
show the solution
# ONE EXPRESSION, FOUR YEARS ----------------------------------
year = 2024
print(year, year % 4 == 0 and (year % 100 != 0 or year % 400 == 0))   # 2024 True

year = 1900
print(year, year % 4 == 0 and (year % 100 != 0 or year % 400 == 0))   # 1900 False

year = 2000
print(year, year % 4 == 0 and (year % 100 != 0 or year % 400 == 0))   # 2000 True

year = 2023
print(year, year % 4 == 0 and (year % 100 != 0 or year % 400 == 0))   # 2023 False

# HOW TO READ IT
#   year % 4 == 0            divisible by 4 -- the ordinary leap year
#   year % 100 != 0          ...unless it is a century year
#   year % 400 == 0          ...unless that century divides by 400
# The chain is "divisible by 4 AND (not a century OR a 400-year century)".
#
# ON THE PARENTHESES: and binds tighter than or, so dropping them gives
#   (year % 4 == 0 and year % 100 != 0) or year % 400 == 0
# which happens to agree on every real year -- any year divisible by 400
# is divisible by 4 too, so the left side can never be wrongly rescued.
# Keep the parentheses anyway. They state the rule you meant, and the
# next reader should not have to prove that coincidence to themselves.
FALSY IS NOT THE SAME AS EQUAL-TO-FALSE
[] is falsy, so if not []: runs — yet [] == False is False. No contradiction: bool([]) asks "is there anything here?" (no → falsy), while == False asks "does this empty list equal the boolean False?" (no → different objects). Truthiness and equality are two different questions; only one of them reads emptiness.
Wait — which zeros are falsy, and is "not a number" one of them? 0.0, -0.0, and even the complex 0j are all falsy — any zero of any numeric type. But float('nan') is truthy: it isn't zero and it isn't empty, so if float('nan'): fires. Truthiness never asks what a value means — only whether it's a zero or an empty, and nan is neither.
THE or-DEFAULT THAT EATS A REAL ZERO
The tidy idiom count = user_count or 10 quietly replaces a genuine 0 with 10 — because 0 is falsy, and or hands back its right operand whenever the left is falsy. If 0 is a valid answer (zero plays, zero unread), that's a silent bug. The fix is to test for the thing you actually mean: user_count if user_count is not None else 10.

05Down to the bits: & | ^ ~ << >>

Chapter 0 taught you the secret every int is hiding: it isn't really a number, it's a row of switches. 12 is the pattern 1100, and 10 is 1010. Every operator you've met so far — +, *, == — treats that row as a quantity and does arithmetic on the whole thing. Six new operators are ruder than that. They reach straight past the number and grab the switches themselves, one column at a time. Meet them: & (AND), | (OR), ^ (XOR), ~ (NOT), and the two shifts << and >> that slide the whole row sideways.

The first three work exactly like laying one number on top of the other and reading down each column. Stack 12 over 10 and ask a yes/no question of each pair of bits. & keeps a 1 only where both switches are on → 1000, which is 8. | keeps a 1 where at least one is on → 1110, which is 14. ^, "exclusive or," keeps a 1 only where exactly one is on, the two switches disagreeing → 0110, which is 6. No carrying, no borrowing, no place value spilling into the next column. Just eight little votes, tallied straight down.

Reading 12 & 10 straight down the columnsfigure
8 4 2 1 a = 12 1 1 0 0 & b = 10 1 0 1 0 a & b = 8 1 0 0 0 the one green column is the only place BOTH switches are on
Fig 05 — 12 & 10 = 8. Each output column is decided by its own two input switches and nothing else — the eights column is the only one where both are lit.

One diagram only shows one operator, so here's the same machine with a dial on it. Set two four-bit numbers and pick which question to ask each column. Watch the bottom row light up, and notice that changing a's 8-bit never disturbs a's 1-bit's answer. The columns are strangers.

Two 4-bit numbers, one operator — mix the columns yourself▸ drag me
8 4 2 1 a = 12 1 1 0 0 0b1100 & b = 10 1 0 1 0 0b1010 a & b = 8 1 0 0 0 0b1000
12
10
&
& needs both switches on, | needs at least one, ^ needs exactly one. Try a = 12, b = 10 under each; then set both sliders equal and watch ^ go to zero — a number XORed with itself is always 0.
Fig 05b — Every operator here is a rule applied independently to each column. Nothing a column does can change its neighbour's answer — which is exactly what makes the next reframe click.
↺ reframe — there is no carry
When you add 1 + 1 in binary you get 10: a carry ripples left into the next column, so column 0 reaches over and touches column 1. That rippling is the whole reason addition is "slow" and has to march right-to-left. Bitwise operators never ripple. Bit 0 of the answer is a function of bit 0 of the inputs and nothing else; all eight columns can be decided at the very same instant, in parallel, in a single tick of the clock. That independence isn't a detail — it's the entire reason these operators are the fastest tools in the box.
What bits are really for: a fistful of flags in one int
Give every song property its own switch: EXPLICIT, DOWNLOADED, LOVED = 1, 2, 4. Now pack a song's whole state into one small integer with |: state = EXPLICIT | LOVED is 5 (0b101) — explicit, not downloaded, loved. The full flag lifecycle is four moves, one operator each: set with state | LOVED; test with state & LOVED (→ 4, truthy — the flag is on; state & DOWNLOADED0, off); clear with state & ~LOVED (the ~ you just met turns LOVED into an all-ones mask with a single hole, so AND blanks exactly that bit and leaves the rest); and toggle with state ^ LOVED. Three independent yes/no facts riding in one number — the very trick behind Unix file permissions (chmod 755), network-packet flag fields, and graphics options, and the reason a whole config can travel as a single int.

The last two operators don't combine two numbers at all. They take one number and slide its whole row of switches sideways. x << 1 shoves every bit one place to the left and feeds a fresh 0 in on the right. x >> 1 shoves everything right and lets the low bit fall off the edge into oblivion. You already know this move in base ten: appending a 0 to 34 makes 340, ten times bigger, and chopping the last digit makes 3. Same trick in base two, so a left shift doubles and a right shift halves. 5 << 2 is 5 × 4 = 20, and 200 >> 1 is 100, the length of "Blinding Lights" cut in half. One catch worth its own breath: because the low bit simply drops off, >> always rounds down. 201 >> 1 is not 100.5. It's 100, the same floor as 201 // 2.

Shift the row — every step left doubles, every step right halves▸ drag me
256 128 64 32 16 8 4 2 1 value 0 0 0 0 0 0 1 0 1 result 0 0 0 1 0 1 0 0 0 5 << 2 = 20
5
<< 2
Push shift right (positive) to slide the switches left and multiply by 2 each step; pull it left (negative) to slide right and halve. Drag it to -3 on an odd value like 5 and watch bits tumble off the right edge — that's flooring, live.
Fig 05c — The blue row never changes; the green row is the same switches after the slide. Left is ×2 per step, right is ÷2 (rounded down) — the low bits that fall off the edge are simply gone.
Shift + mask = pull a field out of the middle
Shifts and & team up to extract a field — and shifts with | team up to build one. A colour like the green 0x5ede78 from chapter 0 is three bytes crammed into one int — red, green, blue, eight switches each. And 0xFF is nothing but those eight switches all on: 0xFF = 0b11111111 = 255. To read the red byte, slide it down to the bottom and keep only the low eight bits: (0x5ede78 >> 16) & 0xFF94. The >> 16 lines the red field up over the 1s column; the & 0xFF mask blanks every switch above bit 7, leaving red standing alone. To go the other way and pack three channels back into one number, shift each into its own lane and OR them together: (r << 16) | (g << 8) | b. Unpack with shift-and-mask, pack with shift-and-OR — the exact move a program makes every time it touches a pixel, a network header, or a date squeezed into one integer.

That leaves ~, the loner. It takes a single number and flips every switch, 10. Flip the bits of 12 = 1100 and you'd swear the answer is 0011 = 3. Run it and Python says ~12 is -13. What? The trick is a thing chapter 0 whispered and this operator shouts: a Python int doesn't stop at the four bits you can see. It carries an endless train of leading zeros off to the left, …0000 1100, and ~ flips every last one of them into a 1. An endless run of leading ones is precisely how binary writes a negative number, and the result lands, without fail, on ~x == -x - 1.

Why ~12 is -13, not 3figure
~ flips EVERY switch — including the endless leading zeros you never see a = 12 0 0 0 0 1 1 0 0 ~ flip every switch ↓ ~a = -13 1 1 1 1 0 0 1 1 the gold ones are the trap: an endless run of leading 1s IS a negative number the 4 bits you saw did flip (1100 → 0011) · every whole number obeys ~x = -x - 1, so ~12 = -13
Fig 05d — "Flip the four bits I can see" is only half the story. Python flips the infinite leading zeros too; that sea of ones is what makes ~12 come out negative, exactly at -x-1.
Wait — if ~ really flips infinitely many bits into ones, why doesn't ~12 need infinite memory, and why isn't it a gigantic positive number? Because those endless ones aren't stored one by one — a leading run of all-ones is just the agreed-upon shorthand for "negative," so Python records it as the tidy little number -13 and reconstructs the ones on demand. The infinity is in the interpretation, not in the storage.
The deeper cut — "flip the bits you see" is a labelled lie; the truth is two's complement

When we said "flipping 1100 gives 0011 = 3," that was a deliberate simplification, true only if a number had a fixed, finite width with nothing to its left. Real Python integers don't. They use two's complement with infinite sign-extension: a non-negative number is padded with an endless run of 0s, a negative number with an endless run of 1s, and that leading digit is the sign. Flipping turns the zeros-sign into a ones-sign, so the answer must be negative. The exact value drops straight out of the arithmetic. Two's complement defines a bit-pattern's value so that flipping all bits then adding one negates the number, -x == ~x + 1. Rearranging gives the identity you can lean on forever, ~x == -x - 1. That's why ~0 is -1 (flip the endless zeros to endless ones, pure negative one), ~12 is -13, and ~200 is -201. No special case, no magic: just the honest, full-width picture the four-bit cartoon was standing in for.

Step back and notice what none of these six operators did: none of them built anything. No new container was allocated, no list copied, no object cloned. Each result is just another int, assembled from switches the operands were already holding. At the level of the metal, &, |, ^, and the shifts are each a single CPU instruction — one tick of the clock, no loop, no memory hunt. That's why, in the machine, bitwise work is about the cheapest thing a processor can do.

ERROR CLINICthree ways an operator tells you it never met these types
Traceback (most recent call last):
  File "check_length.py", line 4, in <module>
    if seconds < 180:
       ^^^^^^^^^^^^^
TypeError: '<' not supported between instances of 'str' and 'int'
You typed a perfectly good 245 at the prompt, and input() handed back the three characters "245" — then < refused to guess whether you wanted dictionary order or arithmetic. Be glad it crashed: compare that string against another string and it answers instantly, silently, and wrongly.
convert at the door - seconds = int(input("How long is the track? "))
Traceback (most recent call last):
  File "banner.py", line 4, in <module>
    print("=" * (width / 2))
          ~~~~^~~~~~~~~~~~~
TypeError: can't multiply sequence by non-int of type 'float'
Nothing is wrong with line 4 — the cause is the single slash inside it. / always hands back a float, so width / 2 is 30.0, and thirty-point-zero copies of a character is not a thing. A repeat count has to be a whole number, and 30.0 is not one.
use floor division where you mean a whole count - "=" * (width // 2)
Traceback (most recent call last):
  File "trim.py", line 4, in <module>
    short = title - "ium"
            ~~~~~~^~~~~~~
TypeError: unsupported operand type(s) for -: 'str' and 'str'
Both sides are strings, so this is not a mixing error — it is an empty seat. Every operator is a method the type either publishes or does not: str answers + and *, and nobody ever wrote a minus for text, so Python has literally nothing to call.
say what you actually mean - title.removesuffix("ium") gives 'Titan'
SYNTAX · the bit familysix operators that reach past the number and grip its switches — plus the flag lifecycle
a & b — AND: a 1 only where BOTH columns are 1 a | b — OR: a 1 where EITHER column is 1 a ^ b — XOR: a 1 where EXACTLY ONE is 1 ~a — NOT: flip every switch, endlessly left (~x == -x - 1) x << n x >> n — slide n columns: x * 2**n and x // 2**n state |= FLAG — set · clear with &= ~FLAG · toggle with ^= FLAG
Type 1column logic — &, | and ^ decide each column from its own two switches. No carry ever crosses.
Type 2the shifts double and halve. 5 << 1 is 10 — the same amplification chapter 0 built out of switches, written as one operator.
Type 3>> floors, because the low bit falls off the edge. 201 >> 1 is 100, exactly like 201 // 2.
Type 4flags — give each fact a power of two, then | to set, & FLAG to test, &= ~FLAG to clear, ^= to toggle.
Type 5the two doors between the views: bin(200) shows you the switches, int("11001000", 2) walks them back to the number.
you type
a, b = 12, 10
print(a & b, a | b, a ^ b, ~a)

print(5 << 1, 5 << 3, 200 >> 1, 201 >> 1)
print(bin(200), int("11001000", 2))

EXPLICIT, DOWNLOADED, LOVED = 1, 2, 4
state = EXPLICIT | LOVED                  # set two switches
state &= ~EXPLICIT                        # clear one
print(state, bin(state), bool(state & LOVED), bool(state & DOWNLOADED))
you see
8 14 6 -13
10 40 100 100
0b11001000 200
4 0b100 True False
where beginners trip
  • & and | bind tighter than every comparison, so x == 1 | y == 2 is not the question you think. Parenthesise the comparisons.
  • they never short-circuit. a & risky() always calls risky(), where a and risky() might not.
  • ^ is not "to the power of". 2 ^ 3 is 1, not 8 — the power operator is **.
  • ~12 is -13, not 3. The flip runs through the endless leading zeros, and an endless run of ones is how binary spells a negative.
  • x << n on a negative n raises ValueError: negative shift count. To go the other way, use the other arrow.
Fast at the metal — not automatically fast in your loop
"Single instruction" is a claim about the hardware, and it's true there. But in CPython, timing a & b against a + b shows them neck-and-neck (about 15 ns versus 13 ns each on this machine) — because the real cost is the interpreter unpacking two int objects, not the one-cycle operation buried inside. So reach for &, |, << because they say exactly what you mean about bits — packing flags, masking fields — not as a superstition that they'll magically speed up ordinary arithmetic.
bitwise.pypython
# six operators that reach past the number, down to its switches
print(12 & 10)      # 8   1100 AND 1010 = 1000  (keep where BOTH are 1)
print(12 | 10)      # 14  1100 OR  1010 = 1110  (keep where EITHER is 1)
print(12 ^ 10)      # 6   1100 XOR 1010 = 0110  (keep where EXACTLY one is 1)
print(5 << 2)       # 20  slide left 2  -> x 4
print(200 >> 1)     # 100 slide right 1 -> half of "Blinding Lights"
print(~12)          # -13 flip every bit: ~x == -x - 1

✗ The myth

| and & are just fancy spellings of or and and. Code like (x == "Y") | (x == "y") even seems to prove it — it works.

✓ The reality

They're bit-column operators that merely coincide with logic on plain bools. Two differences bite. First, they evaluate both sides, always — no short-circuit, so the crash-guard trick you just learned with and/or silently fails. Second, they bind tighter than ==, which is the real reason that line needs its parentheses: without them Python reads "Y" | x first and raises TypeError. Logic on truth values: and/or. Arithmetic on switches: &/|. Never reach for one meaning the other.

You now command a dozen-plus operators, and nothing stops you from stacking five of them in one breathless line: 1 | 2 ^ 1 & 3. Python answers 3 — but why that, and not something else? A new question has appeared: when operators collide, who binds first? →

Wait — XOR is its own undo. x ^ k ^ k is always back to x, because flipping the same switches twice returns them home. That one fact is the seed of the simplest cipher there is — XOR a message by a key, XOR again to decode — and it lets you swap two variables with no temporary at all: a ^= b; b ^= a; a ^= b leaves a and b traded.
A SHIFT THAT NEVER OVERFLOWS
Each left shift doubles: 1 << 10 is 1024, 1 << 20 is 1,048,576 (a megabyte). And because a Python int has no fixed width, 1 << 1000 is a real, exact integer — 302 digits long — computed without a blink. A 64-bit C long would have overflowed to 0 about 936 shifts earlier; Python just grows the number more switches.
Wait — the switches were never a metaphor. bin(200) returns '0b11001000' — the exact byte chapter 0 built by hand, one column at a time — and int('11001000', 2) walks it right back to 200. bin() and int(s, 2) are the front door to the row of switches every int has been hiding all along.
One int, N flag bits — capacity explodes, cost stays flat▸ drag me
one int, some one-bit flags — how many distinct states can it name, and what does each flag cost? 8 FLAG BITS = 2^8 states DISTINCT STATES THIS ONE INT CAN NAME 256 ≈ 256 states — it all fits inside one byte AND THE COST TO SET · TEST · TOGGLE ANY ONE FLAG? 1 operation one & , | , or ^ — no matter how wide the int THE INT — ONE SWITCH PER FLAG 8 of 64 switches live Each bit DOUBLES the states — but flipping any flag is still one instruction. The cost never moves.
8
Drag from 1 bit to 64. The number of distinct states an int can name doubles with every switch you add — yet set (state | flag), test (state & flag) and toggle (state ^ flag) each stay a single operation the whole way. Slide to 64: one machine word already names more configurations than there have been seconds since the Big Bang.
Fig 05e — Capacity is exponential, cost is flat. Each switch you add doubles the states one int can hold while the price to flip any one flag never leaves 1 — the whole reason a single integer can carry an entire configuration.
Trace3 step the machine — idea · code · memory move together
Three yes/no facts riding in one int
OR sets a bit and AND tests one, so several independent true/false flags pack into a single integer and read back column by column — exactly how permissions and packets travel. Press Next and watch the lamps, the register and the code move as one.
3 flagsbase 2bits 1·2·4
Three flag lamps beside a live binary registerint · 3 bitsinit
state
In plain words
Under the hood
SETALU · the operation
Program · flags.py
variables · type
Memory · state + the three flag masks
registers — the running state
state int · one packed field of flags
state & LOVED→ 4truthy · set
state & DOWNLOADED→ 0falsy · clear
what's happening
beat 1 / 1

06Who binds first: precedence

Type 2 + 3 * 4 and Python answers 14, not 20, and it is not being clever or careless. It is obeying a fixed pecking order: * outranks +, so the multiplication gets wrapped in invisible parentheses and settled first, as if you had written 2 + (3 * 4). Every operator you met in this chapter carries a rank, its precedence. And before a single value is computed, Python uses those ranks to decide the shape of the expression: who groups with whom. Nail the shape and the answer falls out for free.

That shape is a ladder, and Python consults the exact same ladder every time. The good news is you do not memorise all thirteen rungs. You memorise two things. First, the rough shape: arithmetic binds tighter than bitwise, which binds tighter than comparisons, which bind tighter than the words and/or. Second, the three surprises that trip up everyone who guessed. Everything else, you reach for a pair of parentheses and stop worrying.

THE ONE IDEA TO CARRY FORWARD
Precedence decides grouping, not the order values pop out. It quietly drops parentheses into your expression to build a tree2 + 3 * 4 becomes 2 + (3 * 4) — and it does this while your code is still being compiled, before anything runs. Read precedence as "what clings to what," never as "what happens first."
The precedence ladder — tightest at the topfigure
binds TIGHTEST — grouped first ( ) parentheses — the manual override ** power (groups right → left) -x +x ~x unary minus, plus, bit-flip * / // % multiply, divide, floor, mod + - add, subtract << >> bit shifts & bitwise AND ^ bitwise XOR | bitwise OR == != < <= > >= is in not Boolean NOT and Boolean AND or Boolean OR binds LOOSEST — grouped last ① ** outranks the minus sign, so -2 ** 2 = -4 ② the bitwise family sits ABOVE the comparisons — so | binds first, not or ③ not ▸ and ▸ or, so True or False and False is True
Fig 06 — The precedence ladder. Higher rungs group first; families are colour-coded (arithmetic gold, bitwise red, comparison cyan, boolean green). On the comparison rung sits one operator this chapter hasn't built yet — in, the membership test song in playlist you'll meet in chapter 6; it binds at the same level as ==. The three circled surprises are the only rungs anyone actually gets wrong. When in doubt, add parentheses and stop doubting.
SYNTAX · precedence, condensedthe ladder as typed shapes — tightest at the top, and the three rungs people get wrong
( ) — grouping. Always wins, always free. ** — power · leans RIGHT: 2 ** 3 ** 2 is 2 ** (3 ** 2) -x +x ~x — the unary signs · weaker than ** * / // % — multiply-ish · then + - below it << >> — shifts · then &, then ^, then | in not in is is not < <= > >= != == — every comparison shares ONE rung not x — then and, then or below that a if c else b — the ternary: looser than everything above
Type 1the rough shape, in six words: arithmetic > bitwise > comparison > and/or. That much covers almost every line you will write.
Type 2surprise one — ** outranks the unary minus, so -2 ** 2 is -4. Mean the other thing? (-2) ** 2.
Type 3surprise two — & ^ | all bind tighter than ==, which is how a comparison quietly becomes a chain.
Type 4surprise three — not beats and beats or, so True or False and False is True.
Type 5everything leans left except **. 8 / 4 / 2 is (8 / 4) / 2; 2 ** 3 ** 2 is 2 ** (3 ** 2).
you type
print(200 + 45 // 2, (200 + 45) // 2)     # // outranks +
print(5 << 1 + 1, (5 << 1) + 1)           # + outranks <<
print(0 or 200 and 245)                   # and outranks or
print(1 == 1 | 3 == 3)                    # | dives under both ==
print("long" if 245 > 240 else "short")   # the ternary binds loosest of all
you see
222 122
20 11
245
False
long
where beginners trip
  • precedence decides grouping, not running order. Operands still evaluate left to right, and and/or still skip what they do not need.
  • x == 1 | y == 2 groups as x == (1 | y) == 2. When you mean logical or, write or, or parenthesise each comparison.
  • -x ** 2 is -(x ** 2), which is never positive. In a distance or a variance that is a sign bug hiding in plain sight.
  • not sits below the comparisons: not a == b flips the finished verdict, and not a in b is better spelled a not in b.
  • parentheses cost nothing at runtime — grouping is settled while your source compiles. Spend them wherever the ladder is not obvious.

Surprise one lives at the top. ** outranks the unary minus, so -2 ** 2 reads as -(2 ** 2) = -4, not (-2) ** 2 = 4. The minus is a smaller fish than the power. Surprise two is the one that ends an old argument. The whole bitwise family — &, ^, | — sits above the comparisons, so in x == 1 | y == 2 the | grabs its neighbours before either == does. It is the final nail in the "| is just a fancier or" myth. | is nothing of the sort. Surprise three lives at the bottom: not beats and beats or, so True or False and False is True. The and runs inside the or, giving True or (False and False).

precedence.pypython
print(2 + 3 * 4)                 # 14  — * binds before +
print((2 + 3) * 4)               # 20  — parentheses rewrite the tree
print(-2 ** 2)                   # -4  — ** binds before unary minus
print(2 ** 3 ** 2)                # 512 — ** groups right-to-left
print(True or False and False)   # True — and binds before or

Reading the ladder is one thing; watching a real line collapse down it is another. Below is a genuine expression built from the playlist — is "Blinding Lights" (200 s) worth keeping? — and it packs six operators across four families. Drag the step control and watch Python fold it up one rung at a time, tightest first. The gold band shows which sub-expression binds at each step; the bottom line is the whole expression after that fold.

Step down the ladder — one binding at a time▸ drag me
one line, six operators, four families — fold it tightest-first 200 + 45 * 2 > 180 and not 200 == 245 Python reads the whole line, then works the tightest rung first the line so far 200 + 45 * 2 > 180 and not 200 == 245
step 0 / 6
Push it rung by rung: 45 * 2 first (multiply is tightest), then +, then the two comparisons, then not, and and dead last. Notice the ladder never jumps around — it always drains from the top.
Fig 06b — The same line, folded tightest-first. Each step wraps one more rung in invisible parentheses; six folds later the whole expression is a single True. That fold order is the ladder.
NOW PREDICT IT YOURSELFthree pairs of lines — write both answers down before you run anything
Each case below is the same expression twice, once bare and once parenthesised. Predict both outputs on paper first; the gap between the two is where the ladder lives. Case A: print(200 + 45 // 2), then print((200 + 45) // 2). Case B: set a, b = 1, 3, then print(a == 1 | b == 3), then print((a == 1) | (b == 3)). Case C: print(200 or 0 and 245), then print((200 or 0) and 245). For each line, do not compute — group first. Ask which operator sits on the higher rung and put invisible parentheses around it, then read what is left. Case B is the one that catches almost everybody, and case C prints something that is not True or False at all.
show the solution
# CASE A ------------------------------------------------------
print(200 + 45 // 2)          # 222
print((200 + 45) // 2)        # 122
# WHY: // sits on a higher rung than +, so it grabs the 45 and the 2
#      first: 200 + (45 // 2) -> 200 + 22. Parentheses rewrite the tree.

# CASE B ------------------------------------------------------
a, b = 1, 3
print(a == 1 | b == 3)        # False
print((a == 1) | (b == 3))    # True
# WHY: | binds TIGHTER than ==, so 1 | b runs first and gives 3.
#      What is left is a run of comparisons -- a == 3 == 3 -- which
#      Python chains into (a == 3) and (3 == 3): False.
#      This is the whole reason "| is just a fancier or" is a myth.

# CASE C ------------------------------------------------------
print(200 or 0 and 245)       # 200
print((200 or 0) and 245)     # 245
# WHY: and outranks or, so line one is 200 or (0 and 245). The left
#      operand is truthy, so or hands 200 straight back and the right
#      half never runs at all. Parenthesise and the or settles first,
#      handing 200 on to the and -- which then returns its last operand.
One rung climbs backwards
Almost every operator leans left: 8 / 4 / 2 is (8 / 4) / 2 = 1.0, worked left to right. Power is the lone rebel — chained ** leans right: 2 ** 3 ** 2 is 2 ** (3 ** 2) = 2 ** 9 = 512, not 8 ** 2 = 64. When two operators share a rung, this left-or-right tie-break is called associativity, and ** is the one you have to remember.
Wait — if every comparison shares one rung, what does 1 < x < 5 even mean — (1 < x) < 5? No. Python spots a run of comparisons and quietly chains them with an and, evaluating the middle once: 1 < x < 5 is 1 < x and x < 5. Elegant when you want it — and the very mechanism behind surprise two. x == 1 | y == 2 is not "compare, then or"; it is the chain x == (1 | y) == 2, because | dived under both ==s first and left a run of two comparisons behind.
↻ reframe
Precedence isn't about time — no operator is racing to go first. It's about grouping: a set of invisible parentheses the language drops in for you before your program even runs. So stop asking "what happens first?" and ask "what clings to what?" The tighter an operator's rung, the more greedily it grabs the values on either side of it — and a greedy * will snatch the 3 out of 2 + 3 every time.

✗ The myth

"| is basically or with a sharper haircut, so x == 1 | y == 2 asks whether either side is true."

✓ The reality

| is bitwise OR, and it binds tighter than every comparison. So that line groups as x == (1 | y) == 2 and quietly becomes a chained comparison — a trap, not a question. When you mean logical OR, write or; when you mean "either of these comparisons," parenthesise: (x == 1) or (y == 2).

PARENTHESES ARE FREE
Grouping is resolved when your source compiles to bytecode (chapter 0), so parentheses add zero runtime work — (total / count) > 180 compiles to the byte-for-byte identical instructions as the bare version. The only thing they change is whether the next human to read your code has to rebuild the ladder in their head. Spend them without guilt.
The deeper cut — precedence is grouping, not the order things run

Throughout this section we have said Python "works the tightest rung first." That is a labelled simplification, the right picture for grouping, but not literally the order values are produced. Precedence and its partner associativity do just one job: they build the tree. They decide that 2 + 3 * 4 means 2 + (3 * 4) and that 2 ** 3 ** 2 means 2 ** (3 ** 2). Full stop. Once that tree exists, Python walks it, and the runtime rule is a different rule. Operands are evaluated strictly left to right, and and/or short-circuit: True or slow() never even calls slow(), because or already has its answer from the left.

There is one more twist worth pocketing now, because you will lean on it constantly later. and and or do not return True/False at all. They return one of the operands. 0 or "default" is "default", and 245 and 200 is 200. Precedence told you how the pieces group into a tree. Short-circuiting decides which pieces of that tree ever run. Two different questions, two different answers, and confusing them is the last precedence bug you will ever chase.

An expression computes a value — but it never decides anything. Staple a decision onto it — if seconds > 180: — and the very same ladder you just learned suddenly steers which lines run at all. That is where your code starts to branch, loop, and react: the Control flow chapter →

SAY IT BACKthe chapter in five breaths
  1. An expression is a question that collapses to exactly one value, and an operator is the verb that answers it — 200 + 245 is really "how long are these two, back to back?"
  2. One slash is always a float, even on a clean division; // counts the whole blocks and % keeps the crumbs, and between them they lose nothing.
  3. == asks same value? and is asks same object? — two genuinely different questions that agree on 200 only by chapter 3's small-integer accident.
  4. and and or stop the moment the answer is settled and hand back an operand rather than a tidy True — so the half they skip is not run faster, it is not run at all.
  5. & | ^ ~ << >> reach past the number and grip its switches, one column at a time; and precedence is not about which operator goes first in time, it is about grouping, settled while the source compiles.
You already owned the pieces: chapter 0 gave you the row of switches; chapter 2 gave you int, float and truthiness; chapter 3 gave you id() and the small-integer shelf — this chapter only handed you the verbs that act on all of them, plus the ladder that decides who binds first.
TYPE THIS · the chapter in one programlabel.py — three verdicts per song, and not one if statement
# label.py  --  three verdicts per song, and not one if-statement

title, seconds = "Blinding Lights", 200

radio  = 120 <= seconds <= 240
length = "short" if seconds < 120 else "standard" if seconds <= 240 else "long"
grade  = "A" if 180 <= seconds <= 240 else "B" if 120 <= seconds <= 300 else "C"
print(f"{title:<16}{seconds:>4}s  {length:<9} grade {grade}  radio={radio}")

title, seconds = "Titanium", 245

radio  = 120 <= seconds <= 240
length = "short" if seconds < 120 else "standard" if seconds <= 240 else "long"
grade  = "A" if 180 <= seconds <= 240 else "B" if 120 <= seconds <= 300 else "C"
print(f"{title:<16}{seconds:>4}s  {length:<9} grade {grade}  radio={radio}")

title, seconds = "Intro (Skit)", 42

radio  = 120 <= seconds <= 240
length = "short" if seconds < 120 else "standard" if seconds <= 240 else "long"
grade  = "A" if 180 <= seconds <= 240 else "B" if 120 <= seconds <= 300 else "C"
print(f"{title:<16}{seconds:>4}s  {length:<9} grade {grade}  radio={radio}")
Blinding Lights  200s  standard  grade A  radio=True
Titanium         245s  long      grade B  radio=False
Intro (Skit)      42s  short     grade C  radio=False
Save it as label.py and run it. Every line of judgement here is an expression — the chapter's whole claim, cashed in. 120 <= seconds <= 240 is a chained comparison: one expression, seconds read once, a bool out. length is a chained ternary that narrows left to right, and the second question is only ever asked because the first said no. grade is the same shape with a different rule, and it is worth tracing by hand: "Titanium" at 245 s misses the A-band by five seconds, then lands in the wider B-band. The three blocks are deliberately identical — same four lines, new song. That repetition is a smell, and it is exactly the smell chapter 5 cures with a loop and chapter 9 cures with a function. Two things to try. Change Intro (Skit) to 119 s and then 120 s, and watch three verdicts flip on one second. Then rewrite grade the long way, with an and instead of the chain — "A" if 180 <= seconds and seconds <= 240 else … — to prove to yourself the chain was only ever shorthand.
Wait — Python has no ++. Type ++x and it's accepted but does nothing — it parses as +(+x), two unary pluses that hand back x unchanged. --x is the same no-op (minus a minus). And x++? A flat SyntaxError. C programmers write a silent no-op on muscle memory; to actually increment you reach for the += 1 from this chapter's first section.
not BINDS LOOSER THAN ==
not 200 == 245 reads as not (200 == 245)not FalseTrue, not as (not 200) == 245. Because not sits near the bottom of the ladder, the comparison happens first and not flips the finished verdict. Obvious once you see it, a misread waiting to happen until you do.
THE MINUS-SQUARED TRAP
** outranks the unary minus, so -2 ** 2 is -(2 ** 2) = -4, never (-2) ** 2 = 4. Fine as trivia — costly in a formula: write -x ** 2 in a variance or a distance and you get -(x**2), which is always ≤ 0, a sign bug that hides until x is what matters. Mean it? Parenthesise: (-x) ** 2.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 04, in working code

Operators are the verbs of arithmetic — small symbols that turn values into new values. Here we watch each one fire in slow motion, from 445 // 60 becoming 7:25 to a boolean that quietly hands back the operand it stopped on.

The seven arithmetic operators
Add, subtract, multiply, and the three that trip people up: true-divide, floor-divide, and modulo. Every one here is a pure computation on two numbers.
Precedence & grouping
One expression, several operators — Python resolves them in a fixed order. Parentheses are the override.
Comparisons & booleans
Operators that answer yes-or-no — and two that behave more subtly than they look: chained comparisons, and the short-circuiting and/or.
Strings as operands
The arithmetic symbols do double duty on text: + joins, * repeats, and in asks about membership.
Formatting, augmentation & divmod
The expressions that shape output and mutate accumulators — f-string format specs, the augmented operators, and the two-in-one divmod.
end of chapter 04 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked