Logging with Winston & Pino
Logging is how a running service tells you what it’s doing, and console.log stops being enough the moment you ship to production. A real logging library gives you severity levels, structured (machine-readable) output, multiple destinations, and contextual metadata that ties log lines back to a request or user. In the Node.js ecosystem two libraries dominate this space: Winston, the flexible workhorse built around pluggable transports, and Pino, a low-overhead logger that emits JSON extremely fast. This page compares the two and shows how to use log levels, structured JSON, transports, and child loggers.
Winston vs Pino at a glance
Both produce structured logs and support levels and child loggers, but they optimize for different things. Winston is endlessly configurable — you compose formats and ship to many transports in one logger. Pino is opinionated and fast: it writes newline-delimited JSON to a stream and offloads pretty-printing and routing to separate processes.
| Concern | Winston | Pino |
|---|---|---|
| Primary goal | Flexibility & transports | Throughput & low overhead |
| Default output | Configurable (text or JSON) | JSON (NDJSON) |
| Performance | Good | Excellent (5-10x faster) |
| Formatting | In-process formats | Out-of-process (pino-pretty) |
| Transports | Built-in, many community ones | pino.transport() worker threads |
| Child loggers | Yes | Yes (very cheap) |
Rule of thumb: choose Pino for high-throughput services where logging cost matters, and Winston when you need rich in-process formatting or unusual transport combinations.
Log levels
Levels let you classify the importance of a message and filter at runtime. Both libraries default to standard severities. Winston uses npm levels (error, warn, info, http, verbose, debug, silly); Pino uses fatal, error, warn, info, debug, trace. In both, setting a level threshold suppresses everything less severe.
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL ?? 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
logger.info('server started', { port: 3000 });
logger.debug('this is hidden when level is info');
logger.error('db connection failed', { code: 'ECONNREFUSED' });
Output:
{"level":"info","message":"server started","port":3000}
{"level":"error","message":"db connection failed","code":"ECONNREFUSED"}
The debug line never appears because the threshold is info. Flip LOG_LEVEL=debug and it shows up — no code change required.
Structured JSON logging
Structured logs are objects, not strings, so log aggregators (Loki, Elasticsearch, Datadog) can index and query individual fields. The key practice is to pass metadata as a separate object rather than interpolating it into the message. Pino is JSON-first, which is why it pairs so well with log shipping pipelines.
import { pino } from 'pino';
const logger = pino({
level: 'info',
// rename fields and stamp ISO timestamps
timestamp: pino.stdTimeFunctions.isoTime,
});
logger.info({ userId: 42, plan: 'pro' }, 'user logged in');
Output:
{"level":30,"time":"2026-06-14T10:22:01.503Z","userId":42,"plan":"pro","msg":"user logged in"}
Pino encodes levels numerically (info is 30) for speed; the included pino-pretty tool decodes them for human reading during development. Note the first argument is the metadata object and the second is the message — a common source of confusion when coming from console.log.
Transports and destinations
A transport decides where logs go. Winston attaches transports directly to the logger, and a single logger can fan out to several at once.
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
logger.warn('disk usage high', { percent: 92 });
Here info and above land in combined.log and the console, while only error lines reach error.log.
Pino keeps the main thread fast by running transports in a worker thread via pino.transport(). This pattern moves pretty-printing or file rotation off the hot path.
import { pino } from 'pino';
const transport = pino.transport({
targets: [
{ target: 'pino/file', options: { destination: './app.log' }, level: 'info' },
{ target: 'pino-pretty', options: { colorize: true }, level: 'debug' },
],
});
const logger = pino({ level: 'debug' }, transport);
logger.info('using async worker-thread transports');
In production, leave Pino writing raw JSON to stdout and let your platform (Docker, Kubernetes, systemd) collect it. Avoid running
pino-prettyin production — it adds overhead and breaks machine parsing.
Child loggers with context
Child loggers inherit their parent’s configuration and add fixed metadata to every line they emit. This is the cleanest way to attach a request ID, user ID, or module name to all logs from a given scope, without repeating yourself on each call.
import { pino } from 'pino';
import { randomUUID } from 'node:crypto';
const logger = pino();
function handleRequest(req) {
const reqLog = logger.child({ requestId: randomUUID(), path: req.path });
reqLog.info('request received');
// ... do work ...
reqLog.info({ status: 200 }, 'request completed');
}
handleRequest({ path: '/checkout' });
Output:
{"level":30,"requestId":"8f3c...","path":"/checkout","msg":"request received"}
{"level":30,"requestId":"8f3c...","path":"/checkout","status":200,"msg":"request completed"}
Every line carries the same requestId, so you can trace one request end-to-end. Winston exposes the same idea through logger.child({ ... }). If you use CommonJS, the imports become const winston = require('winston') and const { pino } = require('pino') — the APIs are otherwise identical.
Best practices
- Log structured objects, not concatenated strings, so fields stay queryable in your aggregator.
- Make the level configurable via an environment variable (
LOG_LEVEL) instead of hardcoding it. - Use child loggers to attach request/correlation IDs and propagate them through your handlers.
- Never log secrets, tokens, or full request bodies; use Pino’s
redactor a Winston format to strip sensitive fields. - In production, write JSON to stdout and let the platform collect it — reserve pretty-printing for local development.
- Prefer Pino for high-throughput APIs; reach for Winston when you need many in-process formats or unusual transports.
- Always handle the logger’s
errorevents and flush logs on shutdown so nothing is lost.