Skip to content
Codeloom
Node.js

Structured Logging in Node.js with Pino

Set up fast structured logging in Node.js using Pino. Covers log levels, child loggers, request tracing, and production configuration.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why structured logging matters for production systems
  • Setting up Pino with proper log levels and formatting
  • Using child loggers for request context
  • Integrating with Express and Fastify

Prerequisites

  • Basic Node.js knowledge
  • Familiarity with npm
  • Understanding of HTTP servers

console.log is fine for development, but in production you need structured, machine-parseable logs that can be searched, filtered, and aggregated. Pino is the fastest JSON logger for Node.js, producing structured log output with minimal performance overhead. It is 5-10x faster than Winston and Bunyan because it defers serialization to a separate process.

Why structured logging?

Unstructured logs look like this:

[2026-07-02 14:30:15] User alice@test.com logged in from 192.168.1.5
[2026-07-02 14:30:16] Order #12345 created, total: $99.50
[2026-07-02 14:30:17] ERROR: Payment failed for order #12345

These are human-readable but impossible to query programmatically. Structured logs are JSON:

{"level":30,"time":1719927015000,"msg":"User logged in","email":"alice@test.com","ip":"192.168.1.5"}
{"level":30,"time":1719927016000,"msg":"Order created","orderId":"12345","total":99.50}
{"level":50,"time":1719927017000,"msg":"Payment failed","orderId":"12345","error":"Card declined"}

You can pipe these into Elasticsearch, Datadog, or CloudWatch and query them: “show me all errors where orderId is 12345.”

Setting up Pino

npm install pino
import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
});

logger.info('Server starting');
logger.info({ port: 3000 }, 'Server listening');
logger.warn({ memoryUsage: process.memoryUsage().heapUsed }, 'High memory');
logger.error({ err: new Error('Connection refused') }, 'Database connection failed');

Output:

{"level":30,"time":1719927015000,"msg":"Server starting"}
{"level":30,"time":1719927015001,"msg":"Server listening","port":3000}
{"level":40,"time":1719927015002,"msg":"High memory","memoryUsage":52428800}
{"level":50,"time":1719927015003,"msg":"Database connection failed","err":{"type":"Error","message":"Connection refused","stack":"Error: Connection refused\n    at..."}}

Log levels

Pino uses numeric log levels:

LevelNumberMethod
trace10logger.trace()
debug20logger.debug()
info30logger.info()
warn40logger.warn()
error50logger.error()
fatal60logger.fatal()

Set the minimum level via the level option. Messages below this level are discarded:

const logger = pino({ level: 'warn' });

logger.info('This is ignored');   // Below 'warn', not logged
logger.warn('This is logged');    // Logged
logger.error('This is logged');   // Logged

In production, use info. In development, use debug or trace.

Pretty printing for development

Raw JSON is hard to read during development. Use pino-pretty:

npm install --save-dev pino-pretty
const logger = pino({
  level: 'debug',
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
      translateTime: 'SYS:standard',
      ignore: 'pid,hostname',
    },
  },
});

Or pipe the output:

node app.js | npx pino-pretty

Never use pino-pretty in production. It adds overhead and defeats the purpose of structured logging.

Child loggers for request context

Child loggers inherit the parent’s configuration and add context fields to every log entry. This is the key pattern for request tracing.

import pino from 'pino';
import { randomUUID } from 'node:crypto';

const logger = pino({ level: 'info' });

function handleRequest(req, res) {
  const requestLogger = logger.child({
    requestId: randomUUID(),
    method: req.method,
    url: req.url,
  });

  requestLogger.info('Request received');

  // Pass the logger to downstream functions
  const user = getUser(requestLogger, req.userId);
  requestLogger.info({ userId: user.id }, 'User authenticated');

  res.json({ status: 'ok' });
  requestLogger.info({ statusCode: 200 }, 'Response sent');
}

function getUser(logger, userId) {
  logger.debug({ userId }, 'Fetching user from database');
  // Every log from this function includes requestId, method, url
  return { id: userId, name: 'Alice' };
}

Every log entry from requestLogger automatically includes requestId, method, and url. This lets you filter all logs for a single request in your log aggregator.

Express integration with pino-http

npm install pino-http
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';

const logger = pino({ level: 'info' });

const app = express();

app.use(pinoHttp({
  logger,
  autoLogging: true,
  customLogLevel: (req, res, err) => {
    if (res.statusCode >= 500 || err) return 'error';
    if (res.statusCode >= 400) return 'warn';
    return 'info';
  },
  customSuccessMessage: (req, res) => {
    return `${req.method} ${req.url} completed`;
  },
  customErrorMessage: (req, res, err) => {
    return `${req.method} ${req.url} failed: ${err.message}`;
  },
  serializers: {
    req: (req) => ({
      method: req.method,
      url: req.url,
      // Don't log headers in production (may contain auth tokens)
    }),
    res: (res) => ({
      statusCode: res.statusCode,
    }),
  },
}));

app.get('/api/users', (req, res) => {
  // req.log is a child logger with request context
  req.log.info('Fetching users');
  res.json([{ id: 1, name: 'Alice' }]);
});

app.listen(3000);

Fastify integration

Fastify uses Pino as its built-in logger:

import Fastify from 'fastify';

const app = Fastify({
  logger: {
    level: 'info',
    serializers: {
      req: (req) => ({
        method: req.method,
        url: req.url,
      }),
    },
  },
});

app.get('/api/users', async (request, reply) => {
  request.log.info('Fetching users');
  return [{ id: 1, name: 'Alice' }];
});

await app.listen({ port: 3000 });

No additional packages needed. Every request gets a child logger with a unique request ID automatically.

Custom serializers

Serializers control how objects are converted to JSON. They prevent logging sensitive data and reduce log size.

const logger = pino({
  level: 'info',
  serializers: {
    user: (user) => ({
      id: user.id,
      email: user.email,
      // Exclude password, tokens, etc.
    }),
    err: pino.stdSerializers.err, // Standard error serializer
  },
});

logger.info({ user: fullUserObject }, 'User created');
// Only logs { id, email }, not the full object

Redaction

Automatically remove sensitive fields from logs:

const logger = pino({
  level: 'info',
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie', 'user.password', 'user.ssn'],
    censor: '[REDACTED]',
  },
});

logger.info({
  user: { name: 'Alice', password: 'secret123', ssn: '123-45-6789' },
}, 'User data');

// Output: {"user":{"name":"Alice","password":"[REDACTED]","ssn":"[REDACTED]"},...}

Sending logs to multiple destinations

Use pino.multistream to write logs to stdout and a file simultaneously:

import pino from 'pino';
import { createWriteStream } from 'node:fs';

const streams = [
  { stream: process.stdout },
  { stream: createWriteStream('./app.log', { flags: 'a' }) },
  { level: 'error', stream: createWriteStream('./error.log', { flags: 'a' }) },
];

const logger = pino(
  { level: 'info' },
  pino.multistream(streams)
);

Production configuration

const isProduction = process.env.NODE_ENV === 'production';

const logger = pino({
  level: isProduction ? 'info' : 'debug',
  ...(isProduction ? {} : {
    transport: {
      target: 'pino-pretty',
      options: { colorize: true },
    },
  }),
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      '*.password',
      '*.token',
      '*.secret',
    ],
    censor: '[REDACTED]',
  },
  serializers: {
    err: pino.stdSerializers.err,
  },
});

export default logger;

Key takeaways

Use Pino for production Node.js logging. Its JSON output integrates with every log aggregation platform. Use child loggers to propagate request context through your application. Add custom serializers and redaction rules to prevent sensitive data from reaching your logs. Use pino-pretty only in development. In production, pipe raw JSON to your log aggregator and let it handle formatting and querying.