Chapter 5: Response Model, Data Validation & Hiding Sensitive Data in FastAPI

Let’s Start With a Real Problem

Picture this: you just built your first API endpoint. A user signs up, you save their username, email, and password to your database, and — proud of yourself — you return the saved record straight back to the client.

A few days later, a teammate reviewing your code asks one simple question:

“Why is the user’s password visible in the API response?”

Awkward silence. 😅

This exact mistake has happened to almost every backend developer at least once — and it’s one of the most common security issues found in real-world code reviews. The good news? FastAPI gives you an elegant, almost effortless way to prevent it. By the end of this chapter, you’ll never make that mistake again.

Let’s dig in.


What You’ll Learn

  • Why sending raw database data back to the client is risky
  • How Pydantic validates incoming data automatically (no manual if checks!)
  • The modern Annotated style for defining schema fields
  • The “In/Out schema” pattern used by real engineering teams
  • How FastAPI’s response_model silently filters out sensitive fields
  • How to test everything using FastAPI’s built-in Swagger UI — no Postman or curl needed
  • Theory + hands-on assignment questions to lock in what you’ve learned

Prerequisites

  • Python 3.11+ installed
  • uv installed → installation guide
  • Basic comfort reading Python functions and classes

Don’t worry if you’re not a validation expert yet — that’s exactly what this chapter is for.


Step 1: Setting Up the Project with uv

uv is the modern, blazing-fast way to manage Python projects — it replaces pip, venv, and poetry-style workflows with one simple tool. Most FastAPI teams have already moved to it, so let’s use it from day one.

uv init fastapi-learning-project
cd fastapi-learning-project

Now add our dependencies:

uv add fastapi --extra standard
uv add pydantic-settings

That’s it — uv automatically creates a virtual environment and a lockfile (uv.lock) so your project runs identically on every machine, including your teammate’s laptop and your production server.

💡 Tip: EmailStr (which we’ll use shortly) needs the email-validator package. Installing fastapi[standard] already includes it, so no extra step needed.


Step 2: An Industry-Style Project Structure

Real teams don’t cram everything into one main.py file. They separate concerns so the codebase stays easy to navigate as it grows. We’ll follow that same approach — and keep building on this exact structure in every future chapter.

fastapi-learning-project/
├── app/
│   ├── __init__.py
│   ├── main.py                  # App entrypoint
│   ├── core/
│   │   ├── __init__.py
│   │   └── config.py            # App-wide settings
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py              # Pydantic models (In/Out)
│   ├── routers/
│   │   ├── __init__.py
│   │   └── users.py             # Route handlers
│   └── services/
│       ├── __init__.py
│       └── user_service.py      # Business logic
├── pyproject.toml
├── uv.lock
└── README.md

Create it in one shot:

mkdir -p app/core app/schemas app/routers app/services
touch app/__init__.py app/core/__init__.py app/schemas/__init__.py app/routers/__init__.py app/services/__init__.py

Simple rule of thumb: schemas define shapes of data, routers define URLs, services define logic. Nothing overlaps, nothing gets messy.


Theory: Why You Should Never Return Raw Data

Here’s what a User might look like inside your system:

{
  "id": 1,
  "username": "amitkumar",
  "email": "amit@example.com",
  "password": "SuperSecret123",
  "hashed_password": "$2b$12$abcdefgh...",
  "is_admin": true
}

If this entire object goes straight into your HTTP response, anyone calling your API can see the password and even discover you have an is_admin flag — a huge red flag for attackers.

FastAPI’s fix is refreshingly simple: define two schemas instead of one.

SchemaPurpose
Input schema (UserCreate)What the client is allowed to send
Output schema (UserOut)What the client is allowed to receive

You then tell FastAPI: “Whatever this function returns, filter it through UserOut before sending it out.” That one instruction — response_model=UserOut — is your safety net.


Program 1: Validating Input Data (Warm-Up)

Before we build the full registration API, let’s warm up with something small: a schema that validates data using the modern Annotated style.

Program Objective

Create a simple Pydantic schema that validates a username and password using field constraints, and see FastAPI’s automatic validation in action.

Step-by-Step Implementation

Step 1: Create app/schemas/user.py and add the input schema:

from typing import Annotated
from pydantic import BaseModel, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[
        str,
        Field(
            min_length=3,
            max_length=30,
            pattern=r"^[a-zA-Z0-9_]+$",
            description="Letters, numbers, and underscores only.",
        ),
    ]
    email: Annotated[EmailStr, Field(description="A valid, unique email address.")]
    password: Annotated[
        str,
        Field(min_length=8, max_length=64, description="Minimum 8 characters."),
    ]

Code Explanation

  • Annotated[str, Field(...)] — the type (str) and its validation rules (Field) are cleanly separated. This is the style you’ll now see across all modern FastAPI code, including the official docs.
  • min_length / max_length — reject usernames or passwords that are too short or absurdly long, before any of your business logic even runs.
  • pattern=r"^[a-zA-Z0-9_]+$" — a simple regex ensuring usernames don’t contain spaces or special characters.
  • EmailStr — a Pydantic type that automatically checks the string looks like a real email address (e.g. rejects "not-an-email").

That’s it — no manual if len(username) < 3: raise ... code anywhere. Pydantic does it for you, and FastAPI turns any validation failure into a clean 422 Unprocessable Entity response automatically.


Program 2: The Full User Registration API (Hiding the Password)

Now let’s put this into a real, working endpoint — the kind you’d genuinely find in a production project.

Program Objective

Build a POST /users/register endpoint that:

  1. Accepts a username, email, and password (validated automatically)
  2. Simulates saving the user (we’ll connect a real database in a later chapter)
  3. Returns the created user without the password or hashed password
  4. Shows up correctly documented in Swagger UI

Step-by-Step Implementation

Step 1: Add the output schema to app/schemas/user.py, right below UserCreate:

from pydantic import ConfigDict


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

Code Explanation:

  • UserOut intentionally does not include password or hashed_password — that’s the whole trick. If a field isn’t declared here, FastAPI will never send it, no matter what the underlying data contains.
  • from_attributes=True lets this schema also work with objects (not just dictionaries) — handy later when we connect a real database in Chapter 8.

Step 2: Create a simple service layer at app/services/user_service.py:

from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id

    # Real password hashing is covered later — this is just a placeholder.
    hashed_password = f"hashed_{user_in.password}"

    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": hashed_password,
        "is_active": True,
    }

    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user

Code Explanation:

  • We’re using a simple in-memory Python list as a stand-in database — this keeps the chapter focused on validation and response filtering, without database setup getting in the way. We’ll swap this for real SQLAlchemy models soon.
  • Notice new_user still includes hashed_password. That’s intentional — this is exactly what a real database row looks like. Watch how the next step filters it out automatically.

Step 3: Build the router at app/routers/users.py:

from fastapi import APIRouter, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user

router = APIRouter(prefix="/users", tags=["Users"])


@router.post(
    "/register",
    response_model=UserOut,
    status_code=status.HTTP_201_CREATED,
    summary="Register a new user",
)
def register_user(user_in: UserCreate) -> UserOut:
    created_user = create_user(user_in)
    return created_user

Code Explanation:

  • response_model=UserOut — this single parameter is doing the heavy lifting. FastAPI takes whatever register_user returns, validates it against UserOut, drops any field not declared there, and then sends the response.
  • status_code=status.HTTP_201_CREATED — the correct HTTP status for “a new resource was successfully created” (we’ll go deeper into status codes in Chapter 6).
  • APIRouter(prefix="/users", tags=["Users"]) — groups all user-related routes together and keeps main.py short and clean.

Step 4: Wire everything together in app/main.py:

from fastapi import FastAPI

from app.core.config import settings
from app.routers import users

app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    description="Learning FastAPI step by step — Chapter 5",
)

app.include_router(users.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": f"Welcome to {settings.app_name}"}

Code Explanation:

  • include_router(users.router) — plugs the entire /users router into the main app in one line, no matter how many routes it eventually contains.
  • settings comes from app/core/config.py (create it with the content below) — a clean, central place for app-wide values like the app name and version.
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "FastAPI Learning Project"
    app_version: str = "1.0.0"


settings = Settings()

Step 5: Run the app:

uv run fastapi dev app/main.py

You should see the server start at http://127.0.0.1:8000.


Testing the API Using Swagger UI (No Postman Needed!)

One of the best things about FastAPI: it generates a fully interactive API testing page automatically, for free. Here’s how to use it:

  1. Open Swagger UI — go to http://127.0.0.1:8000/docs in your browser.
  2. You’ll see your endpoint listed under the Users tag: POST /users/register.
  3. Click on it to expand it, then click the “Try it out” button on the right.
  4. A JSON request body box becomes editable. Replace the example values with your own:
   {
     "username": "amitkumar",
     "email": "amit@example.com",
     "password": "SuperSecret123"
   }
  1. Click the blue “Execute” button.
  2. Scroll down to the “Server response” section. You’ll see:
    • The response body (the created user — no password anywhere!)
    • The response code (201)
    • The exact curl command Swagger generated, in case you want it later

Try it again with a short password like "123" — Swagger will show you a clean 422 validation error explaining exactly what went wrong, without you writing a single line of error-handling code.

💡 Why this matters: Swagger UI isn’t just for testing — it’s automatically generated documentation your frontend team, QA testers, and future you can rely on. Keeping your schemas accurate keeps this documentation accurate too.


Bonus: Other Ways to Shape Response Data

The In/Out schema pattern is the recommended approach, but FastAPI gives you a couple of lightweight shortcuts worth knowing:

Exclude specific fields for one route only:

@router.get("/users/{user_id}", response_model=UserOut, response_model_exclude={"is_active"})
def get_user(user_id: int):
    ...

Return only fields the client actually updated (great for PATCH):

@router.patch("/users/{user_id}", response_model=UserOut, response_model_exclude_unset=True)
def update_user(user_id: int):
    ...

⚠️ Best Practice: Use these shortcuts sparingly. For anything security-sensitive — passwords, tokens, internal flags — always prefer a dedicated schema like UserOut. It’s explicit, self-documenting, and much harder to misconfigure by accident.


Tips & Instructions for Developers

  • ✅ Always create a separate output schema for anything involving user accounts, auth, or payments — never trust yourself to remember to “exclude” a field manually later.
  • ✅ Name your schemas clearly: UserCreate, UserUpdate, UserOut reads far better than UserSchema1, UserSchema2.
  • ✅ Use Annotated going forward — it’s the direction FastAPI and Pydantic are both heading, and it keeps your validation rules and types visually separate.
  • ✅ Keep your service layer free of FastAPI-specific code (no Request, no HTTPException yet) — this makes your business logic reusable and testable outside the web framework.
  • ✅ Add description= and examples to your Field() definitions — future-you (and your API consumers) will thank you when reading /docs.
  • ❌ Never hash or “process” passwords inside your router function — keep that logic in the service layer, where it belongs.
  • ❌ Don’t reuse the same schema for both request and response “just to save time” — it’s the #1 cause of sensitive data leaks in APIs.

Official FastAPI Documentation References


Theory Questions

  1. Why is it risky to return a database model directly as an API response?
  2. What is the difference between an “input schema” and an “output schema”?
  3. What does the response_model parameter actually do behind the scenes?
  4. What advantage does Annotated[type, Field(...)] offer over type = Field(...)?
  5. What HTTP status code should a successful “create” operation return, and why?
  6. What’s the difference between response_model_exclude and defining a separate output schema — and when would you use each?

Practical Assignment

Try these on your own, using the project you just built:

  1. Extend the schema: Add a full_name field to UserCreate (optional, max 50 characters) and make sure it also appears in UserOut.
  2. Add a new field to hide: Add an is_admin: bool = False field to the fake database record in user_service.py, but make sure it never appears in the API response. Verify this using Swagger UI.
  3. New endpoint: Add a GET /users/{user_id} endpoint that looks up a user from _fake_users_db by ID and returns it using UserOut. Test what happens (and what error you get) when you request an ID that doesn’t exist — we’ll fix this properly with exception handling in Chapter 7.

Summary

In this chapter, you learned:

  • Why returning raw data is a real (and common) security mistake
  • How to set up a modern FastAPI project using uv
  • How to structure a project with clean separation of concerns
  • How to validate incoming data using Annotated and Field
  • How response_model automatically hides sensitive fields like passwords
  • How to test everything hands-on using Swagger UI

Scroll to Top