Zod validation in Express and Fastify: request bodies, query strings and useful errors
One schema for runtime checks, TypeScript types and response filtering, with the Express 5 req.query gotcha.

The bug report says “500 on checkout”, and the stack trace ends in Cannot read properties of undefined (reading 'map') because a client sent items: null. The handler trusted req.body. Validating input at the edge turns that 500 into a clear 400 the client can fix, and stops unexpected fields from reaching your database.
The short answer: define a Zod schema per route for the body, query and params; in Express, run safeParse in a small middleware and return a 400 with the issue paths; in Fastify, plug Zod into the schema system with fastify-type-provider-zod so validation, types and response filtering come from one definition. This post uses Zod 4 (stable since July 2025), Express 5 and Fastify 5.
Why Zod validation for request bodies
Zod gives you three things from one schema: a runtime check, a TypeScript type (z.infer), and normalized data (coerced numbers, defaults, stripped unknown keys). Zod 4 also moved string formats to the top level (z.email(), z.uuid(), z.url()) and unified error customization under a single error parameter, and the maintainers’ benchmarks show object parsing several times faster than Zod 3.
Here are schemas for an order API that the Express and Fastify examples both use:
// schemas.ts
import { z } from 'zod';
export const CreateOrder = z.strictObject({
customerId: z.uuid(),
email: z.email(),
items: z
.array(z.object({ sku: z.string().min(1).max(64), qty: z.int().min(1).max(100) }))
.min(1)
.max(50),
note: z.string().max(500).optional(),
});
export type CreateOrderInput = z.infer<typeof CreateOrder>;
export const ListOrders = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().max(200).optional(),
});
export const OrderParams = z.object({ id: z.uuid() });
export const OrderOut = z.object({
id: z.uuid(),
status: z.enum(['pending', 'paid', 'shipped']),
total: z.int(),
});
Three choices in there matter for security, not just tidiness:
z.strictObjectrejects unknown keys. A plainz.objectstrips them silently, which is also safe; what is not safe is spreading the rawreq.bodyinto an insert, because then a client can sendrole: "admin"oraccountIdand you have a mass-assignment bug.- Every string and array has a
max. Validation is your cheapest defense against a 5 MBnotefield. - Query values arrive as strings, so
z.coerce.number()turns?limit=20into20.
Zod validation middleware for Express 5
Express has no schema system, so you write one small middleware:
// validate.ts
import type { RequestHandler } from 'express';
import type { ZodType } from 'zod';
type Parts = { params?: ZodType; query?: ZodType; body?: ZodType };
export const validate = (schemas: Parts): RequestHandler => (req, res, next) => {
const input: Record<string, unknown> = {};
const issues: { in: string; path: string; code: string; message: string }[] = [];
for (const part of ['params', 'query', 'body'] as const) {
const schema = schemas[part];
if (!schema) continue;
const result = schema.safeParse(req[part]);
if (result.success) input[part] = result.data;
else for (const i of result.error.issues) {
issues.push({ in: part, path: i.path.join('.'), code: i.code, message: i.message });
}
}
if (issues.length) return res.status(400).json({ error: 'invalid request', issues });
res.locals.input = input; // req.query is a read-only getter in Express 5
next();
};
Use it per route:
app.use(express.json({ limit: '100kb' }));
app.post('/v1/orders', authenticate, validate({ body: CreateOrder }), async (req, res) => {
const body = res.locals.input.body as CreateOrderInput;
const order = await createOrder(req.user.accountId, body);
res.status(201).json(OrderOut.parse(order));
});
app.get('/v1/orders', authenticate, validate({ query: ListOrders }), async (req, res) => {
const { limit, cursor } = res.locals.input.query;
res.json(await listOrders(req.user.accountId, limit, cursor));
});
The Express 5 gotcha: req.query is read-only
In Express 4, plenty of validation middleware did req.query = parsed. In Express 5, req.query is a getter, so that assignment does not stick. Put parsed values somewhere else (res.locals above, or a typed req.input you declare yourself). Express 5’s default query parser is also now “simple”, so ?filter[status]=paid no longer becomes a nested object unless you set app.set('query parser', 'extended'). If you are still moving over, our Express 5 migration guide covers the rest.
safeParse or parse?
Use safeParse in middleware so a bad request is a normal 400, not an exception. Use parse on data you produced, like OrderOut.parse(order) above, where a failure really is a server bug and should be a 500.
Zod validation in Fastify with a type provider
Fastify validates with JSON Schema and Ajv by default. fastify-type-provider-zod swaps in Zod and gives your handlers typed request.body, request.query and request.params. Versions 5 and 6 of that package need Zod 4; version 7 (June 2026) needs Zod 4.2 or later.
import Fastify from 'fastify';
import {
serializerCompiler,
validatorCompiler,
hasZodFastifySchemaValidationErrors,
type ZodTypeProvider,
} from 'fastify-type-provider-zod';
import { CreateOrder, OrderOut, OrderParams } from './schemas.js';
const app = Fastify({ logger: true }).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.setErrorHandler((err, request, reply) => {
if (hasZodFastifySchemaValidationErrors(err)) {
return reply.code(400).send({ error: 'invalid request', issues: err.validation });
}
request.log.error({ err }, 'unhandled error');
return reply.code(500).send({ error: 'internal error' });
});
app.post('/v1/orders', {
schema: { body: CreateOrder, response: { 201: OrderOut } },
}, async (request, reply) => {
const order = await createOrder(request.body); // typed as CreateOrderInput
return reply.code(201).send(order);
});
app.get('/v1/orders/:id', {
schema: { params: OrderParams, response: { 200: OrderOut } },
}, async (request) => getOrder(request.params.id));
The response schema is worth the extra lines. If getOrder returns a row with internal_notes or stripe_customer_id, the serializer drops fields that are not in OrderOut. That is a cheap guard against leaking columns, the same class of bug as the object-level access mistakes in our BOLA and IDOR post.
Validation runs before preHandler, so by the time your authorization hook runs, the body is already checked; see Fastify hooks for logging and auth.
Zod or Fastify’s built-in JSON Schema?
If raw throughput on large bodies is your bottleneck, Fastify’s compiled Ajv validators are hard to beat, and Zod 4 can emit JSON Schema with z.toJSONSchema() if you want to share definitions. For most small teams, one schema language across Express, Fastify and the front end is worth more than the difference.
What to log when validation fails
Log the issue paths and codes, not the rejected values. A 400 on /v1/login with the full body logged is a password in your logs. A line like { route: "/v1/orders", status: 400, issues: ["items.0.qty"] } is enough to spot a broken client release.
If you only do one thing
- Add a schema to every route that writes data, using
z.strictObjector plainz.objectand never spreading rawreq.body. - Put a
maxon every string and array, and cap body size in the parser. - Return 400 with
{ in, path, code, message }issues. - In Fastify, add a
responseschema to endpoints that return database rows.
A one-person team should start with the write endpoints and add reads later. A ten-person team should share schemas.ts with the client code so a breaking change fails in CI rather than in production.
FAQ
How do I validate query parameters with Zod?
Use z.coerce.number() and z.coerce.boolean() with care, add .default() for optional values, and in Express 5 store the result outside req.query, since it cannot be reassigned.
Should validation errors return 400 or 422?
Either is defensible; pick one and document it. Many APIs use 400 for malformed input and keep 422 for well-formed requests that break a business rule.
Does Zod strip unknown fields?
z.object strips unknown keys by default, z.strictObject rejects them, and z.looseObject passes them through. For write endpoints, strict is the most honest with clients.
Doing this with ApiTraffic
If you would rather not build the logging for this yourself, ApiTraffic captures every request and response under its route pattern, so a client sending bad payloads shows up with one search: url_path == "/v1/orders" AND status_code == 400. Bodies are recorded, credential headers are masked by default, and you can add redaction rules for any other field from the dashboard without a deploy.
Tag requests with the caller (apitraffic.tag('userId', String(req.user.id))) and you can tell one broken integration apart from a general problem.
Start free and see every request your API handles: apitraffic.io
Sources: Zod documentation, “Release notes: Zod 4”; InfoQ, “Zod v4 available” (Aug 23, 2025); fastify-type-provider-zod README; Express, “Migrating to Express 5”
Keep reading
Express 5 migration: what breaks in your API and how to fix it
You bump Express to 5 and the app dies with "Missing parameter name", or worse, starts fine and quietly breaks pagination. Here is what the Express 5 migration actually breaks in an API and how to fix each one.
Fastify hooks for logging and auth: onRequest, preHandler and onResponse explained
Auth checks that run before the body exists, log lines missing the status code, handlers that run after a 401: most come from using the wrong Fastify hook. Here is which hook does which job in Fastify v5, with working code.
BOLA vulnerability in Node APIs: how to fix IDOR and catch it in your logs
Change one digit in a URL and get another customer's invoice. Here is how to fix BOLA and IDOR structurally and spot someone probing for it before they find a gap.