pino
Use Pino for fast structured JSON logging in Node.js - child loggers for request context, redaction for secrets, and minimal event loop impact.
Recipe
Quick-reference recipe card - copy-paste ready.
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["req.headers.authorization", "password"],
});
logger.info({ orderId: "o_1" }, "order created");When to reach for this:
- Every production Node HTTP service (Express, Fastify, NestJS).
- Replacing Winston or
console.logfor JSON pipelines. - Need request-scoped fields without passing
reqthrough every function. - Log volume exceeds 1k lines/sec and performance matters.
Working Example
import pino from "pino";
import express from "express";
import { randomUUID } from "node:crypto";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: "billing-api" },
redact: {
paths: ["req.headers.authorization", "req.headers.cookie", "body.cardNumber"],
censor: "[REDACTED]",
},
timestamp: pino.stdTimeFunctions.isoTime,
});
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const requestId = (req.headers["x-request-id"] as string) ?? randomUUID();
const start = process.hrtime.bigint();
(req as express.Request & { log: pino.Logger }).log = logger.child({ requestId });
res.on("finish", () => {
req.log.info({
event: "request_complete",
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: Number(process.hrtime.bigint() - start) / 1e6,
});
});
next();
});
app.post("/charges", async (req, res) => {
req.log.info({ amount: req.body.amount }, "charge started");
try {
const result = await processCharge(req.body);
req.log.info({ chargeId: result.id }, "charge succeeded");
res.status(201).json(result);
} catch (err) {
req.log.error({ err }, "charge failed");
res.status(500).json({ error: "Internal Server Error" });
}
});
async function processCharge(body: { amount: number }) {
return { id: "ch_1", amount: body.amount };
}
app.listen(3000);What this demonstrates:
- Root logger with
baseservice metadata and ISO timestamps. - Per-request child logger bound in middleware with
requestId. finishevent logs duration and status without blocking response.- Errors logged with
{ err }before generic 5xx response.
Deep Dive
How It Works
- Pino serializes log objects to JSON strings asynchronously (worker thread by default in many setups).
- Numeric levels: trace 10, debug 20, info 30, warn 40, error 50, fatal 60.
- Child loggers inherit parent bindings and add their own - immutable chain.
- Output is one JSON line per log - NDJSON for Loki/Datadog/CloudWatch.
Configuration Options
| Option | Purpose |
|---|---|
level | Minimum level emitted |
base | Fields on every line (pid, hostname can be omitted with base: null) |
redact | Paths to censor |
serializers | Custom req, res, err shapes |
transport | Dev-only pretty print |
Development Pretty Print
const logger = pino({
transport: process.env.NODE_ENV === "development"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
});- Never use
pino-prettyin production - blocks the main thread.
NestJS Integration
npm install nestjs-pino// app.module.ts
import { LoggerModule } from "nestjs-pino";
@Module({
imports: [LoggerModule.forRoot({ pinoHttp: { level: "info" } })],
})
export class AppModule {}Gotchas
- Logging full
reqandres- huge lines, PII risk. Fix: custom serializers with method, url, status only. - pino-pretty in production - throughput collapse. Fix: JSON to stdout; pretty only locally.
- Sync destination under load -
pino.destination({ sync: true })blocks. Fix: default async destination. - Missing err serializer -
log.error("msg", err)wrong signature. Fix:log.error({ err }, "msg"). - Debug logs always on - cost and noise. Fix:
LOG_LEVEL=infoin prod; sample debug. - Child logger not passed to services - lose requestId in deep layers. Fix: AsyncLocalStorage or explicit param.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Pino | Production JSON logging on Node | Team mandate for Winston transports only |
| Winston | Legacy apps with many custom transports | Greenfield Fastify (Pino built-in) |
| Bunyan | Maintaining older services | New projects |
| OpenTelemetry logs | Unified traces + logs | Simple API needing only request logs |
FAQs
Why is Pino faster than Winston?
Minimal allocation, async flush, and no default heavy formatting. Benchmarks matter most above ~1k logs/sec.
How do I test logs?
pino.destination({ dest: 1, sync: true }) to stdout in tests, or pino-test for capture.
Does Fastify require separate Pino setup?
Fastify embeds Pino. Configure via logger: { level, redact } on constructor.
Can I ship logs directly to Datadog?
Prefer stdout + agent sidecar. pino-datadog-transport adds latency and another failure point.
How to log from worker_threads?
Post messages to parent or create child logger with workerId in base. AsyncLocalStorage does not cross threads.
What about log rotation?
Container platforms collect stdout - no file rotation needed. VMs: use journald or log agent, not pino-roll in app.
Structured logging vs OpenTelemetry?
Complementary. Pino for events; OTel for traces and metrics. Correlate with shared trace_id.
How to silence health checks?
Skip logging in middleware when req.path === "/health" or log at debug with aggregation filter.
Related
- Logging Basics - structured logging principles
- Request Correlation IDs - requestId propagation
- PII Redaction & Compliance - GDPR paths
- Logging with Pino on Fastify - Fastify hooks
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.