Who this is for
- Backend Engineers building or maintaining HTTP/JSON APIs.
- Developers integrating services who need robust contracts between systems.
- Anyone aiming to reduce bugs by catching bad inputs/outputs at the boundary.
Prerequisites
- Comfort with HTTP basics: methods, status codes, headers, and JSON.
- Ability to read small code snippets (Node.js/TypeScript, Python, or Java) and JSON Schema.
- Basic understanding of API routing and middleware.
Learning path
- Understand why validation matters and the boundary-first mindset.
- Learn core rules: request and response schemas, error formats, and status codes.
- Practice with worked examples (POST/GET/PATCH scenarios).
- Do the exercises to design schemas and error responses.
- Take the quick test to check understanding. Note: test is available to everyone; only logged-in users get saved progress.
- Build a small project with runtime validation.
Why this matters
- Prevents invalid data from entering your system (costly bugs, security issues).
- Keeps your API contract stable so clients don’t break unexpectedly.
- Makes debugging faster with consistent error formats and messages.
- Improves DX: auto-generated docs, typed clients, and predictable responses.
Concept explained simply
Request/response validation checks that incoming data from a client and outgoing data from your service strictly match an agreed contract (schema). Validate at the edges: before business logic runs and before the response leaves.
Mental model
Imagine your API as a nightclub with two bouncers:
- Request bouncer: checks IDs (types), dress code (formats), and capacity (sizes) before letting anyone in.
- Response bouncer: ensures every guest leaving wears a wristband (required fields) and no one sneaks out with something they shouldn’t (forbidden fields).
Key concepts and rules
- Validate everything at the boundary: path params, query params, headers, body, and files.
- Be explicit about types and formats: integer vs number, min/max, string patterns, enums, date-time, email, uuid.
- Use strict mode by default: reject unknown fields or explicitly strip them.
- Respond with consistent errors: status code, stable error code, message, and field-level details.
- Validate responses too: keep contracts stable and avoid leaking internal fields.
- Backward compatibility: add optional fields; avoid renaming/removing fields in existing versions.
- Performance: compile and reuse schemas; validate at the edges only once.
Tip: Typical tools
- JSON Schema + AJV (Node.js), class-validator (Java/Spring), Pydantic (Python/FastAPI), Zod/Joi (Node.js).
- Schema-first with OpenAPI or code-first that emits OpenAPI for docs and clients.
Worked examples
Example 1 — Node.js (Express + Zod): POST /users
Goal: Validate request body and the response you send back.
// Schema
const UserCreate = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(13).max(120).optional(),
roles: z.array(z.enum(["user","admin"]))
.min(1),
});
const UserPublic = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
roles: z.array(z.enum(["user","admin"]))
});
// Middleware-like validation
app.post('/users', (req, res) => {
const parsed = UserCreate.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid request body",
details: parsed.error.issues
}
});
}
const user = createUser(parsed.data);
const out = UserPublic.parse(user); // response validation
return res.status(201).json(out);
});Example 2 — Python (FastAPI + Pydantic): GET /search
class SearchQuery(BaseModel):
q: constr(min_length=1)
limit: conint(ge=1, le=100) = 10
page: conint(ge=1) = 1
class Item(BaseModel):
id: UUID
title: str
class SearchResponse(BaseModel):
total: conint(ge=0)
items: List[Item]
@app.get("/search", response_model=SearchResponse)
async def search(q: str, limit: int = 10, page: int = 1):
# FastAPI validates query params automatically by type hints/validators
results = query_index(q, limit, page)
return SearchResponse(**results)Example 3 — JSON Schema (AJV): PATCH /orders/{id}
Validate a path param (uuid) and partial body.
// Path param (often validated by router or custom middleware)
const IdSchema = { type: "string", format: "uuid" };
// Body schema: partial update
const OrderPatchSchema = {
type: "object",
additionalProperties: false,
properties: {
status: { enum: ["placed","paid","shipped","cancelled"] },
notes: { type: "string", maxLength: 500 },
},
minProperties: 1
};
// Error shape example
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request",
"details": [
{ "path": ["status"], "message": "must be equal to one of the allowed values" }
]
}
}Hands-on: Follow these steps
- Define the contract: Write request and response schemas for your endpoint. Be explicit about required/optional and unknown fields.
- Add request validation: Place a middleware/guard that checks headers (Content-Type), params, query, then body. Fail fast with a consistent error.
- Add response validation: Validate or serialize the object you send out. Strip internal fields, enforce types.
- Error format: Return { error: { code, message, details } } with appropriate 4xx/5xx.
- Test: Try valid and invalid payloads (missing required, wrong type, extra fields). Confirm statuses and messages.
- Performance: Compile/reuse schemas; do not re-validate inside business logic.
Exercises
Do these in your editor or mentally. Then open the solutions below each exercise card on this page.
- Exercise 1: Design a JSON Schema for a POST /users body and a 400 error shape.
- Exercise 2: Design a 200/404 response contract for GET /orders/{id}, including types and constraints.
Self-check checklist
- Did you limit unknown fields on requests?
- Are all numeric ranges and string lengths explicit?
- Do you provide a stable error code and field-level details?
- Do 200 responses match the documented shape exactly?
Common mistakes and self-check
- Trusting the client: Always validate; do not rely on UI validation.
- Silent coercion: Avoid auto-casting strings to numbers; parse explicitly or reject.
- Inconsistent error shapes: Standardize and reuse one structure.
- Status code mismatch: 200 with error body is confusing; use 4xx/5xx properly.
- Forgetting response validation: You might leak internal fields or return nulls where arrays are expected.
- Over-validation: Rejecting new optional fields during rollout; decide policy (reject or strip) and document it.
- Performance pitfalls: Re-creating validators per request; compile once and reuse.
How to self-test quickly
- Send bad inputs via curl or an API client: wrong types, extra fields, large payloads.
- Randomize values (fuzz) for a minute; the service should fail gracefully with 400, not 500.
- Contract-test your responses: verify types and required fields in automated tests.
Practical projects
- Build a User service with POST /users and GET /users/{id}, including full request/response validation and a consistent error format.
- Add a Search endpoint with validated query params (pagination, limits) and typed responses.
- Introduce versioning: v1 to v1.1 adds optional fields without breaking existing clients; prove with tests.
Quick Test
This quick test is available to everyone. Only logged-in users will have their progress saved.
When you're ready, open the test below.
Next steps
- Apply validation to at least two more endpoints in your project.
- Add automated contract tests to CI to prevent regressions.
- Explore emitting or consuming OpenAPI to keep docs and code in sync.
Mini challenge
Extend an existing endpoint to accept an optional field (e.g., user.timezone). Update request/response schemas, set sensible defaults server-side, and prove backward compatibility by running previous tests unchanged.