Python Review 2026: 21 Essential Examples
For Loops with Index
Python's enumerate() is the idiomatic way to get both the index and the value while iterating. You can also use range(len(...)), but it's less Pythonic.
# Method 1: enumerate (preferred) fruits = ["apple", "banana", "cherry"] for i, fruit in enumerate(fruits): print(f"{i}: {fruit}") # Start index at 1 for i, fruit in enumerate(fruits, start=1): print(f"{i}. {fruit}") # 1. apple # 2. banana # 3. cherry
# Method 2: range(len(...)) — works but less idiomatic for i in range(len(fruits)): print(f"{i}: {fruits[i]}") # Method 3: zip with range for parallel iteration names = ["Alice", "Bob", "Charlie"] scores = [95, 82, 88] for i, (name, score) in enumerate(zip(names, scores)): print(f"{i}: {name} scored {score}") # 0: Alice scored 95 # 1: Bob scored 82 # 2: Charlie scored 88
Arrays in Python
Python doesn't have a built-in array type the way Java or C do. The list is the default go-to. For typed, memory-efficient arrays Python has array.array in the stdlib, and numpy.ndarray for numerical work.
# Lists — the default "array" in Python (heterogeneous, dynamic) my_list = [1, 2, 3, "hello", 3.14] my_list.append(4) print(my_list[0]) # 1 print(my_list[-1]) # 4 print(my_list[1:3]) # [2, 3]
# array.array — typed, compact storage (single type only) from array import array int_arr = array("i", [10, 20, 30]) # "i" = signed int int_arr.append(40) print(int_arr[2]) # 30 # int_arr.append("hello") # TypeError — enforces the type code
# numpy.ndarray — for numerical / scientific work import numpy as np arr = np.array([1, 2, 3, 4]) print(arr * 2) # [2 4 6 8] — element-wise print(arr.mean()) # 2.5
list unless you need performance with millions of homogeneous numbers, in which case reach for array.array or numpy.Access Modifiers
Python has no enforced public/private/protected keywords like Java. Instead it uses naming conventions: a single underscore _name signals "internal — don't touch", and a double underscore __name triggers name mangling. Everything is technically accessible.
class Account: def __init__(self, owner, balance): self.owner = owner # "public" — no underscore self._currency = "USD" # "protected" — single underscore (convention) self.__balance = balance # "private" — double underscore (name-mangled) def get_balance(self): return self.__balance acc = Account("Alice", 1000) print(acc.owner) # Alice — fully accessible print(acc._currency) # USD — accessible but "please don't" print(acc.get_balance()) # 1000 — via getter # print(acc.__balance) # AttributeError! print(acc._Account__balance) # 1000 — name mangling lets you bypass
Name Mangling
When you prefix an attribute with double underscores (e.g. __name), Python internally renames it to _ClassName__name. This is called name mangling. Its purpose is to avoid accidental name collisions in subclasses, not to enforce privacy.
class Person: def __init__(self, name): self.__name = name # stored as _Person__name internally p = Person("Alice") # Direct access fails try: print(p.__name) except AttributeError as e: print(f"Error: {e}") # Error: 'Person' object has no attribute '__name' # Name-mangled access works print(p._Person__name) # Alice # You can see the mangled name in __dict__ print(p.__dict__) # {'_Person__name': 'Alice'}
# WHY it exists — avoiding collisions in subclasses class Base: def __init__(self): self.__value = 10 # _Base__value class Child(Base): def __init__(self): super().__init__() self.__value = 20 # _Child__value (separate!) c = Child() print(c._Base__value) # 10 — parent's version print(c._Child__value) # 20 — child's version
Generators
A generator is a function that yields values one at a time using yield, producing items lazily instead of building the entire collection in memory.
# Basic generator function def countdown(n): while n > 0: yield n n -= 1 for num in countdown(5): print(num, end=" ") # 5 4 3 2 1
# Generator expression (like a list comprehension, but lazy) squares = (x**2 for x in range(1_000_000)) print(next(squares)) # 0 print(next(squares)) # 1 print(next(squares)) # 4
# Practical: reading a huge file line by line def read_lines(filepath): with open(filepath) as f: for line in f: yield line.strip() # Infinite generator def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() print([next(fib) for _ in range(10)]) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Map API
Python has the built-in map() function that applies a function to every element of an iterable. It returns a lazy iterator.
# map() basics nums = [1, 2, 3, 4] doubled = map(lambda x: x * 2, nums) print(list(doubled)) # [2, 4, 6, 8] # With a named function def to_upper(s): return s.upper() names = ["alice", "bob", "charlie"] print(list(map(to_upper, names))) # ['ALICE', 'BOB', 'CHARLIE']
# map() with multiple iterables a = [1, 2, 3] b = [10, 20, 30] print(list(map(lambda x, y: x + y, a, b))) # [11, 22, 33]
# Often a list comprehension is more Pythonic: result = [x * 2 for x in nums] # same as map(lambda x: x*2, nums) # dict: Python's built-in hash map / dictionary d = {"name": "Alice", "age": 30} d["email"] = "alice@example.com" print(d.get("name")) # Alice
Inheritance
Python supports single, multiple, and multilevel inheritance. All classes implicitly inherit from object.
# Single inheritance class Animal: def __init__(self, name): self.name = name def speak(self): return "..." class Dog(Animal): def speak(self): return f"{self.name} says Woof!" class Cat(Animal): def speak(self): return f"{self.name} says Meow!" print(Dog("Rex").speak()) # Rex says Woof! print(isinstance(Dog("Rex"), Animal)) # True
# Multiple inheritance class Flyable: def fly(self): return f"{self.name} is flying" class Swimmable: def swim(self): return f"{self.name} is swimming" class Duck(Animal, Flyable, Swimmable): def speak(self): return f"{self.name} says Quack!" donald = Duck("Donald") print(donald.speak()) # Donald says Quack! print(donald.fly()) # Donald is flying print(donald.swim()) # Donald is swimming
# super() for calling parent methods class Puppy(Dog): def __init__(self, name, toy): super().__init__(name) self.toy = toy def speak(self): return super().speak() + " (tiny woof)" p = Puppy("Tiny", "ball") print(p.speak()) # Tiny says Woof! (tiny woof)
MRO & C3 Linearization
MRO (Method Resolution Order) is the order Python searches classes when you call a method. Python uses the C3 linearization algorithm to compute a consistent, deterministic MRO for multiple inheritance, respecting both local precedence and monotonicity.
class A: def who(self): return "A" class B(A): def who(self): return "B" class C(A): def who(self): return "C" class D(B, C): pass # Check the MRO print(D.__mro__) # (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>) # D has no who(), so Python walks: D → B → found "B" print(D().who()) # B
# C3 algorithm rules (simplified): # 1. The class itself comes first # 2. Then merge the MROs of parents (left to right) # 3. A class only appears once, and before its parents # 4. If ordering is ambiguous/inconsistent → TypeError # Diamond problem resolved cleanly: class Base: def setup(self): print("Base.setup") class Left(Base): def setup(self): print("Left.setup") super().setup() class Right(Base): def setup(self): print("Right.setup") super().setup() class Child(Left, Right): def setup(self): print("Child.setup") super().setup() Child().setup() # Child.setup # Left.setup # Right.setup # Base.setup ← called only ONCE thanks to C3
Type Enforcement
Python is dynamically typed by default. You can add type hints (PEP 484) for documentation and static analysis, but they're not enforced at runtime unless you use tools like mypy or runtime checkers like pydantic or beartype.
# Type hints — NOT enforced at runtime def greet(name: str, times: int) -> str: return name * times print(greet("Hi", 3)) # HiHiHi print(greet(42, 2)) # 84 — no error! Python doesn't enforce it
# Manual enforcement with isinstance def add(a, b): if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("Both arguments must be numbers") return a + b
# Runtime enforcement with dataclasses + __post_init__ from dataclasses import dataclass @dataclass class Point: x: float y: float def __post_init__(self): if not isinstance(self.x, (int, float)): raise TypeError(f"x must be a number, got {type(self.x)}")
# Pydantic — the gold standard for runtime validation from pydantic import BaseModel class User(BaseModel): name: str age: int email: str user = User(name="Alice", age=30, email="a@b.com") # OK # User(name="Alice", age="not a number", email="a@b.com") # ValidationError!
Exception Rethrow
Yes. Use bare raise to re-raise the current exception, preserving the original traceback. You can also chain exceptions with raise ... from ....
# Basic rethrow with bare raise def process(data): try: result = 100 / data except ZeroDivisionError: print("Logging: division by zero occurred") raise # re-raises the ZeroDivisionError with original traceback try: process(0) except ZeroDivisionError as e: print(f"Caught: {e}") # Logging: division by zero occurred # Caught: division by zero
# Exception chaining: raise ... from ... class AppError(Exception): pass def load_config(path): try: with open(path) as f: return f.read() except FileNotFoundError as e: raise AppError(f"Config missing: {path}") from e # Original exception is preserved as __cause__
# Suppress original chain: raise ... from None try: int("abc") except ValueError: raise RuntimeError("Invalid input") from None # Clean traceback without "During handling of..." message
HashSet
Python's built-in set is a hash set — an unordered collection of unique, hashable elements with O(1) average-case lookups.
# Creating sets s = {1, 2, 3, 3, 2} print(s) # {1, 2, 3} — duplicates removed empty = set() # NOT {} — that's an empty dict from_list = set([1, 2, 2, 3]) print(from_list) # {1, 2, 3}
# Common operations s.add(4) s.discard(2) # remove (no error if missing) print(3 in s) # True — O(1) membership test # Set operations a = {1, 2, 3} b = {2, 3, 4} print(a | b) # {1, 2, 3, 4} — union print(a & b) # {2, 3} — intersection print(a - b) # {1} — difference print(a ^ b) # {1, 4} — symmetric difference
# Practical: removing duplicates while preserving order items = ["b", "a", "c", "a", "b"] seen = set() unique = [] for item in items: if item not in seen: seen.add(item) unique.append(item) print(unique) # ['b', 'a', 'c']
Closures
A closure is a nested function that captures and remembers variables from its enclosing scope, even after that scope has finished executing.
def make_multiplier(factor): def multiply(x): return x * factor # 'factor' is captured from the enclosing scope return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15 # The closure "remembers" factor even after make_multiplier returned print(double.__closure__[0].cell_contents) # 2
# Closure with mutable state (use nonlocal) def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = make_counter() print(counter()) # 1 print(counter()) # 2 print(counter()) # 3
Closure Use Cases
Closures shine when you need lightweight, stateful functions without creating a full class.
# 1. Function factories def make_formatter(prefix, suffix): def fmt(text): return f"{prefix}{text}{suffix}" return fmt html_bold = make_formatter("<b>", "</b>") print(html_bold("hello")) # <b>hello</b>
# 2. Callbacks / event handlers def make_handler(action): def handler(event): print(f"Handling {action} for event: {event}") return handler on_click = make_handler("click") on_click("button_submit") # Handling click for event: button_submit
# 3. Memoization / caching def memoize(func): cache = {} def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapper @memoize def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(50)) # 12586269025 — instant with memoization
# 4. Configuration / partial application def make_logger(level): def log(message): print(f"[{level.upper()}] {message}") return log warn = make_logger("warning") warn("disk space low") # [WARNING] disk space low
Decorators
A decorator is a function that wraps another function to extend its behavior. It's syntactic sugar built on closures. The @decorator syntax is equivalent to func = decorator(func).
# Basic decorator import time from functools import wraps def timer(func): @wraps(func) # preserves func.__name__, __doc__, etc. def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed:.4f}s") return result return wrapper @timer def slow_add(a, b): time.sleep(0.1) return a + b print(slow_add(2, 3)) # slow_add took 0.1003s # 5
# Decorator WITH arguments def repeat(n): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def say_hello(name): print(f"Hello, {name}!") say_hello("Alice") # Hello, Alice! # Hello, Alice! # Hello, Alice!
# Stacking decorators def bold(func): @wraps(func) def wrapper(*a, **kw): return f"<b>{func(*a, **kw)}</b>" return wrapper def italic(func): @wraps(func) def wrapper(*a, **kw): return f"<i>{func(*a, **kw)}</i>" return wrapper @bold @italic def greet(name): return f"Hello, {name}" print(greet("World")) # <b><i>Hello, World</i></b>
Multithreading & Multiprocessing
Threading uses OS threads sharing the same memory space — good for I/O-bound work but limited by the GIL for CPU-bound tasks. Multiprocessing spawns separate processes with their own memory — true parallelism for CPU-bound work.
# Threading — good for I/O-bound tasks import threading import time def download(url): print(f"Downloading {url}...") time.sleep(1) # simulate I/O print(f"Done: {url}") urls = ["page1.html", "page2.html", "page3.html"] threads = [threading.Thread(target=download, args=(u,)) for u in urls] for t in threads: t.start() for t in threads: t.join() # All 3 run concurrently — total ≈ 1s, not 3s
# Multiprocessing — good for CPU-bound tasks from multiprocessing import Pool def heavy_math(n): return sum(i * i for i in range(n)) with Pool(4) as pool: results = pool.map(heavy_math, [10_000_000] * 4) print(results) # 4 results computed in parallel
# asyncio — async/await for high-concurrency I/O import asyncio async def fetch(name, delay): print(f"Start {name}") await asyncio.sleep(delay) print(f"Done {name}") return name async def main(): results = await asyncio.gather( fetch("A", 2), fetch("B", 1), fetch("C", 1.5), ) print(results) # ['A', 'B', 'C'] asyncio.run(main()) # All 3 run concurrently — total ≈ 2s
GIL Removal
The GIL (Global Interpreter Lock) has been Python's biggest concurrency limitation. As of Python 3.13 (2024), an experimental free-threaded build (PEP 703) ships that can disable the GIL. It's opt-in and not yet the default.
# The GIL means only one thread executes Python bytecode at a time.
# This is why threading doesn't speed up CPU-bound code:
import threading, time
def count(n):
total = 0
for i in range(n):
total += i
return total
# Even with 4 threads, this is NOT faster than sequential
# because the GIL serializes the Python bytecode execution.
# Use multiprocessing for CPU-bound work instead.
# Python 3.13+ free-threaded mode:
# Install: python3.13t (the "t" suffix)
# Run: python3.13t -Xgil=0 script.py
# Or set: PYTHON_GIL=0multiprocessing and asyncio remain the practical solutions.Recent Python Features (3.8 – 3.13)
A high-level tour of the most impactful additions across recent Python versions.
# 3.8 — Walrus operator (:=) data = [1, 2, 3, 4, 5, 6] big = [x for x in data if (sq := x**2) > 10] print(big) # [4, 5, 6] # 3.8 — Positional-only parameters (see section 21) def f(a, b, /, c): ... # 3.9 — Dict merge operators a = {"x": 1}; b = {"y": 2} merged = a | b # {'x': 1, 'y': 2} a |= b # in-place merge # 3.9 — Built-in generics (no more typing.List) def first(items: list[int]) -> int: return items[0]
# 3.10 — Structural pattern matching (see section 21) match command: case "quit": ... case "go", direction: ... # 3.10 — Union type with | def square(n: int | float) -> int | float: return n ** 2 # 3.11 — Exception groups & except* try: raise ExceptionGroup("errors", [ValueError("a"), TypeError("b")]) except* ValueError as eg: print("Caught ValueErrors:", eg.exceptions)
# 3.12 — Type parameter syntax (see section 19) def first[T](items: list[T]) -> T: return items[0] # 3.12 — Improved f-strings (nested quotes allowed) matrix = [[1, 2], [3, 4]] print(f"Flat: {[x for row in matrix for x in row]}") # 3.13 — Free-threaded mode (experimental, see section 16) # 3.13 — Improved error messages, better REPL
f-strings
f-strings (formatted string literals, PEP 498, Python 3.6+) let you embed expressions inside string literals using {} brackets. They're fast, readable, and the preferred way to format strings.
# Basics name = "Alice" age = 30 print(f"{name} is {age} years old") # Alice is 30 years old # Expressions inside braces print(f"{name.upper()} will be {age + 1} next year") # ALICE will be 31 next year
# Format specifiers pi = 3.14159265 print(f"Pi ≈ {pi:.2f}") # Pi ≈ 3.14 print(f"Big: {1000000:,}") # Big: 1,000,000 print(f"Percent: {0.856:.1%}") # Percent: 85.6% print(f"Padded: {42:05d}") # Padded: 00042 print(f"Left: '{name:<10}'") # Left: 'Alice ' print(f"Right: '{name:>10}'") # Right: ' Alice'
# Debug format (3.8+) — prints expression AND value x = 42 print(f"{x = }") # x = 42 print(f"{x * 2 = }") # x * 2 = 84 # Multiline f-strings record = {"name": "Alice", "dept": "Engineering"} msg = ( f"Employee: {record['name']}\n" f"Department: {record['dept']}" ) print(msg)
New Type-Parameter Syntax (3.12)
Python 3.12 introduced PEP 695 — a cleaner syntax for generics. Instead of importing TypeVar and declaring it separately, you declare type parameters inline with square brackets.
# OLD way (pre-3.12) — verbose from typing import TypeVar, Generic T = TypeVar("T") def first_old(items: list[T]) -> T: return items[0] class Stack_Old(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop()
# NEW way (3.12+) — clean and inline def first[T](items: list[T]) -> T: return items[0] class Stack[T]: def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop() s = Stack[int]() s.push(1)
# Bounded type parameters def max_val[T: (int, float)](a: T, b: T) -> T: return a if a > b else b # Type aliases (also new syntax) type Point = tuple[float, float] type Matrix[T] = list[list[T]]
Recap of 3.8 / 3.9 / 3.10
Python 3.8 highlights:
# Walrus operator (:=) — assign inside expressions while (line := input("> ")) != "quit": print(f"You said: {line}") # Positional-only parameters def pow(base, exp, /, mod=None): ... # base and exp must be positional # f-string = for debugging x = 10 print(f"{x = }, {x**2 = }") # x = 10, x**2 = 100 # typing.Final and typing.Literal from typing import Final, Literal MAX: Final = 100 def direction(d: Literal["N", "S", "E", "W"]): ...
Python 3.9 highlights:
# Built-in generics — no more from typing import List, Dict def process(items: list[int]) -> dict[str, int]: return {str(i): i for i in items} # Dictionary merge (|) and update (|=) defaults = {"color": "blue", "size": "M"} custom = {"size": "L", "qty": 2} config = defaults | custom # {'color': 'blue', 'size': 'L', 'qty': 2} # String removeprefix / removesuffix "HelloWorld".removeprefix("Hello") # "World" "test_file.py".removesuffix(".py") # "test_file"
Python 3.10 highlights:
# Structural pattern matching (full examples in section 21) match status: case 200: print("OK") case 404: print("Not Found") case _: print("Other") # Union type shorthand: int | str instead of Union[int, str] def display(val: int | str) -> None: print(val) # Parenthesized context managers with ( open("in.txt") as src, open("out.txt", "w") as dst, ): dst.write(src.read()) # Improved error messages # "Did you mean 'append'?" when you typo 'lst.apend()'
Positional-Only Parameters & Pattern Matching
Positional-only parameters (3.8+) — parameters before / in the signature can only be passed positionally, never as keyword arguments.
# Syntax: def f(pos_only, /, normal, *, kw_only) def divide(a, b, /, *, round_result=False): result = a / b return round(result) if round_result else result divide(10, 3) # OK divide(10, 3, round_result=True) # OK # divide(a=10, b=3) # TypeError! a and b are positional-only
# Why use it? # 1. Parameter names become an implementation detail — you can rename freely # 2. Avoids conflicts when **kwargs collects keyword arguments # 3. Matches how builtins work (len, range, etc.) def my_func(name, /, **kwargs): print(name, kwargs) my_func("Alice", name="override") # Alice {'name': 'override'} # No conflict! 'name' as positional vs 'name' in kwargs
Pattern matching (3.10+) — Python's match/case goes far beyond a switch statement. It destructures, binds variables, and matches against types and structures.
# Literal matching def http_status(code): match code: case 200: return "OK" case 301 | 302: # OR pattern return "Redirect" case 404: return "Not Found" case _: # wildcard (default) return "Unknown"
# Sequence destructuring def handle_command(command): match command.split(): case ["go", direction]: print(f"Moving {direction}") case ["pick", "up", item]: print(f"Picking up {item}") case ["attack", *targets]: print(f"Attacking: {', '.join(targets)}") handle_command("go north") # Moving north handle_command("pick up sword") # Picking up sword handle_command("attack orc dragon") # Attacking: orc, dragon
# Mapping (dict) patterns def process_event(event): match event: case {"type": "click", "x": x, "y": y}: print(f"Click at ({x}, {y})") case {"type": "keypress", "key": k}: print(f"Key pressed: {k}") process_event({"type": "click", "x": 50, "y": 100}) # Click at (50, 100)
# Class patterns with guards from dataclasses import dataclass @dataclass class Point: x: float y: float def describe(point): match point: case Point(x=0, y=0): return "Origin" case Point(x=x, y=0): return f"On x-axis at {x}" case Point(x=x, y=y) if x == y: # guard clause return f"On diagonal at {x}" case Point(x=x, y=y): return f"Point({x}, {y})" print(describe(Point(0, 0))) # Origin print(describe(Point(5, 0))) # On x-axis at 5 print(describe(Point(3, 3))) # On diagonal at 3 print(describe(Point(1, 7))) # Point(1, 7)