cheat sheet

pino

Package-level reference for pino on npm — child loggers, redaction, transports, serializers, and migration notes.

pino

What it is

pino is a structured JSON logger for Node optimized for extreme low overhead. It writes NDJSON to stdout by default, defers expensive work (serialization, formatting) to a separate process via worker transports, and keeps the hot path under 100 ns per log call. It's the default logger inside Fastify, NestJS Pino, and many production Node services.

Reach for pino when you want structured logs with the lowest possible production overhead and when your log pipeline ingests JSON (Datadog, Splunk, Loki, CloudWatch). Choose winston if you need format flexibility (custom transports, color console + plain file in one process) at the cost of throughput.

Install

bash
npm install pino

Output: added pino to dependencies

bash
pnpm add pino

Output: added 1 package, linked from store

bash
yarn add pino

Output: added pino

bash
bun add pino

Output: installed pino

For pretty-printing in development, add pino-pretty:

bash
npm install --save-dev pino-pretty

Output: added pino-pretty to devDependencies

Versioning & Node support

Current line is pino@9.x.

  • pino@9 — Node 18+. Stable transport API, refined serializers, improved performance.
  • pino@8 — Node 14+. Introduced the worker-based transport API.
  • pino@7 — Older transport model.
  • pino@6 — Historical.

Pino follows semver; the 8→9 transition is largely additive — newer optional features without breaking existing code. Pin minor in production ("pino": "9.x").

Package metadata

  • Maintainer: Matteo Collina + Pino contributors (pinojs/pino)
  • Project home: github.com/pinojs/pino
  • Docs: getpino.io
  • npm: npmjs.com/package/pino
  • License: MIT
  • First released: 2016
  • Downloads: ~12 million+ weekly downloads — the most popular structured logger in Node.

Peer dependencies & extras

pino has no peer-deps. Companion packages:

  • pino-pretty — human-readable formatter for dev
  • pino-roll — rolling file transport with size / time triggers
  • pino-http — request/response logger for Node HTTP servers
  • pino-loki — transport to Grafana Loki
  • pino-elasticsearch — transport to Elasticsearch
  • pino-cloudwatch / pino-cloudwatch-transport — AWS CloudWatch
  • pino-datadog-transport — Datadog HTTP intake
  • pino-multi-stream — fan out to multiple destinations (less common with worker transports)
  • nestjs-pino — Nest module wrapping pino-http
  • fastify — bundles pino as the default logger

Alternatives

LoggerTrade-off
winstonFormat-flexible, multiple transports per logger. Slower hot path.
bunyanPino's spiritual predecessor; structured JSON. Maintenance has slowed.
console.logFree; unstructured; not suited to production.
signale / consolaPretty CLIs, not production loggers.
roarrLightweight structured logger, narrower ecosystem.
OpenTelemetry LogsNew API; pairs with metrics + traces. Pino can emit OTel logs via transport.

Real-world recipes

Basic logger

The default exports a function returning a logger writing JSON to stdout. The level defaults to info.

javascript
import pino from "pino";

const log = pino();

log.info("server starting");
log.warn({ port: 3000 }, "binding port");
log.error({ err: new Error("boom") }, "boom happened");

Output: newline-delimited JSON to stdout — {"level":30,"time":1717000000000,"pid":1234,"hostname":"myhost","msg":"server starting"} per line.

Levels map to numeric values: trace 10, debug 20, info 30, warn 40, error 50, fatal 60. Filtering: pino({ level: "warn" }) drops info and below.

Child logger

A child logger inherits bindings and adds its own. Use for per-request context, per-module tags, or per-job IDs.

javascript
import pino from "pino";

const root = pino();

function handleRequest(reqId, userId) {
  const log = root.child({ reqId, userId });
  log.info("request started");
  // ...
  log.info({ status: 200, durationMs: 12 }, "request completed");
}

handleRequest("r-1", "u-1");

Output: each line includes reqId and userId automatically — no need to pass log everywhere.

Redaction

redact masks sensitive paths before serialization. Paths use dotted notation; arrays support *.

javascript
import pino from "pino";

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

log.info({
  user: { id: "u-1", token: "secret" },
  headers: { authorization: "Bearer abc" },
});

Output: "token":"[REDACTED]" and "authorization":"[REDACTED]" in the JSON. The original values never reach the log sink.

redact is compiled once at logger creation — runtime cost is negligible. For deeper paths consider top-level paths only and shape your log objects accordingly.

Worker-based file transport

Production logs should never block the request path. Pino's transport API runs serialization in a worker thread.

javascript
import pino from "pino";

const log = pino({
  transport: {
    targets: [
      { target: "pino/file", options: { destination: "/var/log/app/app.log" }, level: "info" },
      { target: "pino/file", options: { destination: 1 }, level: "warn" }, // 1 = stdout
    ],
  },
});

log.info("hello");
log.warn({ slow: 1500 }, "slow request");

Output: info and above goes to /var/log/app/app.log; warn and above also goes to stdout. Worker thread serializes and writes; main thread isn't blocked.

For rotating files, use pino-roll:

javascript
const log = pino({
  transport: {
    target: "pino-roll",
    options: { file: "/var/log/app/app", frequency: "daily", size: "10m", mkdir: true },
  },
});

Output: rotates daily or at 10 MB; creates directories on demand.

Serializer customization

serializers transform specific keys before logging. The built-in err serializer formats Error objects.

javascript
import pino from "pino";

const log = pino({
  serializers: {
    err: pino.stdSerializers.err,
    req: (req) => ({ method: req.method, url: req.url, ip: req.ip }),
    res: (res) => ({ statusCode: res.statusCode }),
  },
});

log.error({ err: new Error("upstream timeout") }, "request failed");

Output: Error is logged with type, message, stack fields instead of an empty object. The req / res serializers project HTTP objects to just the fields you want.

Production deployment

Stdout + log shipper

The cloud-native default: pino → stdout → log shipper → backend. Stdout writes are unbuffered and resilient to crashes. Log shippers (Fluent Bit, Vector, Promtail, Filebeat) tail the container stdout and forward to your backend.

javascript
const log = pino();
log.info({ env: process.env.NODE_ENV }, "ready");

Output: NDJSON to stdout; container runtime (Docker, Kubernetes) captures and routes to the shipper.

HTTP transport (direct shipping)

For environments without a sidecar, ship logs directly via an HTTP transport:

javascript
const log = pino({
  transport: {
    target: "pino-loki",
    options: {
      host: "https://loki.example.com",
      basicAuth: { username: process.env.LOKI_USER, password: process.env.LOKI_PASSWORD },
      batching: true,
      interval: 5,
    },
  },
});

Output: batched HTTP POST to Loki every 5 seconds. Worker thread handles batching and retry.

Sampling

For high-volume info logs, sample to reduce ingest costs:

javascript
import pino from "pino";

const SAMPLE = 0.1;
const log = pino({
  hooks: {
    logMethod(args, method, level) {
      if (level <= 30 && Math.random() > SAMPLE) return; // drop 90% of info+ lower
      return method.apply(this, args);
    },
  },
});

Output: ~10% of info logs survive; warn/error always survive. Hooks fire before serialization.

Graceful flush on shutdown

Worker transports buffer asynchronously. Flush before exit:

javascript
import { pino, transport } from "pino";

const t = pino.transport({ target: "pino-pretty" });
const log = pino(t);

process.on("SIGTERM", () => {
  log.flush?.(() => process.exit(0));
});

Output: pending log lines drain before exit. Without flush, the last few hundred lines can be lost.

Performance tuning

  • Don't log in the hot path beyond info. Each log.info is ~1 μs even with pino. At 100k req/s that's 100 ms/s of CPU.
  • Always use worker transports in production. Synchronous transports block the event loop on slow sinks.
  • level-gate expensive serializers. Pass an object: log.debug({ payload }, "got payload"). If level: "info", pino doesn't serialize the object — the call is a no-op.
  • Avoid JSON.stringify in your code. Pass the object directly; pino serializes faster.
  • base: null removes pid and hostname from each line when you don't need them. Saves bytes per log.
  • crlf: false — default. Some log shippers need \n, not \r\n.
  • messageKey lets you rename msg to align with your backend's schema (e.g. Loki's message).
  • Avoid console.log mixed with pino. It breaks NDJSON formatting and dumps unstructured lines into your pipeline.

Version migration guide

From pino@8 to pino@9

Largely additive — improved transport stability, refined types, default transport options.

  1. Node 18+ required.
  2. pino.transport({ targets }) types tightened.
  3. Some deprecated APIs removed (pino.extreme(), pino.destination() defaults).
  4. flushSync works more reliably with transport workers.

From pino@7 to pino@8

  1. Transport API switched to worker threads — older custom transports must be rewritten or wrapped.
  2. pino-pretty is now a transport, not a piped child process.
  3. dest option removed in favour of pino.destination() / transport configuration.

From pino@6 to pino@7

Mostly a clean transition. The transport overhaul started here.

Security considerations

  • Always configure redact for credentials, tokens, PII, session cookies. Defaults log everything you pass.
  • Don't log full request bodies. Login forms, payment info, PII — redact or omit.
  • Audit error serializers. err.stack may include source paths from your app — fine for backend logs, bad if shipped to a less-trusted SIEM.
  • NDJSON injection. If you log user input via log.info(user said: ${input}), a malicious input with a newline + {"...} injects a fake log line. Always log as a field: log.info({ input }, "user said").
  • TLS for HTTP transports. Datadog / Loki transports default to TLS; verify.
  • Buffered transports lose logs on crash. Flush on shutdown; pair with stdout + log shipper for durability.
  • Don't share log files across processes. Concurrent appenders without coordination interleave bytes.

Testing & CI integration

Pino is a no-op in tests if you don't assert on logs. To capture output, use the destination option pointing at a writable stream.

javascript
import { test, expect } from "vitest";
import pino from "pino";
import { Writable } from "node:stream";

test("logs structured error", () => {
  const lines = [];
  const stream = new Writable({
    write(chunk, _enc, cb) { lines.push(chunk.toString()); cb(); },
  });
  const log = pino({}, stream);

  log.error({ code: "E_BOOM" }, "boom");

  const entry = JSON.parse(lines[0]);
  expect(entry.msg).toBe("boom");
  expect(entry.code).toBe("E_BOOM");
});

Output: test runs in microseconds; assertions on structured fields, not regex.

For broader assertions, pino-test:

javascript
import { test } from "vitest";
import pino from "pino";
import { sink, once } from "pino-test";

test("emits info", async () => {
  const stream = sink();
  const log = pino(stream);
  log.info("hello");
  await once(stream, "hello");
});

Ecosystem integrations

ToolRole
fastifyDefault logger.
nestjs-pinoNest module wrapping pino-http.
pino-httpExpress / Connect request logger.
pino-prettyDev formatter.
pino-rollRolling file transport.
pino-lokiGrafana Loki transport.
pino-elasticsearchElasticsearch transport.
pino-datadog-transportDatadog HTTP intake.
pino-cloudwatch-transportAWS CloudWatch.
pino-mongodbMongo log collection.
OpenTelemetry pino instrumentationMap pino logs to OTel log records.

Troubleshooting common errors

Cannot find module 'pino-pretty'transport referenced pino-pretty but it isn't installed. Add as a dev dependency.

Logs disappear after crash — worker transport buffer not flushed. Use pino.final() for catastrophic-exit logging, or call log.flush() on shutdown.

UnhandledPromiseRejection on transport — transport worker errored. Listen on transport.on("error", ...) or check the transport's npm page for known issues.

Unstructured log lines mixed in — direct console.log calls. Replace with pino, or redirect console via pino-console-record.

Slow application after enabling pino — synchronous transport (or dest: process.stdout to a slow disk). Switch to worker transports.

level filter not working — typo in level name. Levels are case-sensitive; "INFO" is rejected.

Pretty output in productionpino-pretty left configured. Gate on NODE_ENV !== "production".

Logger ignores redact — typo in path or non-matching shape. Verify with JSON.stringify on the input first.

Child logger missing parent bindings — recreating the root logger per request. Create once, share, and use root.child(...).

When NOT to use this

  • You need format-rich console output with color, padding, multi-line. winston or consola are better-suited.
  • You log primarily for human-eyeball debugging at the terminal. pino-pretty gets you 80% there but consola is built for that case.
  • Edge runtimes. Pino targets Node — Cloudflare Workers / Vercel Edge log via console.log and platform-specific log shippers. Use those instead.
  • You don't want a separate transport process. Worker transports add complexity. For tiny services, winston's in-process transports may be simpler.
  • Your log backend doesn't ingest JSON. Most do, but if you're stuck with a legacy log format, the format flexibility of winston may save migration work.

See also