Python’s conditional statements are the most basic form of control flow. Unlike many languages, Python uses indentation rather than braces or keywords to delimit blocks.
def classify_temperature ( temp_celsius : float ) -> str :
The condition expression can be any Python object. Python evaluates its truthiness using the __bool__() protocol described in the previous chapter. There is no requirement that the condition Be a boolean — this is consistent with Python’s broader philosophy of duck typing.
## All of these are valid conditional expressions
print ( " non-empty list is truthy " )
print ( " this never executes " )
print ( " None is falsy, so not None is truthy " )
This is one of Python’s most controversial design decisions and the source of the most frequent Criticism from programmers coming from brace-delimited languages. The rationale is both Philosophical and practical:
Eliminates a class of bugs. In C-style languages, mismatched braces are a persistent source of errors. The compiler cannot detect whether the indentation reflects the programmer’s intent because the braces define the actual structure. Python makes the indentation the structure — what you see is what the interpreter sees.
Enforces a single canonical style. Every Python program has consistent block structure. There is no debate over K&R versus Allman versus GNU indentation style because there is no choice. This reduces cognitive overhead in code reviews and eliminates formatting arguments.
Reduces visual noise. Braces, semicolons, and explicit block terminators are syntactic overhead that provides no semantic information beyond what indentation already conveys. Removing them makes code more compact without sacrificing readability.
Historical precedent. Guido van Rossum was influenced by ABC (a teaching language developed at CWI) and Haskell, both of which use indentation-based syntax. The choice was deliberate, not accidental.
The trade-off is sensitivity to whitespace. Mixing tabs and spaces, or inconsistent indentation, Causes IndentationError. Python 3 disallows mixing tabs and spaces entirely within the same file. PEP 8 mandates 4 spaces per indentation level.
And spaces. Configure your editor to insert 4 spaces on Tab. Most linters and formatters (`ruff` `black`) enforce this automatically.Python provides a conditional expression (often called the ternary operator) with an ordering that Reflects the English sentence structure:
## Python's ternary: value_if_true IF condition ELSE value_if_false
status = " adult " if age >= 18 else " minor "
Note the ordering: the value comes first, then the condition. This differs from C’s condition ? value_if_true : value_if_false. The rationale is that in natural English, you state The assertion first (“it is an adult”) and then qualify it (“if age >= 18, otherwise it is a Minor”).
Nested ternary expressions are technically possible but should be avoided:
# Works but harms readability
label = " positive " if x > 0 else " negative " if x < 0 else " zero "
# Prefer a function with if/elif/else for multi-way branching
def sign ( x : float ) -> str :
Python 3.10 introduced structural pattern matching via PEP 634. This is not a C-style switch Statement — it is a fundamentally more powerful construct that performs destructuring of data Structures.
C’s switch is essentially a multi-way if/elif chain with fall-through semantics. It compares a Single value against compile-time constants. Python’s match/case does something fundamentally Different: it matches the structure of the subject against a pattern, binding names to matched Sub-components.
The design was driven by several considerations:
Python already has dictionary dispatch. The use case for simple value-based switching is already well-served by dictionary dispatch: {"a": func_a, "b": func_b}[key](). Adding a C-style switch would be redundant.
Algebraic data types are increasingly common. Python codebases increasingly use dataclasses``NamedTupleAnd TypedDict to model structured data. Pattern matching provides a natural way to destructure these types.
No fall-through. Fall-through is the most error-prone feature of C’s switch. Every case in Python’s match is exclusive — there is no way to accidentally fall through to the next case. This eliminates an entire class of bugs.
def http_status_text ( code : int ) -> str :
return " Internal Server Error "
The _ is the wildcard pattern that matches anything. It is a common convention to place it last as The default case.
Patterns can bind variables, and if guards add additional conditions:
def describe_point ( point : tuple[ float , float ]) -> str :
return f "on y-axis at y= { y } "
return f "on x-axis at x= { x } "
return f "on diagonal at ( { x } , { y } )"
case (x, y) if x > 0 and y > 0 :
return f "first quadrant at ( { x } , { y } )"
Pattern matching works with class instances, matching by the constructor signature:
from dataclasses import dataclass
def area ( shape : Circle | Rectangle | Triangle) -> float :
case Rectangle( width = w, height = h):
case Triangle( base = b, height = h):
This destructuring works because the pattern matches the keyword arguments of the class constructor. For plain classes (not dataclasses), you need to implement __match_args__ or use positional Patterns:
__match_args__ = ( " x " , " y " )
def __init__ ( self , x : float , y : float ):
print ( f "point at ( { x } , { y } )" )
def process_config ( config : dict ) -> str :
case { " database " : { " host " : str (), " port " : int ()}}:
return " valid database config "
case { " database " : { " host " : str ()}}:
return " database config missing port "
case { " cache " : { " provider " : " redis " , ** rest}}:
return f "redis config with options: { rest } "
return " unknown config structure "
flowchart TD
A[match subject] --> B{case pattern 1}
B -->|Match| C[Execute case 1 body]
B -->|No match| D{case pattern 2}
D -->|Match| E[Execute case 2 body]
D -->|No match| F{case pattern N}
F -->|Match| G[Execute case N body]
F -->|No match| H[No match: proceed to next statement]
C --> I[Continue after match block]
E --> I
G --> I
H --> I the `match` block is skipped entirely -- it does not raise an error. This differs from Rust's `match`Which requires exhaustiveness at compile time.Python’s for loop is fundamentally different from C’s for (init; condition; increment) loop. It Operates on iterables — objects that implement the iterator protocol.
# The 'for' loop is syntactic sugar for this:
# Which the interpreter expands to approximately:
iterator = iter (iterable)
This design means that Python’s for loop can iterate over anything that produces values Sequentially — lists, strings, files, database cursors, generator functions, infinite sequences. The iterator protocol is the universal interface for sequential access in Python.
An object is iterable if it implements __iter__() (returning an iterator) or __getitem__() (for Sequence-style access with integer indices starting at 0). An iterator is an object that implements __next__() (returning the next value) and raises StopIteration when exhausted.
range produces an arithmetic sequence of integers. It is lazy — it does not materialize the Entire sequence in memory, regardless of the size.
# range(start, stop, step)
for i in range ( 0 , 10 , 2 ):
# Negative step: count backwards
for i in range ( 5 , 0 , - 1 ):
# range is a sequence type -- supports 'in' and 'len' in O(1)
print ( 5 in range ( 1000000 )) # True, instant check
print ( len ( range ( 1000000 ))) # 1000000
range objects implement the sequence protocol (__contains__``__len__``__getitem__) with O ( 1 ) O(1) O ( 1 ) membership testing. x in range(n) does not iterate through the range — it computes the Answer directly.
enumerate wraps an iterable and yields (index, value) pairs. It is the Pythonic alternative to Manual counter variables.
words = [ " apple " , " banana " , " cherry " ]
# Unpythonic: manual counter
for i, word in enumerate (words):
for i, word in enumerate (words, start = 1 ):
print ( f " { i } : { word } " ) # 1: apple, 2: banana, ...
zip aggregates elements from multiple iterables into tuples. It stops at the shortest iterable.
names = [ " Alice " , " Bob " , " Charlie " ]
grades = [ " A " , " B+ " , " A- " ]
for name, score, grade in zip (names, scores, grades):
print ( f " { name } : { score } ( { grade } )" )
print ( list ( zip (names, scores)))
# [("Alice", 95), ("Bob", 87), ("Charlie", 92)]
# zip_longest from itertools fills missing values
from itertools import zip_longest
print ( list (zip_longest(names, scores, fillvalue = " N/A " )))
The itertools module provides a collection of fast, memory-efficient tools for working with Iterators. These are building blocks for functional-style programming.
from itertools import chain, islice, cycle, repeat, takewhile, dropwhile, groupby, product, permutations, combinations
# chain: flatten iterables
print ( list (chain([ 1 , 2 ], [ 3 , 4 ], [ 5 , 6 ])))
# islice: slice an iterator
print ( list (islice( range ( 100 ), 5 , 10 )))
# cycle: infinite repetition
# for item in cycle(["A", "B", "C"]):
# print(item) # A, B, C, A, B, C, ...
# repeat: repeat a single value
print ( list (repeat( 42 , 3 )))
# takewhile / dropwhile: conditional iteration
print ( list (takewhile( lambda x : x < 5 , range ( 10 ))))
print ( list (dropwhile( lambda x : x < 5 , range ( 10 ))))
# product: Cartesian product (nested loops as an iterator)
print ( list (product([ 1 , 2 ], [ " a " , " b " ])))
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]
# permutations and combinations
print ( list (permutations([ 1 , 2 , 3 ], 2 )))
# [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]
print ( list (combinations([ 1 , 2 , 3 , 4 ], 2 )))
# [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]
while loops repeat as long as a condition remains truthy. They are appropriate when the number of Iterations is not known in advance.
def estimate_pi ( trials : int ) -> float :
x, y = random.random(), random.random()
return 4.0 * inside / trials
def converge_pi ( target_error : float = 1e-5 ) -> float :
new_estimate = estimate_pi(trials)
if abs (new_estimate - estimate) < target_error and trials > 1000 :
(server main loops, event loops), an accidental infinite loop freezes the program. Always ensure There is a reachable termination condition.Python loops support break (exit the loop immediately), continue (skip to the next iteration), And an else clause that executes only when the loop completes without hitting break.
def find_first_prime ( numbers : list[ int ]) -> int | None :
for i in range ( 2 , int (n ** 0.5 ) + 1 ):
break # not prime, try next number
# This 'else' belongs to the inner 'for' loop.
# It executes only if the loop completed without break.
return None # no prime found
# continue: skip current iteration
def process_positive ( numbers : list[ int ]) -> list[ int ]:
# The else clause on while loops
def binary_search ( sorted_list : list[ int ], target : int ) -> int | None :
low, high = 0 , len (sorted_list) - 1
if sorted_list[mid] == target:
elif sorted_list[mid] < target:
# Executes only when low > high (not found)
The loop else clause is one of Python’s most misunderstood features. It is not analogous to the else in if/else. It executes when the loop condition becomes false (for while) or the iterable Is exhausted (for for), but not when the loop is exited via break. The mental model is: the else clause is the “no break” clause.
flowchart TD
A[while condition] -->|True| B[loop body]
B --> C{break?}
C -->|Yes| D[Skip else, continue after loop]
C -->|No| A
A -->|False| E[Execute else clause]
E --> F[Continue after loop] List comprehensions provide a concise syntax for creating lists from iterables. They are more Readable and often faster than equivalent for loops with append.
squares = [x ** 2 for x in range ( 10 )]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# With a condition (filter)
even_squares = [x ** 2 for x in range ( 10 ) if x % 2 == 0 ]
labels = [ f "item_ { i } " for i in range ( 5 )]
# ["item_0", "item_1", "item_2", "item_3", "item_4"]
# Nested comprehension (flattening a matrix)
matrix = [[ 1 , 2 , 3 ], [ 4 , 5 , 6 ], [ 7 , 8 , 9 ]]
flat = [element for row in matrix for element in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]
The execution order of nested comprehensions follows the same left-to-right reading order as nested for loops. The first for is the outer loop, the second for is the inner loop.
Expressions. A comprehension over a billion-element range would consume all available memory.original = { " a " : 1 , " b " : 2 , " c " : 3 }
inverted = {v: k for k, v in original.items()}
# {1: "a", 2: "b", 3: "c"}
prices = { " apple " : 1.2 , " banana " : 0.8 , " cherry " : 2.5 }
with_tax = {item: round (price * 1.1 , 2 ) for item, price in prices.items()}
# From two parallel iterables
mapping = {k: v for k, v in zip (keys, values)}
words = [ " hello " , " world " , " python " , " code " , " rust " ]
lengths = { len (word) for word in words}
# Remove duplicates while transforming
data = [ 1 , 2 , 2 , 3 , 3 , 3 , 4 ]
unique_squares = {x ** 2 for x in data}
Generator expressions have the same syntax as list comprehensions but use parentheses instead of Brackets. They produce values lazily, one at a time, and do not store the entire result in memory.
# List comprehension: creates full list in memory
squares_list = [x ** 2 for x in range ( 1000000 )]
# Generator expression: produces values on demand
squares_gen = (x ** 2 for x in range ( 1000000 ))
# Generator expressions are consumed by iteration
total = sum (x ** 2 for x in range ( 1000000 )) # no intermediate list
# Passing a generator to functions that accept iterables
max_root = max (math.sqrt(x) for x in range ( 100 ))
# Chaining generator expressions
result = (x for x in range ( 100 ) if x % 2 == 0 )
result = (x * 2 for x in result)
result = (x + 1 for x in result)
print ( list (result)) # [1, 5, 9, 13, ...]
`sum(x**2 for x in range(100))` is valid. The generator expression syntax `(x**2 for x in range(100))` is required in all other contexts.Comprehensions have their own local scope in Python 3. Variables assigned inside a comprehension do Not leak into the enclosing scope (this was a change from Python 2, where list comprehensions leaked The loop variable).
# Python 3: comprehension has its own scope
[x * 2 for x in range ( 5 )]
print (x) # "before" -- x is unchanged
# The iteration variable is local to the comprehension
result = [y for y in range ( 10 )]
# 'y' does not exist here in Python 3
The assignment expression (walrus operator, PEP 572, Python 3.8+) allows you to assign a value to a Variable as part of an expression. This eliminates the need for separate assignment statements in Cases where you want to both use a value and give it a name.
# Without walrus: two steps
if (data := get_data()) is not None :
# Filtering with computation (avoiding double evaluation)
results = [y for x in data if (y := expensive_transform(x)) is not None ]
# While loop with inline update
while chunk := file .read( 8192 ):
# Reuse in multiple conditions
if (match := pattern.search(text)) and match.group( 1 ).isdigit():
number = int (match.group( 1 ))
The walrus operator has lower precedence than most operators but higher than commas. Parentheses are Required in comprehensions and if/while conditions.
Computation or awkward workarounds. It harms clarity when it makes a single line do too much. The Guiding principle: use it when it eliminates a clear redundancy, not just to save a line.Python’s exception handling mechanism is the primary error-handling idiom. Unlike return codes or Error objects, exceptions decouple error detection from error handling — the function that detects The error does not need to know how to handle it.
def read_config ( path : str ) -> dict :
except FileNotFoundError :
raise ConfigError( f "Config file not found: { path } " )
except json.JSONDecodeError as e:
raise ConfigError( f "Invalid JSON in config file: { path } " ) from e
# Executes only if no exception was raised
# Useful for code that should only run on success
log.info( f "Config loaded from { path } " )
# Always executes, regardless of exceptions
# Useful for cleanup that must happen no matter what
The four clauses have distinct roles:
Clause Executes when Purpose exceptThe specified exception is raised Handle the error, recover, or re-raise elseNo exception is raised in the try block Code that depends on the try succeeding finallyAlways, even if an exception is unhandled Cleanup that must happen regardless
The else clause exists to prevent a subtle bug: catching an exception that was raised by the Error-handling code itself, not by the code you intended to protect.
# BUG : if json.load succeeds but log.info raises an exception,
# the except catches it, masking the real problem
log.info( " loaded successfully " ) # this is protected too
except json.JSONDecodeError:
# CORRECT: the else clause separates success code from protected code
except json.JSONDecodeError:
log.info( " loaded successfully " ) # only runs if json.load succeeded
All built-in exceptions inherit from BaseException. The hierarchy matters because except clauses Catch the specified exception and all its subclasses.
flowchart TD
BE["BaseException"]
BE --> SysE["SystemExit"]
BE --> KE["KeyboardInterrupt"]
BE --> GE["GeneratorExit"]
BE --> E["Exception"]
E --> SE["StopIteration"]
E --> AE["ArithmeticError"]
AE --> ZE["ZeroDivisionError"]
AE --> OE["OverflowError"]
E --> LE["LookupError"]
LE --> KE2["KeyError"]
LE --> IE["IndexError"]
E --> TE["TypeError"]
E --> VE["ValueError"]
E --> AE2["AttributeError"]
E --> NE["NameError"]
E --> FE["FileNotFoundError"]
VE --> JE["JSONDecodeError"] Or `except Exception` without careful consideration. Catching too broadly masks real errors and Makes debugging extremely difficult. Catch the most specific exception possible.class AppError ( Exception ):
"""Base class for all application exceptions."""
class ConfigError ( AppError ):
"""Raised when configuration is invalid or missing."""
class NetworkError ( AppError ):
"""Raised when a network operation fails."""
class ValidationError ( AppError ):
"""Raised when input validation fails."""
def __init__ ( self , field : str , message : str ):
super (). __init__ ( f "Validation failed for ' { field } ': { message } " )
Custom exceptions should inherit from Exception (not BaseException). Group related exceptions Under a common base class so callers can catch the entire category:
result = perform_operation()
log.error( f "Unexpected application error: { e } " )
Python 3 supports explicit exception chaining, which preserves the original cause when raising a new Exception.
def fetch_user ( user_id : int ) -> dict :
response = requests.get( f "https://api.example.com/users/ { user_id } " )
response.raise_for_status()
except requests.HTTPError as e:
raise NetworkError( f "Failed to fetch user { user_id } " ) from e
except requests.ConnectionError as e:
raise NetworkError( f "Cannot connect to API" ) from e
The from e clause sets __cause__ on the new exception, creating an explicit chain. The full Traceback includes both the original and the wrapping exception, making diagnosis straightforward.
# Explicit chaining: raise NewError from original_error
# __cause__ is set to original_error
# Implicit chaining: raise NewError inside an except block
# __context__ is set to the caught exception automatically
# Suppress chaining: raise NewError from None
# No __cause__ or __context__ is set
# Use this when the new exception is self-explanatory
flowchart TD
A["Code raises exception"] --> B{"except matches?"}
B -->|Yes| C["Execute except handler"]
C --> D{"else clause?"}
D -->|Yes| E["Execute else clause"]
D -->|No| F{"finally clause?"}
E --> F
F -->|Yes| G["Execute finally clause"]
F -->|No| H["Continue after try block"]
G --> H
B -->|No| I{"outer except?"}
I -->|Yes| C2["Handle in outer scope"]
I -->|No| J["Uncaught: print traceback, exit"]
C2 --> F2{"finally in that scope?"}
F2 -->|Yes| G2["Execute finally clause"]
F2 -->|No| K["Continue"]
G2 --> K Assertions are debugging aids that check conditions that should be true. They are not for data Validation or error handling.
def binary_search ( arr : list[ int ], target : int ) -> int :
low, high = 0 , len (arr) - 1
assert 0 <= mid < len (arr), f "mid= { mid } out of bounds"
Input validation or security checks. Use explicit `if/raise` for conditions that must be checked in Production.Context managers manage resources that need explicit setup and teardown. The with statement Guarantees that cleanup code runs regardless of whether the block succeeds or raises an exception.
# File handling: the file is closed even if an exception occurs
with open ( " data.txt " , " r " ) as f:
# f.close() is called automatically here
# Lock management: the lock is released even on exception
from threading import Lock
# lock.release() is called automatically here
The with statement calls __enter__() on the context manager when entering the block and __exit__(exc_type, exc_val, exc_tb) when leaving. The __exit__ method receives the exception Information if an exception was raised, allowing it to suppress the exception by returning True.
def __init__ ( self , name : str ):
self .elapsed: float = 0.0
self .start = time.perf_counter()
def __exit__ ( self , exc_type , exc_val , exc_tb ):
self .elapsed = time.perf_counter() - self .start
print ( f " { self .name } : { self .elapsed :.4f } s" )
return False # do not suppress exceptions
with Timer( " data processing " ):
The contextlib module provides utilities for creating and composing context managers.
The @contextmanager decorator turns a generator function into a context manager. The code before yield runs on entry, the code after yield runs on exit.
from contextlib import contextmanager
def temporary_directory ():
"""Create a temporary directory that is cleaned up on exit."""
tmpdir = tempfile.mkdtemp()
with temporary_directory() as tmpdir:
# tmpdir exists and is usable
# tmpdir is deleted here, even if write_files raised an exception
suppress provides a cleaner way to ignore specific exceptions, replacing the common pattern of Empty except blocks.
from contextlib import suppress
os.remove( " temp_file.txt " )
except FileNotFoundError :
with suppress( FileNotFoundError ):
os.remove( " temp_file.txt " )
closing calls the close() method on any object that has one, ensuring cleanup.
from contextlib import closing
with closing(urllib.request.urlopen( " https://example.com " )) as response:
Python supports entering multiple context managers in a single with statement:
with open ( " input.txt " ) as infile, open ( " output.txt " , " w " ) as outfile:
outfile.write(infile.read())
# For long lines, use parentheses
open ( " input.txt " ) as infile,
open ( " output.txt " , " w " ) as outfile,
outfile.write(infile.read())
Languages like C++ use RAII (Resource Acquisition Is Initialization) — destructors run Automatically when objects go out of scope. Python does not use RAII because:
Garbage collection is non-deterministic. Python uses reference counting with a cycle-detecting garbage collector. Objects are not destroyed at a predictable time. An object’s __del__ method (Python’s equivalent of a destructor) may run long after the object becomes unreachable, or not at all if it is part of a reference cycle.
Explicit is better than implicit. The with statement makes resource acquisition and release visible in the code structure. A reader can see exactly where resources are managed without tracing object lifetimes.
Exceptions require explicit handling. RAII destructors cannot distinguish between normal scope exit and exception-propagated scope exit without additional machinery. The __exit__ method receives exception information directly.
# This is unreliable -- __del__ may run much later, or not at all
self .close() # may never be called if part of a reference cycle
# This is reliable -- cleanup happens at a well-defined point
def __exit__ ( self , * args ):
Other resource that requires explicit cleanup. Never rely on `__del__` or the garbage collector for Resource management.Control flow is the road system of your program. if/elif/else are intersections where you choose which road to take. A for loop is a conveyor belt — items arrive one at a time and you process each one. The iterator protocol is the rule that the conveyor belt must follow: keep producing items until you run out. while loops are like waiting rooms — you stay until a condition changes. Pattern matching in Python 3.10 is not just a fancy switch statement — it is like a sorting machine that unpacks boxes and routes each piece to the right bin based on what is inside. Context managers are the RAII of Python — they guarantee cleanup happens even when exceptions occur, like a lifeguard who watches you swim and pulls you out regardless of whether you finish or drown.
Confusing authentication (who you are) with authorisation (what you can do) in security contexts.
Forgetting edge cases in algorithm design (e.g., empty input, single element, already sorted data).
Neglecting to normalise database designs, leading to data redundancy and update anomalies.
Confusing an algorithm with a program. An algorithm is a step-by-step procedure, not its implementation in code.
The key principles covered in this topic are linked in the sub-pages above. Focus on understanding the definitions, applying the formulas or frameworks, and evaluating strengths and limitations of each approach.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Types and Variables — Boolean truthiness, None checks, and the bool protocol determine how conditions evaluate in control flow.Collections — Iterating over lists, dicts, and sets in for loops uses the iterator protocol that underpins all collection types.Python Internals — The bytecode instructions generated by if/else and for loops are visible in the dis module output.Dicts, Sets, and Collections Deep Dive — Pattern matching and dictionary dispatch relate to the dict-based dispatch patterns in collections.