Python Expressions: Advanced
A Python float is 64 bits holding a binary fraction: a sum of fractions whose denominators are powers of 2, like 1/2 or 1/8. The decimal 0.75 is 1/2 + 1/4, so it fits exactly. The decimal 0.1 has no finite binary form: in base 2 it is 0.000110011001100... with 1100 repeating forever. The hardware keeps the leading digits and rounds off the rest, so the variable holds the nearest number the format can represent, which sits a hair above one tenth.
| Decimal | Binary expansion | Stored exactly? |
|---|---|---|
| 0.5 | 0.1 | yes |
| 0.25 | 0.01 | yes |
| 0.75 | 0.11 | yes |
| 0.1 | 0.000110011... | no |
| 0.2 | 0.00110011... | no |
| 0.3 | 0.0100110011... | no |
Decimal(0.1) from the decimal module prints the value a float really holds, digit for digit, because building a Decimal from a float copies the stored binary value without rounding it. Run the example and compare the sums: the inexact values carry their tiny errors into the addition, while the exact ones add cleanly.
Small errors add up
Each addition rounds its result to the nearest representable float, and those roundings do not cancel. Adding 0.1 to a running total 10 times walks the total away from the decimal you expect. A pipeline that adds up 100,000 fees of 10 cents as floats ends the day at 10000.000000018848 instead of 10000.0, and a reconciliation that tests with == reports a mismatch that no accountant can find.
total = 0.0total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1total += 0.1print(total)print(total == 1.0)
The total drifts at the 3rd addition, prints clean again at the 4th, and stays off from the 8th on.
Compare with a tolerance
== on floats asks whether 2 stored binary values match bit for bit, so 0.1 + 0.2 == 0.3 is False. math.isclose(a, b) asks whether they differ by at most a relative tolerance, rel_tol=1e-09 by default: one billionth of the larger value's size. A relative tolerance scales with the numbers, so it works for 3 rupees and 3 billion bytes alike. It breaks next to zero: one billionth of 0 is 0, so even a microscopic drift fails. Pass abs_tol whenever one side can be 0.
Rounding works on the stored value
round(x, 2) rounds the stored binary value, and it breaks exact ties toward the even digit, a rule called banker's rounding, so round(2.5) is 2. The literal 2.675 is stored as slightly less than 2.675, so it rounds down to 2.67 before any tie rule applies. For money or any figure a person audits, build a Decimal from a string, which keeps the decimal digits you typed, and choose the tie rule with quantize.
Integers too big for a float
Up to 2**53, which is 9007199254740992, a float can hold every whole number. Past that point the gap between neighbouring floats grows to 2, then 4, then more, so an integer that lands between them is rounded to a neighbour. A 64-bit ID that passes through a float can come back as a different ID, which is why Discord's API documentation says its snowflake IDs are always returned as strings.
- Compare floats with math.isclose, adding abs_tol when a side can be 0
- Build each Decimal from a string, as in Decimal("19.99")
- Store money as integer cents or as Decimal
- Keep IDs as int or str from source to warehouse
- Test floats with == or !=
- Pass a float to Decimal() and expect the digits you typed
- Expect round() to send every .5 upward
- Let a parser read 64-bit IDs into floats
Both 0.125 and 0.375 are eighths, so both are stored exactly. What does this print?
Totals are where the drift becomes a business problem. A refund ledger adds its amounts one at a time, and the payout it must match was added in a different order or computed elsewhere, so the 2 floats can disagree in their final digits: 0.1 + 0.1 + 0.1 prints 0.30000000000000004. A reconciliation written as math.isclose(refunds, payout) accepts that difference and still catches a missing refund, which is off by whole cents.
and and or evaluate their left operand first and stop the moment the answer is settled. If the left side of and is false, the whole expression is false whatever the right side holds, so Python returns the left operand and never evaluates the right. If the left side of or is true, the same thing happens in mirror image. A skipped operand does nothing at all, so a function call inside it never runs and a division inside it never gets the chance to raise an error.
print returns None, which makes it a witness: its message appears only when Python reaches the right operand. On the third line Python does reach it, and the value of the whole or is whatever that right operand returned, None.
| Expression | Left operand is | Right operand evaluated? | Result |
|---|---|---|---|
| `a and b` | false | no | `a` |
| `a and b` | true | yes | `b` |
| `a or b` | true | no | `a` |
| `a or b` | false | yes | `b` |
Read the Result column closely: and and or hand back an operand, unchanged. True and 7 is 7, and None or False is False. The next section pins down exactly which values count as false; for now bools and None are enough.
Put the guard on the left
Because the right operand runs only when needed, a cheap test on the left can protect a risky one on the right. Dividing by a row count is safe when row_count != 0 comes first. Reversing the operands evaluates the division before the test gets a chance to stop it.
The same ordering protects against None. In latency is not None and latency > 500, a missing reading stops at the left test; the reversed form evaluates None > 500 and raises TypeError. Ordering also saves work: when 99 rows in 100 fail a quick comparison on the left, an expensive conversion on the right runs for only 1 of them.
Chains stop at the deciding operand
- ▸
a and b and creturns the first false operand, orcwhen every operand is true. - ▸
a or b or creturns the first true operand, orcwhen none is. - ▸Operands after the deciding one are never evaluated.
- ▸
andbinds tighter thanor, soa or b and cmeansa or (b and c).
The first chain finds its first true operand in "eu-west" and returns it; "us-east" is never examined. The second runs into None as its first false operand and returns it, so the print at the end never fires and the word never appears.
Defaults with or, and their trap
label = configured_label or "unlabeled" keeps the configured value when it is set and falls back when it is None. The fallback also fires for every other false value. A retry setting of 0 means 'never retry', yet configured_retries or 3 replaces it with 3, because 0 counts as false. When a false value is a legitimate setting, test for None with is, since or cannot tell 0 from a missing value.
The third line keeps the setting intact: configured_retries is None is False, because 0 is a value and not a missing one. Defaults also chain. cli_region or env_region or "us-east" returns the first setting that is filled in, which lets a job prefer a command-line flag, then an environment value, then a hard-coded fallback, all in one expression. Every link in that chain has the retry setting's problem: a link holding 0 or an empty string is skipped as if it were unset.
What does this program print?
Every Python object has a truth value. You can read it with bool(x), and and consults it, as do or and not. Among the built-in types, the false values form a short, fixed list: False, None, every numeric zero (0, 0.0, Decimal("0.00")), and every empty container ("", [], (), {}). Every other built-in value is truthy, including several a human would read as false. A class you define can set its own truth value, which the last part of this section covers.
| Value | bool(value) | Why |
|---|---|---|
| `0` | False | numeric zero |
| `-1` | True | any nonzero number |
| `0.0` | False | numeric zero |
| `float("nan")` | True | NaN is not zero |
| `""` | False | empty string |
| `" "` | True | a space is a character |
| `"0"` | True | a 1-character string |
| `"False"` | True | a 5-character string |
| `None` | False | always false |
| `[]` | False | empty list |
| `[0]` | True | a list holding 1 item |
The last line is a classic ingestion bug. A CSV export or a web form delivers is_active as the text "False", and bool() reports True for any non-empty string. bool() checks emptiness and ignores meaning, so a text flag needs an explicit comparison against the spelling the source uses.
bool(float("nan")) is True, so it passes a truthiness check, and every comparison with NaN (==, <, <=, >, >=) gives False, even float("nan") == float("nan"), while != is True. Use math.isnan(x) to find NaN rows before they reach a filter.What not, and, or return
not x always returns a real bool: not "" is True and not 5 is False. and and or still return an operand, now judged by truthiness: "" or "unknown" is "unknown", 0 and 1/0 is 0 without dividing, and "eu" and 42 is 42. Because bool is a subclass of int, True + True is 2, which is why adding up comparison results counts how many were true.
What does this print?
Falsy and missing are different facts
The retry setting in the short-circuit section already showed or treating 0 and None as the same value. What remains is choosing the right test for each field, and the field's meaning decides it. For units_sold, a store that sold 0 units reported a real number, while a store whose feed never arrived reported None. units_sold is None finds broken feeds; units_sold == 0 finds stores that opened and sold nothing. A daily report usually needs both counts, kept apart, because one is an engineering incident and the other is a sales fact. Keep truthiness for fields where every false value means the same thing. In an optional comment field, "" and None both mean nobody wrote one.
- None: flagged
- 0: flagged
- 12: passes
- Fits when every false value means nothing arrived
- None: flagged
- 0: passes
- 12: passes
- Fits when 0 is a real measurement
Truth values for your own classes
An instance of a class you define is truthy by default, whatever its attributes hold. Defining __bool__ lets the class decide: Python calls it whenever the object meets bool() or not, and the same goes for and and or. The method must return a real bool; returning 1 raises TypeError: __bool__ should return bool, returned int.
Batch(0) is false because its __bool__ returned False, so or passes over it and returns the fallback string. Plain() defines no __bool__ and is truthy even though it holds nothing. A loader can then treat an empty batch like any other false value, with the class itself deciding what empty means.
An int is stored as bits too, one per power of 2. bin(13) returns '0b1101': 8 + 4 + 1. Bitwise operators work on those columns one position at a time, with no carrying between them, which lets an integer hold many independent yes or no answers.
| Row | bit 3 (8) | bit 2 (4) | bit 1 (2) | bit 0 (1) | Value |
|---|---|---|---|---|---|
| `12` | 1 | 1 | 0 | 0 | 12 |
| `10` | 1 | 0 | 1 | 0 | 10 |
| `12 & 10` | 1 | 0 | 0 | 0 | 8 |
| `12 | 10` | 1 | 1 | 1 | 0 | 14 |
| `12 ^ 10` | 0 | 1 | 1 | 0 | 6 |
| Operator | Rule | Example | Result |
|---|---|---|---|
| `&` | 1 where both bits are 1 | `12 & 10` | `8` |
| `|` | 1 where either bit is 1 | `12 | 10` | `14` |
| `^` | 1 where the 2 bits differ | `12 ^ 10` | `6` |
| `~` | flips every bit, giving `-x - 1` | `~12` | `-13` |
| `<<` | shifts left, filling with 0s | `3 << 2` | `12` |
| `>>` | shifts right, dropping low bits | `12 >> 2` | `3` |
Packing flags into one integer
Give each yes or no fact its own bit, written 1 << n. A single integer then holds all of them. Discord's permission system works this way: its developer documentation assigns VIEW_CHANNEL to 1 << 10 and SEND_MESSAGES to 1 << 11, with ATTACH_FILES going to 1 << 15, so a role's permissions travel as a single number. | grants a flag and & ~ revokes it. ^ toggles the flag, and & tests it.
VIEW_CHANNEL = 1 << 10SEND_MESSAGES = 1 << 11ATTACH_FILES = 1 << 15perms = 0perms |= VIEW_CHANNELperms |= SEND_MESSAGES | ATTACH_FILESprint(perms, bin(perms))can_attach = (perms & ATTACH_FILES) != 0perms &= ~ATTACH_FILEScan_attach_now = (perms & ATTACH_FILES) != 0perms ^= SEND_MESSAGESprint(perms, bin(perms))print(can_attach, can_attach_now)
Grant with |=, revoke with &= ~, toggle with ^=, test with a parenthesized &.
Watch the test lines: perms & ATTACH_FILES keeps only that bit, so the result is either 0 or the flag's own value, 32768. Comparing with != 0 turns it into a bool, and by the truthiness rules bool(perms & ATTACH_FILES) gives the same answer.
& and ^ rank above the comparisons in Python, as does |, so perms & SEND_MESSAGES != 0 already means (perms & SEND_MESSAGES) != 0; the parentheses are for the next reader. The shifts << and >> sit lower in the table than + and -. That makes 1 << 10 + 1 equal to 1 << 11, which is 2048, while (1 << 10) + 1 is 1025. Parenthesize any line that mixes shifts with arithmetic.What does this print?
^ also diffs 2 snapshots. An audit job that stores a role's permissions each day can compute before ^ after: every bit that changed comes out as 1, and every unchanged bit as 0. Masking the diff with & after keeps the grants, and masking with & before keeps the revokes.
Bit 15 comes out as the grant of ATTACH_FILES and bit 11 as the revoke of SEND_MESSAGES. The middle line has no parentheses and still prints True, because & runs before ==; the last line keeps them only so a reader sees the grouping at a glance.
Reading fields out of a packed ID
Shifts and masks pull apart a number built from several fields. Discord documents its snowflake IDs as 64 bits in 4 fields: the top 42 bits count milliseconds since the Discord epoch, 1420070400000 (the first second of 2015), bits 17 to 21 hold an internal worker ID, bits 12 to 16 an internal process ID, and the low 12 bits an increment. That puts 22 bits below the timestamp, so id >> 22 drops them and leaves the timestamp. id & 0xFFF keeps the lowest 12, because 0xFFF is 12 ones in binary.
The last line joins this section to the first: the ID passes through float, its low bits round away, and the increment reads 0 where the real value is 7. The timestamp survives this time only because it sits in the high bits, which the float keeps.
~FLAG is a negative number: ~(1 << 15) is -32769. In two's complement that value has every bit set except bit 15, so perms & ~FLAG clears exactly that bit, which is all a revoke needs.name := expression evaluates the expression, binds the result to name, and hands the same value to whatever surrounds it. Plain = is a statement and cannot sit inside a larger expression. :=, nicknamed the walrus operator for its eyes and tusks, is an expression. It arrived in Python 3.8, so any 3.8 or later interpreter accepts it.
Parentheses decide what gets bound
:= binds more loosely than every other operator, so the comparisons and even and and or are evaluated before it. Inside the parentheses, count := 3 > 2 binds count to the result of 3 > 2, which is the bool True, and the 3 itself is never stored. Wrap the walrus around exactly the value you want to keep.
- ▸A bare statement:
total := 5alone on a line is aSyntaxError; writetotal = 5. - ▸The right side of
=without parentheses:x = y := 5is aSyntaxError. - ▸Attributes:
(self.count := 1)is refused, because only a plain name can be bound. - ▸Augmented forms: Python has no
+:=.
Compute once, test, then reuse
The walrus pays off where an expression needs a value twice: once to test it and once to report it. Paired with a short-circuit guard, it computes the average row width only after the zero check passes, and keeps the result for the next line.
The binding happens only if Python reaches the walrus. Set row_count to 0 and and stops at its left operand, so the name avg is never created and reading it raises NameError.
A guard over a stream of readings
def readings():yield 12.5yield 0.0yield Nonestream = readings()ok1 = (r1 := next(stream)) is not None and r1 > 10ok2 = (r2 := next(stream)) is not None and r2 > 10ok3 = (r3 := next(stream)) is not None and r3 > 10print(ok1, ok2, ok3)print(r1, r2, r3)print(r1 and r1 > 10, r2 and r2 > 10)
r3 is None, so the comparison on that line never runs.
- Wrap every walrus in parentheses around the exact value to keep
- Bind the value where it is first needed, then reuse the name
- Read a name bound inside and or or only on the path that bound it
- Pack several walruses into a line nobody can scan
- Use := where an ordinary = statement on its own line reads fine
- Read a walrus name after a short-circuit that may have skipped it
What does this print?
Capstone: one event, every operator
A pipeline step like this one usually breaks through a small edit, and most such edits are one of 3 slips: a guard moved to the right of the division it protects, so the division runs first; a revoke written with |, which can only turn bits on, where & ~ was needed; and a float result checked with == after it took a different arithmetic path from its target. The next program contains one of each.
> A Discord analytics job ingests a day of message events. Each event carries a snowflake ID, the sender role's permission integer, an attachment count with a total byte size, and a spam score as a float that can be missing. The job must keep IDs exact, derive each message's creation time, flag messages from roles without SEND_MESSAGES, compute bytes per attachment without crashing on text-only messages, and check averaged spam scores against a 0.3 threshold.
int, because a float rounds away the low increment bits, and (id >> 22) + 1420070400000 recovers the creation time.(perms & SEND_MESSAGES) != 0 keeps only that bit and turns it into a bool; the parentheses make the grouping plain to a reader, since & already runs before !=.attachment_count != 0 and (per_file := total_bytes / attachment_count) > 8000000 guards the division and keeps the ratio for the report.0.0 is a real score, so the job tests score is not None, which lets 0.0 through.math.isclose, never ==, since summing floats drifts.math.isclose and round money as a Decimal built from a string.2**53 lose their low digits in a float; keep IDs as int or str.and and or evaluate left to right, stop at the deciding operand, and return that operand.False and None are false, as is every numeric zero and every empty container. Values like "0" and "False" are truthy, and so is [0].is None when 0 or an empty string is a real value.| grants a flag and & ~ revokes it; ^ toggles it and & tests it. & already runs before ==, while + runs before <<.:= binds and returns a value inside an expression, and binds only if Python reaches it.Predict what Python's trickiest expressions return: floats, and/or, truthiness, bit flags and the walrus.
- Category
- Python
- Difficulty
- advanced
- Duration
- 40 minutes
- Challenges
- 19 hands-on challenges
Topics covered: Floating-Point Surprises, Short-circuit Evaluation, Truthy and Falsy Values, Bitwise Operators on Integers, Walrus Operator (:=)
Lesson Sections
- Floating-Point Surprises (concepts: Unpacking)
Small errors add up Compare with a tolerance Rounding works on the stored value Integers too big for a float
- Short-circuit Evaluation (concepts: Boolean Operators)
Put the guard on the left Chains stop at the deciding operand Defaults with or, and their trap
- Truthy and Falsy Values (concepts: Boolean Operators)
What not, and, or return Falsy and missing are different facts Truth values for your own classes
- Bitwise Operators on Integers (concepts: Ternary Expression)
Packing flags into one integer Reading fields out of a packed ID
- Walrus Operator (:=) (concepts: Variables)
Parentheses decide what gets bound Compute once, test, then reuse A guard over a stream of readings The animation pulls 3 readings from a generator and checks each on a single line. The walrus captures the reading, the identity test lets the real zero through as a reading, and the comparison runs only when the feed produced a number. The last print shows why the identity test matters. With truthiness in its place, the reading of 0.0 short-circuits to the float 0.0, and a report treating that val