Validation and error handling
Never trust request data. Validate input with Pydantic models, compute totals on the server, return one consistent JSON error shape, and handle unexpected crashes safely, with the same pattern shown in Express with Zod.
Every request your API receives came from outside, and you cannot trust it. Users make typos, other developers misread your docs, and attackers send deliberately strange input. Validation checks that input is acceptable before you use it. Error handling makes sure that when something still goes wrong, the client gets a clear, consistent answer and your logs get the details.
What you'll build: the Brew & Bean POST /orders and PATCH /orders/{id}/status endpoints with strict validation (names, Indian mobile numbers, menu items, quantities, pickup times), one consistent JSON error shape for every failure, and a safe handler for unexpected crashes.
Prerequisites: Python 3.10+ and a basic FastAPI app (see *Build an API with Python and FastAPI*). HTTP status codes are explained in *HTTP in depth*.
Validate at the edge
Think of the shop counter. The barista checks "which drink, how many?" before anything goes to the machine. If you wait until the order is deep inside your code, a bad value (a quantity of -5, a missing item) causes confusing failures far from the cause.
Rules of thumb:
- Validate at the boundary, where data enters (request body, query string, path, headers, uploaded files).
- Whitelist, don't blacklist. Say what is allowed (qty from 1 to 20) rather than trying to list everything bad.
- Never trust the client for values the server can compute. The client sends item ids and quantities; the server looks up prices and computes total. If you accepted total from the client, anyone could order a cold brew for ₹1.
- Client-side validation is for convenience; server-side validation is for safety. Browsers can be bypassed with curl.
Pydantic models do the work
FastAPI uses Pydantic. You describe the shape of the data as a class, and FastAPI parses and checks every request against it. If the data is wrong, the request is rejected with 422 Unprocessable Content before your function runs. You write the rules once, as types and constraints:
- Types: str, int, list[OrderItem], str | None (optional).
- Field(...) constraints: min_length, max_length, ge (greater or equal), le, pattern.
- Literal[...] for a fixed set of values (the allowed statuses).
- @field_validator for custom rules, such as "this item must be on the menu" and "this must be an Indian mobile number".
Setup
- 1Create a folder
validation-demo, set up a virtual environment and runpip install fastapi uvicorn. - 2Save the code below as
main.py. - 3Run
uvicorn main:app --reloadand openhttp://127.0.0.1:8000/docs. - 4Send good and bad orders with the
curlcommands after the code.
import logging
import re
from typing import Literal
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from starlette.exceptions import HTTPException as StarletteHTTPException
logger = logging.getLogger("brewbean")
MENU = {
"espresso": 120,
"cappuccino": 180,
"cold-brew": 220,
"filter-coffee": 80,
"masala-chai": 90,
"croissant": 150,
"banana-bread": 130,
"veg-sandwich": 160,
}
app = FastAPI(title="Brew & Bean validation demo")
orders: dict[int, dict] = {}
# ---------- Models: the rules live here ----------
class OrderItem(BaseModel):
item_id: str
qty: int = Field(ge=1, le=20)
@field_validator("item_id")
@classmethod
def item_must_exist(cls, value: str) -> str:
if value not in MENU:
raise ValueError(f"unknown menu item '{value}'")
return value
class OrderCreate(BaseModel):
customer_name: str = Field(min_length=2, max_length=60)
phone: str
items: list[OrderItem] = Field(min_length=1, max_length=20)
pickup_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
@field_validator("customer_name")
@classmethod
def clean_name(cls, value: str) -> str:
return value.strip()
@field_validator("phone")
@classmethod
def indian_mobile(cls, value: str) -> str:
digits = re.sub(r"[\s-]", "", value)
if not re.fullmatch(r"[6-9]\d{9}", digits):
raise ValueError("must be a 10-digit Indian mobile number")
return digits
class StatusUpdate(BaseModel):
status: Literal["preparing", "ready", "picked_up", "cancelled"]
# ---------- One error shape for every failure ----------
class AppError(Exception):
def __init__(self, status_code: int, code: str, message: str):
self.status_code = status_code
self.code = code
self.message = message
def error_body(code: str, message: str, details: list | None = None) -> dict:
body = {"error": {"code": code, "message": message}}
if details:
body["error"]["details"] = details
return body
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status_code, content=error_body(exc.code, exc.message))
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
details = [
{
"field": ".".join(str(part) for part in err["loc"][1:]),
"message": err["msg"],
}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
content=error_body("validation_error", "Some fields are invalid", details),
)
@app.exception_handler(StarletteHTTPException)
async def http_error_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(status_code=exc.status_code, content=error_body("http_error", str(exc.detail)))
@app.exception_handler(Exception)
async def unexpected_error_handler(request: Request, exc: Exception):
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
# The client gets no internals; the log has the full traceback.
return JSONResponse(status_code=500, content=error_body("internal_error", "Something went wrong"))
# ---------- Routes ----------
@app.post("/orders", status_code=201)
def create_order(body: OrderCreate):
total = sum(MENU[i.item_id] * i.qty for i in body.items) # the server computes the total
order_id = len(orders) + 1
orders[order_id] = {
"id": order_id,
"customer_name": body.customer_name,
"phone": body.phone,
"items": [i.model_dump() for i in body.items],
"total": total,
"status": "received",
"pickup_time": body.pickup_time,
}
return orders[order_id]
@app.patch("/orders/{order_id}/status")
def update_status(order_id: int, body: StatusUpdate):
order = orders.get(order_id)
if order is None:
raise AppError(404, "order_not_found", f"Order {order_id} does not exist")
if order["status"] in ("picked_up", "cancelled"):
raise AppError(409, "order_closed", f"Order is already {order['status']}")
order["status"] = body.status
return order
@app.get("/crash")
def crash():
return 1 / 0 # simulates a bug, to see the 500 handlerThe whole demo in one file. Models hold the rules; handlers at the bottom of the middle section give every error the same shape.
Reading the code
Models. OrderItem demands qty between 1 and 20 and checks the item id against MENU. OrderCreate strips spaces from the name, normalises the phone number to 10 digits (and rejects numbers that do not start with 6-9), and requires between 1 and 20 items. pickup_time is optional but, if present, must look like 09:30. StatusUpdate uses Literal, so "flying" is rejected automatically.
Computed total. create_order ignores any price from the client and sums MENU prices itself.
Error handlers. There are four kinds of failure, and each gets its own handler:
Four kinds of failure
| Failure | Example | Status | Handler |
|---|---|---|---|
| Invalid input | qty is 0, phone has 5 digits | 422 | validation_handler lists each bad field |
| Known business rule | Order does not exist; order already cancelled | 404 or 409 | AppError raised by your code |
| Framework HTTP errors | Unknown URL | 404 | http_error_handler |
| Unexpected bug | Division by zero, database down | 500 | unexpected_error_handler: logs the traceback, hides it from the client |
Do not leak internals
Never send stack traces, SQL errors, file paths or library versions to the client. They help attackers and confuse users. Log them on the server, and return a short message plus (optionally) a request ID the user can quote to support.
Try it
# A valid order
curl -X POST http://127.0.0.1:8000/orders -H "Content-Type: application/json" -d '{"customer_name":"Asha","phone":"98765 43210","items":[{"item_id":"cappuccino","qty":2}],"pickup_time":"09:30"}'
# An invalid order
curl -X POST http://127.0.0.1:8000/orders -H "Content-Type: application/json" -d '{"customer_name":"A","phone":"12345","items":[{"item_id":"pizza","qty":0}],"pickup_time":"25:00"}'
# An order that does not exist
curl -X PATCH http://127.0.0.1:8000/orders/99/status -H "Content-Type: application/json" -d '{"status":"ready"}'
# A bug
curl http://127.0.0.1:8000/crashOn Windows PowerShell use curl.exe, or just try these in the /docs page.
{"id":1,"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":2}],"total":360,"status":"received","pickup_time":"09:30"}
{"error":{"code":"validation_error","message":"Some fields are invalid","details":[
{"field":"customer_name","message":"String should have at least 2 characters"},
{"field":"phone","message":"Value error, must be a 10-digit Indian mobile number"},
{"field":"items.0.item_id","message":"Value error, unknown menu item 'pizza'"},
{"field":"items.0.qty","message":"Input should be greater than or equal to 1"},
{"field":"pickup_time","message":"String should match pattern '^([01]\\d|2[0-3]):[0-5]\\d$'"}]}}
{"error":{"code":"order_not_found","message":"Order 99 does not exist"}}
{"error":{"code":"internal_error","message":"Something went wrong"}}Notice that the invalid order reports all problems at once, each with a field path, so a frontend can show every message next to the right input. Notice also that the crash returned a calm 500 while the server terminal printed the full traceback.
Choosing status codes
| Situation | Code |
|---|---|
| Created something | 201 |
| Malformed or invalid input | 400 or 422 (FastAPI uses 422 for validation) |
| Not logged in | 401 |
| Logged in but not allowed | 403 |
| Resource does not exist | 404 |
| Conflicts with current state (already cancelled, duplicate email) | 409 |
| Too many requests | 429 |
| Our bug or an outage | 500 (never blame the client for these) |
Machine-readable and human-readable
A frontend wants a stable error code such as order_not_found to branch on, and a human message to show. Don't make clients parse sentences. This is the same idea as the error design in *Designing REST APIs*.
The same idea in Express
In Node, Pydantic's job is done by a schema library such as Zod. The pattern is identical: define the schema, parse the body, return a 400 with the issues on failure, and add one error-handling middleware at the end.
// npm install express zod
import express from "express";
import { z } from "zod";
const OrderItem = z.object({
item_id: z.string(),
qty: z.number().int().min(1).max(20),
});
const OrderCreate = z.object({
customer_name: z.string().trim().min(2).max(60),
items: z.array(OrderItem).min(1).max(20),
});
const app = express();
app.use(express.json());
app.post("/orders", (req, res) => {
const result = OrderCreate.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: { code: "validation_error", message: "Some fields are invalid", details: result.error.issues },
});
}
res.status(201).json(result.data);
});
// Error-handling middleware: four arguments, registered last
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: { code: "internal_error", message: "Something went wrong" } });
});
app.listen(3000);Save as server.mjs (the .mjs extension lets Node use import). safeParse returns { success, data } or { success: false, error }.
Validation is not the same as security
Validation reduces bad input but does not replace other defences. Always use parameterised queries (or an ORM) so input can never become SQL, set limits on body size, and escape output where it is shown. These topics are covered in *SQL and PostgreSQL for backend developers* and *API security essentials*.
I am building a [describe endpoint, e.g. POST /bookings for a salon app] with these fields: [list fields and what they mean]. Write Pydantic v2 models (using Field constraints and field_validator) that validate it strictly. Then list 8 test inputs, both valid and invalid, with the exact error each should produce.What to expect: Check that the models use Pydantic v2 syntax (field_validator, Field(pattern=...)), that nothing computed by the server is accepted from the client, and that the test inputs include boundary values (0, 1, max, max+1).
Try it: Extend the rules
Add these to main.py:
1. pickup_time must not be before 8:00 or after 21:45 (the shop closes at 10 pm and needs 15 minutes to prepare).
2. A new optional note field, at most 100 characters.
3. Reject orders where the same item_id appears twice in items. The message should say which item.
Try it: Write three tests
Using fastapi.testclient.TestClient (it needs pip install httpx pytest), write pytest tests for: a valid order returns 201 and the right total; a quantity of 0 returns 422 with a details entry for items.0.qty; cancelling an already cancelled order returns 409.
Validation and error checklist
- Every endpoint validates body, path, query and headers
- Limits exist on lengths, list sizes and numbers
- The server computes anything it can (prices, totals, timestamps)
- All errors share one JSON shape with a stable code
- Unexpected errors are logged in full and returned as a safe 500
- Status codes match the situation (400/422, 401, 403, 404, 409)
Key takeaways
- Validate at the boundary and whitelist what is allowed; the client is never trusted.
- Pydantic models (or Zod in Node) express rules as types and constraints and reject bad input before your code runs.
- Compute prices and totals on the server, never accept them from the client.
- Use one consistent error shape with a machine-readable code, a human message and per-field details.
- Log unexpected errors fully but return only a generic 500 message; use 409 for state conflicts and 404 for missing things.
Check yourself
Finishing the quiz marks the lesson as done.
1. A client sends total: 1 with an order for 3 cold brews. What should the API do?
2. Which status best fits trying to cancel an order that has already been picked up?
3. Why does the demo return a generic message for unexpected exceptions?
4. What does qty: int = Field(ge=1, le=20) do?
5. Why is client-side validation alone not enough?
6. What is the benefit of returning all validation errors at once, each with a field path?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.