Winston takes the opposite position from pino 18,227 : formatting and routing are first-class, configured per transport, and you pay for the flexibility in throughput. One logger prints colored text to the console and writes JSON errors to a file, each with its own level.
import winston from 'winston';
const { combine, timestamp, json, errors, colorize, printf } = winston.format;
const logger = winston.createLogger({
level: process.env.LOG_LEVEL ?? 'info',
defaultMeta: { service: 'bookshelf' },
format: combine(errors({ stack: true }), timestamp(), json()),
transports: [
new winston.transports.Console({ format: combine(colorize(),
printf(({ level, message, requestId }) => `${level} ${requestId ?? '-'} ${message}`)) }),
new winston.transports.File({ filename: 'errors.log', level: 'error' })
] });
logger.warn('slow query', { requestId: 'r-17', ms: 812 });
logger.error(new Error('store unreachable'), { requestId: 'r-18' });
logger.debug('not printed at level info');Output
warn r-17 slow query
error r-18 store unreachable
$ cat errors.log
{"level":"error","message":"store unreachable","requestId":"r-18","service":"bookshelf",
"stack":"Error: store unreachable\n at file:///.../winston-demo.js:20:14",
"timestamp":"2026-09-17T17:44:06.934Z"}errors({ stack: true }) is what makes logger.error(err) keep the stack; without it winston 24,519 logs the message and discards the trace. defaultMeta stamps every record with the service name, so one index can hold several services and stay filterable. The debug call printed nothing because the logger sits at info — winston uses npm 2,036 's ordering, error 0 through silly 6, and a transport's own level narrows further, never widens.
| pino 10.3.1 | winston 3.19.0 | morgan 1.12.1 8,204 | |
|---|---|---|---|
| Output | JSON, one object per line | any format per transport | one text line per response |
| Scope | application and HTTP | application | HTTP only |
| Strength | throughput, redaction | transport routing | zero configuration |
| Weekly downloads | 36.3 M | 19.6 M | 9.7 M |