Topic 1 · Section 1.1
Python Runtime
Before building FastAPI apps, you need a rock-solid understanding of how Python actually runs your code — from source file to bytecode to execution. This foundation explains why FastAPI makes the architectural decisions it does.
1.1.1
CPython — The Reference Interpreter
Interpreter Architecture
▶
CPython is the official, most-used Python implementation, written in C. When you run
The pipeline: Source code (.py) → Tokenizer/Parser → AST → Bytecode compiler → .pyc file → Python VM executes bytecode
python app.py, CPython is doing all the work. Understanding its architecture helps you write faster, more efficient FastAPI code.
The pipeline: Source code (.py) → Tokenizer/Parser → AST → Bytecode compiler → .pyc file → Python VM executes bytecode
FastAPI relevance: CPython's GIL (Global Interpreter Lock) means only one thread runs Python at a time. This is WHY FastAPI uses async/await — to achieve concurrency without threads.
python
# You can inspect CPython's compilation pipeline yourself import dis import py_compile # 1. Write a simple function def add(a, b): return a + b # 2. Disassemble: see the bytecode CPython generates dis.dis(add) # Output shows bytecode instructions like: # LOAD_FAST 0 (a) # LOAD_FAST 1 (b) # BINARY_OP 0 (+) # RETURN_VALUE # 3. Inspect the code object CPython creates print(add.__code__.co_consts) # constants print(add.__code__.co_varnames) # local variable names print(add.__code__.co_code) # raw bytecode bytes
Compilation Pipeline & Bytecode
▶
CPython does NOT compile to native machine code. It compiles to bytecode — a compact, platform-independent set of instructions for the Python Virtual Machine. Bytecode is cached in
__pycache__/ as .pyc files to speed up future runs.
python
import marshal, dis # Read a compiled .pyc file and inspect its bytecode with open('__pycache__/mymodule.cpython-312.pyc', 'rb') as f: f.read(16) # skip magic + metadata code = marshal.load(f) # load the bytecode object dis.dis(code) # disassemble — shows bytecode instructions # Python also gives you the AST (Abstract Syntax Tree) import ast tree = ast.parse("x = 1 + 2") print(ast.dump(tree, indent=2)) # Module(body=[Assign(targets=[Name(id='x')], # value=BinOp(left=Constant(1), op=Add(), right=Constant(2)))])
Virtual Machine (PVM)
▶
The Python Virtual Machine is a stack-based interpreter that executes bytecode one instruction at a time. It maintains an evaluation stack where operands are pushed/popped. It also manages frames — one per function call.
GIL (Global Interpreter Lock): The PVM allows only ONE thread to execute Python bytecode at a time. This is why CPU-bound multithreading doesn't give speedup in Python. FastAPI solves I/O bottlenecks using async/await, not threads.
python
import sys # Each function call creates a "frame" on the call stack def inner(): frame = sys._getframe() # current frame print(frame.f_code.co_name) # 'inner' print(frame.f_back.f_code.co_name) # caller's name def outer(): inner() outer() # sys.getrecursionlimit() — PVM enforces max stack depth print(sys.getrecursionlimit()) # 1000 by default
1.1.2
Execution Model
Module Loading & Imports
▶
When Python imports a module, it: (1) searches
sys.path, (2) compiles to bytecode if needed, (3) executes the module at top-level, (4) stores it in sys.modules for caching. Second imports reuse the cached version.
FastAPI: Module-level code runs once at startup. This is where you initialize your database engines, settings, and shared resources — not inside request handlers.
python
# module: myapp/database.py print("database.py is being executed") # runs ONCE engine = create_engine("sqlite:///app.db") # created ONCE # main.py import myapp.database # executes database.py → prints message import myapp.database # CACHED — does NOT execute again # Inspect what's loaded import sys print('myapp.database' in sys.modules) # True # sys.path controls where Python looks for modules print(sys.path) # list of directories to search
Name Resolution & LEGB Rule
▶
Python looks up variable names in this order: Local → Enclosing → Global → Built-in. Understanding LEGB is critical for writing correct FastAPI dependency functions and middleware.
python
# LEGB in action x = "global" # Global scope def outer(): x = "enclosing" # Enclosing scope def inner(): x = "local" # Local scope print(x) # → "local" (L wins) def inner2(): print(x) # → "enclosing" (E, since no local x) inner() inner2() outer() print(x) # → "global" (G) print(len) # → built-in function (B) # Modifying outer scope: use 'global' or 'nonlocal' counter = 0 def increment(): global counter # explicitly target global counter += 1 increment() print(counter) # → 1
Closures
▶
A closure is a function that "remembers" variables from its enclosing scope even after that scope has finished executing. FastAPI uses closures extensively in dependency injection and middleware factories.
python
# Basic closure def make_multiplier(factor): def multiply(x): return x * factor # 'factor' is a free variable return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # → 10 print(triple(5)) # → 15 # Inspect the closure's captured variables print(double.__closure__[0].cell_contents) # → 2 # ── FastAPI real-world pattern ── def require_role(role: str): """Returns a FastAPI dependency that checks for a specific role""" async def dependency(current_user = Depends(get_current_user)): if role not in current_user.roles: raise HTTPException(status_code=403) return current_user return dependency # 'role' is captured in closure # Usage: # @app.get("/admin", dependencies=[Depends(require_role("admin"))])
1.1.3
Memory Management
Stack vs Heap Memory
▶
In Python: Stack holds call frames (local variables, function calls) — fast, automatically managed. Heap holds all Python objects (lists, dicts, class instances) — managed by CPython's memory allocator and garbage collector.
Stack
- Function call frames
- Local variable names
- References (pointers)
- Auto-cleaned on return
Heap
- All Python objects
- Lists, dicts, instances
- Strings, numbers
- Managed by GC
Reference Counting
▶
Every Python object has a reference count. When you assign an object to a variable, the count goes up. When the variable goes out of scope or is reassigned, the count goes down. When it hits zero, the memory is freed immediately.
python
import sys # Reference counting demo a = [] # create list object → refcount = 1 b = a # another reference → refcount = 2 print(sys.getrefcount(a)) # → 3 (a, b, and getrefcount's arg) c = [a, a] # list holding 2 refs to same object print(sys.getrefcount(a)) # → 5 del b # refcount drops by 1 del c # refcount drops by 2 print(sys.getrefcount(a)) # → 2 (back to a + getrefcount) # Object identity vs equality x = "hello" y = "hello" print(x is y) # Often True — string interning! print(id(x)) # memory address of x print(id(y)) # same address — same object
Cyclic Garbage Collection
▶
Reference counting fails for circular references (A → B → A). Python's cyclic GC runs periodically to detect and collect these cycles. It uses generational collection — younger objects are collected more frequently since most objects die young.
FastAPI caution: Avoid circular references in your models and service objects. They delay GC, and in high-traffic APIs this accumulates memory pressure.
python
import gc # Create a circular reference class Node: def __init__(self): self.ref = None a = Node() b = Node() a.ref = b # a → b b.ref = a # b → a (cycle!) del a del b # refcount of both is 1 (due to cycle), NOT freed by refcount gc.collect() # manually trigger cyclic GC # Check GC generations print(gc.get_threshold()) # (700, 10, 10) — gen0, gen1, gen2 print(gc.get_count()) # objects in each generation # Object lifecycle summary: # __new__ → allocate memory # __init__ → initialize fields # __del__ → called before deallocation (avoid relying on this!)
Topic 1 · Section 1.2
Object-Oriented Programming
FastAPI is deeply object-oriented — from Pydantic models to dependency injection classes. Mastering Python OOP lets you write modular, reusable, and testable FastAPI applications.
1.2.1
Classes
Class Definition, Attributes & Methods
▶
A class is a blueprint for creating objects. It defines attributes (data) and methods (behavior). In FastAPI, classes are used for dependency injection, service layers, and Pydantic models.
python
class UserService: # Class attribute (shared across all instances) service_name: str = "UserService" # Constructor — called when you do UserService(...) def __init__(self, db_url: str): # Instance attributes (unique per instance) self.db_url = db_url self._users: list = [] # _ prefix = convention for "private" self.__secret = "hidden" # __ prefix = name mangling # Instance method def get_user(self, user_id: int): return next((u for u in self._users if u['id'] == user_id), None) # Class method — receives the CLASS not instance @classmethod def from_env(cls): import os return cls(db_url=os.getenv("DATABASE_URL")) # Static method — no access to class or instance @staticmethod def validate_email(email: str) -> bool: return "@" in email # __repr__ and __str__ for debugging def __repr__(self): return f"UserService(db_url={self.db_url!r})" svc = UserService("postgresql://localhost/app") print(svc.validate_email("user@example.com")) # True svc2 = UserService.from_env() # factory pattern
1.2.2
Inheritance
Single & Multiple Inheritance + MRO
▶
Python supports single (one parent) and multiple (many parents) inheritance. The MRO (Method Resolution Order) — computed via the C3 linearization algorithm — determines which method is called when multiple parents define the same name.
python
# Single Inheritance class BaseRepository: def __init__(self, db): self.db = db def find_by_id(self, id: int): raise NotImplementedError class UserRepository(BaseRepository): # inherits BaseRepository def find_by_id(self, id: int): # override return self.db.query(f"SELECT * FROM users WHERE id={id}") def find_by_email(self, email: str): return self.db.query(f"SELECT * FROM users WHERE email='{email}'") # Multiple Inheritance class TimestampMixin: def created_at(self): return "now" class AuditMixin: def audit_log(self): return "logged" class AuditedUserRepo(UserRepository, TimestampMixin, AuditMixin): pass # Inspect MRO — shows lookup order print(AuditedUserRepo.__mro__) # (AuditedUserRepo, UserRepository, BaseRepository, TimestampMixin, AuditMixin, object) # super() follows MRO — always prefer it over direct parent calls class ExtendedRepo(UserRepository): def find_by_id(self, id: int): result = super().find_by_id(id) # calls UserRepository.find_by_id return {"data": result, "cached": False}
1.2.3
Composition
Has-a vs Is-a (Composition over Inheritance)
▶
Composition ("has-a") is often preferred over inheritance ("is-a"). Instead of subclassing, you inject dependencies as constructor parameters. FastAPI's dependency injection is built on this principle.
python
# ❌ Inheritance approach — tightly coupled class UserService(SQLAlchemyEngine): # BAD: UserService IS a DB engine? pass # ✅ Composition approach — loosely coupled, testable class UserRepository: def __init__(self, db: AsyncSession): self.db = db # HAS-A database session async def get(self, user_id: int): return await self.db.get(User, user_id) class EmailService: def __init__(self, smtp_host: str): self.smtp_host = smtp_host async def send(self, to: str, body: str): ... class UserService: def __init__( self, repo: UserRepository, # HAS-A repo email: EmailService, # HAS-A email service ): self.repo = repo self.email = email async def register(self, user_data: dict): user = await self.repo.create(user_data) await self.email.send(user.email, "Welcome!") return user # FastAPI DI wires this automatically: # async def get_user_service(db=Depends(get_db)) -> UserService: # return UserService(repo=UserRepository(db), email=EmailService(...))
1.2.4
Mixins
Reusable Mixin Patterns
▶
A Mixin is a small class that provides specific, reusable behavior meant to be mixed into other classes. Mixins don't stand alone — they supplement the main class without creating deep inheritance trees.
python
# Mixin: adds created_at / updated_at to any SQLAlchemy model from datetime import datetime class TimestampMixin: created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) def touch(self): self.updated_at = datetime.utcnow() class SoftDeleteMixin: deleted_at: datetime | None = None def soft_delete(self): self.deleted_at = datetime.utcnow() def is_deleted(self) -> bool: return self.deleted_at is not None # Use mixins to compose rich models cleanly class UserModel(TimestampMixin, SoftDeleteMixin, BaseModel): id: int name: str email: str # inherits: created_at, updated_at, touch(), soft_delete(), is_deleted() user = UserModel(id=1, name="Alice", email="a@b.com") user.touch() # update timestamp user.soft_delete() # mark as deleted without DB DELETE print(user.is_deleted()) # True
1.2.5
Abstract Classes
ABC & Abstract Methods
▶
Abstract classes define a contract — a set of methods that subclasses MUST implement. You cannot instantiate an abstract class directly. Use
abc.ABC and @abstractmethod. This is the foundation for the Repository Pattern in FastAPI.
python
from abc import ABC, abstractmethod from typing import Generic, TypeVar T = TypeVar('T') # Abstract base — defines the interface (contract) class BaseRepository(ABC, Generic[T]): @abstractmethod async def get(self, id: int) -> T | None: ... @abstractmethod async def create(self, data: dict) -> T: ... @abstractmethod async def delete(self, id: int) -> bool: ... # Concrete implementation class SQLUserRepository(BaseRepository[User]): def __init__(self, session: AsyncSession): self.session = session async def get(self, id: int) -> User | None: return await self.session.get(User, id) async def create(self, data: dict) -> User: user = User(**data) self.session.add(user) await self.session.commit() return user async def delete(self, id: int) -> bool: user = await self.get(id) if user: await self.session.delete(user) await self.session.commit() return True return False # Can't instantiate abstract class: # repo = BaseRepository() → TypeError! # Must use concrete: repo = SQLUserRepository(session) # Benefit: swap implementations easily in tests class InMemoryUserRepository(BaseRepository[User]): def __init__(self): self._store = {} async def get(self, id): return self._store.get(id) async def create(self, data): ... async def delete(self, id): ...
Topic 1 · Section 1.3
Python Typing System
FastAPI is type-first — it reads your type annotations to automatically parse requests, validate data, and generate OpenAPI docs. A deep understanding of Python's typing system is non-negotiable.
1.3.1
Basic Types
int, str, float, bool — Type Annotations
▶
Python type annotations are hints for tools (like FastAPI, mypy, and your IDE) — they don't enforce types at runtime by themselves. FastAPI DOES enforce them at request time using Pydantic.
python
# Basic type annotations def greet(name: str, repeat: int = 1) -> str: return (name + " ") * repeat # Variable annotations user_id: int = 42 is_active: bool = True price: float = 9.99 username: str = "alice" # Collection types from typing import List, Dict, Tuple, Set # Python 3.8 style tags: List[str] = ["fastapi", "python"] # Python 3.9+ — use built-in generics directly tags: list[str] = ["fastapi", "python"] meta: dict[str, int] = {"age": 30} coords: tuple[float, float] = (12.5, 77.6) # FastAPI uses these to generate OpenAPI schema automatically # @app.get("/users/{user_id}") # async def get_user(user_id: int): ← FastAPI validates int # ... ← 422 if non-integer given
1.3.2
Advanced Types
Optional & Union
▶
Optional[X] is shorthand for
Union[X, None] — means the value can be X or None. Union[X, Y] means either X or Y. Python 3.10+ uses the | operator as a shorthand.
python
from typing import Optional, Union # Optional — value can be str or None def find_user(email: Optional[str] = None) -> Optional[dict]: if email is None: return None return {"email": email} # Union — accepts multiple types def process(value: Union[int, str]) -> str: return str(value) # Python 3.10+ shorthand (preferred now) def find_user(email: str | None = None) -> dict | None: ... def process(value: int | str) -> str: ... # FastAPI query params example: # @app.get("/users") # async def list_users(role: str | None = None): ← optional filter # ...
Literal, Any & Generic
▶
Literal constrains a value to specific literal values. Any disables type checking (use sparingly). Generic lets you build reusable typed containers like a generic Response wrapper.
python
from typing import Literal, Any, Generic, TypeVar # Literal — only these exact values allowed Status = Literal["active", "inactive", "pending"] def set_status(status: Status) -> None: ... set_status("active") # ✅ OK # set_status("deleted") → mypy error # Any — opt out of type checking (avoid in production code) def legacy_func(data: Any) -> Any: return data # Generic — build reusable typed wrappers T = TypeVar('T') class APIResponse(Generic[T]): def __init__(self, data: T, message: str = "success"): self.data = data self.message = message self.success = True # Typed responses for different resources user_resp: APIResponse[User] = APIResponse(data=user) list_resp: APIResponse[list[User]] = APIResponse(data=users) # Pydantic v2 Generic Model (used in FastAPI responses) from pydantic import BaseModel class PaginatedResponse(BaseModel, Generic[T]): items: list[T] total: int page: int size: int # @app.get("/users", response_model=PaginatedResponse[UserOut])
1.3.3
Structural Typing
Protocol — Duck Typing, Typed
▶
Protocol enables structural (duck) typing — a class satisfies the protocol if it has the right methods, without explicitly inheriting from it. Perfect for defining interfaces that third-party classes should satisfy.
python
from typing import Protocol, runtime_checkable @runtime_checkable class Cacheable(Protocol): def cache_key(self) -> str: ... def to_dict(self) -> dict: ... # Any class with these methods satisfies Cacheable class UserDTO: def __init__(self, id: int, name: str): self.id = id; self.name = name def cache_key(self) -> str: return f"user:{self.id}" def to_dict(self) -> dict: return {"id": self.id, "name": self.name} def cache_object(obj: Cacheable, redis): # accepts anything with cache_key+to_dict redis.set(obj.cache_key(), obj.to_dict()) user = UserDTO(1, "Alice") print(isinstance(user, Cacheable)) # True (runtime check) cache_object(user, redis_client) # ✅ UserDTO never imports Cacheable
TypedDict
▶
TypedDict lets you define the expected shape of a dictionary with typed keys. Useful when working with legacy code or external APIs that return plain dicts rather than Pydantic models.
In FastAPI: Prefer Pydantic BaseModel over TypedDict for request/response bodies — you get validation, serialization, and docs for free. Use TypedDict for internal data structures where you want typing without Pydantic overhead.
python
from typing import TypedDict, Required, NotRequired # Define a typed dict shape class UserDict(TypedDict): id: int name: str email: str # With optional fields (Python 3.11+) class UserUpdateDict(TypedDict, total=False): # all keys optional name: str email: str # Or mix required and optional (Python 3.11+) class UserCreateDict(TypedDict): name: Required[str] email: Required[str] role: NotRequired[str] # optional def process_user(user: UserDict) -> str: return f"{user['name']} <{user['email']}>" data: UserDict = {"id": 1, "name": "Alice", "email": "a@b.com"} print(process_user(data)) # Alice <a@b.com>