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.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
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. This makes it 3-10x faster than alternatives in benchmarks.
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 (multiple files, email alerts) — Winston is more flexible
Solution
Setup
npm install pino
Basic logging
const pino = require("pino");
const logger = pino({
level: "info",
});
logger.info("server_started", { port: 3000 });
logger.warn("cache_miss", { key: "user:42" });
logger.error("database_error", { error: "Connection refused" });
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("processing_order", { order_id: "ord_456" });
requestLogger.info("order_validated", { items: 3 });
requestLogger.warn("inventory_low", { sku: "widget-001", stock: 2 });
Every log line from requestLogger includes request_id and user_id.
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("server_started", { port: 3000 });
Install pino-pretty separately: npm install -D 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);
Install pino-http: 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.
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);
}
Best Practices
-
For a deeper guide, see Rotate Logs Daily with Winston.
-
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.
FAQ
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). You can call logger.info() inside async functions without issues.
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, supports multiple outputs). 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]. Setting remove: true removes the key entirely from the log output.
Related Resources
Structured 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 applications, including error handlers, performance monitoring, 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.