JSON in Fastify: Serialization, Validation, and Pino Logging

Last updated:

Fastify serializes JSON responses 10x faster than JSON.stringify by pre-compiling JSON Schema definitions into serializers using the fast-json-stringify library. Define a schema.response on each route and Fastify skips generic serialization entirely. For request validation, add schema.body and Fastify uses Ajv to reject malformed requests with a 400 error before your handler runs. Structured JSON logging is built in via Pino — the fastest Node.js logger — with no configuration needed. This guide covers 5 topics: schema-based response serialization, request body validation with JSON Schema, Pino structured logging, JSON error responses, and TypeScript type inference from schemas.

Validate your Fastify JSON schemas

Paste a Fastify JSON response into Jsonic to validate structure and spot missing fields.

Open JSON Validator

Schema-Based JSON Serialization

Bottom line: define a schema.response on every route that returns JSON. Fastify compiles that schema into a dedicated serializer function at startup using fast-json-stringify, bypassing JSON.stringify entirely for those routes. Response time drops and unknown fields are automatically stripped — preventing accidental data leakage.

The response schema is keyed by HTTP status code. Define separate schemas for 200, 201, and 400 if those routes return different shapes. When you call reply.send(object), Fastify looks up the compiled serializer for the current status code and runs it. If no schema is defined for a given status code, Fastify falls back to JSON.stringify. Defining schemas is optional but strongly recommended for any route on a hot path. The serializer strips extra fields silently — treat it as a whitelist of safe-to-expose properties.

import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

// Response schema — fast-json-stringify compiles this at startup
const getUserSchema = {
  schema: {
    response: {
      200: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          name: { type: 'string' },
          email: { type: 'string' },
        },
        // additionalProperties: false — unknown fields are stripped by default
      },
      404: {
        type: 'object',
        properties: {
          error: { type: 'string' },
          statusCode: { type: 'integer' },
        },
      },
    },
  },
}

fastify.get('/users/:id', getUserSchema, async (request, reply) => {
  const user = await db.findUser(Number(request.params.id))
  if (!user) {
    return reply.status(404).send({ error: 'Not found', statusCode: 404 })
  }
  // reply.send uses the compiled serializer for status 200
  // Fields not in the schema (e.g. passwordHash) are silently dropped
  return user
})

await fastify.listen({ port: 3000 })

Validate Request Bodies with JSON Schema

Bottom line: add schema.body to your route definition. Fastify runs the parsed body through Ajv 8 before calling your handler. Any validation failure triggers an automatic 400 response with a detailed error array — your handler never executes for invalid input.

Body schemas use JSON Schema Draft 7. The required array enforces mandatory fields; format keywords like "email" and "date-time" are validated by Ajv's format plugin. Ajv runs in strict mode by default in Fastify 4+, so unknown keywords cause startup errors rather than silent ignoring. For custom error messages, add the ajv-errors plugin and use the errorMessage keyword. For internationalized errors, use ajv-i18n. The 400 error body includes a validation array with instancePath, schemaPath, and message for each failure.

const createUserSchema = {
  schema: {
    body: {
      type: 'object',
      required: ['name', 'email', 'age'],
      properties: {
        name: { type: 'string', minLength: 1, maxLength: 100 },
        email: { type: 'string', format: 'email' },
        age: { type: 'integer', minimum: 0, maximum: 150 },
        role: { type: 'string', enum: ['admin', 'user', 'guest'] },
      },
      additionalProperties: false,
    },
    response: {
      201: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          name: { type: 'string' },
          email: { type: 'string' },
        },
      },
    },
  },
}

fastify.post('/users', createUserSchema, async (request, reply) => {
  // request.body is typed and guaranteed valid by Ajv
  const { name, email, age, role } = request.body
  const user = await db.createUser({ name, email, age, role: role ?? 'user' })
  return reply.status(201).send(user)
})

// Default 400 error body from Ajv (when "email" is missing):
// {
//   "statusCode": 400,
//   "error": "Bad Request",
//   "message": "body must have required property 'email'",
//   "validation": [
//     {
//       "instancePath": "",
//       "schemaPath": "#/required",
//       "keyword": "required",
//       "params": { "missingProperty": "email" },
//       "message": "must have required property 'email'"
//     }
//   ]
// }

Structured JSON Logging with Pino

Bottom line: pass { logger: true } when creating the Fastify instance and every log line — including per-request start/end events — is emitted as a JSON object to stdout. Pino is the fastest Node.js logger available; enabling it has negligible overhead compared to writing no logs at all.

Each request gets a child logger with a unique reqId field, so all log lines for a request are correlated without manual effort. Use request.log.info() inside handlers to add structured context alongside the built-in request metadata. In development, pipe output through pino-pretty: node server.js | pino-pretty. In production, ship the raw JSON to your log aggregator (Datadog, CloudWatch, Loki) for structured querying. Override serializers to control exactly which request and reply fields appear in each log line and to redact sensitive headers.

import Fastify from 'fastify'

const fastify = Fastify({
  logger: {
    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
    // Redact sensitive headers from logs
    redact: ['req.headers.authorization', 'req.headers.cookie'],
    // Custom request serializer — control which fields appear
    serializers: {
      req(req) {
        return {
          method: req.method,
          url: req.url,
          hostname: req.hostname,
          remoteAddress: req.ip,
        }
      },
      res(reply) {
        return { statusCode: reply.statusCode }
      },
    },
  },
})

fastify.get('/orders/:id', async (request, reply) => {
  const { id } = request.params

  // Child logger inherits reqId — all lines for this request are correlated
  request.log.info({ orderId: id }, 'Fetching order')

  const order = await db.findOrder(id)
  if (!order) {
    request.log.warn({ orderId: id }, 'Order not found')
    return reply.status(404).send({ error: 'Not found' })
  }

  request.log.info({ orderId: id, total: order.total }, 'Order fetched')
  return order
})

// Sample JSON log output:
// {"level":30,"time":1716820000000,"reqId":"req-1","method":"GET","url":"/orders/42","msg":"incoming request"}
// {"level":30,"time":1716820000005,"reqId":"req-1","orderId":"42","msg":"Fetching order"}
// {"level":30,"time":1716820000010,"reqId":"req-1","orderId":"42","total":99.99,"msg":"Order fetched"}
// {"level":30,"time":1716820000012,"reqId":"req-1","statusCode":200,"responseTime":12.3,"msg":"request completed"}

JSON Error Responses

Bottom line: register a global setErrorHandler to control the shape of every error response. Fastify calls this handler for all uncaught errors, validation failures, and 404/405 responses — giving you a single place to enforce a consistent error format across the whole API.

For APIs that follow RFC 7807 Problem Details, set Content-Type: application/problem+json and return a body with type, title, status, and instance fields. Fastify's ValidationError (status 400) exposes a validation array you can pass through directly. Use @fastify/error to create typed error classes with a stable code property that maps to HTTP status codes. The error handler also receives the request object, so you can log context or read headers before responding.

import createError from '@fastify/error'

// Define typed error classes
const NotFoundError = createError('NOT_FOUND', 'Resource not found', 404)
const ForbiddenError = createError('FORBIDDEN', 'Access denied', 403)

// RFC 7807 Problem Details error handler
fastify.setErrorHandler(function (error, request, reply) {
  const statusCode = error.statusCode ?? 500

  // Log 5xx errors as errors; 4xx as warnings
  if (statusCode >= 500) {
    request.log.error({ err: error }, 'Unhandled error')
  } else {
    request.log.warn({ err: error }, 'Client error')
  }

  reply
    .status(statusCode)
    .header('Content-Type', 'application/problem+json')
    .send({
      type: `https://jsonic.io/errors/${error.code ?? 'internal'}`,
      title: error.message,
      status: statusCode,
      instance: request.url,
      // Forward Ajv validation details for 400 errors
      ...(error.validation ? { errors: error.validation } : {}),
    })
})

// Throw typed errors inside handlers
fastify.get('/admin/settings', async (request, reply) => {
  if (!request.user?.isAdmin) {
    throw new ForbiddenError()
  }
  return { darkMode: true }
})

// 404 handler for unmatched routes
fastify.setNotFoundHandler(function (request, reply) {
  reply.status(404).header('Content-Type', 'application/problem+json').send({
    type: 'https://jsonic.io/errors/not-found',
    title: 'Route not found',
    status: 404,
    instance: request.url,
  })
})

TypeScript and JSON Type Inference

Bottom line: use TypeBox schemas with the @fastify/type-provider-typebox plugin to derive TypeScript types directly from your JSON Schema definitions. One schema serves both runtime validation (Ajv) and compile-time type safety — no duplicate interface declarations.

TypeBox generates JSON Schema-compatible objects from TypeScript-like builder calls. Type.Object(), Type.String(), Type.Number() produce JSON Schema nodes that Fastify uses at runtime and TypeScript's Static<typeof Schema> utility extracts the equivalent TypeScript interface at compile time. After calling fastify.withTypeProvider<TypeBoxTypeProvider>(), route handler parameters — request.body, request.params, request.query — are fully typed without any manual type annotation. This eliminates an entire class of mismatches between the schema you validate against and the type you write business logic against.

import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type, Static } from '@sinclair/typebox'

const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>()

// Define schema once — used for both validation (Ajv) and TS types
const CreateUserBody = Type.Object({
  name: Type.String({ minLength: 1, maxLength: 100 }),
  email: Type.String({ format: 'email' }),
  age: Type.Integer({ minimum: 0 }),
  role: Type.Optional(Type.Union([
    Type.Literal('admin'),
    Type.Literal('user'),
    Type.Literal('guest'),
  ])),
})

const UserResponse = Type.Object({
  id: Type.Integer(),
  name: Type.String(),
  email: Type.String(),
  createdAt: Type.String({ format: 'date-time' }),
})

// Static<T> extracts the TypeScript type — useful for service layer types
type CreateUserInput = Static<typeof CreateUserBody>
type UserDto = Static<typeof UserResponse>

fastify.post(
  '/users',
  {
    schema: {
      body: CreateUserBody,
      response: { 201: UserResponse },
    },
  },
  async (request, reply) => {
    // request.body is typed as CreateUserInput — no type assertion needed
    const { name, email, age, role } = request.body

    const user: UserDto = await userService.create({ name, email, age, role })
    return reply.status(201).send(user)
  }
)

Content-Type Negotiation and Parsing

Bottom line: Fastify parses application/json request bodies out of the box using a built-in parser based on secure-json-parse (which guards against prototype pollution). For other content types — text/plain, application/x-www-form-urlencoded, or multipart/form-data — register a custom content-type parser.

Fastify checks the incoming Content-Type header and routes the raw body to the matching parser. You can register parsers with wildcards: "application/*" matches any application subtype not already handled. For multipart file uploads, use @fastify/multipart — it streams parts directly without buffering the full body. The global bodyLimit (default 1 MB) applies to all parsers; override per-route for endpoints that accept larger payloads. Use reply.header('Content-Type', 'application/json') explicitly when returning raw strings that are valid JSON, or use reply.send(object) which sets the header automatically when a response schema is present.

import Fastify from 'fastify'
import multipart from '@fastify/multipart'

const fastify = Fastify({
  bodyLimit: 1 * 1024 * 1024,  // 1 MB global limit
})

// Register multipart plugin for file uploads
await fastify.register(multipart, {
  limits: { fileSize: 10 * 1024 * 1024 },  // 10 MB per file
})

// Custom parser for text/plain — parse as JSON if valid, else raw string
fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, function (req, body, done) {
  try {
    done(null, JSON.parse(body))
  } catch {
    done(null, body)  // return raw string if not valid JSON
  }
})

// Custom parser for application/x-www-form-urlencoded
fastify.addContentTypeParser(
  'application/x-www-form-urlencoded',
  { parseAs: 'string' },
  function (req, body, done) {
    done(null, Object.fromEntries(new URLSearchParams(body)))
  }
)

// Per-route body limit override (50 MB for bulk import)
fastify.post(
  '/import',
  { bodyLimit: 50 * 1024 * 1024 },
  async (request, reply) => {
    const data = request.body as Record<string, unknown>[]
    return { imported: data.length }
  }
)

// Multipart file upload endpoint
fastify.post('/upload', async (request, reply) => {
  const file = await request.file()
  if (!file) return reply.status(400).send({ error: 'No file provided' })

  // Pipe to storage — never buffer the whole file in memory
  const buffer = await file.toBuffer()
  await storage.upload(file.filename, buffer)
  return { filename: file.filename, size: buffer.length }
})

FAQ

How does Fastify serialize JSON faster than Express?

Fastify uses fast-json-stringify to pre-compile a dedicated serializer function from your schema.response at startup. This compiled function knows the exact shape of the output and generates it without any dynamic property inspection. Express calls JSON.stringify on every response, which must handle arbitrary shapes. The result is approximately 10x faster for 1 KB payloads and 30x faster for 10 KB payloads.

How do I validate a request body in Fastify?

Add a schema.body property to your route definition using JSON Schema Draft 7 syntax. Fastify passes the parsed body through Ajv 8 before invoking your handler. If validation fails, Fastify automatically returns a 400 response with a validation array detailing every error. Your handler never runs for invalid input. Use @sinclair/typebox with @fastify/type-provider-typebox to also get TypeScript types from the same schema.

How do I configure Pino JSON logging in Fastify?

Pass { logger: true } or a Pino options object when creating the Fastify instance. Every log line is JSON by default. In development, pipe output through pino-pretty for readable formatting. Use request.log.info(obj, 'message') inside handlers to add structured fields. Set redact to remove sensitive headers from logs. The reqId field is added automatically to every request's child logger.

How do I send a custom JSON error response in Fastify?

Register a global error handler with fastify.setErrorHandler(function(error, request, reply) { ... }). Call reply.status(error.statusCode).send({ ... }) inside it to control the response shape. For RFC 7807 Problem Details format, set Content-Type: application/problem+json. Use @fastify/error to create typed error classes with stable codes and status codes that you can throw anywhere in your handlers.

How do I add TypeScript types to Fastify JSON schemas?

Install @sinclair/typebox and @fastify/type-provider-typebox. Call fastify.withTypeProvider<TypeBoxTypeProvider>() on your instance, then define schemas with Type.Object(), Type.String(), etc. TypeScript automatically infers request.body, request.params, and request.query types from the route schema — no separate interface declarations needed.

What is the maximum JSON body size in Fastify?

The default maximum body size is 1 MB. Increase it globally with Fastify({ bodyLimit: 10 * 1024 * 1024 }) for 10 MB. Override per-route with the bodyLimit option in the route definition. Requests that exceed the limit are rejected with a 413 error before parsing. For very large payloads, use the @fastify/multipart plugin to stream directly to storage without buffering the full body in memory.

Further reading and primary sources

  • Fastify Documentation: Validation and SerializationOfficial Fastify reference for schema.body validation with Ajv and schema.response serialization with fast-json-stringify
  • fast-json-stringify on GitHubThe library that powers Fastify's schema-based JSON serialization — includes benchmarks showing the 10–30x speedup over JSON.stringify
  • Fastify TypeBox providerOfficial plugin for using TypeBox schemas with Fastify to get TypeScript type inference from JSON Schema definitions
  • Pino logger docsPino is the fastest Node.js logger and Fastify's built-in logger — documentation covers log levels, redaction, serializers, and pino-pretty
  • Fastify error handlingOfficial Fastify documentation on setErrorHandler, validation errors, HTTP error codes, and the @fastify/error utility