Coding

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.

Zod validation, before the 500. Cover graphic for a guide to validating request bodies and query strings with Zod in Express and Fastify.

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:

ts
// 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.strictObject rejects unknown keys. A plain z.object strips them silently, which is also safe; what is not safe is spreading the raw req.body into an insert, because then a client can send role: "admin" or accountId and you have a mass-assignment bug.
  • Every string and array has a max. Validation is your cheapest defense against a 5 MB note field.
  • Query values arrive as strings, so z.coerce.number() turns ?limit=20 into 20.

Zod validation middleware for Express 5

Express has no schema system, so you write one small middleware:

ts
// 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:

ts
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.

ts
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.strictObject or plain z.object and never spreading raw req.body.
  • Put a max on every string and array, and cap body size in the parser.
  • Return 400 with { in, path, code, message } issues.
  • In Fastify, add a response schema 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