How HTTP Really Works
Before writing a single line of FastAPI, understand what's actually happening on the wire. Every framework abstracts HTTP β you need to understand what it's abstracting.
Every API interaction is a request from a client and a response from a server. That's it. HTTP is a text-based protocol β the actual bytes going across the network are readable text.
# The client sends this text to the server: POST /api/tasks HTTP/1.1 Host: api.myapp.com Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... Content-Length: 52 {"title": "Learn FastAPI", "priority": 3} # The server responds with this text: HTTP/1.1 201 Created Content-Type: application/json Location: /api/tasks/42 {"id": 42, "title": "Learn FastAPI", "priority": 3, "done": false}
Every HTTP message has the same structure: a start line (method + path + version, or status code), headers (key-value metadata), a blank line, and an optional body. FastAPI handles all of this β but knowing the structure helps you debug anything.
| Method | Meaning | Has Body? | Idempotent? |
|---|---|---|---|
| GET | Read a resource β never modify data | No | β Yes β same result every time |
| POST | Create a new resource | Yes | β No β creates new item each call |
| PUT | Replace entire resource | Yes | β Yes β result same if called twice |
| PATCH | Partially update a resource | Yes | Usually yes |
| DELETE | Remove a resource | Rarely | β Yes β deleting twice = same state |
Design your APIs around idempotency
An idempotent operation produces the same result whether called once or ten times. GET /tasks/42 always returns the same task. DELETE /tasks/42 on a deleted task returns 404 but doesn't break anything. POST /tasks is not idempotent β calling it 10 times creates 10 tasks.
This matters for retries. When a network call fails, can you safely retry it? If the endpoint is idempotent, yes. If it's a POST that charges a credit card, no β you need idempotency keys.
| Code | Meaning | When to Use |
|---|---|---|
200 OK | Success with body | Successful GET, PUT, PATCH |
201 Created | New resource created | Successful POST |
204 No Content | Success, no body | Successful DELETE |
400 Bad Request | Client sent invalid data | Validation failures, malformed JSON |
401 Unauthorized | Not authenticated | Missing or invalid token |
403 Forbidden | Authenticated but no permission | Accessing another user's data |
404 Not Found | Resource doesn't exist | Task ID not in database |
409 Conflict | State conflict | Duplicate username, already completed |
422 Unprocessable Entity | Validation error (FastAPI default) | Wrong type, missing required field |
500 Internal Server Error | Server crashed | Unhandled exception β never intentional |
REST (Representational State Transfer) is an architectural style β a set of conventions for designing URLs and using HTTP correctly. Following them makes your API predictable to any developer.
/getTasks or /deleteTask β the HTTP method IS the verb. Use plural nouns for collections (/tasks not /task). Use lowercase with hyphens for multi-word resources (/user-profiles).FastAPI β First Steps
FastAPI is the modern Python web framework β faster than Flask, cleaner than Django, with automatic API docs, async support, and Pydantic validation built in from day one.
| Feature | FastAPI | Flask | Django |
|---|---|---|---|
| Speed (requests/sec) | Very fast (async) | Moderate | Moderate |
| Auto API docs | β Built-in Swagger + ReDoc | β Plugin needed | β Plugin needed |
| Type hints / Pydantic | β First class | β Manual | β Manual |
| Request validation | β Automatic | β Manual | Partial (forms) |
| Learning curve | Low | Very low | High |
| Best for | APIs, microservices | Simple apps | Full-stack web apps |
pip install "fastapi[standard]" # [standard] includes uvicorn (the server) and extra tools
from fastapi import FastAPI app = FastAPI( title="Task Manager API", description="Manage your tasks via REST", version="1.0.0" ) @app.get("/") def root(): return {"message": "Task Manager API is running"} @app.get("/health") def health_check(): return {"status": "ok"} # Run with: uvicorn main:app --reload # --reload restarts the server on every file save (dev mode)
uvicorn main:app --reload INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Started reloader process INFO: Application startup complete.
http://127.0.0.1:8000/docs β you get a full Swagger UI where you can test every endpoint in the browser. Also try /redoc for an alternative view. FastAPI generates these from your code automatically β no extra work.FastAPI is an ASGI framework (Asynchronous Server Gateway Interface). It needs an ASGI server to run β that's uvicorn. Think of it like Tomcat to Java or Nginx to PHP.
| Command | When |
|---|---|
uvicorn main:app --reload | Development β auto-restarts on changes |
uvicorn main:app --host 0.0.0.0 --port 8000 | Production β binds to all interfaces |
uvicorn main:app --workers 4 | Production β multiple worker processes |
main:app means: find the file main.py, use the variable app inside it. If your file is api/server.py and the FastAPI instance is named application, you'd write api.server:application.
Routes & Parameters
Path parameters, query parameters, and how FastAPI automatically validates, converts, and documents them β all from your type hints alone.
| Type | In URL | Example | FastAPI syntax |
|---|---|---|---|
| Path parameter | Part of the path | /tasks/42 | {task_id} in route + function arg |
| Query parameter | After ? | /tasks?done=false&limit=10 | Function arg with default |
| Request body | Not in URL β in body | JSON payload | Pydantic model arg |
from fastapi import FastAPI, Path, Query, HTTPException from typing import Optional app = FastAPI() # ββ Path parameter β {task_id} in the route ββ @app.get("/tasks/{task_id}") def get_task( task_id: int = Path(..., ge=1, description="The task ID") ): """ FastAPI automatically: - converts task_id from string to int - validates it's >= 1 - documents it in /docs - returns 422 if it's not an int """ task = db.get_task(task_id) if not task: # Raise HTTPException to return proper HTTP error responses raise HTTPException(status_code=404, detail=f"Task {task_id} not found") return task # ββ Query parameters β function args with defaults ββ @app.get("/tasks") def list_tasks( done: Optional[bool] = Query(default=None, description="Filter by done status"), priority: Optional[int] = Query(default=None, ge=1, le=5), limit: int = Query(default=20, le=100), offset: int = Query(default=0, ge=0), ): """ GET /tasks β all tasks, first 20 GET /tasks?done=false β only pending GET /tasks?priority=5 β only critical GET /tasks?limit=5&offset=10 β pagination """ tasks = db.all_tasks() if done is not None: tasks = [t for t in tasks if t["done"] == done] if priority: tasks = [t for t in tasks if t["priority"] == priority] return tasks[offset : offset + limit] # ββ Multiple path parameters ββ @app.get("/users/{user_id}/tasks/{task_id}") def get_user_task(user_id: int, task_id: int): return {"user_id": user_id, "task_id": task_id} # ββ Enum path parameter β only specific string values ββ from enum import Enum class SortField(str, Enum): created = "created" priority = "priority" title = "title" @app.get("/tasks/sorted/{field}") def sorted_tasks(field: SortField): # FastAPI validates and documents the allowed values return {"sorted_by": field.value}
Don't return error dicts β raise exceptions
Returning {"error": "not found"} with a 200 status is wrong β the HTTP layer carries status information for a reason. Raising HTTPException(status_code=404, detail="...") returns a proper HTTP 404 with a JSON body. Client libraries, browsers, and logging tools all know how to handle proper status codes. A 404 in the logs means something different from a 500.
def get_task(task_id: int) for route /tasks/{task_id}. A client requests /tasks/abc. What happens?Request & Response Bodies
Pydantic models are the contract between your API and its clients. Define them carefully β they drive validation, serialization, documentation, and type safety all at once.
from pydantic import BaseModel, Field from typing import Optional from datetime import date from fastapi import FastAPI app = FastAPI() # ββ Separate schemas for create vs response ββ # This is the right pattern β don't reuse one model for everything class TaskCreate(BaseModel): """What the client sends when creating a task.""" title: str = Field(..., min_length=1, max_length=200) priority: int = Field(default=2, ge=1, le=5) due: Optional[date] = None tags: list[str] = [] class TaskUpdate(BaseModel): """What the client sends when updating β all fields optional (PATCH).""" title: Optional[str] = Field(default=None, min_length=1) priority: Optional[int] = Field(default=None, ge=1, le=5) done: Optional[bool] = None due: Optional[date] = None class TaskResponse(BaseModel): """What the server returns β includes server-generated fields.""" id: int title: str priority: int done: bool due: Optional[date] tags: list[str] created: str # server sets this β client doesn't send it # ββ Routes using the schemas ββ @app.post("/tasks", response_model=TaskResponse, status_code=201) def create_task(task: TaskCreate): """ FastAPI automatically: 1. Parses the JSON body 2. Validates it against TaskCreate 3. Returns 422 if invalid 4. Calls your function with a proper TaskCreate object 5. Serializes the return value through TaskResponse """ new_task = db.create(task) return new_task # response_model filters to only TaskResponse fields @app.patch("/tasks/{task_id}", response_model=TaskResponse) def update_task(task_id: int, update: TaskUpdate): task = db.get(task_id) if not task: raise HTTPException(404, "Task not found") # model_dump(exclude_unset=True) β only the fields the client sent changes = update.model_dump(exclude_unset=True) return db.update(task_id, changes)
response_model=TaskResponse does two things: it filters the output (so internal fields like passwords are never accidentally returned), and it generates accurate response documentation in /docs. Always set it β never return raw dicts from production endpoints.from fastapi import Request from fastapi.responses import JSONResponse # Catch any unhandled exception β return JSON, not an HTML crash page @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): import logging logging.getLogger("api").error("Unhandled error", exc_info=exc) return JSONResponse( status_code=500, content={"detail": "Internal server error"} # Never expose exc details to clients in production ) # Custom exception class + handler class NotFoundError(Exception): def __init__(self, resource: str, id: int): self.resource = resource self.id = id @app.exception_handler(NotFoundError) async def not_found_handler(request: Request, exc: NotFoundError): return JSONResponse( status_code=404, content={"detail": f"{exc.resource} {exc.id} not found"} )
Database with SQLModel
SQLModel combines SQLAlchemy (the Python database powerhouse) with Pydantic β so your database models and API schemas share the same class. One definition, no duplication.
pip install sqlmodel # SQLModel includes SQLAlchemy and Pydantic β no separate installs
from sqlmodel import create_engine, SQLModel, Session # SQLite for development β zero setup, file-based DATABASE_URL = "sqlite:///tasks.db" # PostgreSQL for production: # DATABASE_URL = "postgresql://user:password@localhost:5432/mydb" engine = create_engine( DATABASE_URL, echo=True, # logs every SQL query (disable in production) connect_args={"check_same_thread": False} # SQLite-only setting ) def create_db_and_tables(): SQLModel.metadata.create_all(engine) # creates tables if they don't exist def get_session(): with Session(engine) as session: yield session # FastAPI dependency injection uses this
from sqlmodel import SQLModel, Field from typing import Optional from datetime import date, datetime # ββ The base fields shared across schemas ββ class TaskBase(SQLModel): title: str = Field(min_length=1, max_length=200) priority: int = Field(default=2, ge=1, le=5) done: bool = False due: Optional[date] = None # ββ The database table β table=True creates the SQL table ββ class Task(TaskBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) created: datetime = Field(default_factory=datetime.utcnow) # ββ API schemas β inherit from TaskBase ββ class TaskCreate(TaskBase): pass # exactly the base fields β no id, no created class TaskUpdate(SQLModel): title: Optional[str] = None priority: Optional[int] = None done: Optional[bool] = None due: Optional[date] = None class TaskResponse(TaskBase): id: int created: datetime
from fastapi import FastAPI, Depends, HTTPException from sqlmodel import Session, select from models import Task, TaskCreate, TaskUpdate, TaskResponse from database import get_session, create_db_and_tables app = FastAPI() @app.on_event("startup") def on_startup(): create_db_and_tables() # runs once on server start # ββ CREATE ββ @app.post("/tasks", response_model=TaskResponse, status_code=201) def create_task(task_in: TaskCreate, session: Session = Depends(get_session)): task = Task.model_validate(task_in) # create Task from TaskCreate session.add(task) session.commit() session.refresh(task) # load server-generated id and created return task # ββ READ ALL ββ @app.get("/tasks", response_model=list[TaskResponse]) def list_tasks( done: bool | None = None, offset: int = 0, limit: int = 20, session: Session = Depends(get_session) ): stmt = select(Task) if done is not None: stmt = stmt.where(Task.done == done) return session.exec(stmt.offset(offset).limit(limit)).all() # ββ READ ONE ββ @app.get("/tasks/{task_id}", response_model=TaskResponse) def get_task(task_id: int, session: Session = Depends(get_session)): task = session.get(Task, task_id) if not task: raise HTTPException(404, f"Task {task_id} not found") return task # ββ UPDATE ββ @app.patch("/tasks/{task_id}", response_model=TaskResponse) def update_task(task_id: int, update: TaskUpdate, session: Session = Depends(get_session)): task = session.get(Task, task_id) if not task: raise HTTPException(404, f"Task {task_id} not found") changes = update.model_dump(exclude_unset=True) task.sqlmodel_update(changes) session.add(task) session.commit() session.refresh(task) return task # ββ DELETE ββ @app.delete("/tasks/{task_id}", status_code=204) def delete_task(task_id: int, session: Session = Depends(get_session)): task = session.get(Task, task_id) if not task: raise HTTPException(404, f"Task {task_id} not found") session.delete(task) session.commit() # Return None β status 204 means "no content"
Authentication β API Keys & JWT
Two levels of auth: simple API keys for server-to-server, and JWT tokens for user authentication. Understand both β when to use each and how to implement them properly.
API keys are the simplest auth pattern β a secret string in a header. Perfect for server-to-server calls, webhooks, and internal tools.
from fastapi import Security, HTTPException, status from fastapi.security import APIKeyHeader import os, secrets # Reads the X-API-Key header from every request api_key_scheme = APIKeyHeader(name="X-API-Key") VALID_API_KEYS = { os.getenv("API_KEY_ADMIN"): "admin", os.getenv("API_KEY_READONLY"): "reader", } def get_current_role(api_key: str = Security(api_key_scheme)) -> str: role = VALID_API_KEYS.get(api_key) if not role: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key" ) return role # Protect an endpoint β inject the dependency @app.delete("/tasks/{task_id}", status_code=204) def delete_task( task_id: int, role: str = Depends(get_current_role), # auth happens here session: Session = Depends(get_session) ): if role != "admin": raise HTTPException(403, "Admin role required") # ... delete logic # Generate a secure API key def generate_api_key() -> str: return secrets.token_urlsafe(32) # 32 bytes = 43 characters, URL-safe
JWT (JSON Web Token) is the standard for user authentication. Users log in with a username/password, receive a token, and send that token with every subsequent request.
pip install "python-jose[cryptography]" passlib[bcrypt] # jose = JWT creation/validation | passlib = password hashing
from jose import JWTError, jwt from passlib.context import CryptContext from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from datetime import datetime, timedelta from pydantic import BaseModel import os SECRET_KEY = os.getenv("JWT_SECRET_KEY") # long random string in .env ALGORITHM = "HS256" TOKEN_EXPIRE = 30 # minutes pwd_ctx = CryptContext(schemes=["bcrypt"]) oauth2 = OAuth2PasswordBearer(tokenUrl="token") class Token(BaseModel): access_token: str token_type: str def hash_password(plain: str) -> str: return pwd_ctx.hash(plain) def verify_password(plain: str, hashed: str) -> bool: return pwd_ctx.verify(plain, hashed) def create_access_token(data: dict) -> str: payload = data.copy() payload["exp"] = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE) return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def get_current_user(token: str = Depends(oauth2)): credentials_err = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username = payload.get("sub") if not username: raise credentials_err except JWTError: raise credentials_err user = db.get_user_by_name(username) if not user: raise credentials_err return user # ββ Login endpoint β returns a JWT ββ @app.post("/token", response_model=Token) def login(form: OAuth2PasswordRequestForm = Depends()): user = db.get_user_by_name(form.username) if not user or not verify_password(form.password, user.hashed_password): raise HTTPException(401, "Incorrect username or password") token = create_access_token({"sub": user.username}) return {"access_token": token, "token_type": "bearer"} # ββ Protected endpoint ββ @app.get("/me") def get_me(current_user = Depends(get_current_user)): return current_user
Dependency Injection
FastAPI's Depends() system is one of its most powerful features β it makes authentication, database sessions, configuration, and shared logic reusable and testable with almost no boilerplate.
When FastAPI sees Depends(something) in a function signature, it calls something() first, passes the result in, and handles cleanup. This lets you declare "I need a database session" or "I need the current user" without every endpoint knowing how to obtain them.
from fastapi import Depends, FastAPI, Query from sqlmodel import Session app = FastAPI() # ββ Dependency: database session ββ def get_db() -> Session: with Session(engine) as session: yield session # yield = context manager β cleans up after request # ββ Dependency: pagination params ββ class Pagination: def __init__(self, offset: int = Query(0, ge=0), limit: int = Query(20, le=100) ): self.offset = offset self.limit = limit # ββ Dependency: current user (from JWT) ββ def get_user(token: str = Depends(oauth2_scheme)): return decode_and_validate_token(token) # ββ Chained dependency: admin user ββ def require_admin(user = Depends(get_user)): if user.role != "admin": raise HTTPException(403, "Admin access required") return user # Depends can chain β admin depends on get_user # ββ Endpoints use dependencies β clean and readable ββ @app.get("/tasks") def list_tasks( pagination: Pagination = Depends(Pagination), # pagination logic session: Session = Depends(get_db), # db session user = Depends(get_user), # authentication ): # The function body only cares about business logic stmt = select(Task).where(Task.user_id == user.id) return session.exec(stmt.offset(pagination.offset).limit(pagination.limit)).all() @app.delete("/tasks/{id}", status_code=204) def delete_task( id: int, session: Session = Depends(get_db), admin = Depends(require_admin), # chained: must be admin ): # Only admins reach this code task = session.get(Task, id) session.delete(task) session.commit()
app.dependency_overrides[get_db] = get_test_db. This swaps the real database for an in-memory test database without changing a single line of your endpoint code. This is the pattern that makes FastAPI apps so testable.Testing APIs with pytest
FastAPI's TestClient lets you make real HTTP requests to your API in tests β no server needed, no mocking. Combined with dependency overrides, your tests are fast, isolated, and trustworthy.
pip install httpx pytest pytest-asyncio
import pytest from fastapi.testclient import TestClient from sqlmodel import create_engine, SQLModel, Session from sqlmodel.pool import StaticPool from main import app from database import get_session # In-memory SQLite β created fresh for each test TEST_ENGINE = create_engine( "sqlite://", # no file β in memory only connect_args={"check_same_thread": False}, poolclass=StaticPool, # same connection for whole test ) def get_test_session(): with Session(TEST_ENGINE) as session: yield session @pytest.fixture(autouse=True) def fresh_db(): """Recreate tables before every test β clean slate.""" SQLModel.metadata.create_all(TEST_ENGINE) yield SQLModel.metadata.drop_all(TEST_ENGINE) @pytest.fixture def client(): """TestClient with the real database swapped for test database.""" app.dependency_overrides[get_session] = get_test_session yield TestClient(app) app.dependency_overrides.clear()
import pytest class TestCreateTask: def test_create_task_success(self, client): response = client.post("/tasks", json={ "title": "Learn FastAPI", "priority": 3 }) assert response.status_code == 201 data = response.json() assert data["title"] == "Learn FastAPI" assert data["priority"] == 3 assert data["done"] == False assert "id" in data # server assigned an ID assert "created" in data def test_create_task_empty_title(self, client): response = client.post("/tasks", json={"title": ""}) assert response.status_code == 422 # validation failure def test_create_task_invalid_priority(self, client): response = client.post("/tasks", json={"title": "Test", "priority": 10}) assert response.status_code == 422 class TestGetTask: def test_get_existing_task(self, client): # Create a task first created = client.post("/tasks", json={"title": "Test"}).json() task_id = created["id"] response = client.get(f"/tasks/{task_id}") assert response.status_code == 200 assert response.json()["id"] == task_id def test_get_nonexistent_task(self, client): response = client.get("/tasks/99999") assert response.status_code == 404 class TestUpdateTask: def test_partial_update(self, client): task = client.post("/tasks", json={"title": "Original", "priority": 1}).json() response = client.patch(f"/tasks/{task['id']}", json={"done": True}) assert response.status_code == 200 updated = response.json() assert updated["done"] == True assert updated["title"] == "Original" # unchanged assert updated["priority"] == 1 # unchanged # Run all tests: # pytest tests/ -v
Task Manager API β Full Stack
Build a complete, production-quality REST API β authenticated, database-backed, tested, and documented. This is the centerpiece of your backend portfolio.
β Full CRUD β POST, GET (list + single), PATCH, DELETE for tasks
β JWT auth β register, login returns token, protected endpoints
β User ownership β users can only see/edit their own tasks
β Query params β filter by done, priority; paginate with limit/offset
β Proper status codes β 201, 204, 404, 401, 403, 422
β response_model on every endpoint β no raw dicts
β SQLModel + SQLite β data persists between restarts
β 10+ tests β success cases, error cases, auth failures
β Auto docs β /docs works, all endpoints documented
β .env β no secrets in source code
After the core API works:
- Add task tags stored as a separate
Tagtable with a many-to-many relationship - Add a
GET /tasks/statsendpoint returning counts by status and priority - Add rate limiting β max 100 requests per minute per user using a simple in-memory counter
- Add a
GET /tasks/exportendpoint that returns the user's tasks as a CSV file download - Add CORS middleware so your future React frontend can call this API