Structured Logging in Node.js with Pino
Set up fast structured logging in Node.js using Pino. Covers log levels, child loggers, request tracing, and production configuration.
What you'll learn
- ✓Why structured logging matters for production systems
- ✓Setting up Pino with proper log levels and formatting
- ✓Using child loggers for request context
- ✓Integrating with Express and Fastify
Prerequisites
- •Basic Node.js knowledge
- •Familiarity with npm
- •Understanding of HTTP servers
console.log is fine for development, but in production you need structured, machine-parseable logs that can be searched, filtered, and aggregated. Pino is the fastest JSON logger for Node.js, producing structured log output with minimal performance overhead. It is 5-10x faster than Winston and Bunyan because it defers serialization to a separate process.
Why structured logging?
Unstructured logs look like this:
[2026-07-02 14:30:15] User alice@test.com logged in from 192.168.1.5
[2026-07-02 14:30:16] Order #12345 created, total: $99.50
[2026-07-02 14:30:17] ERROR: Payment failed for order #12345
These are human-readable but impossible to query programmatically. Structured logs are JSON:
{"level":30,"time":1719927015000,"msg":"User logged in","email":"alice@test.com","ip":"192.168.1.5"}
{"level":30,"time":1719927016000,"msg":"Order created","orderId":"12345","total":99.50}
{"level":50,"time":1719927017000,"msg":"Payment failed","orderId":"12345","error":"Card declined"}
You can pipe these into Elasticsearch, Datadog, or CloudWatch and query them: “show me all errors where orderId is 12345.”
Setting up Pino
npm install pino
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
});
logger.info('Server starting');
logger.info({ port: 3000 }, 'Server listening');
logger.warn({ memoryUsage: process.memoryUsage().heapUsed }, 'High memory');
logger.error({ err: new Error('Connection refused') }, 'Database connection failed');
Output:
{"level":30,"time":1719927015000,"msg":"Server starting"}
{"level":30,"time":1719927015001,"msg":"Server listening","port":3000}
{"level":40,"time":1719927015002,"msg":"High memory","memoryUsage":52428800}
{"level":50,"time":1719927015003,"msg":"Database connection failed","err":{"type":"Error","message":"Connection refused","stack":"Error: Connection refused\n at..."}}
Log levels
Pino uses numeric log levels:
| Level | Number | Method |
|---|---|---|
| trace | 10 | logger.trace() |
| debug | 20 | logger.debug() |
| info | 30 | logger.info() |
| warn | 40 | logger.warn() |
| error | 50 | logger.error() |
| fatal | 60 | logger.fatal() |
Set the minimum level via the level option. Messages below this level are discarded:
const logger = pino({ level: 'warn' });
logger.info('This is ignored'); // Below 'warn', not logged
logger.warn('This is logged'); // Logged
logger.error('This is logged'); // Logged
In production, use info. In development, use debug or trace.
Pretty printing for development
Raw JSON is hard to read during development. Use pino-pretty:
npm install --save-dev pino-pretty
const logger = pino({
level: 'debug',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
Or pipe the output:
node app.js | npx pino-pretty
Never use pino-pretty in production. It adds overhead and defeats the purpose of structured logging.
Child loggers for request context
Child loggers inherit the parent’s configuration and add context fields to every log entry. This is the key pattern for request tracing.
import pino from 'pino';
import { randomUUID } from 'node:crypto';
const logger = pino({ level: 'info' });
function handleRequest(req, res) {
const requestLogger = logger.child({
requestId: randomUUID(),
method: req.method,
url: req.url,
});
requestLogger.info('Request received');
// Pass the logger to downstream functions
const user = getUser(requestLogger, req.userId);
requestLogger.info({ userId: user.id }, 'User authenticated');
res.json({ status: 'ok' });
requestLogger.info({ statusCode: 200 }, 'Response sent');
}
function getUser(logger, userId) {
logger.debug({ userId }, 'Fetching user from database');
// Every log from this function includes requestId, method, url
return { id: userId, name: 'Alice' };
}
Every log entry from requestLogger automatically includes requestId, method, and url. This lets you filter all logs for a single request in your log aggregator.
Express integration with pino-http
npm install pino-http
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({ level: 'info' });
const app = express();
app.use(pinoHttp({
logger,
autoLogging: true,
customLogLevel: (req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
customSuccessMessage: (req, res) => {
return `${req.method} ${req.url} completed`;
},
customErrorMessage: (req, res, err) => {
return `${req.method} ${req.url} failed: ${err.message}`;
},
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
// Don't log headers in production (may contain auth tokens)
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
}));
app.get('/api/users', (req, res) => {
// req.log is a child logger with request context
req.log.info('Fetching users');
res.json([{ id: 1, name: 'Alice' }]);
});
app.listen(3000);
Fastify integration
Fastify uses Pino as its built-in logger:
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: 'info',
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
}),
},
},
});
app.get('/api/users', async (request, reply) => {
request.log.info('Fetching users');
return [{ id: 1, name: 'Alice' }];
});
await app.listen({ port: 3000 });
No additional packages needed. Every request gets a child logger with a unique request ID automatically.
Custom serializers
Serializers control how objects are converted to JSON. They prevent logging sensitive data and reduce log size.
const logger = pino({
level: 'info',
serializers: {
user: (user) => ({
id: user.id,
email: user.email,
// Exclude password, tokens, etc.
}),
err: pino.stdSerializers.err, // Standard error serializer
},
});
logger.info({ user: fullUserObject }, 'User created');
// Only logs { id, email }, not the full object
Redaction
Automatically remove sensitive fields from logs:
const logger = pino({
level: 'info',
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'user.password', 'user.ssn'],
censor: '[REDACTED]',
},
});
logger.info({
user: { name: 'Alice', password: 'secret123', ssn: '123-45-6789' },
}, 'User data');
// Output: {"user":{"name":"Alice","password":"[REDACTED]","ssn":"[REDACTED]"},...}
Sending logs to multiple destinations
Use pino.multistream to write logs to stdout and a file simultaneously:
import pino from 'pino';
import { createWriteStream } from 'node:fs';
const streams = [
{ stream: process.stdout },
{ stream: createWriteStream('./app.log', { flags: 'a' }) },
{ level: 'error', stream: createWriteStream('./error.log', { flags: 'a' }) },
];
const logger = pino(
{ level: 'info' },
pino.multistream(streams)
);
Production configuration
const isProduction = process.env.NODE_ENV === 'production';
const logger = pino({
level: isProduction ? 'info' : 'debug',
...(isProduction ? {} : {
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}),
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.secret',
],
censor: '[REDACTED]',
},
serializers: {
err: pino.stdSerializers.err,
},
});
export default logger;
Key takeaways
Use Pino for production Node.js logging. Its JSON output integrates with every log aggregation platform. Use child loggers to propagate request context through your application. Add custom serializers and redaction rules to prevent sensitive data from reaching your logs. Use pino-pretty only in development. In production, pipe raw JSON to your log aggregator and let it handle formatting and querying.
Related articles
- Node.js Node.js Pino Logging Tutorial
Set up structured, high-performance logging in Node.js with Pino, including child loggers, redaction, and pretty-printing for development.
- Node.js Node.js vs Deno vs Bun: JavaScript Runtimes Compared
Compare Node.js, Deno, and Bun on performance, security, compatibility, and developer experience. Choose the right JavaScript runtime for 2026.
- Node.js Node.js Cluster Module for Multi-Core Scaling
Scale your Node.js application across all CPU cores using the cluster module. Covers load balancing, zero-downtime restarts, and production patterns.
- Node.js Corepack: Managing Package Managers in Node.js
Use Corepack to enforce consistent package manager versions across your team. Covers setup, configuration, and CI integration.