Skip to content

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/else and understand that indentation defines the block.
  • Apply Python's truthiness rules to write idiomatic empty-check conditions.
  • Distinguish == (value equality) from is (identity) and apply the correct one for None.
  • 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.

conditionals.py
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
truthiness.py
# 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.

walrus_guards.py
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.

ternary_short_circuit.py
# 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?

  • y is Noneor always returns the left operand.
  • 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.
  • y is Trueor always 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" then if 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/else block 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/else branch on conditions; indentation defines the block — it's the syntax, not decoration.
  • Truthiness: empty/zero/None values are falsy; prefer if items: over if len(items) > 0:.
  • == compares value; is compares identity — use is 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 and while conditions.
  • Short-circuit or for defaults has a falsy-value trap — be careful when 0, False, or "" are valid.