Python FastAPI: Schemas

Python FastAPI: Schemas Thumbnail

1. What is a “schema” in FastAPI?

When you build an API, you’re always sending and receiving data:

  • The client sends request bodies (JSON → Python)
  • Your API returns responses (Python → JSON)

A schema is simply the shape of that data: which fields exist, what their types are, and which ones are required.

In FastAPI, schemas are usually defined using Pydantic’s BaseModel:

code
from pydantic import BaseModelclass ProductCreate(BaseModel):    name: str    price: float    in_stock: bool

This gives you:

  1. Validation – if price is not a number, FastAPI returns a validation error.
  2. Clear contracts – the frontend can see exactly what the API expects and returns.
  3. Automatic docs – schemas show up in /docs (Swagger) and /redoc.

So, mentally keep this definition:

Schema = contract for the data. Pydantic BaseModel = tool to write that contract in Python.


2. The mini-project: Product CRUD

Instead of jumping straight into SQL, let’s stay in memory first.

We’ll build a very small project:

  • Goal: Simple CRUD for Product

  • Files:

    • product_service.py – schemas + in-memory “database” + business logic
    • router.py – FastAPI routes that use those schemas
  • Storage: A Python list that acts like a fake table

Product fields

To keep things simple, each product will have:

  • id: int
  • name: str
  • price: float
  • in_stock: bool

We’ll use three schemas:

  1. ProductCreate – what the client sends when creating
  2. ProductUpdate – what the client sends when updating
  3. Product – what the API returns in responses

We’ll demonstrate them through three scenarios.


3. Step 1 – Defining schemas (the foundation)

All of these go into product_schema.py.

code
from typing import Optionalfrom pydantic import BaseModel, Fieldclass ProductBase(BaseModel):    name: str = Field(..., example="Coffee Mug")    price: float = Field(..., ge=0, example=199.0)    in_stock: bool = Field(default=True, example=True)class ProductCreate(ProductBase):    """    Data the client must send when creating a product.    """    passclass ProductUpdate(BaseModel):    """    Data the client can send when updating a product.    """    name: Optional[str] = None    price: Optional[float] = Field(None, ge=0)    in_stock: Optional[bool] = Noneclass Product(ProductBase):    """    Full Product representation returned by the API.    Includes the `id`.    """    id: int

At this point, even without any routes, you already have:

  • Clear input schema (ProductCreate, ProductUpdate)
  • Clear output schema (Product)

Next we add a simple in-memory “database” and service functions under these schemas.

code
# product_service.py (in-memory data + services)PRODUCTS: List[Product] = []_next_id = 1  # simple counter for idsdef list_products() -> List[Product]:    return PRODUCTSdef create_product(data: ProductCreate) -> Product:    global _next_id    new_product = Product(id=_next_id, **data.dict())    _next_id += 1    PRODUCTS.append(new_product)    return new_productdef get_product(product_id: int) -> Optional[Product]:    for product in PRODUCTS:        if product.id == product_id:            return product    return Nonedef update_product(product_id: int, data: ProductUpdate) -> Optional[Product]:    product = get_product(product_id)    if not product:        return None    stored_data = product.dict()    update_data = data.dict(exclude_unset=True)    stored_data.update(update_data)    updated_product = Product(**stored_data)    index = PRODUCTS.index(product)    PRODUCTS[index] = updated_product    return updated_productdef delete_product(product_id: int) -> bool:    product = get_product(product_id)    if not product:        return False    PRODUCTS.remove(product)    return True
Important: We’re still focusing on schemas and logic, not real persistence. Restarting the app will clear the list – and that’s okay for learning.

4. Three scenarios

Now let’s see how these schemas show up in your FastAPI routes.

All routes will live in router.py and will import from product_service.py.

Scenario 1 – Creating a product

When the client sends:

code
POST /products{  "name": "Notebook",  "price": 50.0,  "in_stock": true}

Example in postman

postman post product

The route uses:

  • Request body schema: ProductCreate
  • Response schema: Product

Snapshot:

code
@router.post("/", response_model=Product, status_code=201)def create_product_endpoint(payload: ProductCreate):    # payload is already validated by Pydantic here    return create_product(payload)
Key idea: The router doesn’t care how we store it, it only trusts the schema.

Scenario 2 – Listing & reading products

When the client wants all products:

code
GET /products

Example in postman

postman get products

The route returns a list of Product:

code
@router.get("/", response_model=List[Product])def get_products():    # FastAPI will serialize List[Product] to JSON    return list_products()

When the client wants a single product:

code
GET /products/1

Example in postman

postman get product by id

The route uses product_id: int and still responds with Product:

code
@router.get("/{product_id}", response_model=Product)def get_product_endpoint(product_id: int):    product = get_product(product_id)    if not product:        raise HTTPException(status_code=404, detail="Product not found")    return product

Schemas define what we send back; the router just returns Python objects.


Scenario 3 – Updating a product (partial update)

Suppose the client only wants to update the price:

code
PUT /products/1{  "price": 80.0}

Here we use:

  • Request body schema: ProductUpdate (all fields optional)
  • Response schema: Product (the updated product)

Snapshot:

code
@router.put("/{product_id}", response_model=Product)def update_product_endpoint(product_id: int, payload: ProductUpdate):    # payload may contain only some fields    updated = update_product(product_id, payload)    if not updated:        raise HTTPException(status_code=404, detail="Product not found")    return updated

Again, schemas are doing the heavy lifting:

  • ensuring types (price must be a number)
  • allowing missing fields (exclude_unset=True in the service layer)

5. Full router.py file

Here’s the complete router using the service and schemas above.

code
# router.pyfrom typing import Listfrom fastapi import APIRouter, HTTPExceptionfrom product_schema import Product, ProductCreate, ProductUpdatefrom product_service import list_products, create_product, get_product, update_product,    delete_productrouter = APIRouter(    prefix="/api/products",    tags=["products"],)# Scenario 2: list products@router.get("/", response_model=List[Product])def get_products():    return list_products()# Scenario 1: create product@router.post("/", response_model=Product, status_code=201)def create_product_endpoint(payload: ProductCreate):    return create_product(payload)# Scenario 2 (part 2): get single product@router.get("/{product_id}", response_model=Product)def get_product_endpoint(product_id: int):    product = get_product(product_id)    if not product:        raise HTTPException(status_code=404, detail="Product not found")    return product# Scenario 3: update product@router.put("/{product_id}", response_model=Product)def update_product_endpoint(product_id: int, payload: ProductUpdate):    product = update_product(product_id, payload)    if not product:        raise HTTPException(status_code=404, detail="Product not found")    return product# delete product@router.delete("/{product_id}", status_code=204)def delete_product_endpoint(product_id: int):    ok = delete_product(product_id)    if not ok:        raise HTTPException(status_code=404, detail="Product not found")    return

In your existing FastAPI app, you would just include this router:

code
# somewhere in your main app file (example)from fastapi import FastAPIfrom router import router as product_routerapp = FastAPI()app.include_router(product_router)

6. Activity for you

Here’s an exercise so the reader really feels how schemas work.

Activity A – Add a new field

  1. In ProductBase, add:

    code
    category: str = Field(..., example="Office Supplies")
  2. Restart the app and open /docs.

  3. Observe how all the endpoints that use Product, ProductCreate, etc. now expect/return category.

One schema change updates multiple endpoints automatically.


Activity B – Add a “summary” schema

  1. In product_schema.py, add:

    code
    class ProductSummary(BaseModel):    id: int    name: str
  2. Add a new service function:

    code
    def list_product_summaries() -> List[ProductSummary]:    return [ProductSummary(id=p.id, name=p.name) for p in PRODUCTS]
  3. In router.py, add a new endpoint:

    code
    @router.get("/summary", response_model=List[ProductSummary])def get_product_summaries():    return list_product_summaries()
  4. Check /products/summary in /docs.

You can have different schemas for different views of the same data, depending on what the client needs.


Activity C – Add simple validation rule

In ProductBase, try to add your own rule. Example: name must be at least 3 characters.

code
from pydantic import validatorclass ProductBase(BaseModel):    name: str    price: float = Field(..., ge=0)    in_stock: bool = True    @validator("name")    def name_must_not_be_too_short(cls, value: str) -> str:        if len(value) < 3:            raise ValueError("name must be at least 3 characters")        return value

Test sending an invalid name from the postman.

Schemas are not just shapes; they can also hold your validation rules.

Resources

GitHub repo: https://github.com/thirdygayares/python-fast-api-groundtruth/tree/master/schema_groundtruth

Postman collection: https://www.postman.com/gayaresthirdy/thirdy-gayares/request/ngdufin/create-product?tab=body

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.