Conditionals & Boolean Logic¶
What this lesson gives you
Branching with if/elif/else, the truthiness rules that trip people up, and why indentation is the syntax.
Estimated time: 22 min read · Part: Making Decisions: Control Flow
Learning objectives
- Write multi-branch conditionals with
if/elif/elseand understand that indentation defines the block. - Apply Python's truthiness rules to write idiomatic empty-check conditions.
- Distinguish
==(value equality) fromis(identity) and apply the correct one forNone. - Use the walrus operator (
:=) to assign and test in a single expression. - Apply short-circuit evaluation and guard clauses to simplify deeply nested logic.
A conditional runs a block of code only when a condition is true. Python uses if, optional elif (else-if) branches, and an optional else. Unlike C, Java, or JavaScript, Python uses indentation — not curly braces — to mark which lines belong to a block. The indentation isn't cosmetic; it's the grammar. Get it wrong and you get a syntax or logic error. The standard indentation is four spaces (not tabs) per level, enforced by any serious linter.
score = 82
if score >= 90:
grade = "A"
elif score >= 80: # checked only if the first was False
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"
print(grade) # B
# Comparison operators:
# == != < > <= >= is is not in not in
# Logical operators combine conditions:
age, member = 25, True
if age >= 18 and member: # both must be True
print("full access")
if not member or age < 13: # either suffices
print("restricted")
# Chained comparisons (unique to Python — reads like maths):
if 0 <= score <= 100:
print("valid score") # equivalent to score >= 0 and score <= 100
B full access valid score
Truthiness in depth¶
An if statement doesn't need a literal boolean. Python evaluates the truthiness of any value by calling its __bool__ method (or __len__ if __bool__ isn't defined). This means any object can be used directly as a condition, and the behaviour follows a consistent pattern: empty things are falsy, non-empty things are truthy.
| Value | Truthy or Falsy | Why |
|---|---|---|
False | Falsy | The boolean False itself |
None | Falsy | The null sentinel |
0, 0.0, 0j | Falsy | Zero in any numeric type |
"" | Falsy | Empty string |
[], (), {}, set() | Falsy | Empty containers |
b"" | Falsy | Empty bytes |
| Any non-zero number | Truthy | Non-zero |
| Any non-empty string | Truthy | Non-empty |
| Any non-empty container | Truthy | Has elements |
| Custom objects | Truthy by default | Unless __bool__ or __len__ returns falsy |
# Idiomatic truthiness checks:
items = []
if not items: # ✓ idiomatic: "if the list is empty"
print("no items")
name = ""
if not name: # ✓ idiomatic: "if name is blank"
print("name required")
value = None
if value is None: # ✓ explicit None check — preferred
print("missing value")
# Watch out: 0 is falsy, but 0 is a valid value!
count = 0
if not count: # BUG: this fires for 0 AND None AND "" !
print("no count")
if count is None: # correct if you mean "not yet set"
print("count not set")
# Short-circuit evaluation:
# 'and': stops at first falsy value (returns it)
# 'or' : stops at first truthy value (returns it)
default = None
label = default or "unknown" # "unknown" — common default pattern
print(label)
no items name required missing value no count unknown
vs is — a common interview trap
Misconception: "== and is are interchangeable." == checks equal value (calls __eq__); is checks same object in memory (identity). Use == for comparing values. Reserve is for None: write if x is None:, never if x == None:. Using is on numbers or strings "works" only by accident due to CPython's object caching and will betray you on larger values — a classic gotcha that appears constantly in interviews.
Walrus operator and guard clauses¶
Python 3.8 introduced the walrus operator (:=), formally called the "assignment expression." It combines assignment and test into a single expression, eliminating the need to call a function twice or set a variable before an if. It's particularly useful in loops and while conditions.
Guard clauses are a coding pattern where you return (or raise) early from a function when preconditions fail, eliminating deep nesting. A flat sequence of guard clauses is almost always more readable than a pyramid of nested if statements.
import re
# Walrus operator: assign inside a condition
data = "error: connection refused on port 8080"
if m := re.search(r"port (\d+)", data):
print(f"Port: {m.group(1)}") # Port: 8080
# Without walrus: m = re.search(...); if m: ...
# Guard clauses vs nested if:
def process_deal_nested(deal: dict | None) -> str:
if deal is not None:
if deal.get("mrr"):
if deal["mrr"] > 0:
return f"Valid: ${deal['mrr']:,.2f}"
return "invalid" # hard to see this at the bottom of nesting
def process_deal_guards(deal: dict | None) -> str:
if deal is None: # guard: bail early
return "invalid"
if not deal.get("mrr"): # guard: bail early
return "invalid"
if deal["mrr"] <= 0: # guard: bail early
return "invalid"
return f"Valid: ${deal['mrr']:,.2f}" # happy path at the end
Port: 8080
TRUTHINESS DECISION TREE
═══════════════════════════════════════════════════════
if x:
│
▼
Is x False / None / 0 / 0.0 / "" / [] / {} / () ?
│ │
YES NO
│ │
▼ ▼
FALSY → skip block TRUTHY → run block
Rule of thumb:
- "is this thing absent/empty/zero?" → use truthiness: if not x
- "is this thing explicitly None?" → use identity: if x is None
- "is this number zero?" → use ==: if x == 0
(because 0 is falsy, so 'if not x' catches 0 AND None AND "" together)
Conditional expression, short-circuit, and operator precedence¶
Python's conditional expression (often called the ternary operator) compresses a simple if/else into one line: value_if_true if condition else value_if_false. It evaluates the condition and returns one of two expressions. It's useful for assignments and return values but should be avoided when the logic is complex — readability comes first.
Short-circuit evaluation is how and and or work: and returns the first falsy operand (or the last if all are truthy); or returns the first truthy operand (or the last if all are falsy). They don't just return True/False — they return the actual value. This enables concise default patterns like config.get("timeout") or 30.
# Conditional expression:
deals = 5
status = "open" if deals > 0 else "none" # "open"
# Short-circuit 'or' for defaults:
timeout = None
effective_timeout = timeout or 30 # 30 (None is falsy)
timeout2 = 0
effective2 = timeout2 or 30 # BUG: 30, not 0 — 0 is falsy!
# Fix when 0 is valid:
effective3 = timeout2 if timeout2 is not None else 30 # 0
# Short-circuit 'and' for safe access:
user = {"name": "Ada"}
email = user and user.get("email") # None — user exists but no email key
no_user = None
no_email = no_user and no_user.get("email") # None, no AttributeError
print(status, effective_timeout, effective2, effective3)
open 30 30 0 | Operator | Precedence (low → high) | Notes | |---|---|---| | or | Lowest | Returns first truthy or last value | | and | Above or | Returns first falsy or last value | | not | Above and | Unary boolean negation | | in, not in, is, is not, <, <=, >, >=, ==, != | Comparisons | Chainable: 0 <= x <= 100 | | \|, ^, & | Bitwise | Operate on integers bit-by-bit | | <<, >> | Shift | Bit shifts | | +, - | Additive | | | *, /, //, % | Multiplicative | | | ** | Above multiplicative | Right-associative: 2**3**2 = 2**9 | | +x, -x, ~x | Unary | |
WHY indentation as syntax Van Rossum's bet: programmers indent for clarity anyway, so make the indentation carry the meaning. It eliminates the "else belongs to which if?" ambiguity that plagues C-style braces, and it prevents the "off-by-a-brace" bugs that hide in minified or obfuscated code. The cost is that inconsistent indentation causes real errors — which is why every linter enforces 4 spaces universally.
HOW to read complex boolean expressions Break it left to right using precedence. not a or b and c is (not a) or (b and c) — not binds tightest among the logical operators, then and, then or. When in doubt, add parentheses — they're free and they document intent. A complex boolean condition with no parentheses is a maintenance hazard, even if it's technically unambiguous.
WHERE guard clauses matter most in practice In any function that validates inputs before doing work: API handlers that check authentication, data pipeline stages that validate row structure, CLI commands that check argument combinations. Guard clauses let you read top-to-bottom: "what can go wrong?" first, then the happy path last. This structure also makes tests easier — each guard clause is a separate, independently testable failure mode.
Knowledge check
InterviewWhich check correctly tests "this list is empty" in idiomatic Python, and which subtly misbehaves?
if items == []:is the cleanest and always preferred.if not items:is idiomatic (uses truthiness); it's preferred over comparing to[]and is more general across empty containers.if items is []:is correct because it checks the empty list.- All three are exactly equivalent and equally idiomatic.
Answer
if not items: is idiomatic (uses truthiness); it's preferred over comparing to [] and is more general across empty containers.
if not items: leverages truthiness, reads cleanly, and works for any empty container. if items is []: is a real bug — it checks identity against a brand-new empty list and is essentially always False. Comparing with == works but is less idiomatic and less general.
Knowledge check
Concept checkWhat does x = None; y = x or "default" set y to, and when would using or as a default be a bug?
yisNone—oralways returns the left operand.yis"default"becauseNoneis falsy, soortakes the right side. It becomes a bug if a valid value like0,False, or""should be kept — because those are also falsy and would incorrectly be replaced with the default.yisTrue—oralways returns a boolean.- It raises a TypeError.
Answer
y is "default" because None is falsy, so or takes the right side. It becomes a bug if a valid value like 0, False, or "" should be kept — because those are also falsy and would incorrectly be replaced with the default.
Short-circuit or returns the first truthy value. None or "default" returns "default" . The bug scenario: if the variable could legitimately hold 0 , False , or "" , using or for defaults silently discards those valid values. Use x if x is not None else default when the variable might hold falsy-but-valid values.
Knowledge check
AppliedYou're writing a function that validates a deal record dict. The record must have a non-empty "name" and an "mrr" that is a positive number. Write the guard clauses.
if not deal["name"] and not deal["mrr"] > 0: return "invalid"- Two separate guards:
if not deal.get("name"): return "name required"thenif not isinstance(deal.get("mrr"), (int, float)) or deal["mrr"] <= 0: return "invalid mrr"— distinct, testable failure modes. - Nest everything in one deep
if/elif/elseblock for compactness. - Use a try/except around the whole validation.
Answer
Two separate guards: if not deal.get("name"): return "name required" then if not isinstance(deal.get("mrr"), (int, float)) or deal["mrr"] <= 0: return "invalid mrr" — distinct, testable failure modes.
Separate guard clauses for each failure mode are far superior: they produce distinct error messages, each clause is independently readable and testable, and the happy path at the end is clear. Combining conditions into a single and / or expression makes the error message generic and makes the logic harder to audit.
Exercise
Write a function classify_account(account: dict) -> str that assigns a tier label based on MRR: "Enterprise" (≥ \(10,000), "Growth" (\)1,000–\(9,999), "Starter" (\)100–$999), and "Prospect" (< $100). Also apply these additional rules using guard clauses: if the account is missing an "mrr" field, return "unqualified"; if mrr is negative, return "error: negative mrr". Write at least four test calls demonstrating each branch.
Show solution
def classify_account(account: dict) -> str:
if "mrr" not in account:
return "unqualified"
mrr = account["mrr"]
if not isinstance(mrr, (int, float)):
return "error: non-numeric mrr"
if mrr < 0:
return "error: negative mrr"
if mrr >= 10_000:
return "Enterprise"
if mrr >= 1_000:
return "Growth"
if mrr >= 100:
return "Starter"
return "Prospect"
# Tests:
print(classify_account({"name": "Acme", "mrr": 15000})) # Enterprise
print(classify_account({"name": "Globex", "mrr": 3500})) # Growth
print(classify_account({"name": "New", "mrr": 0})) # Prospect
print(classify_account({"name": "Bad"})) # unqualified
print(classify_account({"name": "Err", "mrr": -100})) # error: negative mrr
Key takeaways
if/elif/elsebranch on conditions; indentation defines the block — it's the syntax, not decoration.- Truthiness: empty/zero/None values are falsy; prefer
if items:overif len(items) > 0:. ==compares value;iscompares identity — useis None/is not None, never== None.- Guard clauses return early on failure, keeping the happy path flat and readable at the end.
- The walrus operator (
:=) assigns and tests in one step — useful in loops andwhileconditions. - Short-circuit
orfor defaults has a falsy-value trap — be careful when 0, False, or "" are valid.