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
ifchecks!) - The modern
Annotatedstyle for defining schema fields - The “In/Out schema” pattern used by real engineering teams
- How FastAPI’s
response_modelsilently 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
uvinstalled → 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 theemail-validatorpackage. Installingfastapi[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.
| Schema | Purpose |
|---|---|
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:
- Accepts a username, email, and password (validated automatically)
- Simulates saving the user (we’ll connect a real database in a later chapter)
- Returns the created user without the password or hashed password
- 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:
UserOutintentionally does not includepasswordorhashed_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=Truelets 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_userstill includeshashed_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 whateverregister_userreturns, validates it againstUserOut, 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 keepsmain.pyshort 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/usersrouter into the main app in one line, no matter how many routes it eventually contains.settingscomes fromapp/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:
- Open Swagger UI — go to
http://127.0.0.1:8000/docsin your browser. - You’ll see your endpoint listed under the Users tag:
POST /users/register. - Click on it to expand it, then click the “Try it out” button on the right.
- A JSON request body box becomes editable. Replace the example values with your own:
{
"username": "amitkumar",
"email": "amit@example.com",
"password": "SuperSecret123"
}
- Click the blue “Execute” button.
- 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
curlcommand 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,UserOutreads far better thanUserSchema1,UserSchema2. - ✅ Use
Annotatedgoing 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, noHTTPExceptionyet) — this makes your business logic reusable and testable outside the web framework. - ✅ Add
description=andexamplesto yourField()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
- Response Model – Return Type
- Extra Models (In/Out schema pattern)
- Body – Fields
- Query Parameters and String Validations
- Bigger Applications – Multiple Files (APIRouter)
- Settings and Environment Variables
Theory Questions
- Why is it risky to return a database model directly as an API response?
- What is the difference between an “input schema” and an “output schema”?
- What does the
response_modelparameter actually do behind the scenes? - What advantage does
Annotated[type, Field(...)]offer overtype = Field(...)? - What HTTP status code should a successful “create” operation return, and why?
- What’s the difference between
response_model_excludeand defining a separate output schema — and when would you use each?
Practical Assignment
Try these on your own, using the project you just built:
- Extend the schema: Add a
full_namefield toUserCreate(optional, max 50 characters) and make sure it also appears inUserOut. - Add a new field to hide: Add an
is_admin: bool = Falsefield to the fake database record inuser_service.py, but make sure it never appears in the API response. Verify this using Swagger UI. - New endpoint: Add a
GET /users/{user_id}endpoint that looks up a user from_fake_users_dbby ID and returns it usingUserOut. 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
AnnotatedandField - How
response_modelautomatically hides sensitive fields like passwords - How to test everything hands-on using Swagger UI
