FastAPI in Practice: Build Clean Request Validation with Pydantic Models

FastAPI in Practice: Build Clean Request Validation with Pydantic Models

FastAPI is a strong choice for building APIs because it makes validation, documentation, and type safety feel natural. Instead of manually checking whether a request body has the right fields, you can describe the expected shape with Pydantic models and let FastAPI do the heavy lifting.

In this hands-on article, we will build a small product API that validates incoming data, rejects bad requests automatically, and keeps response formats predictable. The examples are simple enough for junior developers, but the structure is practical for real projects.

What We Are Building

We will create a small API with these endpoints:

  • POST /products — create a product with validated input.
  • GET /products/{product_id} — return a single product.
  • PATCH /products/{product_id} — partially update a product.

The focus is not database persistence. To keep the example easy to run, we will use an in-memory dictionary. In a real application, this layer could be replaced with SQLAlchemy, SQLModel, Prisma, or another database tool.

Project Setup

Create a new folder and install the dependencies:

mkdir fastapi-validation-demo cd fastapi-validation-demo python -m venv .venv source .venv/bin/activate pip install fastapi uvicorn pydantic

On Windows, activate the virtual environment with:

.venv\Scripts\activate

Now create a file named main.py.

Define Your Pydantic Models

Pydantic models describe the data your API expects. They also validate types, required fields, default values, and constraints.

from typing import Optional from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field app = FastAPI(title="Product API") class ProductCreate(BaseModel): name: str = Field(..., min_length=2, max_length=80) price: float = Field(..., gt=0) stock: int = Field(default=0, ge=0) category: Optional[str] = Field(default=None, max_length=50) class ProductUpdate(BaseModel): name: Optional[str] = Field(default=None, min_length=2, max_length=80) price: Optional[float] = Field(default=None, gt=0) stock: Optional[int] = Field(default=None, ge=0) category: Optional[str] = Field(default=None, max_length=50) class ProductResponse(BaseModel): id: int name: str price: float stock: int category: Optional[str] = None

There are three separate models here:

  • ProductCreate validates data when a product is created.
  • ProductUpdate allows partial updates, so all fields are optional.
  • ProductResponse controls what the API sends back to the client.

This separation keeps your API easier to maintain. The shape of incoming data is often not the same as the shape of outgoing data.

Create Products with Validated Input

Next, add a simple in-memory store and a POST endpoint.

products: dict[int, ProductResponse] = {} next_product_id = 1 @app.post("/products", response_model=ProductResponse, status_code=201) def create_product(payload: ProductCreate): global next_product_id product = ProductResponse( id=next_product_id, name=payload.name, price=payload.price, stock=payload.stock, category=payload.category, ) products[next_product_id] = product next_product_id += 1 return product

Run the API:

uvicorn main:app --reload

Now send a valid request:

curl -X POST http://127.0.0.1:8000/products \ -H "Content-Type: application/json" \ -d '{ "name": "Mechanical Keyboard", "price": 129.99, "stock": 15, "category": "Accessories" }'

You should get a response like this:

{ "id": 1, "name": "Mechanical Keyboard", "price": 129.99, "stock": 15, "category": "Accessories" }

Now try sending invalid data:

curl -X POST http://127.0.0.1:8000/products \ -H "Content-Type: application/json" \ -d '{ "name": "A", "price": -10, "stock": -3 }'

FastAPI will automatically return a 422 Unprocessable Entity response because the request violates the model rules. The name is too short, price must be greater than zero, and stock cannot be negative.

Return a Product by ID

Now let’s add a GET endpoint. This endpoint checks whether the product exists and returns a clean 404 error if it does not.

@app.get("/products/{product_id}", response_model=ProductResponse) def get_product(product_id: int): product = products.get(product_id) if product is None: raise HTTPException( status_code=404, detail=f"Product with id {product_id} was not found" ) return product

Test it with:

curl http://127.0.0.1:8000/products/1

If the product exists, the API returns it. If it does not, the API returns:

{ "detail": "Product with id 999 was not found" }

Handle Partial Updates with PATCH

A common mistake in API development is treating PATCH like PUT. A PATCH request should allow clients to send only the fields they want to change.

That is why we created ProductUpdate with optional fields.

@app.patch("/products/{product_id}", response_model=ProductResponse) def update_product(product_id: int, payload: ProductUpdate): existing_product = products.get(product_id) if existing_product is None: raise HTTPException( status_code=404, detail=f"Product with id {product_id} was not found" ) update_data = payload.model_dump(exclude_unset=True) updated_product = existing_product.model_copy(update=update_data) products[product_id] = updated_product return updated_product

The important line is:

update_data = payload.model_dump(exclude_unset=True)

This tells Pydantic to include only the fields that were actually sent by the client. Without exclude_unset=True, missing fields might be treated as None, which could accidentally erase existing values.

Test the update:

curl -X PATCH http://127.0.0.1:8000/products/1 \ -H "Content-Type: application/json" \ -d '{ "price": 99.99, "stock": 20 }'

The response should preserve the unchanged fields:

{ "id": 1, "name": "Mechanical Keyboard", "price": 99.99, "stock": 20, "category": "Accessories" }

Add Custom Validation

Field constraints are useful, but sometimes you need business rules. For example, you may want to reject products with suspiciously low prices in certain categories.

You can add a Pydantic validator like this:

from pydantic import BaseModel, Field, model_validator class ProductCreate(BaseModel): name: str = Field(..., min_length=2, max_length=80) price: float = Field(..., gt=0) stock: int = Field(default=0, ge=0) category: Optional[str] = Field(default=None, max_length=50) @model_validator(mode="after") def validate_price_for_category(self): if self.category == "Electronics" and self.price < 5: raise ValueError("Electronics products must cost at least 5.00") return self

Now this request will fail:

curl -X POST http://127.0.0.1:8000/products \ -H "Content-Type: application/json" \ -d '{ "name": "USB Adapter", "price": 2.99, "stock": 10, "category": "Electronics" }'

This kind of validation is helpful when your API must enforce business rules before data reaches the database.

Use Response Models to Avoid Leaking Data

Response models are not only for documentation. They also protect your API from returning fields that should stay internal.

Imagine your internal product object has a supplier cost:

internal_product = { "id": 1, "name": "Mechanical Keyboard", "price": 129.99, "stock": 15, "category": "Accessories", "supplier_cost": 62.50 }

If your endpoint uses response_model=ProductResponse, FastAPI returns only the fields defined in ProductResponse. The supplier_cost field is excluded from the response.

This is a simple but powerful habit. Never return raw database objects directly unless you are certain they contain no private or internal fields.

Practical Validation Tips

  • Use separate models for create, update, and response operations.
  • Prefer Field() constraints over manual if checks where possible.
  • Use exclude_unset=True for PATCH endpoints.
  • Keep business validation close to the model when it depends on request data.
  • Use response models to prevent accidental data leaks.
  • Do not put database logic inside Pydantic models; keep persistence in a service or repository layer.

Complete Example

from typing import Optional from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field, model_validator app = FastAPI(title="Product API") class ProductCreate(BaseModel): name: str = Field(..., min_length=2, max_length=80) price: float = Field(..., gt=0) stock: int = Field(default=0, ge=0) category: Optional[str] = Field(default=None, max_length=50) @model_validator(mode="after") def validate_price_for_category(self): if self.category == "Electronics" and self.price < 5: raise ValueError("Electronics products must cost at least 5.00") return self class ProductUpdate(BaseModel): name: Optional[str] = Field(default=None, min_length=2, max_length=80) price: Optional[float] = Field(default=None, gt=0) stock: Optional[int] = Field(default=None, ge=0) category: Optional[str] = Field(default=None, max_length=50) class ProductResponse(BaseModel): id: int name: str price: float stock: int category: Optional[str] = None products: dict[int, ProductResponse] = {} next_product_id = 1 @app.post("/products", response_model=ProductResponse, status_code=201) def create_product(payload: ProductCreate): global next_product_id product = ProductResponse( id=next_product_id, name=payload.name, price=payload.price, stock=payload.stock, category=payload.category, ) products[next_product_id] = product next_product_id += 1 return product @app.get("/products/{product_id}", response_model=ProductResponse) def get_product(product_id: int): product = products.get(product_id) if product is None: raise HTTPException( status_code=404, detail=f"Product with id {product_id} was not found" ) return product @app.patch("/products/{product_id}", response_model=ProductResponse) def update_product(product_id: int, payload: ProductUpdate): existing_product = products.get(product_id) if existing_product is None: raise HTTPException( status_code=404, detail=f"Product with id {product_id} was not found" ) update_data = payload.model_dump(exclude_unset=True) updated_product = existing_product.model_copy(update=update_data) products[product_id] = updated_product return updated_product

Conclusion

Good validation makes an API easier to use, easier to debug, and safer to maintain. FastAPI and Pydantic give you a clean way to define request rules, response shapes, and business constraints without filling your endpoints with repetitive checks.

For junior and mid-level developers, the key habit is simple: do not treat request bodies as plain dictionaries. Model them. Validate them. Return controlled response objects. Once this pattern becomes normal, your FastAPI projects will feel much more predictable.


Leave a Reply

Your email address will not be published. Required fields are marked *