Backend Development
Hands-onIntermediate 35 min· Production-ready APIs · 10 of 20

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

  1. 1Create a folder validation-demo, set up a virtual environment and run pip install fastapi uvicorn.
  2. 2Save the code below as main.py.
  3. 3Run uvicorn main:app --reload and open http://127.0.0.1:8000/docs.
  4. 4Send good and bad orders with the curl commands after the code.
main.py
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 handler

The 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

FailureExampleStatusHandler
Invalid inputqty is 0, phone has 5 digits422validation_handler lists each bad field
Known business ruleOrder does not exist; order already cancelled404 or 409AppError raised by your code
Framework HTTP errorsUnknown URL404http_error_handler
Unexpected bugDivision by zero, database down500unexpected_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

Terminal
# 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/crash

On Windows PowerShell use curl.exe, or just try these in the /docs page.

What you should see (wording of Pydantic messages can differ slightly between versions)
{"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

SituationCode
Created something201
Malformed or invalid input400 or 422 (FastAPI uses 422 for validation)
Not logged in401
Logged in but not allowed403
Resource does not exist404
Conflicts with current state (already cancelled, duplicate email)409
Too many requests429
Our bug or an outage500 (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.

server.mjs
// 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*.

Prompt · Design validation rules
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

  1. Every endpoint validates body, path, query and headers
  2. Limits exist on lengths, list sizes and numbers
  3. The server computes anything it can (prices, totals, timestamps)
  4. All errors share one JSON shape with a stable code
  5. Unexpected errors are logged in full and returned as a safe 500
  6. Status codes match the situation (400/422, 401, 403, 404, 409)

Key takeaways

  1. Validate at the boundary and whitelist what is allowed; the client is never trusted.
  2. Pydantic models (or Zod in Node) express rules as types and constraints and reject bad input before your code runs.
  3. Compute prices and totals on the server, never accept them from the client.
  4. Use one consistent error shape with a machine-readable code, a human message and per-field details.
  5. 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.

Start free