Python

Python is a high-level, dynamically typed language that optimises for readability — its whole design philosophy is that code is read far more often than it is written. This reference walks from how a Python program actually runs through the features that make the language distinctive: comprehensions, generators, decorators, and the concurrency story shaped by the GIL.

How Python runs

Python is often called an "interpreted" language, but that is only half the story. When you run a program, the reference implementation — CPython — first compiles your source into an intermediate bytecode, then executes that bytecode on a virtual machine.

  1. Source (.py) — the code you write.
  2. Bytecode (.pyc) — a lower-level, platform-independent representation. CPython caches it in a __pycache__ directory so unchanged modules skip recompilation on the next run.
  3. Python Virtual Machine (PVM) — a loop that reads and executes the bytecode instructions one at a time.

CPython is the standard implementation, written in C. Alternatives exist for different trade-offs: PyPy uses a just-in-time compiler for speed, Jython targets the JVM, and MicroPython runs on microcontrollers. Unless stated otherwise, "Python" in practice means CPython.

Syntax, indentation, and variables

Python has no braces and no semicolons. Blocks are defined by indentation — consistent whitespace is not a style choice, it is the syntax. A colon opens a block and the indented lines beneath it form the body.

# Indentation defines the block — no braces
def greet(name):
    if name:
        print(f"Hello, {name}")   # f-string interpolation
    else:
        print("Hello, stranger")

greet("Ada")   # Hello, Ada

Variables are just names bound to objects — there is no declaration keyword and no fixed type. Assignment creates the binding; reassigning to a value of another type is perfectly legal.

x = 5        # x refers to an int
x = "five"   # now the same name refers to a str — no error

a = b = 0            # chained assignment
first, *rest = [1, 2, 3, 4]   # unpacking: first=1, rest=[2, 3, 4]

Data types and dynamic typing

Python is dynamically typed (types are checked at runtime, not compile time) and strongly typed (it will not silently coerce a string into a number). Every value is an object with a type you can inspect with type().

  1. Numbersint (arbitrary precision), float, complex.
  2. Textstr, an immutable sequence of Unicode characters.
  3. BooleansTrue / False (a subtype of int).
  4. None — the single null-like value, of type NoneType.
  5. Collectionslist, tuple, dict, set (covered below).

Mutability is the key distinction to internalise: int, str, tuple, and frozenset are immutable, while list, dict, and set are mutable. It drives how objects behave when passed to functions or used as dictionary keys.

Type hints

Since Python 3.5 you can annotate types. Hints are optional and ignored at runtime — the interpreter does not enforce them — but tools like mypy and IDEs use them to catch bugs statically.

def add(a: int, b: int) -> int:
    return a + b

names: list[str] = ["Ada", "Alan"]

# The hint is not enforced — this runs fine, mypy would flag it
add("x", "y")

Conditions and loops

Conditionals use if/elif/else. Truthiness is generous: empty collections, 0, "", and None are all falsy.

n = 7
if n % 2 == 0:
    print("even")
elif n % 3 == 0:
    print("divisible by 3")
else:
    print("other")

# Ternary expression
label = "even" if n % 2 == 0 else "odd"

Python's for loop iterates over any iterable rather than counting an index. Use range() when you genuinely need numbers, and enumerate() when you need both the index and the value.

for fruit in ["apple", "pear"]:
    print(fruit)

for i in range(3):        # 0, 1, 2
    print(i)

for i, fruit in enumerate(["apple", "pear"]):
    print(i, fruit)       # 0 apple / 1 pear

# while with else — the else runs if the loop finished without break
while n > 0:
    n -= 1
else:
    print("done")

Functions, *args/**kwargs, and scope

Functions are first-class objects — they can be passed around, returned, and assigned. Arguments can be positional or keyword, with defaults, and two special forms capture arbitrary numbers of each.

def connect(host, port=5432, *args, **kwargs):
    # *args   -> tuple of extra positional arguments
    # **kwargs -> dict of extra keyword arguments
    print(host, port, args, kwargs)

connect("db", 5433, "extra", timeout=30)
# db 5433 ('extra',) {'timeout': 30}

# Lambdas are single-expression anonymous functions
square = lambda x: x * x

Scope follows the LEGB rule — a name is resolved by searching Local, then Enclosing, then Global, then Built-in scopes in that order. To rebind a name from an outer scope you must declare it global or nonlocal.

Gotcha: default argument values are evaluated once, when the function is defined — so a mutable default like [] is shared across calls. Use None as the sentinel instead.

# Wrong — the list persists between calls
def append(x, acc=[]):
    acc.append(x)
    return acc

# Right
def append(x, acc=None):
    if acc is None:
        acc = []
    acc.append(x)
    return acc

Lists, tuples, dicts, and sets

Four built-in collections cover most needs. Choosing the right one is mostly about mutability and whether you need ordering, keys, or uniqueness.

  1. list — ordered, mutable sequence. [1, 2, 3]. The workhorse.
  2. tuple — ordered, immutable sequence. (1, 2, 3). Good for fixed records and as dictionary keys.
  3. dict — key-value mapping, insertion-ordered since 3.7. {"a": 1}. Average O(1) lookup.
  4. set — unordered collection of unique elements. {1, 2, 3}. Fast membership tests and set algebra.
nums = [1, 2, 3]
nums.append(4)
nums[1:3]            # slicing -> [2, 3]

point = (10, 20)     # tuple, immutable
x, y = point         # unpacking

ages = {"ada": 36}
ages["alan"] = 41
ages.get("grace", 0)  # 0 — safe default instead of KeyError

seen = {1, 2, 2, 3}   # {1, 2, 3}
seen & {2, 3, 4}      # intersection -> {2, 3}

Comprehensions

Comprehensions build a collection from an iterable in a single, readable expression — replacing the classic "create empty list, loop, append" pattern. They exist for lists, dicts, and sets, and there is a lazy generator form.

# List comprehension: [expression for item in iterable if condition]
squares = [x * x for x in range(5)]            # [0, 1, 4, 9, 16]
evens   = [x for x in range(10) if x % 2 == 0] # [0, 2, 4, 6, 8]

# Dict comprehension
lengths = {w: len(w) for w in ["hi", "hello"]}  # {'hi': 2, 'hello': 5}

# Set comprehension
unique = {x % 3 for x in range(10)}             # {0, 1, 2}

# Generator expression — lazy, uses () and does not build a list
total = sum(x * x for x in range(1000))

Prefer comprehensions for straightforward transformations and filters. If the logic needs several statements or nested branches, a normal loop is clearer — readability wins.

Object-oriented programming

Classes are defined with class. The constructor is __init__, and the first parameter of every instance method is self — the instance itself, passed explicitly.

class Animal:
    def __init__(self, name):
        self.name = name        # instance attribute

    def speak(self):
        raise NotImplementedError

class Dog(Animal):              # inheritance
    def speak(self):            # method override
        return f"{self.name} says woof"

Dog("Rex").speak()             # Rex says woof

Dunder methods

"Dunder" (double-underscore) methods let your objects hook into Python's built-in behaviour — this is how the language achieves operator overloading and duck typing. Define __len__ and len(obj) works; define __eq__ and == works.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):                 # developer-facing string
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other):           # enables the + operator
        return Vector(self.x + other.x, self.y + other.y)

    def __eq__(self, other):            # enables ==
        return (self.x, self.y) == (other.x, other.y)

Vector(1, 2) + Vector(3, 4)   # Vector(4, 6)

Related decorators shape class behaviour: @property exposes a method as a computed attribute, while @staticmethod and @classmethod define methods that do not take self. @dataclass auto-generates __init__, __repr__, and __eq__ from annotated fields.

Iterators and generators

An iterable is anything you can loop over; an iterator is the object that produces its values one at a time via __next__. The forloop calls iter() then next() under the hood.

Generators are the easy way to write iterators. A function that uses yield instead of return becomes a generator: each yieldproduces a value and pauses, resuming where it left off on the next request. Values are produced lazily, so a generator can represent an infinite or very large sequence without holding it all in memory.

def count_up(start):
    n = start
    while True:          # infinite — but lazy
        yield n
        n += 1

gen = count_up(10)
next(gen)   # 10
next(gen)   # 11

# Reading a huge file line by line without loading it all
def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

Decorators

A decorator is a function that takes another function and returns a modified version of it — wrapping behaviour around it without changing its body. Because functions are first-class, this falls out naturally. The @decorator syntax is just sugar for fn = decorator(fn).

import time
from functools import wraps

def timed(fn):
    @wraps(fn)                       # preserves fn's name and docstring
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

@timed
def slow():
    time.sleep(1)

slow()   # slow took 1.0002s

Decorators are everywhere in real code: @property and @staticmethod in classes, @app.route(...) in Flask, @pytest.fixture in tests. They are the idiomatic way to layer cross-cutting concerns — timing, caching, authentication, logging — onto a function in one line.

Exception handling

Python favours EAFP — "Easier to Ask Forgiveness than Permission." Rather than checking whether an operation will succeed, you attempt it and handle the exception if it fails. This reads more cleanly and avoids race conditions between the check and the action.

try:
    value = data["key"]
    result = 10 / value
except KeyError:
    print("missing key")
except ZeroDivisionError as e:
    print(f"cannot divide: {e}")
else:
    print("succeeded:", result)   # runs only if no exception
finally:
    print("always runs")          # cleanup

# Raising your own
if value < 0:
    raise ValueError("value must be non-negative")

Exceptions form a class hierarchy rooted at BaseException; catch the most specific type you can handle. The with statement (a context manager) handles the common acquire/release cleanup case — with open(path) as f: guarantees the file is closed even if the block raises.

Concurrency & the GIL

CPython has a Global Interpreter Lock (GIL) — a mutex that allows only one thread to execute Python bytecode at a time. It exists to make memory management (reference counting) simple and safe, but it means threads cannot run Python code in true parallel on multiple cores. This single fact shapes every concurrency decision in Python.

The consequence is that the right tool depends on whether your work is I/O-bound or CPU-bound.

  1. Threading (threading) — good for I/O-bound work (network calls, disk, waiting). The GIL is released while a thread waits on I/O, so other threads make progress. It does not speed up CPU-bound work.
  2. Multiprocessing (multiprocessing) — the answer for CPU-bound work. Each process has its own interpreter and its own GIL, so they run in genuine parallel across cores — at the cost of heavier memory use and inter-process communication.
  3. Async (asyncio) — single-threaded cooperative concurrency. async/await lets one thread juggle thousands of I/O-bound tasks by switching at await points. Ideal for high-concurrency network servers.
import asyncio

async def fetch(n):
    await asyncio.sleep(1)      # yields control while "waiting"
    return n * 2

async def main():
    # all three run concurrently, finishing in ~1s total
    results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
    print(results)              # [2, 4, 6]

asyncio.run(main())

Rule of thumb: I/O-bound and lots of waiting → asyncio or threads; CPU-bound and number-crunching → multiprocessing. Ongoing work to make the GIL optional (the "free-threaded" builds introduced in Python 3.13) may soften this trade-off in future versions.