Skip to content

Instantly share code, notes, and snippets.

@lantzbuilds
Created February 12, 2026 05:25
Show Gist options
  • Select an option

  • Save lantzbuilds/9297991a44970ee6c399852534a708cd to your computer and use it in GitHub Desktop.

Select an option

Save lantzbuilds/9297991a44970ee6c399852534a708cd to your computer and use it in GitHub Desktop.
FastAPI concepts for tech interview interviewers

FastAPI Concepts for Interviewers

A reference guide covering key FastAPI/Pydantic patterns used in the Employee Directory API solution.


1. Flask Dataclass vs Pydantic BaseModel

In the Flask solution, we use Python's built-in dataclass:

from dataclasses import dataclass, asdict

@dataclass
class Employee:
    id: str
    name: str
    email: str
    department: str
    title: str
    manager_id: Optional[str] = None

In the FastAPI solution, we use Pydantic's BaseModel:

from pydantic import BaseModel

class Employee(BaseModel):
    id: str
    name: str
    email: str
    department: str
    title: str
    manager_id: Optional[str] = None

They look similar but serve different purposes:

Dataclass is a convenience for defining classes that primarily store data. It auto-generates __init__, __repr__, and __eq__ methods. However, it performs no validation at runtime — if you pass an integer where a string is expected, Python won't complain:

emp = Employee(id=123, name="Sarah", ...)  # No error — 123 is stored as-is

To serialize a dataclass to a dictionary, you use the asdict() helper function.

BaseModel does everything a dataclass does, plus:

  • Runtime validation: Pydantic validates and coerces types when a model is instantiated. If a field expects str and receives 123, Pydantic will coerce it to "123". If it receives a value that can't be coerced, it raises a ValidationError.
  • Serialization built-in: Methods like .model_dump() (to dict) and .model_json_schema() (to JSON Schema) are built into the class.
  • FastAPI integration: When a BaseModel is used as a route parameter, FastAPI automatically parses the request body, validates it, and returns a 422 response with detailed error messages if validation fails. This is why the FastAPI solution doesn't need manual "missing required fields" checks — Pydantic handles it.
# Flask — manual validation
data = request.get_json()
required_fields = ['name', 'email', 'department', 'title']
missing_fields = [f for f in required_fields if f not in data]
if missing_fields:
    return jsonify({'error': 'Missing required fields', 'missing': missing_fields}), 400

# FastAPI — Pydantic handles it automatically
@app.post("/employees", status_code=201)
def create_employee(data: EmployeeCreate):  # Pydantic validates before this runs
    ...

Why we use separate models in FastAPI (EmployeeCreate, EmployeeUpdate, Employee):

  • EmployeeCreate — all fields required except manager_id (for POST)
  • EmployeeUpdate — all fields optional (for PATCH)
  • Employee — includes id (for responses)

This pattern avoids a single model trying to serve all purposes, which would require making fields optional that should be required on creation.


2. The @asynccontextmanager and yield in Lifespan

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: runs BEFORE the app starts accepting requests
    seed_data()
    print(f"Seeded {len(employees_db)} employees")
    yield
    # Shutdown: runs AFTER the app stops accepting requests
    # (cleanup code would go here)

app = FastAPI(title="Employee Directory API", lifespan=lifespan)

How it works

The yield keyword splits the function into two phases:

  1. Everything before yield runs during startup (before the server accepts requests)
  2. Everything after yield runs during shutdown (after the server stops accepting requests)

Think of it like a sandwich — the app's entire lifetime happens at the yield point:

seed_data()          ← startup code
yield                ← app runs here, serving requests...
cleanup_resources()  ← shutdown code (when app stops)

Why async?

The @asynccontextmanager decorator is the async version of Python's @contextmanager. FastAPI is built on ASGI (Asynchronous Server Gateway Interface), so its lifecycle hooks are async. Even though our seed_data() function is synchronous, the lifespan function itself must be async because that's what FastAPI's interface expects.

Historical context

The older FastAPI pattern used event decorators:

# Deprecated — don't use
@app.on_event("startup")
async def startup():
    seed_data()

@app.on_event("shutdown")
async def shutdown():
    cleanup()

The lifespan approach replaced this because it allows sharing state between startup and shutdown (e.g., opening a database connection before yield and closing it after), and it's a single function rather than two disconnected ones.


3. FastAPI's Query Class — ge and le

from fastapi import Query

@app.get("/employees")
def list_employees(
    department: Optional[str] = Query(None, description="Filter by department"),
    q: Optional[str] = Query(None, description="Search employee names"),
    page: int = Query(1, ge=1, description="Page number"),
    per_page: Optional[int] = Query(None, ge=1, le=100, description="Items per page"),
):

What Query does

Query tells FastAPI that a parameter comes from the URL's query string (the ?key=value part). You could omit Query entirely and FastAPI would still treat these as query parameters based on their position in the function signature. But Query adds:

  • A default value — the first argument (e.g., Query(None) means optional, Query(1) means defaults to 1)
  • Validation constraints — parameters like ge, le, gt, lt
  • OpenAPI metadatadescription appears in the auto-generated Swagger docs at /docs

ge and le

These are numeric validators borrowed from Pydantic's constraint vocabulary:

Parameter Meaning Mnemonic
ge greater than or equal to (>=) ge=1 means value must be >= 1
le less than or equal to (<=) le=100 means value must be <= 100
gt greater than (>) gt=0 means value must be > 0
lt less than (<) lt=101 means value must be < 101

So per_page: Optional[int] = Query(None, ge=1, le=100) means:

  • It's optional (default is None)
  • If provided, it must be an integer between 1 and 100 inclusive
  • FastAPI returns a 422 with a clear error message if the constraint is violated

Compare this to the Flask solution where we validate manually:

# Flask — manual clamping (silently corrects bad input)
per_page = min(max(1, per_page), 100)

# FastAPI — Query validation (rejects bad input with 422)
per_page: Optional[int] = Query(None, ge=1, le=100)

The Flask approach silently clamps values (requesting per_page=500 gives you 100). The FastAPI approach rejects invalid values outright with a validation error. Both are valid design choices — silent correction is more forgiving, explicit rejection is more transparent.

FastAPI also provides Path and Body classes that work identically to Query but for path parameters and request body fields, respectively.


4. model_dump() and model_copy()

These are Pydantic v2 methods on any BaseModel instance.

model_dump()

Converts a Pydantic model to a plain dictionary.

emp = Employee(id="emp-001", name="Sarah", email="sarah@cascade.ai",
               department="Engineering", title="VP of Engineering")

emp.model_dump()
# {'id': 'emp-001', 'name': 'Sarah', 'email': 'sarah@cascade.ai',
#  'department': 'Engineering', 'title': 'VP of Engineering', 'manager_id': None}

This is the Pydantic v2 equivalent of asdict() for dataclasses.

The exclude_unset parameter is the key pattern for PATCH endpoints:

data = EmployeeUpdate(title="Staff Engineer")

data.model_dump()
# {'name': None, 'email': None, 'department': None, 'title': 'Staff Engineer', 'manager_id': None}

data.model_dump(exclude_unset=True)
# {'title': 'Staff Engineer'}

Without exclude_unset=True, every field appears in the dict — including ones the client never sent, which would overwrite existing values with None. With exclude_unset=True, only fields the client explicitly included in the request body are returned. This is what makes true partial updates possible.

Pydantic tracks which fields were explicitly set during instantiation vs which used their default values, so it can distinguish between "client sent manager_id: null" and "client didn't send manager_id at all" — a distinction that's impossible with plain dictionaries.

Pydantic v1 equivalent: The older API used .dict(exclude_unset=True). If a candidate uses .dict(), it still works but indicates they learned from older tutorials or documentation.

model_copy()

Creates a shallow copy of a model with optional field overrides.

emp = Employee(id="emp-001", name="Sarah", email="sarah@cascade.ai",
               department="Engineering", title="VP of Engineering")

updated = emp.model_copy(update={"title": "CTO", "department": "Executive"})
# updated is a new Employee with title="CTO" and department="Executive"
# emp is unchanged

The update parameter is a dictionary of fields to override in the copy. This is how the FastAPI solution applies partial updates:

update_data = data.model_dump(exclude_unset=True)   # Only fields the client sent
updated = emp.model_copy(update=update_data)         # New model with those fields changed
employees_db[employee_id] = updated                  # Replace in storage

Compare this to the Flask solution which mutates the dataclass in place:

# Flask — mutates the existing object
for field in ['name', 'email', 'department', 'title', 'manager_id']:
    if field in data:
        setattr(emp, field, data[field])

The model_copy approach is more idiomatic for Pydantic because BaseModel instances are conventionally treated as immutable (even though they technically aren't in v2). Creating a new instance rather than mutating also means validation runs on the updated data.

Pydantic v1 equivalent: The older API used .copy(update={...}).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment