Your API just returned the password hash. Again.

A user hits GET /users/me. Your handler loads the user row and returns it. Somewhere in the JSON body you see "password_hash": "$2b$12$...". You delete it with a hotfix. 2 weeks later you return a Session object and realize it includes an internal_trace_id that leaks the underlying agent framework. You delete that too. A month later the same pattern shows up for a 3rd time on a different endpoint.

This is the price of returning database models directly from API routes. Every new field you add to a table becomes a field in the API. Every time you rename an internal column, the API contract breaks. Every sensitive field is one forgotten del row.password_hash away from a leak.

The fix is separation: a schema layer for the DB, a schema layer for the API response, and a third for API input. 3 Pydantic classes per resource. Slightly more code, zero accidental leaks, forever.

Why is returning db models from API routes a bug?

Because the DB model is the union of everything you ever wanted to store about an entity, and the API response is the subset of that you want to expose to the world. Conflating them leaks the difference.

3 things break when DB and API share a class:

  1. Sensitive fields leak. Password hashes, internal IDs, tool call raw outputs, billing token counts. Anything you added to the DB for debugging or auth suddenly shows up in API responses.
  2. Rename-the-column breaks the public contract. You rename user_email to email in the DB; every external client that parsed user_email from your API now breaks on the next deploy.
  3. Required fields become optional and vice versa. Your DB has created_at always set, but your POST request body does not accept it. Returning the DB class forces you to make it optional, which weakens every query that depends on the non-null invariant.
graph LR
    Client[API Client] -->|POST /users| In[UserCreate]
    In -->|validate| Svc[Service layer]
    Svc -->|write| DB[UserDB]
    DB -->|read| Svc
    Svc -->|map| Out[UserPublic]
    Out -->|serialize| Client

    style In fill:#dbeafe,stroke:#1e40af
    style DB fill:#fef3c7,stroke:#b45309
    style Out fill:#dcfce7,stroke:#15803d

3 schemas, 3 responsibilities. UserCreate is what the client sends. UserDB is what the database stores. UserPublic is what the API returns. They share fields but are never the same class.

What are the 3 Pydantic schemas you need per resource?

Start with a shared Base class that holds fields common to all 3. Derive Create for POST bodies, DB for database rows, and Public for API responses. The Public class is the only one the API ever returns.

# filename: schemas.py
# description: 3-layer Pydantic schemas for a User resource.
# Base = shared fields, Create = input, DB = storage, Public = output.
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field


class UserBase(BaseModel):
    email: EmailStr
    display_name: str


class UserCreate(UserBase):
    password: str = Field(min_length=8)


class UserDB(UserBase):
    id: int
    password_hash: str
    tenant_id: int
    is_admin: bool
    internal_notes: Optional[str] = None
    created_at: datetime

    class Config:
        from_attributes = True


class UserPublic(UserBase):
    id: int
    created_at: datetime

Look at what is in UserDB and not in UserPublic. The password hash, the internal notes, the admin flag, the tenant ID. None of those should leak to the API. The UserPublic class is the allowlist. It cannot accidentally include a new sensitive field because you have to explicitly add fields to it.

How do you wire the schemas into a FastAPI route?

Declare the input as UserCreate, the response model as UserPublic, and do the mapping in the route handler (or, better, in a service function). FastAPI handles the validation and serialization automatically.

# filename: routes.py
# description: A FastAPI route that accepts UserCreate, writes UserDB,
# and returns UserPublic. Three schemas, one handler.
from fastapi import APIRouter, Depends, HTTPException
from schemas import UserCreate, UserPublic
from services import create_user

router = APIRouter()


@router.post('/users', response_model=UserPublic, status_code=201)
async def post_user(body: UserCreate) -> UserPublic:
    try:
        user_db = await create_user(body)
    except ValueError as exc:
        raise HTTPException(400, str(exc))
    return UserPublic.model_validate(user_db, from_attributes=True)

The response_model=UserPublic parameter is the critical piece. FastAPI will validate the return value against UserPublic and strip any field that is not in the public schema before serializing. Even if the service layer accidentally returned a UserDB, the response would be filtered to the public shape before it hit the wire.

This is different from just documenting the schema. response_model is enforced at runtime, not at type-check time. A service that returns the wrong shape gets a clear validation error instead of a silent leak.

What does the service layer look like?

The service layer is where Create becomes DB and DB becomes Public. It owns the password hashing, the tenant resolution, and any business logic that does not belong in a route.

# filename: services.py
# description: The service layer maps between the 3 schema types and
# owns the business logic that route handlers should not see.
from passlib.hash import bcrypt
from schemas import UserCreate, UserDB
from db import get_session
from models import UserRow


async def create_user(body: UserCreate) -> UserDB:
    async with get_session() as db:
        existing = await db.get(UserRow, body.email)
        if existing:
            raise ValueError('email already registered')
        row = UserRow(
            email=body.email,
            display_name=body.display_name,
            password_hash=bcrypt.hash(body.password),
            tenant_id=1,
            is_admin=False,
        )
        db.add(row)
        await db.commit()
        await db.refresh(row)
        return UserDB.model_validate(row, from_attributes=True)

Notice the route handler does not touch the password, the hash function, or the database directly. It calls one service function and wraps the result. The service converts the UserRow (the SQLAlchemy model) into a UserDB (the Pydantic DB schema). The route converts UserDB into UserPublic. The boundaries are clean.

For the related pattern of unifying Pydantic and SQLAlchemy into one class when appropriate, see the SQLModel for Agentic AI: Beyond JSON Blob Storage post. SQLModel fuses the DB and Pydantic layers for cases where you want one type; this post's separation is for cases where the API shape legitimately differs from the storage shape.

How do you handle nested resources without leaking children?

Each child resource gets its own Public schema. You never nest a DB model inside a public response, even through a relationship attribute. The parent Public schema explicitly lists which children it includes and uses the child's Public class for the nested field.

# filename: nested_schemas.py
# description: Session with messages, where both have public schemas and
# the parent explicitly nests the child's public class.
from typing import List

class MessagePublic(BaseModel):
    id: int
    role: str
    content: str
    created_at: datetime


class SessionPublic(BaseModel):
    id: int
    title: str
    status: str
    created_at: datetime
    messages: List[MessagePublic] = []

If a message ever grows a sensitive field (say, internal_reasoning_trace), that field lives on the DB class and not on MessagePublic. The session endpoint is immune because it nests MessagePublic, not MessageDB.

This is tedious to write the first time and pays for itself from the second sensitive field onward. The alternative is opening the JSON response in a debugger every time you add a field to a DB model, which is what most teams actually do, which is how fields leak.

What about internal services that need the full db shape?

Keep an internal API on a separate router with separate response models. Internal consumers (the agent loop, the evaluation pipeline, the admin dashboard) can use UserDB directly because they run inside your security perimeter.

The public router and the internal router live in different Python modules. The public router has response_model=UserPublic everywhere. The internal router has response_model=UserDB or no response model at all. A 403 check at the router level gates access.

For the broader auth picture that layers on top of this, see the JWT Authentication for Agentic APIs post. For the production stack overall, see the System Design: Building a Production-Ready AI Chatbot walkthrough.

What to do Monday morning

  1. Grep your codebase for response_model=. Any route that does not have one is a field-leak bomb waiting to go off. Add a Public schema for every resource that is missing one.
  2. For each resource, write the 3 Pydantic classes: Create, DB, Public. Start with the highest-traffic endpoint so the payoff is immediate.
  3. Move the mapping code out of route handlers and into a service layer. Routes should not import the ORM directly.
  4. Write a test that hits your API, decodes the JSON, and asserts that no field in the response has "hash", "secret", "internal", or "password" in its name. Run it in CI forever.
  5. When you add a new field to a DB model, decide deliberately whether it belongs in the public schema. The default is no. Adding it to Public should require a real reason and a pull request reviewer who knows what "public" means.

The headline: 3 schemas per resource is the difference between an API that leaks sensitive fields on every change and one that is forced to declare its contract explicitly. 15 extra lines per resource. Zero accidental leaks forever.

Frequently asked questions

Why should I not return SQLAlchemy models directly from FastAPI routes?

Because the SQLAlchemy model represents everything you ever wanted to store, including password hashes, internal IDs, billing fields, and debug metadata. Returning it directly leaks those fields into API responses. Adding a separate Pydantic Public schema that lists only the fields you want to expose is the only pattern that prevents the leak at the framework level.

How many Pydantic schemas do I need per API resource?

3 is the sweet spot: Create for POST bodies, DB for internal storage shape, and Public for API responses. Some teams add an Update class for PATCH bodies where most fields are optional. More than 4 per resource is usually a sign that the resource should be split into smaller ones.

What is the response_model parameter in FastAPI doing?

It enforces that every response is validated and filtered against the declared Pydantic class before it hits the wire. Fields not in the response_model class are stripped, even if your route handler returned them by accident. This turns schema separation from a convention into a runtime guarantee.

Can SQLModel replace the 3-schema pattern?

Sometimes. SQLModel unifies the DB and Pydantic layers into one class, which is great when the DB shape and API shape genuinely match. When they differ (password hashes, internal flags, computed fields), you still want a separate Public schema. Use SQLModel for the storage layer and derive Public schemas for the fields that cross the API boundary.

Where does the schema mapping code live?

In a service layer between routes and the database, not in route handlers. Routes accept Create schemas, call service functions, and return Public schemas. Services take Create schemas, do business logic, write DB rows, and return DB schemas. The layering keeps routes thin and makes the mapping explicit wherever a type transition happens.

Key takeaways

  1. Returning DB models from API routes leaks sensitive fields the moment someone adds a new column. The fix is schema separation, not vigilance.
  2. 3 Pydantic classes per resource: Create (input), DB (storage), Public (output). They share a common Base for the fields that overlap.
  3. response_model= in FastAPI enforces the public schema at runtime. Fields not listed are stripped before serialization, which turns the pattern into a guarantee.
  4. Nested resources need nested public schemas. Never nest a DB class inside a public response, even through a relationship attribute.
  5. Keep internal APIs on a separate router with separate schemas. Admin tools and agent loops can see the full DB shape; public clients cannot.
  6. To see this pattern wired into a full production agent stack with auth, streaming, and observability, walk through the Build Your Own Coding Agent course, or start with the AI Agents Fundamentals primer.

For the FastAPI documentation on response models and the exact filtering semantics, see the FastAPI response model docs. The response_model_exclude_unset and response_model_exclude_none flags there are worth knowing for nullable fields.

Share this post

Continue Reading

Weekly Bytes of AI

Technical deep-dives for engineers building production AI systems.

Architecture patterns, system design, cost optimization, and real-world case studies. No fluff, just engineering insights.

Unsubscribe anytime. We respect your inbox.

Ready to go deeper?

Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.