Python Handbook

Python is a high-level, dynamically typed, interpreted language created by Guido van Rossum in 1991. Its design philosophy — "there should be one obvious way to do it" — produces unusually readable code. Python is the dominant language in data science, machine learning, scripting, and rapid prototyping, and has large frameworks for the web (Django, FastAPI, Flask) and automation (Ansible, Fabric, Playwright). CPython 3.12+ brings significant speed improvements; the GIL is being removed in 3.13+.

Pick Python when

  • Data science, ML, or AI — NumPy, Pandas, scikit-learn, PyTorch, TensorFlow, and Jupyter are all Python-first. No other language comes close for this domain.
  • Rapid prototyping — Python lets you write working code in a fraction of the time it takes in C++, Java, or Go. Ideal when you're still exploring the problem space.
  • Scripting and automation — file system operations, shell pipelines, REST API calls, web scraping (BeautifulSoup, Playwright), and CI scripts are all Python's sweet spot.
  • Web APIs with low-to-medium traffic — FastAPI and Django REST framework are excellent for building services quickly. FastAPI + async is competitive in throughput benchmarks.
  • You need a huge package ecosystem — PyPI has 500k+ packages. Whatever you need, it's probably already there.
  • Teaching and learning — Python's syntax is minimal, errors are clear, and the REPL gives instant feedback. It's the standard first language in academia.

Think twice before choosing Python when

  • Raw CPU performance matters — Python (CPython) is typically 10–100× slower than compiled languages. For compute-heavy code, use NumPy/PyTorch (which drop to C under the hood), Cython, or switch to a compiled language.
  • True parallelism is required — the GIL prevents multiple OS threads from running Python bytecode simultaneously. Use multiprocessing or asyncio for concurrency; or switch to Go/Rust for heavy parallel workloads.
  • You're deploying to mobile or embedded — CPython is heavy. MicroPython exists for microcontrollers but is limited.
  • Type safety at scale — Python's type hints are optional and only checked by external tools (mypy, pyright). Large teams maintaining complex Python codebases invest heavily in tooling to get the safety that TypeScript or Go give by default.

Python vs. its closest alternatives

  • Python vs JavaScript (Node) — both are interpreted and dynamically typed. Python dominates data/ML; JS/Node dominates the browser and real-time web. For a pure backend API, both are fine; Node handles more concurrent connections per machine.
  • Python vs Go — Go is much faster, compiles to a single binary, and has first-class concurrency. Python is far quicker to write. Use Go when you ship a long-running service; Python when you need results today.
  • Python vs R — R is unmatched for statistical analysis and publishing. Python has overtaken R in ML/DL thanks to PyTorch and scikit-learn. New projects: Python unless you're in academic statistics.

Resources

Topics

Variables & Types

Python uses dynamic typing — the type of a variable is inferred at runtime.

python
# No declaration needed — just assign
age = 30
pi = 3.14159
name = 'Alice'
active = True
nothing = None

# Type hints (Python 3.5+ — not enforced at runtime)
score: float = 9.5
items: list[int] = [1, 2, 3]

# Check types
type(age)             # <class 'int'>
isinstance(age, int)  # True

# Multiple assignment
a, b, c = 1, 2, 3
x = y = z = 0

# Swap
a, b = b, a

# Constants (convention — Python doesn't enforce)
MAX_RETRIES = 3

Strings

python
s = 'hello'

# f-strings (Python 3.6+)
name = 'Alice'
greeting = f'Hello, {name}!'
expr = f'{2 ** 10 = }'   # '2 ** 10 = 1024'

# Common methods
s.upper()                       # 'HELLO'
s.strip()                       # remove whitespace
s.split(',')                    # split to list
','.join(['a', 'b', 'c'])       # 'a,b,c'
s.replace('h', 'H')
s.startswith('he')
s.endswith('lo')
len(s)                          # 5

# Indexing and slicing
s[0]       # 'h'
s[-1]      # 'o'
s[1:4]     # 'ell'
s[::-1]    # reverse: 'olleh'

Control Flow

python
x = 42

# if / elif / else
if x > 100:
    print('big')
elif x > 10:
    print('medium')
else:
    print('small')

# Ternary (conditional expression)
label = 'even' if x % 2 == 0 else 'odd'

# match-case (Python 3.10+)
match x % 3:
    case 0: print('divisible by 3')
    case 1: print('remainder 1')
    case _: print('remainder 2')

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

for i in range(2, 10, 2):   # 2,4,6,8
    print(i)

# Enumerate and zip
names = ['Alice', 'Bob']
for i, name in enumerate(names):
    print(i, name)

for a, b in zip([1, 2, 3], ['x', 'y', 'z']):
    print(a, b)

# while with break / continue
n = 10
while n > 0:
    n -= 1

for i in range(10):
    if i == 3: continue
    if i == 7: break
    print(i)

Functions

python
# Basic function
def add(a: int, b: int) -> int:
    return a + b

# Default arguments
def greet(name: str, greeting: str = 'Hello') -> str:
    return f'{greeting}, {name}!'

# *args and **kwargs
def variadic(*args, **kwargs):
    print(args)    # tuple
    print(kwargs)  # dict

variadic(1, 2, 3, color='red', size=5)

# Keyword-only arguments
def only_keyword(*, name, age):
    print(name, age)
only_keyword(name='Alice', age=30)

# Lambda
double = lambda x: x * 2

# Generators
def countdown(n):
    while n > 0:
        yield n
        n -= 1

# Decorators
import functools

def log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f'Calling {func.__name__}')
        result = func(*args, **kwargs)
        return result
    return wrapper

@log
def say_hello():
    print('Hello!')

Collections

python
# List — ordered, mutable
nums = [1, 2, 3, 4, 5]
nums.append(6)
nums.insert(0, 0)
nums.pop()           # remove last
nums.remove(3)       # remove by value
nums.sort()
len(nums)

# Tuple — ordered, immutable
point = (10, 20)
x, y = point         # unpack

# Set — unordered, unique
s = {1, 2, 3, 2, 1}   # {1, 2, 3}
s.add(4)
s.discard(2)
s1 = {1, 2, 3}
s2 = {2, 3, 4}
s1 & s2  # intersection → {2, 3}
s1 | s2  # union        → {1, 2, 3, 4}
s1 - s2  # difference   → {1}

# Dict — key/value pairs
person = {'name': 'Alice', 'age': 30}
person['city'] = 'Paris'
person.get('missing', 'default')
del person['age']

for key, value in person.items():
    print(f'{key}: {value}')

Comprehensions

python
# List comprehension
squares = [x**2 for x in range(10)]
evens   = [x for x in range(20) if x % 2 == 0]
pairs   = [(x, y) for x in range(3) for y in range(3)]

# Dict comprehension
squared_map = {x: x**2 for x in range(6)}

# Set comprehension
unique_lengths = {len(w) for w in ['hi', 'hello', 'hey']}

# Generator expression (lazy — no list created)
gen = (x**2 for x in range(1000))
total = sum(gen)

Classes & OOP

python
class Animal:
    species = 'Unknown'   # class variable

    def __init__(self, name: str, sound: str):
        self.name = name        # instance variable
        self._sound = sound     # convention: protected

    def speak(self) -> str:
        return f'{self.name} says {self._sound}'

    def __repr__(self) -> str:
        return f'Animal({self.name!r})'

    @classmethod
    def from_dict(cls, data: dict) -> 'Animal':
        return cls(data['name'], data['sound'])

    @staticmethod
    def is_valid_name(name: str) -> bool:
        return len(name) > 0


class Dog(Animal):
    def __init__(self, name: str, breed: str):
        super().__init__(name, 'Woof')
        self.breed = breed

    def speak(self) -> str:
        return super().speak() + '!'


# Dataclass (Python 3.7+)
from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    label: str = ''
    tags: list = field(default_factory=list)

    def distance(self) -> float:
        return (self.x**2 + self.y**2) ** 0.5

Exceptions

python
# try / except / else / finally
try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f'Error: {e}')
except (TypeError, ValueError):
    print('Type or value error')
else:
    print('No error:', result)    # runs if no exception
finally:
    print('Always runs')

# Raise
def divide(a, b):
    if b == 0:
        raise ValueError('Cannot divide by zero')
    return a / b

# Custom exception
class AppError(Exception):
    def __init__(self, message: str, code: int = 0):
        super().__init__(message)
        self.code = code

# Context manager (with statement)
with open('file.txt', 'r') as f:
    content = f.read()
# file is automatically closed

File I/O

python
from pathlib import Path

# Write / read
path = Path('output.txt')
path.write_text('Hello, World!')
content = path.read_text()

# Line by line
with open('file.txt', 'r', encoding='utf-8') as f:
    for line in f:
        print(line.strip())

# JSON
import json
data = {'name': 'Alice', 'scores': [9, 8, 10]}
json_str = json.dumps(data, indent=2)
parsed  = json.loads(json_str)

with open('data.json', 'w') as f:
    json.dump(data, f)

# Path utilities
p = Path('/home/user/docs/file.txt')
p.name         # 'file.txt'
p.stem         # 'file'
p.suffix       # '.txt'
p.parent       # Path('/home/user/docs')
p.exists()
list(Path('.').glob('*.py'))

Modules

python
import os
import sys
from pathlib import Path
from collections import defaultdict, Counter
from typing import Optional, Union

# Standard library highlights
import math
math.pi; math.sqrt(2); math.floor(3.7)

import random
random.randint(1, 10)
random.choice(['a', 'b', 'c'])

import datetime
now = datetime.datetime.now()
today = datetime.date.today()

import re
pattern = re.compile(r'\d+')
pattern.findall('abc 123 def 456')   # ['123', '456']
re.sub(r'\s+', ' ', '  hello   world  ')

import itertools
list(itertools.chain([1, 2], [3, 4]))
list(itertools.product('AB', repeat=2))
list(itertools.combinations([1, 2, 3], 2))

Generators & Itertools

python
# Generator function — yields values one at a time
def countdown(n):
    while n > 0:
        yield n
        n -= 1

list(countdown(5))   # [5, 4, 3, 2, 1]

# Generator expression — lazy, no list in memory
squares = (x**2 for x in range(1000))
next(squares)   # 0

# yield from — delegate to a sub-generator
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

list(flatten([1, [2, [3, 4]], 5]))   # [1, 2, 3, 4, 5]

# send() / throw() / close() — coroutine-style generator
def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            return
        total += value

gen = accumulator()
next(gen)        # prime the generator → 0
gen.send(10)     # → 10
gen.send(5)      # → 15
gen.close()      # triggers GeneratorExit inside

# itertools
import itertools

list(itertools.chain([1, 2], [3, 4]))              # [1, 2, 3, 4]
list(itertools.product('AB', repeat=2))            # [('A','A'), ('A','B'), ...]
list(itertools.combinations([1, 2, 3], 2))         # [(1,2), (1,3), (2,3)]
list(itertools.permutations([1, 2, 3], 2))         # 6 tuples
list(itertools.islice(itertools.count(1), 5))      # [1, 2, 3, 4, 5]
list(itertools.accumulate([1, 2, 3, 4]))           # [1, 3, 6, 10]
list(itertools.repeat('x', 3))                     # ['x', 'x', 'x']
list(itertools.starmap(pow, [(2, 3), (3, 2)]))     # [8, 9]
for k, g in itertools.groupby([1, 1, 2, 2, 3]):
    print(k, list(g))

Decorators

python
import functools
from functools import cached_property

# functools.wraps preserves __name__, __doc__, __annotations__
def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f'Calling {func.__name__}')
        return func(*args, **kwargs)
    return wrapper

# Decorator factory — decorator that accepts arguments
def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times): func(*args, **kwargs)
        return wrapper
    return decorator

@log_call
@repeat(3)              # stacked — applied bottom-up
def greet(name): print(f'Hi, {name}!')

# Class decorator — singleton pattern
def singleton(cls):
    _inst = {}
    @functools.wraps(cls)
    def get(*a, **kw):
        if cls not in _inst: _inst[cls] = cls(*a, **kw)
        return _inst[cls]
    return get

# @property / @setter / @deleter / @staticmethod / @classmethod
class Circle:
    def __init__(self, r): self._r = r

    @property
    def radius(self): return self._r

    @radius.setter
    def radius(self, v):
        if v < 0: raise ValueError('radius must be >= 0')
        self._r = v

    @radius.deleter
    def radius(self): del self._r

    @staticmethod
    def unit(): return Circle(1)

    @classmethod
    def from_diameter(cls, d): return cls(d / 2)

# @cached_property (3.8+) — computed once, then cached as instance attr
class DataSet:
    def __init__(self, data): self.data = data

    @cached_property
    def mean(self): return sum(self.data) / len(self.data)

Context Managers

python
import time
from contextlib import contextmanager, suppress, ExitStack

# __enter__ / __exit__ protocol
class Timer:
    def __enter__(self):
        self._start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self._start
        return False   # False → do not suppress exceptions

with Timer() as t:
    sum(range(1_000_000))
print(f'elapsed: {t.elapsed:.4f}s')

# contextlib.contextmanager — generator-based shorthand
@contextmanager
def managed(name):
    print(f'open {name}')
    try:
        yield name
    finally:
        print(f'close {name}')

with managed('db') as conn:
    print(f'using {conn}')

# contextlib.suppress — silently swallow specific exceptions
with suppress(FileNotFoundError):
    open('no-such-file.txt')

# ExitStack — dynamic / variable number of context managers
with ExitStack() as stack:
    files = [stack.enter_context(open(f, 'w')) for f in ['a.txt', 'b.txt']]
    # all files closed on exit regardless of exceptions

# Async context manager
class AsyncConn:
    async def __aenter__(self):
        # await self.connect()
        return self

    async def __aexit__(self, *args):
        # await self.disconnect()
        pass

import asyncio
async def use_conn():
    async with AsyncConn() as c:
        pass   # c is connected inside the block

Type Hints & typing

python
from typing import (
    Optional, Union, Any, Literal, TypeVar, Generic,
    Protocol, TypedDict, Final, ClassVar, overload,
    TYPE_CHECKING, cast, ParamSpec
)

# Optional / Union — Python 3.10+ prefers X | Y syntax
def find(id: int) -> Optional[str]: ...     # same as str | None
def process(v: Union[int, str]) -> str: ... # same as int | str

# Literal — constrain to exact values
Mode = Literal['r', 'w', 'a']

# TypeVar + Generic
T = TypeVar('T')

class Stack(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()

# Protocol — structural subtyping (duck typing + hints)
class Drawable(Protocol):
    def draw(self) -> None: ...

# TypedDict — typed dict schema
class Movie(TypedDict):
    title: str
    year: int

# Final / ClassVar
MAX: Final[int] = 1024

class App:
    count: ClassVar[int] = 0

# overload — multiple call signatures, one implementation
@overload
def double(x: int) -> int: ...
@overload
def double(x: str) -> str: ...
def double(x): return x * 2

# TYPE_CHECKING guard — avoids circular imports at runtime
if TYPE_CHECKING:
    from collections.abc import Callable

# cast — tells the checker to trust you (no runtime effect)
raw: Any = '42'
num: int = cast(int, raw)

# ParamSpec (3.10+) — capture parameter types in decorator wrappers
P = ParamSpec('P')

# Self (3.11+) — return type for fluent / builder methods
from typing import Self

class Builder:
    def set_name(self, name: str) -> Self:
        self.name = name
        return self

Async / Await

python
import asyncio

async def fetch(url: str) -> str:
    await asyncio.sleep(0.1)   # simulate network I/O
    return f'data from {url}'

# asyncio.run — entry point (starts and stops the event loop)
asyncio.run(fetch('https://api.example.com'))

# asyncio.gather — run concurrently, collect all results
async def main():
    a, b, c = await asyncio.gather(
        fetch('https://a.com'),
        fetch('https://b.com'),
        fetch('https://c.com'),
    )
    return a, b, c

# asyncio.create_task — schedule without blocking the caller
async def with_tasks():
    t1 = asyncio.create_task(fetch('https://x.com'))
    t2 = asyncio.create_task(fetch('https://y.com'))
    await asyncio.sleep(0)    # yield control; tasks start running
    return await t1, await t2

# asyncio.timeout (Python 3.11+)
async def guarded():
    async with asyncio.timeout(5.0):
        return await fetch('https://slow.example.com')

# asyncio.Queue — producer / consumer pattern
async def producer(q: asyncio.Queue):
    for i in range(5):
        await q.put(i)
        await asyncio.sleep(0.05)
    await q.put(None)   # sentinel to signal done

async def consumer(q: asyncio.Queue):
    while True:
        item = await q.get()
        if item is None:
            break
        print(f'consumed: {item}')

async def pipeline():
    q = asyncio.Queue()
    await asyncio.gather(producer(q), consumer(q))

asyncio.run(pipeline())

functools & operator

python
import functools
import operator

# partial — pre-fill arguments to create a new callable
def power(base, exp): return base ** exp

square = functools.partial(power, exp=2)
cube   = functools.partial(power, exp=3)
square(5)   # 25
cube(3)     # 27

# reduce — fold a sequence into a single value
functools.reduce(operator.add, [1, 2, 3, 4, 5])     # 15
functools.reduce(operator.mul, range(1, 6))          # 120

# lru_cache / cache — memoization
@functools.lru_cache(maxsize=128)
def fib(n: int) -> int:
    return n if n < 2 else fib(n-1) + fib(n-2)

@functools.cache             # Python 3.9+, unbounded
def fact(n: int) -> int:
    return 1 if n == 0 else n * fact(n - 1)

fib.cache_info()             # CacheInfo(hits=..., misses=...)

# total_ordering — supply __eq__ + one comparison, get the rest free
@functools.total_ordering
class Version:
    def __init__(self, major, minor): self.major, self.minor = major, minor
    def __eq__(self, o): return (self.major, self.minor) == (o.major, o.minor)
    def __lt__(self, o): return (self.major, self.minor) < (o.major, o.minor)

# singledispatch — function overloading based on argument type
@functools.singledispatch
def process(arg):
    raise TypeError(f'unsupported type: {type(arg).__name__}')

@process.register(int)
def _(arg): return arg * 2

@process.register(str)
def _(arg): return arg.upper()

# operator module — use in sorted / map / filter instead of lambdas
data = [{'name': 'Alice', 'age': 30}, {'name': 'Bob', 'age': 25}]
sorted(data, key=operator.itemgetter('age'))
sorted(data, key=operator.itemgetter('name'))

from operator import attrgetter, methodcaller
# sorted(points, key=attrgetter('x'))
# list(map(methodcaller('strip'), lines))

dataclasses

python
from dataclasses import dataclass, field, InitVar, ClassVar
import dataclasses

# @dataclass generates __init__, __repr__, __eq__ automatically
@dataclass
class Point:
    x: float
    y: float
    label: str = ''

# field() — customize individual field behavior
@dataclass
class Config:
    host: str = 'localhost'
    port: int = 8080
    tags: list[str] = field(default_factory=list)
    _hash: int = field(default=0, init=False, repr=False)

    def __post_init__(self):   # called after __init__
        self._hash = hash(self.host)

# frozen=True — immutable and hashable
@dataclass(frozen=True)
class Color:
    r: int; g: int; b: int

hash(Color(255, 0, 0))   # works because frozen

# order=True — auto-generates __lt__, __le__, __gt__, __ge__
@dataclass(order=True)
class Version:
    major: int; minor: int; patch: int

Version(1, 2, 3) < Version(2, 0, 0)   # True

# kw_only=True (3.10+) and slots=True (3.10+)
@dataclass(kw_only=True, slots=True)
class DBConfig:
    host: str
    port: int
    name: str

# InitVar — passed to __post_init__, not stored as a field
@dataclass
class Circle:
    radius: float
    diameter: InitVar[float] = None
    count: ClassVar[int] = 0

    def __post_init__(self, diameter):
        if diameter is not None: self.radius = diameter / 2
        Circle.count += 1

# Utility functions
p = Point(3.0, 4.0, 'origin')
dataclasses.asdict(p)           # {'x': 3.0, 'y': 4.0, 'label': 'origin'}
dataclasses.astuple(p)          # (3.0, 4.0, 'origin')
dataclasses.replace(p, x=0.0)  # new Point(0.0, 4.0, 'origin')

Metaclasses

Everything in Python is an object — including classes. A metaclass is the class of a class. type is the default metaclass. Custom metaclasses intercept class creation to enforce patterns, register subclasses, or modify attributes. __init_subclass__ is a lighter alternative for most use cases.

python
# Metaclass — the class that creates classes (default: type)
print(type(int))     # <class 'type'>
print(type(list))    # <class 'type'>

# type() can create classes dynamically
Dog = type('Dog', (object,), {'sound': 'woof', 'speak': lambda self: self.sound})
fido = Dog()
print(fido.speak())  # woof

# Custom metaclass — intercept class creation
class SingletonMeta(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Config(metaclass=SingletonMeta):
    def __init__(self):
        self.debug = False

# Both calls return the same object
c1 = Config()
c2 = Config()
assert c1 is c2

# __init_subclass__ — hook called when a class is subclassed (simpler than metaclass)
class Plugin:
    _registry: dict = {}
    def __init_subclass__(cls, name: str = '', **kwargs):
        super().__init_subclass__(**kwargs)
        if name:
            Plugin._registry[name] = cls

class AudioPlugin(Plugin, name='audio'):
    pass

class VideoPlugin(Plugin, name='video'):
    pass

print(Plugin._registry)  # {'audio': AudioPlugin, 'video': VideoPlugin}

Descriptors & Properties

Descriptors are objects that customise attribute access by implementing __get__, __set__, or __delete__. They power property, classmethod, staticmethod, __slots__, and ORM field definitions.

python
# Descriptor — object that defines __get__, __set__, and/or __delete__
# Used to implement properties, classmethods, staticmethods, slots, etc.

class Validated:
    '''Descriptor that enforces a minimum value.'''
    def __set_name__(self, owner, name):
        self.name = name
        self.private = '_' + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self   # accessed on the class itself
        return getattr(obj, self.private, 0)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f'{self.name} must be non-negative')
        setattr(obj, self.private, value)

class Circle:
    radius = Validated()
    def __init__(self, r):
        self.radius = r   # calls Validated.__set__

c = Circle(5)
print(c.radius)   # 5
# c.radius = -1  → ValueError

# property — built-in descriptor shorthand
class Temperature:
    def __init__(self, celsius=0.0):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, v):
        if v < -273.15:
            raise ValueError('below absolute zero')
        self._celsius = v

    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

t = Temperature(100)
print(t.fahrenheit)   # 212.0

Protocols & ABCs

Abstract Base Classes enforce an interface through inheritance. Protocols (PEP 544, Python 3.8+) use structural subtyping — any class that implements the required methods satisfies the protocol, without needing to inherit from it. Use @runtime_checkable to enable isinstance checks.

python
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable

# Abstract Base Class — enforce interface via inheritance
class Animal(ABC):
    @abstractmethod
    def speak(self) -> str: ...

    @abstractmethod
    def move(self) -> None: ...

class Dog(Animal):
    def speak(self) -> str:  return 'woof'
    def move(self)  -> None: print('run')

# Protocol (PEP 544) — structural subtyping (duck typing + static checks)
@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...
    def bounding_box(self) -> tuple[int, int, int, int]: ...

class Circle:   # does NOT inherit Drawable
    def draw(self): print('O')
    def bounding_box(self): return (0, 0, 10, 10)

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())              # works — Circle structurally matches Drawable
print(isinstance(Circle(), Drawable))  # True (runtime_checkable)

# Built-in ABCs in collections.abc
from collections.abc import Sequence, Mapping, Callable, Iterable

def sum_items(items: Iterable[int]) -> int:
    return sum(items)

sum_items([1, 2, 3])     # list — works
sum_items((1, 2, 3))     # tuple — works
sum_items({1, 2, 3})     # set — works

Multiprocessing & Threading

Use threading for I/O-bound work — threads share memory but the GIL limits CPU parallelism. Use multiprocessing for CPU-bound work — each process gets its own GIL-free interpreter. concurrent.futures provides a unified executor API for both.

python
import threading
import multiprocessing
import concurrent.futures

# threading — for I/O-bound tasks (GIL limits CPU parallelism)
results = []
lock = threading.Lock()

def fetch(url):
    # simulate I/O
    with lock:
        results.append(url)

threads = [threading.Thread(target=fetch, args=(f'url{i}',)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()

# multiprocessing — for CPU-bound tasks (separate processes, no GIL)
def cpu_heavy(n):
    return sum(i * i for i in range(n))

with multiprocessing.Pool(processes=4) as pool:
    totals = pool.map(cpu_heavy, [10**6] * 4)

# concurrent.futures — unified high-level interface
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
    futs = [ex.submit(fetch, f'url{i}') for i in range(20)]
    for f in concurrent.futures.as_completed(futs):
        _ = f.result()   # raise if exception occurred

with concurrent.futures.ProcessPoolExecutor() as ex:
    results = list(ex.map(cpu_heavy, range(4)))

# multiprocessing.Queue — safe inter-process communication
q = multiprocessing.Queue()

def producer(q):
    for i in range(5):
        q.put(i)
    q.put(None)   # sentinel

def consumer(q):
    while (item := q.get()) is not None:
        print(item)

__slots__ & Dunder Methods

__slots__ replaces the per-instance __dict__ with a fixed-size array, reducing memory by ~30–50% for objects with many instances. Dunder methods define how objects behave with built-in operators, functions, and protocols.

python
# __slots__ — declare allowed instance attributes to save memory
class Point:
    __slots__ = ('x', 'y')   # no __dict__, ~30% less memory per instance
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
# p.z = 3  → AttributeError — no __dict__

# Essential dunder methods
class Vector:
    def __init__(self, x, y): self.x, self.y = x, y

    # Representation
    def __repr__(self):  return f'Vector({self.x}, {self.y})'
    def __str__(self):   return f'({self.x}, {self.y})'

    # Arithmetic operators
    def __add__(self, other): return Vector(self.x + other.x, self.y + other.y)
    def __mul__(self, s):     return Vector(self.x * s, self.y * s)
    def __rmul__(self, s):    return self.__mul__(s)   # s * v

    # Comparison
    def __eq__(self, other):  return self.x == other.x and self.y == other.y
    def __lt__(self, other):  return abs(self) < abs(other)

    # Numeric protocol
    def __abs__(self):       return (self.x**2 + self.y**2) ** 0.5
    def __bool__(self):      return self.x != 0 or self.y != 0
    def __neg__(self):       return Vector(-self.x, -self.y)

    # Container protocol
    def __len__(self):       return 2
    def __getitem__(self, i): return (self.x, self.y)[i]
    def __iter__(self):      return iter((self.x, self.y))

    # Hash — required if __eq__ is defined and object must be hashable
    def __hash__(self):      return hash((self.x, self.y))

v1, v2 = Vector(1, 2), Vector(3, 4)
print(v1 + v2)     # (4, 6)
print(3 * v1)      # (3, 6)
print(abs(v1))     # 2.23...
x, y = v1          # unpacking via __iter__

itertools & more-itertools

itertools provides composable building blocks for lazy iteration: combinatorics (product, combinations, permutations), filtering (compress, takewhile, dropwhile), grouping (groupby), and infinite iterators (count, cycle, repeat). more-itertools extends this with chunked, windowed, pairwise back-ports, and dozens more utilities.

python
import itertools
from itertools import (
    chain, chain_from_iterable, islice, takewhile, dropwhile,
    groupby, accumulate, compress, filterfalse, zip_longest,
    product, combinations, combinations_with_replacement, permutations,
    tee, pairwise, starmap, cycle, repeat, count
)
import operator

# chain — flatten iterables
list(chain([1, 2], [3, 4], [5]))           # [1, 2, 3, 4, 5]
list(chain.from_iterable([[1, 2], [3]]))   # [1, 2, 3]

# islice — lazy slice of any iterable
list(islice(count(10), 5))                 # [10, 11, 12, 13, 14]

# takewhile / dropwhile
list(takewhile(lambda x: x < 5, [1, 3, 5, 2]))  # [1, 3]
list(dropwhile(lambda x: x < 5, [1, 3, 5, 2]))  # [5, 2]

# compress — selector mask
list(compress('ABCDE', [1, 0, 1, 0, 1]))   # ['A', 'C', 'E']

# groupby — group consecutive equal keys (sort first!)
data = [('a', 1), ('a', 2), ('b', 3)]
for key, grp in groupby(data, key=lambda t: t[0]):
    print(key, list(grp))

# accumulate — running totals / products
list(accumulate([1, 2, 3, 4], operator.mul))   # [1, 2, 6, 24]

# zip_longest — zip with fillvalue for unequal lengths
list(zip_longest([1, 2, 3], ['a', 'b'], fillvalue='-'))

# pairwise (3.10+) — sliding pairs
list(pairwise([1, 2, 3, 4]))    # [(1,2), (2,3), (3,4)]

# tee — split one iterator into n independent copies
it1, it2 = tee(range(5), 2)

# more-itertools highlights (pip install more-itertools)
# from more_itertools import chunked, windowed, flatten, first, last, one
# list(chunked([1,2,3,4,5], 2))       # [[1,2],[3,4],[5]]
# list(windowed([1,2,3,4,5], 3))      # [(1,2,3),(2,3,4),(3,4,5)]
# first(x for x in range(10) if x > 5)  # 6

pathlib & File System

pathlib.Path (Python 3.4+) replaces os.path string manipulation with an object-oriented API. The / operator builds paths, methods cover reading, writing, globbing, stat queries, and directory creation. shutil handles higher-level operations like copying directory trees, and tempfile provides secure temporary files and directories.

python
from pathlib import Path
import shutil, tempfile, os

# Construct paths — platform-independent
base = Path('/home/user/projects')
cfg  = base / 'app' / 'config.toml'   # / operator

# Inspect
cfg.name        # 'config.toml'
cfg.stem        # 'config'
cfg.suffix      # '.toml'
cfg.suffixes    # ['.toml']
cfg.parent      # Path('/home/user/projects/app')
cfg.parts       # ('/', 'home', 'user', 'projects', 'app', 'config.toml')
cfg.is_absolute()

# I/O
p = Path('output.txt')
p.write_text('hello', encoding='utf-8')
p.write_bytes(b'\x00\x01')
content = p.read_text()
raw     = p.read_bytes()

# Filesystem queries
p.exists()
p.is_file()
p.is_dir()
p.stat().st_size      # bytes
p.stat().st_mtime     # modification time

# Create / delete
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
p.unlink(missing_ok=True)
shutil.rmtree(base, ignore_errors=True)

# Globbing
list(Path('.').glob('*.py'))              # shallow
list(Path('.').rglob('**/*.py'))          # recursive
list(Path('.').glob('test_*.py'))

# Rename / copy
p.rename(p.with_suffix('.bak'))
p.replace(Path('/tmp/file.txt'))          # atomic on same filesystem
shutil.copy2(str(p), '/tmp/')

# Temporary files / dirs
with tempfile.TemporaryDirectory() as td:
    tmp = Path(td) / 'work.txt'
    tmp.write_text('temp data')

# Path from environment variable
home = Path(os.environ.get('HOME', '~')).expanduser()
cfg_dir = Path.home() / '.config' / 'myapp'

Regular Expressions

The re module provides Perl-compatible regular expressions. Compile patterns with re.compile() for reuse. Key operations: search (anywhere), match (start only), fullmatch, findall, finditer, sub, and split. Named groups ((?P<name>...)), lookaheads, and verbose mode (re.VERBOSE) make complex patterns maintainable.

python
import re

# Compile for reuse
pat = re.compile(r'\\d{4}-\\d{2}-\\d{2}')   # ISO date

# search vs match vs fullmatch
re.search(r'\\d+', 'abc 123 def')        # Match at pos 4
re.match(r'\\d+', '123 abc')             # Match only at start
re.fullmatch(r'\\d+', '123')             # Whole string

# findall / finditer
re.findall(r'\\d+', 'a1 b22 c333')       # ['1', '22', '333']
for m in re.finditer(r'\\w+', 'hello world'):
    print(m.group(), m.start(), m.end())

# Groups — numbered, named, non-capturing
m = re.match(r'(?P<year>\\d{4})-(?P<month>\\d{2})', '2024-03')
m.group('year')    # '2024'
m.group(1)         # '2024'
m.groups()         # ('2024', '03')
m.groupdict()      # {'year': '2024', 'month': '03'}

# Substitution
re.sub(r'\\s+', ' ', '  hello   world  ')          # 'hello world'
re.sub(r'(\\w+)', r'[\\1]', 'foo bar')             # '[foo] [bar]'
re.subn(r'\\d', '#', 'a1b2c3')                      # ('a#b#c#', 3)

# Split
re.split(r'[,;\\s]+', 'one, two; three  four')     # ['one', 'two', 'three', 'four']

# Flags
re.search(r'hello', 'HELLO', re.IGNORECASE)
re.findall(r'^\\w+', 'line1\nline2', re.MULTILINE)

# Lookahead / lookbehind
re.findall(r'\\w+(?=\'s)', "Alice's cat Bob's dog")  # ['Alice', 'Bob']
re.findall(r'(?<=@)\\w+', 'user@mail.com')            # ['mail']

# Verbose mode for complex patterns
DATE = re.compile(r'''
    (?P<year>  \\d{4}) -   # year
    (?P<month> \\d{2}) -   # month
    (?P<day>   \\d{2})     # day
''', re.VERBOSE)

JSON & Serialization

Python offers several serialization formats: json for human-readable interchange, pickle for arbitrary Python objects (binary, Python-only), shelve for a persistent dict backed by pickle, and struct for packing C-style binary data. Custom encoders/decoders extend JSON to handle datetime, dataclass, and other non-default types.

python
import json, pickle, shelve, struct
from dataclasses import dataclass, asdict
from typing import Any

# json.dumps / json.loads — human-readable text
data = {'name': 'Alice', 'scores': [9, 8, 10], 'active': True}
s = json.dumps(data, indent=2, sort_keys=True)
obj = json.loads(s)

# Custom encoder for non-serializable types
import datetime
class DateEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, datetime.date):
            return o.isoformat()
        return super().default(o)

json.dumps({'ts': datetime.date.today()}, cls=DateEncoder)

# Custom decoder hook
def decode_hook(d):
    if 'ts' in d:
        d['ts'] = datetime.date.fromisoformat(d['ts'])
    return d

json.loads('{"ts": "2024-01-01"}', object_hook=decode_hook)

# pickle — binary, Python-only, arbitrary objects
@dataclass
class Model:
    weights: list[float]
    name: str

m = Model([0.1, 0.2, 0.3], 'v1')
blob = pickle.dumps(m, protocol=pickle.HIGHEST_PROTOCOL)
m2   = pickle.loads(blob)

with open('model.pkl', 'wb') as f: pickle.dump(m, f)
with open('model.pkl', 'rb') as f: m3 = pickle.load(f)

# shelve — persistent dict backed by pickle
with shelve.open('store') as db:
    db['model'] = m
    db['config'] = {'lr': 0.01}

with shelve.open('store') as db:
    loaded = db['model']

# struct — pack/unpack C-style binary data
packed = struct.pack('>HH', 1920, 1080)    # big-endian two unsigned shorts
w, h   = struct.unpack('>HH', packed)

# json with dataclasses
@dataclass
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
json.dumps(asdict(p))   # '{"x": 1.0, "y": 2.0}'

Testing with pytest

pytest autodiscovers test files matching test_*.py and rewrites assert statements for rich failure messages. Key features: parametrize for data-driven tests, fixtures for reusable setup/teardown with configurable scope, monkeypatch for environment isolation, tmp_path for temporary files, and marks (skip, xfail) for conditional execution.

python
# test_math.py  — run with:  pytest -v
import pytest

# Basic assertions (pytest rewrites assert for rich diffs)
def add(a, b): return a + b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

# Parametrize — data-driven tests
@pytest.mark.parametrize('a, b, expected', [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add_param(a, b, expected):
    assert add(a, b) == expected

# Fixtures — reusable setup / teardown
@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

# Fixture with scope
@pytest.fixture(scope='module')
def db_conn():
    conn = {'connected': True}   # simulate DB
    yield conn
    conn['connected'] = False    # teardown

# Exception testing
def divide(a, b):
    if b == 0: raise ValueError('division by zero')
    return a / b

def test_divide_raises():
    with pytest.raises(ValueError, match='division by zero'):
        divide(1, 0)

# Monkeypatch — replace objects during test
def get_env(key): import os; return os.environ.get(key, '')

def test_env(monkeypatch):
    monkeypatch.setenv('MY_KEY', 'test_value')
    assert get_env('MY_KEY') == 'test_value'

# tmp_path — built-in fixture for temp files
def test_file(tmp_path):
    f = tmp_path / 'data.txt'
    f.write_text('hello')
    assert f.read_text() == 'hello'

# Marks — skip, xfail, custom
@pytest.mark.skip(reason='not implemented yet')
def test_future(): ...

@pytest.mark.xfail(strict=True)
def test_known_bug():
    assert 1 == 2

Packaging & Virtual Envs

pyproject.toml (PEP 517/518) is the modern standard for declaring build systems, metadata, dependencies, and CLI entry points. Virtual environments isolate project dependencies. uv is a fast Rust-based alternative to pip/venv. Build with python -m build and publish with twine. The __all__ list in __init__.py controls the public API.

python
# pyproject.toml (PEP 517/518) — modern project definition
# [build-system]
# requires = ['setuptools>=68', 'wheel']
# build-backend = 'setuptools.backends.legacy:build'
#
# [project]
# name = 'mypackage'
# version = '0.1.0'
# requires-python = '>=3.11'
# dependencies = ['requests>=2.28', 'pydantic>=2']
#
# [project.optional-dependencies]
# dev = ['pytest', 'ruff', 'mypy']
#
# [project.scripts]
# my-cli = 'mypackage.cli:main'

# Virtual environments
# python -m venv .venv
# source .venv/bin/activate    (Unix)
# .venv\Scripts\activate       (Windows)
# deactivate

# pip essentials
# pip install -e .              # editable install
# pip install -e '.[dev]'       # with extras
# pip freeze > requirements.txt
# pip install -r requirements.txt

# uv — fast modern package manager
# uv venv
# uv pip install -e '.[dev]'
# uv run pytest

# Building and publishing
# python -m build              # produces dist/*.whl and dist/*.tar.gz
# twine upload dist/*          # to PyPI

# Package structure
# mypackage/
#   __init__.py
#   core.py
#   utils.py
#   py.typed              # marker for PEP 561 (typed package)
# tests/
#   test_core.py
# pyproject.toml
# README.md

# __init__.py — control public API
# from .core import MyClass
# from .utils import helper
# __all__ = ['MyClass', 'helper']

# Namespace packages (no __init__.py needed in PEP 420 style)
# Useful for splitting a package across multiple directories / repos

ctypes & C Extensions

ctypes is the standard library's foreign function interface — it loads shared libraries, defines C-compatible types and structures, and calls C functions without writing any C code. For more ergonomic FFI, cffi allows embedding C declarations as strings. Both approaches enable calling platform libraries, wrapping legacy code, or hitting OS APIs directly from Python.

python
import ctypes
import ctypes.util

# Load a shared library
libc = ctypes.CDLL(ctypes.util.find_library('c'))

# Call a C function: strlen
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype  = ctypes.c_size_t
libc.strlen(b'hello')   # 5

# Fundamental types
ctypes.c_int(42).value
ctypes.c_double(3.14).value
ctypes.c_char_p(b'hi').value

# Structures — map to C structs
class Point(ctypes.Structure):
    _fields_ = [('x', ctypes.c_double), ('y', ctypes.c_double)]

p = Point(1.0, 2.0)
p.x   # 1.0

# Arrays
IntArray5 = ctypes.c_int * 5
arr = IntArray5(1, 2, 3, 4, 5)
list(arr)   # [1, 2, 3, 4, 5]

# Pointers
val = ctypes.c_int(10)
ptr = ctypes.pointer(val)
ptr.contents.value   # 10
ptr[0] = 99
val.value            # 99

# Pass a Python buffer (byref / create_string_buffer)
buf = ctypes.create_string_buffer(128)
libc.strcpy(buf, b'hello world')
buf.value   # b'hello world'

# cffi (alternative — more ergonomic)
# from cffi import FFI
# ffi = FFI()
# ffi.cdef('int add(int a, int b);')
# lib = ffi.dlopen('./mylib.so')
# lib.add(2, 3)  # 5

AST & Code Generation

The ast module parses Python source into an Abstract Syntax Tree, enabling static analysis, linting, code transformation, and metaprogramming. ast.NodeVisitor traverses the tree read-only; ast.NodeTransformer rewrites it in place. After transformation, ast.fix_missing_locations + compile + exec turns the modified AST into running code.

python
import ast, textwrap

# Parse source into an AST
source = '''
def add(a, b):
    return a + b
result = add(1, 2)
'''

tree = ast.parse(textwrap.dedent(source))

# Walk all nodes
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        print('function:', node.name)
    if isinstance(node, ast.Call):
        if isinstance(node.func, ast.Name):
            print('call:', node.func.id)

# Dump the AST (Python 3.8+ indent arg)
print(ast.dump(tree, indent=2))

# NodeVisitor — visitor pattern
class FunctionLister(ast.NodeVisitor):
    def visit_FunctionDef(self, node):
        print(f'def {node.name}({len(node.args.args)} args) at line {node.lineno}')
        self.generic_visit(node)

FunctionLister().visit(tree)

# NodeTransformer — rewrite the AST
class NegateReturns(ast.NodeTransformer):
    def visit_Return(self, node):
        node.value = ast.UnaryOp(op=ast.USub(), operand=node.value)
        return node

new_tree = NegateReturns().visit(ast.parse('def f(): return 42'))
ast.fix_missing_locations(new_tree)
code = compile(new_tree, '<string>', 'exec')
ns = {}
exec(code, ns)
ns['f']()   # -42

# Build an AST from scratch and compile it
body = [ast.Return(value=ast.Constant(value=99))]
func = ast.FunctionDef(
    name='answer', args=ast.arguments(
        posonlyargs=[], args=[], vararg=None, kwonlyargs=[],
        kw_defaults=[], kwarg=None, defaults=[]
    ),
    body=body, decorator_list=[], returns=None
)
mod = ast.Module(body=[func], type_ignores=[])
ast.fix_missing_locations(mod)
exec(compile(mod, '<generated>', 'exec'), ns)
ns['answer']()   # 99

Performance & Profiling

Profile before optimising. timeit measures micro-benchmarks; cProfile + pstats reveals function-level hotspots; tracemalloc tracks memory allocations. dis shows compiled bytecode to understand what Python actually executes. Practical optimisations include local variable hoisting, comprehensions over loops, __slots__, functools.cache, and vectorised libraries like NumPy.

python
import timeit, cProfile, pstats, tracemalloc, sys, dis

# timeit — micro-benchmarks
timeit.timeit('sum(range(1000))', number=10_000)
timeit.timeit(lambda: sum(range(1000)), number=10_000)

# cProfile — function-level profiling
def slow():
    return sum(i**2 for i in range(100_000))

cProfile.run('slow()', sort='cumulative')

# pstats — programmatic access to cProfile output
import io
pr = cProfile.Profile()
pr.enable()
slow()
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(10)
print(s.getvalue())

# tracemalloc — memory profiling
tracemalloc.start()
data = [list(range(1000)) for _ in range(100)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics('lineno')[:3]:
    print(stat)
tracemalloc.stop()

# dis — bytecode inspector (understand what Python compiles to)
def fib(n): return n if n < 2 else fib(n-1) + fib(n-2)
dis.dis(fib)

# sys.getsizeof — object memory in bytes
sys.getsizeof([])         # 56
sys.getsizeof(list(range(1000)))  # 8056

# Optimization tips
# 1. Use local variables inside tight loops (faster LOAD_FAST)
# 2. Prefer list comprehensions over append() loops
# 3. Use slots=True on dataclasses / __slots__ for many instances
# 4. numpy / pandas vectorisation beats Python loops by 100x
# 5. functools.cache for expensive pure functions
# 6. Use generators instead of materialising large lists
# 7. Profile first — never optimise without data

Logging & Configuration

Python's logging module provides a hierarchical logger tree, multiple handlers (stream, file, rotating), formatters, and filters. Always get loggers by logging.getLogger(__name__) so the hierarchy mirrors the package structure. Use logging.config.dictConfig in production for declarative configuration. For structured logging in async web apps, combine contextvars.ContextVar with a custom Filter to attach request IDs to every log line.

python
import logging
import logging.config
import json, sys
from pathlib import Path

# Basic setup (call once at app entry point)
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
    handlers=[
        logging.StreamHandler(sys.stdout),
        logging.FileHandler('app.log', encoding='utf-8'),
    ]
)

# Module-level loggers — use __name__ (propagates to root by default)
log = logging.getLogger(__name__)
log.debug('debug message')
log.info('info message')
log.warning('something odd')
log.error('an error occurred')
log.critical('system down')
log.exception('caught exception', exc_info=True)  # includes traceback

# dictConfig — recommended for production apps
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {'format': '%(asctime)s %(levelname)-8s %(name)s: %(message)s'},
        'json':    {'()': 'logging.Formatter', 'fmt': '%(message)s'},
    },
    'handlers': {
        'console': {'class': 'logging.StreamHandler', 'formatter': 'verbose'},
        'file':    {'class': 'logging.FileHandler', 'filename': 'app.log',
                    'formatter': 'verbose', 'encoding': 'utf-8'},
    },
    'root': {'level': 'INFO', 'handlers': ['console', 'file']},
    'loggers': {
        'myapp.db': {'level': 'WARNING', 'propagate': True},
    },
}
logging.config.dictConfig(LOGGING)

# Structured / JSON logging (manual)
class JSONFormatter(logging.Formatter):
    def format(self, record):
        d = {'ts': self.formatTime(record), 'level': record.levelname,
             'msg': record.getMessage(), 'logger': record.name}
        if record.exc_info:
            d['exc'] = self.formatException(record.exc_info)
        return json.dumps(d)

# contextvar-based request ID (useful in async web servers)
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar('request_id', default='-')

class RequestFilter(logging.Filter):
    def filter(self, record):
        record.request_id = request_id.get()
        return True

Best Practices

Writing Pythonic Code

Python rewards idiomatic code — comprehensions, EAFP, built-in iteration helpers, and modern syntax (walrus operator, f-strings, star unpacking) produce shorter, more readable, and often faster programs.

python
# Comprehensions over loops
squares = [x**2 for x in range(10)]
evens   = {x for x in range(20) if x % 2 == 0}
index   = {v: i for i, v in enumerate(['a', 'b', 'c'])}

# EAFP (Easier to Ask Forgiveness than Permission)
# Prefer try/except over checking first
try:
    value = my_dict['key']
except KeyError:
    value = 'default'

# Use enumerate(), not range(len())
for i, item in enumerate(['a', 'b', 'c']):
    print(i, item)

# zip() for parallel iteration
for name, score in zip(names, scores):
    print(name, score)

# Throwaway variable
for _ in range(5):
    do_something()

# Star unpacking
first, *rest = [1, 2, 3, 4, 5]
head, *middle, tail = range(10)

# Walrus operator := (Python 3.8+)
if (n := len(data)) > 10:
    print(f'too long: {n}')

# any() / all()
all(x > 0 for x in nums)
any(x > 100 for x in nums)

# collections
from collections import defaultdict, Counter, deque
word_count = Counter('banana')        # Counter({'a': 3, 'n': 2, 'b': 1})
graph = defaultdict(list)             # no KeyError on missing key
q = deque([1, 2, 3], maxlen=5)       # O(1) append/pop from both ends

Functions & Arguments

Use keyword-only and positional-only parameters to enforce call-site clarity. Annotate all public functions with type hints. Keep functions small and focused, and use functools.cache / lru_cache for expensive pure functions.

python
# Keyword-only args — force callers to name them (after *)
def create_user(name: str, *, admin: bool = False, notify: bool = True):
    pass

create_user('Alice', admin=True)      # OK
# create_user('Alice', True)          # TypeError

# Positional-only args — callers cannot name them (before /)
def parse(text: str, sep: str, /, maxsplit: int = -1) -> list[str]:
    return text.split(sep, maxsplit)

# Type hints on every public function
from typing import Sequence
def average(values: Sequence[float]) -> float:
    return sum(values) / len(values)

# Default args must be immutable — never use mutable defaults
def append_item(item, result=None):   # OK: None, then create list
    if result is None:
        result = []
    result.append(item)
    return result
# def bad(item, result=[]):  WRONG — shared across calls

# Small, focused functions: one task, one level of abstraction
# Use *args/**kwargs sparingly — prefer explicit signatures

# Memoization with functools.cache / lru_cache
from functools import cache, lru_cache

@cache                                # Python 3.9+ unbounded
def fib(n: int) -> int:
    return n if n < 2 else fib(n-1) + fib(n-2)

@lru_cache(maxsize=256)
def expensive(x: int, y: int) -> float: ...

# One-line docstrings for public functions
def greet(name: str) -> str:
    'Return a greeting string for the given name.'
    return f'Hello, {name}!'

# Use dataclasses for parameter groups instead of long arg lists
from dataclasses import dataclass

@dataclass
class QueryOptions:
    limit: int = 100
    offset: int = 0
    order_by: str = 'id'

Classes & OOP

Prefer composition over inheritance, use @dataclass for data containers, and __slots__ when creating many instances. Always implement __repr__. Lean on @property and @classmethod over raw getters and imperative constructors.

python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Protocol

# Prefer composition over inheritance
class Logger:
    def log(self, msg: str) -> None: print(msg)

class Service:
    def __init__(self, logger: Logger) -> None:
        self._logger = logger        # composed, not inherited

# @dataclass for data-holding classes — auto __init__, __repr__, __eq__
@dataclass
class Point:
    x: float
    y: float

# __slots__ for memory-sensitive classes (many instances)
@dataclass(slots=True)              # Python 3.10+
class Particle:
    x: float; y: float; z: float

# Always implement __repr__ for debuggability
class Config:
    def __repr__(self) -> str:
        return f'Config(debug={self.debug!r})'

# @classmethod for alternative constructors
class Color:
    def __init__(self, r, g, b): self.r, self.g, self.b = r, g, b

    @classmethod
    def from_hex(cls, hex_str: str) -> 'Color':
        h = hex_str.lstrip('#')
        return cls(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

# @property instead of get_/set_ methods
class Circle:
    def __init__(self, radius: float) -> None:
        self._radius = radius

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value < 0: raise ValueError('radius must be non-negative')
        self._radius = value

# Keep __init__ simple — defer heavy work to factory methods or @property
# Use ABCs / Protocols for interfaces, not deep inheritance chains
class Drawable(Protocol):
    def draw(self) -> None: ...

Error Handling

Be specific in except clauses — never use bare except:. Log with exc_info=True, use contextlib.suppress for ignorable errors, and chain exceptions with raise ... from. Validate only at system boundaries.

python
import logging
import warnings
from contextlib import suppress

log = logging.getLogger(__name__)

# Be specific in except clauses
try:
    result = risky_op()
except ValueError as exc:
    handle_value_error(exc)
except (TypeError, KeyError) as exc:
    handle_other(exc)

# Never bare except: — catches SystemExit, KeyboardInterrupt, etc.
# BAD:  except:
# GOOD: except Exception:

# Log exceptions with exc_info=True (includes traceback)
try:
    connect()
except ConnectionError as exc:
    log.error('Connection failed', exc_info=True)

# contextlib.suppress for ignorable errors — explicit and readable
with suppress(FileNotFoundError):
    Path('cache.tmp').unlink()

# Re-raise with raise...from to preserve the original traceback
try:
    raw = fetch()
except requests.HTTPError as exc:
    raise ServiceError('upstream failed') from exc

# Custom exception hierarchies for domain errors
class AppError(Exception):
    'Base class for all application errors.'

class ValidationError(AppError):
    def __init__(self, field: str, message: str) -> None:
        super().__init__(f'{field}: {message}')
        self.field = field

class NotFoundError(AppError): ...

# Deprecation warnings — use warnings module, not print()
def old_api():
    warnings.warn('old_api() is deprecated, use new_api()', DeprecationWarning, stacklevel=2)

# Validate at system boundaries (input parsing, API responses)
# Do not add defensive checks inside already-trusted internal code

Tooling & Project Structure

Standardise on pyproject.toml, ruff for linting and formatting, mypy/pyright for type checking, and pytest for tests. Separate src/ from tests/, expose a clean public API via __all__, and use logging instead of print.

python
# pyproject.toml — single source of truth for the project
# [project]
# name = 'myapp'
# requires-python = '>=3.11'
# dependencies = ['httpx>=0.27', 'pydantic>=2']
# [project.optional-dependencies]
# dev = ['pytest', 'ruff', 'mypy']

# ruff — fast linter + formatter (replaces flake8 + isort + black)
# ruff check .          lint
# ruff check --fix .    auto-fix safe issues
# ruff format .         format (black-compatible)

# mypy / pyright — static type checking
# mypy src/             check all files under src/
# pyright src/          Microsoft's type checker (used by Pylance)

# pytest — test runner
# pytest -v             verbose output
# pytest -x             stop on first failure
# pytest --cov=src      with coverage (pytest-cov plugin)

# Virtual environments
# python -m venv .venv
# source .venv/bin/activate   (Unix)
# .venvScriptsactivate      (Windows)

# Pin dependencies for reproducibility
# pip freeze > requirements.txt

# Recommended project layout
# src/
#   myapp/
#     __init__.py        expose public API via __all__
#     core.py
#     utils.py
#     py.typed           PEP 561 marker (package ships type stubs)
# tests/
#   test_core.py
# pyproject.toml

# __all__ — explicit public API, aids IDEs and star-imports
# from .core import MyClass, helper
# __all__ = ['MyClass', 'helper']

# Use logging, not print(), for anything beyond scripts
import logging
log = logging.getLogger(__name__)

# pre-commit hooks — run ruff, mypy, tests before every commit
# .pre-commit-config.yaml  (pip install pre-commit; pre-commit install)

Performance

Profile before optimising with cProfile and timeit. Use generators for large data, sets for O(1) lookup, and local variables in tight loops. Delegate CPU-bound work to multiprocessing, I/O-bound work to asyncio, and numerical work to numpy/pandas.

python
import cProfile, timeit, tracemalloc
from functools import cache

# 1. Profile before optimising — never guess
cProfile.run('my_function()', sort='cumulative')
timeit.timeit('sum(range(1000))', number=10_000)

# 2. Generators for large datasets — no list in memory
def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

total = sum(1 for _ in read_lines('big.log'))

# 3. Sets for O(1) membership lookup
valid_ids = {1, 2, 3, 4, 5}
if user_id in valid_ids:      # O(1) vs O(n) for list
    allow()

# 4. Hoist to local variables in tight loops
import math
sqrt = math.sqrt              # avoid repeated attribute lookup
result = [sqrt(x) for x in data]

# 5. Avoid global variables — LOAD_GLOBAL is slower than LOAD_FAST

# 6. numpy / pandas for numerical work — 100x faster than loops
import numpy as np
arr = np.arange(1_000_000)
arr ** 2                       # vectorised, no Python loop

# 7. multiprocessing for CPU-bound work
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as ex:
    results = list(ex.map(heavy_fn, data))

# 8. asyncio for I/O-bound concurrency
import asyncio
async def fetch_all(urls):
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(u)) for u in urls]
    return [t.result() for t in tasks]

# 9. __slots__ to reduce per-instance memory (~30-50%)
class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y): self.x, self.y = x, y

# 10. Built-ins beat manual loops — map/filter/sum/any/all are C-level
total = sum(x**2 for x in range(10_000))   # faster than for-loop accumulate