Logging is often the last thing added to a Nodejs service and the first thing developers reach for when something breaks at 2 AM. Plain console.log calls are fine on a laptop, but they stop being useful the moment a service runs across multiple instances, ships logs to an aggregator, or has to answer “what happened to request X?” in production. Pino solves this by writing logs as JSON, fast enough that the logger itself rarely shows up in profiles.

Understanding Structured Logging
Structured logging is the practice of writing log entries as machine-readable data, usually JSON, instead of free-form text. Each field is its own key, which means log aggregators can index and query them directly.
Plain Text vs Structured
A traditional log line looks like this:
2024-01-15 10:30:00 INFO User 123 logged in from 192.168.1.100
The same event as a structured log entry:
{
"level": "info",
"time": 1705315800000,
"msg": "User logged in",
"userId": 123,
"timestamp": "2024-01-15T10:30:00.000Z",
"ip": "192.168.1.100",
"service": "auth-service"
}
The text version is readable. The JSON version is queryable.
Why It Matters in Production
Structured logs give you four concrete wins:
- Field-level search - Filter by
userId,requestId, orstatusCodewithout regex - Consistent schema - Same field names across every service
- Native parsing - Elasticsearch, Loki, Splunk, and Datadog all read JSON out of the box
- Smaller storage - JSON compresses better than equivalent prose
Why Pino
Pino is the fastest JSON logger for Nodejs. It writes asynchronously, has no required dependencies, and produces JSON by default with no configuration. Benchmarks against Winston and Bunyan are published in the Pino benchmarks document.
Two design choices set it apart. First, the hot path inside logger.info() does almost no work; serialization happens on a separate tick. Second, the ecosystem around it (pino-http, pino-pretty, pino-roll, pino-elasticsearch) is maintained by the same team, so transports stay compatible across releases.
If you are setting up Pino with Express for the first time, the companion post Setup logging with Pino and express-http-context in Expressjs walks through request-scoped context and middleware wiring.
Getting Started with Pino
Installation is one command. Pick whichever package manager your project uses:
npm install pino
# or
yarn add pino
# or
pnpm add pino
A minimal logger fits on three lines:
const pino = require('pino');
const logger = pino();
logger.info('Hello, structured world!');
The output is JSON:
{"level":30,"time":1705315800000,"msg":"Hello, structured world!"}
The numeric level is intentional. It maps cleanly to comparison operators in query languages. You can attach base fields and switch to ISO timestamps when you need human-readable output:
const logger = pino({
level: 'info',
base: { service: 'my-api' },
timestamp: pino.stdTimeFunctions.isoTime
});
logger.info('Application started');
// {"level":30,"time":"2024-01-15T10:30:00.000Z","service":"my-api","msg":"Application started"}
Log Levels
Pino uses six levels with numeric values: trace (10), debug (20), info (30), warn (40), error (50), and fatal (60). Setting the logger level to info drops everything below it.
Picking the Right Level
Use the levels with intent. A useful split:
- trace, debug - Diagnostic detail. Off in production unless you flip a flag
- info - Normal lifecycle events. Requests, jobs starting and finishing
- warn - Recoverable problems. Retries, deprecated usage, rate-limit headroom
- error - Failures that need human attention
- fatal - The process is about to exit
logger.trace({ operation: 'db-query', query: 'SELECT * FROM users' }, 'Executing database query');
logger.debug({ requestId: 'abc123' }, 'Processing request');
logger.info({ userId: 123, action: 'login' }, 'User logged in successfully');
logger.warn({ retries: 3 }, 'Connection retry attempt');
logger.error({ err: error, userId: 123 }, 'Failed to process payment');
logger.fatal({ err: error }, 'Database connection lost, shutting down');
Serializers: Handling Complex Data
Serializers transform an object before it gets written. They are the right place to format errors, strip secrets, and normalize request objects.
Built-in Error Serializer
Logging an Error directly drops the stack and most fields. The built-in err serializer fixes that:
const pino = require('pino');
const logger = pino({
serializers: {
err: pino.stdSerializers.err
}
});
try {
throw new Error('Database connection failed');
} catch (error) {
logger.error({ err: error }, 'Operation failed');
}
The output now includes message, stack, and any custom error code.
Custom Serializers
Write your own for domain objects. This is also where you keep sensitive fields out of the log:
const logger = pino({
serializers: {
req: function(req) {
return {
method: req.method,
url: req.url,
headers: {
userAgent: req.headers['user-agent'],
contentType: req.headers['content-type']
}
};
},
user: function(user) {
return {
id: user.id,
email: user.email,
role: user.role
// password and tokens deliberately excluded
};
}
}
});
logger.info({ req: incomingRequest, user: currentUser }, 'Request processed');
Child Loggers and Context
A child logger inherits the parent’s configuration and adds extra fields to every entry. This is how you attach a request ID once and have it appear in every downstream log.
Per-Request Context
const baseLogger = pino({
base: { service: 'payment-service' }
});
app.use((req, res, next) => {
req.logger = baseLogger.child({
requestId: generateRequestId(),
ip: req.ip
});
next();
});
app.post('/payment', (req, res) => {
req.logger.info({ amount: 99.99 }, 'Processing payment');
// entry includes: service, requestId, ip, time, msg
});
Per-Component Loggers
Child loggers also work well for tagging components:
const paymentLogger = baseLogger.child({ component: 'payment-processor' });
const emailLogger = baseLogger.child({ component: 'email-service' });
const cacheLogger = baseLogger.child({ component: 'cache-manager' });
paymentLogger.info('Initializing payment processor');
emailLogger.info('Email service ready');
cacheLogger.info('Cache warm-up complete');
Filter by component in your log viewer to see only one subsystem at a time.
Performance Notes
Pino writes asynchronously and streams to stdout by default. The hot path is short, but a few habits make it shorter.
- Do not
awaitlogger calls. There is nothing to wait for - Run at
infoor higher in production. Drop todebugvia env var when triaging - Skip logging inside tight loops, or sample it
- Push expensive object building inside serializers, not at the call site
A simple sampling pattern for high-volume debug logs:
const logger = pino();
const shouldLog = Math.random() < 0.01;
if (shouldLog) {
logger.debug({ expensiveData }, 'Detailed operation info');
}
Framework Integrations
Pino has first-class adapters for the common Nodejs web frameworks.
Express with pino-http
const express = require('express');
const pinoHttp = require('pino-http');
const app = express();
app.use(pinoHttp({
logger: baseLogger,
customLogLevel: (req, res, err) => {
if (res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
customSuccessMessage: (req, res) => `${req.method} ${req.url} completed`,
customErrorMessage: (req, res, err) => `${req.method} ${req.url} failed`
}));
app.get('/', (req, res) => {
res.send('Hello World');
});
For a production-ready setup that includes per-request context propagation with express-http-context, see Setup logging with Pino and express-http-context in Expressjs.
Fastify
Fastify ships with Pino built in:
const fastify = require('fastify');
const app = fastify({
logger: {
level: 'info',
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
}
}
}
});
app.get('/', async (req, res) => {
req.log.info('Processing request');
return 'Hello World';
});
app.listen({ port: 3000 });
NestJS
NestJS uses @nestjs/pino:
import { Module } from '@nestjs/common';
import { LoggerModule } from '@nestjs/pino';
import { AppController } from './app.controller';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: 'info',
serializers: {
req: () => undefined,
res: () => undefined,
},
},
}),
],
controllers: [AppController],
})
export class AppModule {}
Transports
Transports decide what happens to log entries after Pino writes them. Pretty-print in development, rotate or ship in production.
Pretty Printing in Development
const pino = require('pino');
const logger = pino({
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
}
}
});
Do not run pino-pretty in production. The JSON is what your aggregator wants.
Log Rotation
Pino does not rotate files itself, and that is by design. You have two options: an in-process transport like pino-roll, or the OS-level logrotate utility.
const pino = require('pino');
const logger = pino({
level: 'info',
transport: {
target: 'pino-roll',
options: {
filename: '/var/log/myapp/logs',
size: '100m',
maxFiles: 10
}
}
});
For most server deployments, logrotate is the better choice because it keeps the rotation logic out of the application process. The full setup is covered in Why Pino with Logrotate is the Best for Managing Logs in Nodejs Apps.
Remote Shipping
pino-socket ships logs to a TCP or UDP endpoint. pino-elasticsearch pushes them straight into an Elasticsearch index.
const pino = require('pino');
const logger = pino({
level: 'info',
transport: {
target: 'pino-socket',
options: {
address: 'logs.example.com',
port: 5044,
mode: 'tcp'
}
}
});
Production Patterns
A few patterns pay back over time.
Centralized Logger Factory
Pick the conventions once and enforce them everywhere:
// logger-factory.js
const pino = require('pino');
function createLogger(serviceName, options = {}) {
return pino({
level: process.env.LOG_LEVEL || 'info',
base: {
service: serviceName,
version: process.env.APP_VERSION || 'unknown',
environment: process.env.NODE_ENV || 'development'
},
formatters: {
level: (label) => ({ severity: label })
},
timestamp: pino.stdTimeFunctions.isoTime,
...options
});
}
module.exports = { createLogger };
Redacting Sensitive Fields
Pino has built-in redaction. Use it instead of rolling your own:
const logger = pino({
redact: {
paths: ['password', 'token', 'creditCard', 'ssn', 'req.headers.authorization'],
censor: '[REDACTED]'
}
});
Correlation IDs Across Services
Pass the request ID downstream so a single transaction can be traced across services:
async function processOrder(orderId, correlationId) {
const logger = baseLogger.child({ correlationId });
logger.info({ orderId }, 'Processing order');
await paymentService.charge(orderId, { correlationId });
await inventoryService.reserve(orderId, { correlationId });
logger.info({ orderId }, 'Order processed');
}
Per-Environment Log Levels
const level = process.env.NODE_ENV === 'production'
? (process.env.DEBUG_MODE === 'true' ? 'debug' : 'info')
: 'debug';
const logger = pino({ level });
Patterns vs Anti-Patterns
| Pattern | Do | Avoid |
|---|---|---|
| Message content | Structured fields with a short msg | String concatenation |
| Errors | Pass via err key with serializer |
Logging error.toString() |
| Sensitive data | Use redact paths |
Logging raw request bodies |
| Context | Child logger with requestId | Re-passing context manually |
| Volume | Sample high-frequency debug logs | Logging inside tight loops |
A clear example of the difference:
// Anti-pattern: string concatenation loses structure
logger.info('User ' + user.id + ' performed action ' + action + ' at ' + new Date());
// Pattern: structured fields stay queryable
logger.info({ userId: user.id, action, timestamp: new Date() }, 'User performed action');
Related Articles
- Setup logging with Pino and express-http-context in Expressjs - Wire Pino into Express with request-scoped context
- Why Pino with Logrotate is the Best for Managing Logs in Nodejs Apps - Rotate logs with the OS instead of the app
- Understanding Nodejs Event Loop - Why async logging matters
- Working with Streams in Nodejs - The mechanism behind Pino transports
Final Thoughts: Treat Logging as Infrastructure
Pino turns logs from text files into queryable data. The mental shift is the same one developers made with metrics a decade ago: stop writing prose, start writing key-value pairs.
The setup that pays off in production is consistent: a centralized factory, child loggers for context, redaction for secrets, and logrotate (or a transport) handling file lifecycle. None of it is exotic, and Pino’s performance means you can apply it broadly without watching the event loop.
✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.