StackPractices
intermediate By Mathias Paulenko

High-Performance Logging with pino

How to use pino for fast structured JSON logging in Node.js, including log levels, child loggers, transports, and integration with Express and Fastify.

Overview

pino is the fastest Node.js logger — it writes JSON to stdout synchronously with minimal overhead. Unlike Winston or Bunyan, pino avoids async I/O in the hot path by deferring formatting to a worker thread via transports. In benchmarks that shows up as a 3-10x gap over the alternatives.

When to Use

  • High-throughput APIs where logging overhead matters (thousands of requests per second)
  • Microservices that ship JSON logs to a log aggregator (ELK, Loki, Datadog)
  • Express or Fastify apps that need request logging middleware
  • Replacing console.log with structured, filterable output

When NOT to Use

  • CLI tools that need colored human-readable output — use winston with Console transport
  • Scripts where console.log is sufficient — pino adds a dependency
  • Applications that need complex log routing (one file per level, email alerts on errors) — Winston is more flexible

Solution

Setup

npm install pino

Basic logging

const pino = require("pino");

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

logger.info({ port: 3000 }, "server_started");
logger.warn({ key: "user:42" }, "cache_miss");
logger.error({ error: "Connection refused" }, "database_error");

pino’s signature is logger.info(mergingObject, message) — the object comes first. Passing it after the string (Winston style) turns it into a printf argument that’s silently dropped, so the fields never reach the log line.

Output (JSON to stdout):

{"level":30,"time":1719900000000,"pid":1,"hostname":"api-01","msg":"server_started","port":3000}

Child loggers for request context

const logger = pino();

const requestLogger = logger.child({ request_id: "req-abc-123", user_id: 42 });

requestLogger.info({ order_id: "ord_456" }, "processing_order");
requestLogger.info({ items: 3 }, "order_validated");
requestLogger.warn({ sku: "widget-001", stock: 2 }, "inventory_low");

Both bound fields ride along on every line the child emits.

Pretty printing in development

const logger = pino({
  transport: {
    target: "pino-pretty",
    options: {
      colorize: true,
      translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
      ignore: "pid,hostname",
    },
  },
});

logger.info({ port: 3000 }, "server_started");

Add it as a dev-only dependency: npm install -D pino-pretty. A cheaper local alternative that never touches your process: node app.js | npx pino-pretty.

Log levels

const logger = pino({ level: "debug" });

logger.trace("very_detailed");   // level 10
logger.debug("debug_info");      // level 20
logger.info("general_info");     // level 30
logger.warn("warning");          // level 40
logger.error("error_occurred");  // level 50
logger.fatal("crash");           // level 60

Integration with Express

const express = require("express");
const pino = require("pino");
const pinoHttp = require("pino-http");

const logger = pino({ level: "info" });
const app = express();

app.use(pinoHttp({ logger }));

app.get("/api/users/:id", (req, res) => {
  req.log.info({ user_id: req.params.id }, "fetching_user");
  res.json({ id: req.params.id, name: "Alice" });
});

app.listen(3000);

pino-http is the middleware that wires it up: npm install pino-http.

Integration with Fastify

const fastify = require("fastify")({
  logger: {
    level: "info",
    transport: process.env.NODE_ENV === "development"
      ? { target: "pino-pretty" }
      : undefined,
  },
});

fastify.get("/api/users/:id", async (request, reply) => {
  request.log.info({ user_id: request.params.id }, "fetching_user");
  return { id: request.params.id, name: "Alice" };
});

fastify.listen({ port: 3000 });

Fastify has pino built in — no extra setup needed.

Custom serializers

const logger = pino({
  serializers: {
    req(req) {
      return {
        method: req.method,
        url: req.url,
        headers: { "user-agent": req.headers["user-agent"] },
      };
    },
    res(res) {
      return { statusCode: res.statusCode };
    },
    err(err) {
      return { type: err.constructor.name, message: err.message, stack: err.stack };
    },
  },
});

logger.info({ req: { method: "GET", url: "/api/users", headers: { "user-agent": "curl/8.0" } } }, "request_received");

File transport with rotation

const logger = pino({
  transport: {
    targets: [
      {
        target: "pino/file",
        options: { destination: "./logs/app.log", mkdir: true },
        level: "info",
      },
      {
        target: "pino/file",
        options: { destination: "./logs/errors.log", mkdir: true },
        level: "error",
      },
    ],
  },
});

logger.info("goes_to_app_log");
logger.error("goes_to_both_logs");

For rotation, use pino-roll:

const logger = pino({
  transport: {
    target: "pino-roll",
    options: {
      file: "./logs/app",
      frequency: "daily",
      mkdir: true,
    },
  },
});

Redacting sensitive fields

const logger = pino({
  redact: {
    paths: ["password", "api_key", "token", "headers.authorization", "*.credit_card"],
    censor: "[REDACTED]",
  },
});

logger.info({ user: { email: "alice@example.com", password: "secret123" } }, "user_login");
// Output: {"user": {"email": "alice@example.com", "password": "[REDACTED]"}, ...}

Error logging with stack traces

const logger = pino();

try {
  JSON.parse("{invalid}");
} catch (err) {
  logger.error({ err }, "json_parse_failed");
}

pino automatically serializes the err key with type, message, and stack. For production error reporting with grouping and alerting, pair it with Sentry error tracking.

Variants

Using pino with TypeScript

import pino from "pino";

const logger = pino({
  level: "info",
  base: { service: "user-api", version: "1.0.0" },
});

interface UserLogData {
  user_id: number;
  action: string;
}

function logUserAction(data: UserLogData): void {
  logger.info(data, "user_action");
}

Using pino with OpenTelemetry

const { trace, context } = require("@opentelemetry/api");
const logger = pino();

function logWithTrace(msg, data = {}) {
  const span = trace.getSpan(context.active());
  const traceId = span?.spanContext().traceId;
  const spanId = span?.spanContext().spanId;
  logger.info({ ...data, trace_id: traceId, span_id: spanId }, msg);
}

Explanation

pino gets its speed from two design choices. First, it writes newline-delimited JSON to stdout synchronously through a minimal stream — there’s no formatting pipeline sitting between logger.info() and the write. Second, transports run in a worker thread, so shipping logs to files or external services never blocks your event loop.

The trade-off is that pino asks you to think differently than Winston — less about formatters, more about where each line ends up. Two entry points matter:

  • pino.destination() — a raw synchronous stream. The fastest option, but one output only.
  • transport — runs the target (pino-pretty, pino-roll, remote shippers) in a worker thread. A bit slower per call, but you can fan out to several targets without slowing the app.

child() is cheap: the new logger shares the parent’s stream and just merges bound fields into each line. That’s exactly what pino-http does under the hood — one child per request, so nobody repeats request_id by hand.

pino-pretty is the exception that proves the rule: as an inline transport it formats every line synchronously, so it’s useful in development and harmful under production load. If you need readable logs locally without any in-app cost, pipe instead: node app.js | npx pino-pretty.

One detail that trips people up: logger.info("msg", { data }) looks like it should work — Winston accepts that order — but pino treats the second argument as printf-style interpolation for the message string. With no %j placeholder, your fields are silently dropped. Always logger.info({ data }, "msg").

Best Practices

  • For a deeper guide, see Rotate Logs Daily with Winston. For runnable code, grab the companion examples (logger config, Express and Fastify apps).

  • Use pino-pretty only in development — it adds overhead in production

  • Use child loggers for request-scoped context instead of repeating fields in every call

  • Set level via environment variable: level: process.env.LOG_LEVEL || "info"

  • Use redact paths to strip sensitive fields — don’t rely on developers to remember

  • Use serializers for complex objects (req, res, err) to control what gets logged

  • Ship JSON to stdout in production — let a log collector handle file writing and rotation

  • Use pino-http for Express or built-in Fastify logging for automatic request/response logs

Common Mistakes

  • Using pino-pretty in production: it’s synchronous and blocks the event loop. Only use in development.
  • Not using child loggers: repeating request_id in every logger.info() call is error-prone. Bind once with child().
  • Logging large objects: pino serializes synchronously. Logging a 10MB object blocks the event loop. Use serializers to trim.
  • Not setting level: default is info. If you need debug in staging, set it via env var.
  • Using console.log alongside pino: console.log writes unstructured text to stdout, mixing with pino’s JSON. Remove all console.log calls.

Frequently Asked Questions

How fast is pino compared to Winston?

In benchmarks, pino is 3-10x faster than Winston for synchronous JSON logging. The difference comes from pino's synchronous write with deferred formatting vs Winston's async chain.

How do I send pino logs to Elasticsearch?

Pipe stdout to a log collector (Filebeat, Fluent Bit, Logstash). The JSON format is already structured — no parsing rules needed:

node app.js | filebeat --config=filebeat.yml
Can I use pino with async/await?

Yes. pino itself is synchronous (no async I/O in the hot path), so calling logger.info() inside an async function is just one more synchronous write — nothing to await.

How do I add a request ID middleware with pino?
const { randomUUID } = require("crypto");

app.use((req, res, next) => {
  req.id = req.headers["x-request-id"] || randomUUID();
  req.log = logger.child({ request_id: req.id });
  next();
});
What is the difference between pino transports and pino destinations?

Destinations are writable streams (fast, synchronous). Transports run in a worker thread (async, and they can fan out to stdout plus files or remote services at once). Use destinations for raw speed, transports for routing to files or external services.

How do I redact sensitive fields in pino logs?

Use the redact option in pino configuration. Specify paths to remove or censor: redact: { paths: ['req.headers.authorization', 'password', '*.token'], remove: false }. Setting remove: false replaces the value with [Redacted] (the default censor — the example above shows a custom one). Setting remove: true removes the key entirely from the log output.