Index Ai Academy
Phase 8 · APIs · Building APIs

Validating requests with schemas

Check every request against a JSON Schema and say exactly what's wrong.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. A schema says what valid looks like
  2. How a validator works
  3. The type trap
  4. Errors people and agents can act on
  5. Why one schema beats scattered checks
  6. 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:

keywordchecks
typeobject, array, string, number, integer, boolean or null
properties, requiredwhich keys an object has, and which must be present
additionalProperties: falseno keys beyond those listed
enumthe value is one of a fixed list
minimum, maximumnumber limits
minLength, maxLength, patternstring length and a regular expression
items, minItems, maxItemseach 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.

Quick check
Under the schema above, which body is valid?
  1. {"name": "Spring", "budget": 250, "tags": ["q2"]}
  2. {"name": "Spring"}
  3. {"name": "Spring", "budget": 250, "channel": "tv"}
  4. {"name": "", "budget": 250}
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))
It runs right here in your browser, with nothing to install.

That's the whole pattern: check here, then recurse with a longer path.

Try it now

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.

Quick check
What path describes the price of the second item in {"items": [{...}, {"price": -1}]}?
  1. $.items[2].price
  2. $.price
  3. $.items[1].price
  4. 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.

Quick check
This is the type trap from the lesson. What does it print?
print(isinstance(True, int), type(True) is int)
  1. False False
  2. True False
  3. False True
  4. True True
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

Create a free account

Try a quiz question

What does "additionalProperties": false protect against?

  1. Misspelled or unexpected keys being silently accepted and ignored
  2. Missing required keys being silently filled with defaults
  3. Values of the wrong type being converted without a warning
  4. Arrays that are too long being cut down to the allowed length

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson