FastAPI Guide: Build a Production API Step by Step

A step-by-step FastAPI build: request validation with Pydantic models, response models that control what leaves the API, dependency injection, proper status codes and errors, tests with TestClient and dependency overrides, then the settings, workers and health checks a production deployment needs.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 24 September 2026

Share:

FastAPI turns ordinary type-hinted Python functions into an HTTP API with request validation, JSON serialisation and interactive documentation. That makes the demo easy. This tutorial goes further, to the parts that decide whether the API survives real clients: strict input models, response models, dependencies you can replace in tests, proper errors, and the deployment settings that matter.

It builds a small products API. Install FastAPI with its standard extras, which include the server and the fastapi command: python -m pip install "fastapi[standard]".

Step 1: models first

Define what a client may send and what the API returns as Pydantic models. Constraints live in the model, not scattered through the endpoint code (FastAPI: request body):

python
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, status
from pydantic import BaseModel, Field

class ProductIn(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)
    stock: int = Field(default=0, ge=0)

class Product(ProductIn):
    id: int

Separate input and output models matter. ProductIn has no id, so a client cannot choose its own ID; Product is what the API promises to return.

Step 2: a dependency for storage

Endpoints should not reach for a global database connection. They declare what they need with Depends, and FastAPI provides it for each request (FastAPI: dependencies). Here the "database" is an in-memory store, which a real application would replace with a database session:

python
class Store:
    def __init__(self):
        self.items: dict[int, Product] = {}
        self.next_id = 1

    def add(self, data: ProductIn) -> Product:
        product = Product(id=self.next_id, **data.model_dump())
        self.items[product.id] = product
        self.next_id += 1
        return product

_store = Store()

def get_store() -> Store:
    return _store

StoreDep = Annotated[Store, Depends(get_store)]

Step 3: endpoints with explicit status codes and errors

python
app = FastAPI(title="Products API", version="1.0.0")

@app.post("/products", status_code=status.HTTP_201_CREATED)
def create_product(data: ProductIn, db: StoreDep) -> Product:
    return db.add(data)

@app.get("/products/{product_id}")
def read_product(product_id: int, db: StoreDep) -> Product:
    if product_id not in db.items:
        raise HTTPException(status_code=404, detail="Product not found")
    return db.items[product_id]

@app.get("/products")
def list_products(
    db: StoreDep,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    in_stock: bool = False,
) -> list[Product]:
    items = [p for p in db.items.values() if p.stock > 0 or not in_stock]
    return items[:limit]

@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}

Every type hint is doing work. product_id: int rejects /products/abc. The Query(ge=1, le=100) caps page size, so no client can ask for a million rows. The return annotation (-> Product) is the response model: FastAPI validates the output against it and drops any field not in the model (FastAPI: response model), which is how internal fields such as a password hash stay out of responses.

Step 4: exercise it with TestClient

TestClient sends requests straight to the application, with no server running (FastAPI: testing):

python
from fastapi.testclient import TestClient

client = TestClient(app)

r = client.post("/products", json={"name": "Keyboard", "price": 1499, "stock": 3})
print(r.status_code, r.json())

r = client.post("/products", json={"name": "", "price": -5})
print(r.status_code, [(e["loc"][-1], e["msg"]) for e in r.json()["detail"]])

print(client.get("/products/99").status_code, client.get("/products/99").json())
print(client.get("/products?limit=500").status_code)
text
201 {'name': 'Keyboard', 'price': 1499.0, 'stock': 3, 'id': 1}
422 [('name', 'String should have at least 1 character'), ('price', 'Input should be greater than 0')]
404 {'detail': 'Product not found'}
422

The invalid product returns both problems at once, with the field each belongs to, and the handler never ran. That behaviour came from the model definitions alone.

Tests must not share state with each other or with production. dependency_overrides swaps a dependency for the duration of a test, which is why storage was a dependency in the first place:

python
app.dependency_overrides[get_store] = lambda: Store()     # a fresh, empty store
print(client.get("/products").json())
app.dependency_overrides.clear()
print(len(client.get("/products").json()))
text
[]
1

In a pytest suite, put the override in a fixture so every test starts clean; unit testing with pytest covers fixtures.

Step 5: what production adds

The code above is correct; production is about how it runs. FastAPI's deployment guide frames this as a set of concerns: HTTPS, starting on boot, restarting after crashes, running several processes, and memory (FastAPI: deployment concepts).

text
fastapi dev main.py                      # development: auto-reload
fastapi run main.py --workers 4          # production: several worker processes
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4    # the same, with Uvicorn directly
  • Settings from the environment. Database URLs and keys come from environment variables, loaded once at startup; see environment variables and secrets in Python apps.
  • def or async def. FastAPI runs plain def endpoints in a threadpool, so blocking database drivers are fine there. async def endpoints run on the event loop and must never call blocking code (FastAPI: concurrency); the asyncio guide explains why.
  • Workers. One process uses one CPU core for Python code. Run several workers, or several containers behind a load balancer.
  • A health endpoint like /health above, for the load balancer or orchestrator to poll.
  • HTTPS at the edge, usually terminated by a reverse proxy or the platform, and CORS configured only for the front-end origins that need it.
  • A container image. Package the app with pinned dependencies; the FastAPI and Docker guide for ML models shows the pattern with a model behind the endpoint.

Growing beyond one file

A real service outgrows a single module quickly. FastAPI's APIRouter groups related endpoints, so each resource gets its own module, for example routers/products.py and routers/orders.py, each creating a router with a shared prefix and tags. main.py then only creates the app and includes the routers with app.include_router(...). Models, the database session dependency and settings live in their own modules, imported where needed. The layout conventions in how to structure a Python project apply unchanged.

Authentication

FastAPI provides building blocks rather than a finished user system. Security dependencies such as OAuth2PasswordBearer or HTTPBearer extract a token from the request, and your own dependency verifies it and returns the current user. Endpoints that need a signed-in user simply declare that dependency, and the generated documentation shows which endpoints are protected. Keep password hashing, token signing keys and expiry settings out of the code, and prefer an established identity provider over hand-written login flows when the project allows it.

Errors clients can handle

HTTPException covers most cases. For a consistent error body across the whole API, register exception handlers with @app.exception_handler(...) that convert your own exception classes into responses. A domain error such as "out of stock" then becomes a 409 with the same JSON shape everywhere, instead of each endpoint formatting its own.

Common mistakes

  • One model for input and output, which lets clients set fields like id or is_admin.
  • Calling requests or a synchronous database driver inside async def endpoints.
  • Tests that hit the real database because storage was imported, not injected.
  • Returning 200 with {"error": ...} instead of a real 4xx status.
  • Doing slow work, such as sending email or building a report, inside the request. BackgroundTasks runs small jobs after the response is sent; heavy or must-not-lose work belongs in a task queue.
  • Leaving the interactive /docs page open on a public production API that should not advertise its endpoints. Pass docs_url=None and redoc_url=None to FastAPI(...), or put the docs behind authentication.

For how FastAPI compares with Flask and Django for different projects, see Flask vs FastAPI vs Django; for the design rules behind these endpoints, see building REST APIs with Python. API development with Flask or FastAPI appears in the model deployment modules of the Data Science and MLOps courses.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course