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
npm install pino
Output: added pino to dependencies
pnpm add pino
Output: added 1 package, linked from store
yarn add pino
Output: added pino
bun add pino
Output: installed pino
For pretty-printing in development, add pino-pretty:
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 devpino-roll— rolling file transport with size / time triggerspino-http— request/response logger for Node HTTP serverspino-loki— transport to Grafana Lokipino-elasticsearch— transport to Elasticsearchpino-cloudwatch/pino-cloudwatch-transport— AWS CloudWatchpino-datadog-transport— Datadog HTTP intakepino-multi-stream— fan out to multiple destinations (less common with worker transports)nestjs-pino— Nest module wrapping pino-httpfastify— bundles pino as the default logger
Alternatives
| Logger | Trade-off |
|---|---|
winston | Format-flexible, multiple transports per logger. Slower hot path. |
bunyan | Pino's spiritual predecessor; structured JSON. Maintenance has slowed. |
console.log | Free; unstructured; not suited to production. |
signale / consola | Pretty CLIs, not production loggers. |
roarr | Lightweight structured logger, narrower ecosystem. |
| OpenTelemetry Logs | New 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.
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.
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 *.
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.
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:
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.
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.
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:
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:
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:
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. Eachlog.infois ~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"). Iflevel: "info", pino doesn't serialize the object — the call is a no-op.- Avoid
JSON.stringifyin your code. Pass the object directly; pino serializes faster. base: nullremovespidandhostnamefrom each line when you don't need them. Saves bytes per log.crlf: false— default. Some log shippers need\n, not\r\n.messageKeylets you renamemsgto align with your backend's schema (e.g. Loki'smessage).- Avoid
console.logmixed 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.
- Node 18+ required.
pino.transport({ targets })types tightened.- Some deprecated APIs removed (
pino.extreme(),pino.destination()defaults). flushSyncworks more reliably with transport workers.
From pino@7 to pino@8
- Transport API switched to worker threads — older custom transports must be rewritten or wrapped.
pino-prettyis now a transport, not a piped child process.destoption removed in favour ofpino.destination()/ transport configuration.
From pino@6 to pino@7
Mostly a clean transition. The transport overhaul started here.
Security considerations
- Always configure
redactfor 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.stackmay 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.
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:
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
| Tool | Role |
|---|---|
fastify | Default logger. |
nestjs-pino | Nest module wrapping pino-http. |
pino-http | Express / Connect request logger. |
pino-pretty | Dev formatter. |
pino-roll | Rolling file transport. |
pino-loki | Grafana Loki transport. |
pino-elasticsearch | Elasticsearch transport. |
pino-datadog-transport | Datadog HTTP intake. |
pino-cloudwatch-transport | AWS CloudWatch. |
pino-mongodb | Mongo log collection. |
| OpenTelemetry pino instrumentation | Map 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 production — pino-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.
winstonorconsolaare better-suited. - You log primarily for human-eyeball debugging at the terminal.
pino-prettygets you 80% there butconsolais built for that case. - Edge runtimes. Pino targets Node — Cloudflare Workers / Vercel Edge log via
console.logand 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
winstonmay save migration work.
See also
- npm: winston — format-rich alternative logger
- Concept: json — NDJSON serialization
- Concept: async — worker threads, backpressure