Validating requests with schemas
Check every request against a JSON Schema and say exactly what's wrong.
Create a free account Try part of it here
Why it matters
Every request body is untrusted input, like a form anyone can fill in. Checking it by hand, with an if for each field, gets long and inconsistent, and it drifts out of date. A schema describes the valid shape once, and one validator enforces it everywhere. The same schema language, JSON Schema, describes Claude's tool inputs, MCP's inputSchema, structured outputs and OpenAPI documents. Learn it once and you can read all of them.
What you'll be able to do
- Write a JSON Schema for a request body: types, required fields, limits and enums
- Build a schema validator that reports every problem with its exact location
- Avoid the classic type traps, such as True counting as an integer
- Return validation errors that a person or an AI agent can act on
What it covers
- A schema says what valid looks like
- How a validator works
- The type trap
- Errors people and agents can act on
- Why one schema beats scattered checks
- In real projects, validate at the edge
A schema says what valid looks like
Think of a paper form at a bank. It has boxes, and each box has rules: "date of birth", "numbers only", "required". A schema is that form, written down for a computer.
JSON Schema is itself JSON. Here's one for creating a campaign:
{
"type": "object",
"required": ["name", "budget"],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 80},
"budget": {"type": "number", "minimum": 1},
"channel": {"type": "string", "enum": ["search", "social", "email"]},
"tags": {"type": "array", "items": {"type": "string"}, "maxItems": 10}
},
"additionalProperties": false
}
Read it as: "an object with a name (1 to 80 characters) and a budget (at least 1), optionally a channel from a fixed list and up to 10 string tags, and nothing else".
The keywords you'll use most:
| keyword | checks |
|---|---|
type | object, array, string, number, integer, boolean or null |
properties, required | which keys an object has, and which must be present |
additionalProperties: false | no keys beyond those listed |
enum | the value is one of a fixed list |
minimum, maximum | number limits |
minLength, maxLength, pattern | string length and a regular expression |
items, minItems, maxItems | each array item's schema, and the array's length |
additionalProperties: false catches typos. Without it, a client that sends "budjet" gets a silent success, while the budget it meant to set is ignored. That's what the keyword protects against: misspelled or unexpected keys being silently accepted and ignored.
Show the answer
{"name": "Spring", "budget": 250, "tags": ["q2"]}
The others miss the required budget, have an empty name (minLength 1), or use a channel outside the enum.
How a validator works
A teacher marking a test doesn't stop at the first mistake and hand it back. They go through the whole paper, and circle everything that's wrong. A validator does the same. It collects errors instead of stopping at the first, because a client fixing a form wants all five problems at once, not one per round trip.
It walks the data and the schema together. At each point it checks the rules for that value, then steps into object properties and array items and does the same there. A function that calls itself this way is recursive, as you saw in Phase 7.
Each error records where it is, as a path from the root, like directions to a seat: $ is the root, .name is a key and [2] is a list position. Counting starts at 0, so $.items[2].qty is the qty of the third item.
def check_required(data, schema, path="$"): errors = [] for key in schema.get("required", []): if key not in data: errors.append((f"{path}.{key}", "is required")) for key, sub in schema.get("properties", {}).items(): if key in data and isinstance(data[key], dict): errors += check_required(data[key], sub, f"{path}.{key}") return errors schema = {"required": ["name", "address"], "properties": {"address": {"required": ["city", "zip"]}}} print(check_required({"address": {"city": "Denver"}}, schema))
That's the whole pattern: check here, then recurse with a longer path.
Write errors a person or an agent can act on.
The type trap
JSON has fewer types than Python, so the mapping needs care. In Python, True counts as a kind of int, so isinstance(True, int) is True. In JSON Schema, a boolean is not an integer or a number. So a validator that forgets this accepts {"qty": true} as a quantity of 1:
print(isinstance(True, int), True + True) def is_integer(v): return isinstance(v, int) and not isinstance(v, bool) print(is_integer(3), is_integer(True), is_integer(3.0))
Also, 3.0 is a float in Python, but JSON Schema's integer accepts any number with no fractional part. Real validators accept 3.0 as an integer. The exercise keeps to whole int values for simplicity.
{"items": [{...}, {"price": -1}]}?Show the answer
$.items[1].price
Start at the root $, the key items, position 1 (counting from 0), then the key price.
Errors people and agents can act on
"Your form was rejected" is no help. "Box 3 needs a date, and box 7 must be one of these three options" is. A good validation response is a 4xx (often 422) listing every problem with its location:
{
"error": {
"type": "invalid_request_error",
"message": "2 problems with the request body",
"details": [
{"path": "$.budget", "message": "must be at least 1"},
{"path": "$.channel", "message": "must be one of search, social, email"}
]
}
}
AI agents calling your API (or tools you give a model) recover far better from errors like these. "must be one of search, social, email" tells the model exactly what to send next time. "Bad request" leaves it guessing.
In real projects, use a library
- jsonschema validates any JSON Schema in Python.
- Pydantic builds the schema from a class with type hints. FastAPI uses it for every request body, and turns failures into a 422 automatically:
from pydantic import BaseModel, Field from typing import Literal class CampaignIn(BaseModel): name: str = Field(min_length=1, max_length=80) budget: float = Field(ge=1) channel: Literal["search", "social", "email"] = "search" print(CampaignIn.model_json_schema()) # the JSON Schema, generated for you
- Zod does the same for TypeScript.
The academy's Python doesn't include these libraries, which is a good excuse to build the core yourself and see there's no magic inside.
print(isinstance(True, int), type(True) is int)
Show the answer
True False
bool is a subclass of int, so isinstance says True, but its exact type is bool, so type(True) is int says False. A validator that wants a real integer must reject bools explicitly.
Why one schema beats scattered checks
The usual way to check input is a pile of if statements near the top of each handler. It starts small. A few months later, three routes check the budget in three slightly different ways, and one of them forgets. Nobody can say what valid input actually is without reading all the code.
A schema puts all of that in one place. It works as documentation, your validator reads it, and it can generate your API docs. Change the rule once, and it changes everywhere.
Reject what you don't recognize
What should happen when a request includes a field you don't know about? It's tempting to ignore it. But consider a client that sends {"budjet": 500} with a typo. If you ignore unknown fields, you apply the default budget and report success, and they find out at the end of the month. Reject it with a clear error, and they find out right now.
There's a security reason too. If you pass the whole request on to your update code, then a client can send {"is_admin": true} and possibly set it. Validation that only allows named fields closes that door.
Tell people how to fix it
Compare these two answers to the same request:
import json bad = {"error": "Bad Request"} good = {"error": "validation_failed", "fields": [ {"path": "budget", "problem": "must be a number", "got": "lots"}, {"path": "name", "problem": "is required"}, ]} print(json.dumps(bad)) print(json.dumps(good))
The first means a developer has to guess. The second lists everything wrong at once, so it can be fixed in one try, not one error per attempt. An agent can read it and correct itself as well, which is becoming a big share of API traffic. The path, the problem and the value you received are almost always enough.
Do watch what you repeat back, though. Echoing the value is fine for a budget, and it isn't fine for a password, so leave sensitive fields out of error messages.
In real projects, validate at the edge
Validate once, where data enters your system, then pass on a clean object. Further in, code can trust what it receives, and it no longer needs its own checks. Pydantic and JSON Schema libraries do this well. Writing the small version in this lesson is how you learn what those tools are doing for you when one of them gives you a confusing error.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does "additionalProperties": false protect against?