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.logwith structured, filterable output
When NOT to Use
- CLI tools that need colored human-readable output — use
winstonwithConsoletransport - Scripts where
console.logis 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-prettyonly in development — it adds overhead in production -
Use child loggers for request-scoped context instead of repeating fields in every call
-
Set
levelvia environment variable:level: process.env.LOG_LEVEL || "info" -
Use
redactpaths 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-httpfor 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_idin everylogger.info()call is error-prone. Bind once withchild(). - Logging large objects: pino serializes synchronously. Logging a 10MB object blocks the event loop. Use serializers to trim.
- Not setting
level: default isinfo. If you needdebugin staging, set it via env var. - Using
console.logalongside pino:console.logwrites unstructured text to stdout, mixing with pino’s JSON. Remove allconsole.logcalls.
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.
Related Resources
Structured Logging
Implement structured logging with JSON output, correlation IDs, and log aggregation for production observability.
RecipeStructured JSON Logging with structlog
How to emit structured JSON logs in Python using structlog, including context binding, log levels, processors, and integration with standard logging.
RecipeError Tracking with Sentry in Express
How to integrate Sentry for error tracking in Node.js Express apps with the v8+ SDK — error handlers, performance spans, release tracking and source maps.
RecipeRotate Logs Daily with Winston
How to configure daily log rotation in Node.js using winston and winston-daily-rotate-file, including size limits, retention, compression, and transport combining.