pino
Pino is a very low overhead JSON logger for Node.js. It writes newline-delimited JSON to stdout and deliberately does as little as possible in your process: pretty printing, shipping to log services and other processing are pushed into separate worker threads or processes called transports. The project's benchmarks show it over 5x faster than alternatives in many cases, which is the whole reason it exists. It is the default logger inside Fastify.
The right default for Node services that log JSON to a collector: fastest mainstream option, actively maintained, and the transport model keeps log processing off your event loop. If you want batteries-included formatting and file rotation in one config, winston will annoy you less.
Use it if
- You run high-throughput HTTP services where logger overhead actually shows up in latency and requests per second
- Your logs go to a collector (Datadog, Loki, Elasticsearch) that wants structured JSON, not formatted strings
- You use Fastify, where pino is already the built-in logger, or Express/Koa via pino-http
- You want child loggers to stamp requestId or tenant context onto every line cheaply
- You want readable logs out of the box: production pino is raw JSON, and human formatting requires installing pino-pretty separately and wiring it as a dev transport
- You need rich in-process transports (rotating files, colorized console, per-transport formats) configured in one place: that is winston's model, pino pushes all of it out of process
- Your code is browser-first; pino has a browser API but its selling points (worker-thread transports, low overhead) are server-side
- It is a small script or CLI where console.error is fine; pino brings an 11-package dependency tree you do not need
Setup reality
npm install pino and one line gives you a working JSON logger. The friction is everything around it: pino-pretty is a separate install for dev output, transports run in worker threads so their options must be serializable (no functions, no closures), logs buffered in a transport can be lost on a hard crash unless you set up sync destinations for fatal paths, and levels are numbers in the output (30 = info) which trips up log queries until you map them. Express integration means adding pino-http too.
Patterns
Create a logger and log structured databasic-logger
import pino from 'pino';
const logger = pino();
logger.info({ userId: 42 }, 'user logged in');Merge object first, message second; the arguments are reversed from what console.log habits expect.
Stamp context onto every log linechild-logger
const child = logger.child({ requestId: req.id });
child.info('processing');
// {"level":30,...,"requestId":"req-1","msg":"processing"}Children are cheap by design; create one per request instead of passing metadata to every call.
Readable logs in developmentpretty-dev-logs
npm i -D pino-pretty
const logger = pino(
process.env.NODE_ENV !== 'production'
? { transport: { target: 'pino-pretty', options: { colorize: true } } }
: {}
);pino-pretty is a separate package on purpose; do not ship it as the production output format, collectors want the raw JSON.
Control level from the environmentset-log-level
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
logger.debug('hidden unless LOG_LEVEL=debug');Output shows numeric levels (10 trace, 20 debug, 30 info, 40 warn, 50 error, 60 fatal); use formatters.level if your collector needs label strings.
Redact sensitive fields before they hit diskredact-secrets
const logger = pino({
redact: {
paths: ['req.headers.authorization', '*.password', 'user.email'],
censor: '[redacted]',
},
});Redaction paths match the merged log object, not your variable names; wildcards cover one level, not deep nesting.
Log an Error with stack tracelog-errors
try {
await task();
} catch (err) {
logger.error({ err }, 'task failed');
}Use the key err exactly; the built-in error serializer only kicks in for that key and expands message, stack and type.
Request logging in Expresshttp-logging
import express from 'express';
import pinoHttp from 'pino-http';
const app = express();
app.use(pinoHttp());
app.get('/', (req, res) => {
req.log.info('inside handler');
res.send('ok');
});pino-http is a separate install; it attaches req.log with request context and logs a completion line per response.
Write logs to a file off the event loopfile-transport
const logger = pino({
transport: {
target: 'pino/file',
options: { destination: '/var/log/app.log', mkdir: true },
},
});Transports run in a worker thread, so options must survive structured clone: plain JSON only, no functions.
Send different levels to different targetsmultiple-transports
const logger = pino({
transport: {
targets: [
{ target: 'pino/file', options: { destination: '/var/log/app.log' }, level: 'info' },
{ target: 'pino/file', options: { destination: '/var/log/error.log' }, level: 'error' },
],
},
});Each target gets its own level floor; a fatal crash can still lose buffered lines, so keep fatal paths synchronous where it matters.
Trim noisy objects before loggingcustom-serializers
const logger = pino({
serializers: {
user: (u) => ({ id: u.id, plan: u.plan }),
},
});
logger.info({ user }, 'checkout started');Serializers run only for their exact top-level key; anything else is logged as-is, including 10KB ORM entities if you let them through.