Python FastAPI: Schemas
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:
codefrom pydantic import BaseModelclass ProductCreate(BaseModel): name: str price: float in_stock: bool
This gives you:
- Validation – if
priceis not a number, FastAPI returns a validation error. - Clear contracts – the frontend can see exactly what the API expects and returns.
- 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 logicrouter.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: intname: strprice: floatin_stock: bool
We’ll use three schemas:
ProductCreate– what the client sends when creatingProductUpdate– what the client sends when updatingProduct– 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.
codefrom 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:
codePOST /products{ "name": "Notebook", "price": 50.0, "in_stock": true}
Example in postman

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)
Scenario 2 – Listing & reading products
When the client wants all products:
codeGET /products
Example in postman

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:
codeGET /products/1
Example in postman

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:
codePUT /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 (
pricemust be a number) - allowing missing fields (
exclude_unset=Truein 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
-
In
ProductBase, add:codecategory: str = Field(..., example="Office Supplies") -
Restart the app and open
/docs. -
Observe how all the endpoints that use
Product,ProductCreate, etc. now expect/returncategory.
One schema change updates multiple endpoints automatically.
Activity B – Add a “summary” schema
-
In
product_schema.py, add:codeclass ProductSummary(BaseModel): id: int name: str -
Add a new service function:
codedef list_product_summaries() -> List[ProductSummary]: return [ProductSummary(id=p.id, name=p.name) for p in PRODUCTS] -
In
router.py, add a new endpoint:code@router.get("/summary", response_model=List[ProductSummary])def get_product_summaries(): return list_product_summaries() -
Check
/products/summaryin/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.
codefrom 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.
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