Winston
Winston is the most popular and most flexible Node.js logging library. Its defining feature is the transport abstraction: a single logger can write the same events to multiple destinations — the console, files, a database, an HTTP endpoint, cloud services — each with its own level and format. It supports custom log levels, structured metadata, formatting pipelines, and per-transport configuration. This page covers creating a logger, levels and formats, transports, child loggers for request context, and how Winston compares to the faster Pino.
Setup and a basic logger
Bash
npm install winston
logger.js — a single shared logger instance
JS
import winston from 'winston'
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }), // log Error stack traces
winston.format.json(), // structured JSON output
),
defaultMeta: { service: 'orders-api' }, // attached to every log line
transports: [
new winston.transports.Console(),
],
})JS
import { logger } from './logger.js'
logger.info('server started', { port: 3000 })
logger.warn('cache miss', { key: 'user:42' })
logger.error('payment failed', { orderId: 9981, err }){"level":"info","message":"server started","port":3000,
"service":"orders-api","timestamp":"2026-06-21T09:20:01.004Z"}Create ONE logger module and import it everywhere — don't call createLogger per file
Define the logger once in a module and import that single instance across your app. This gives you consistent formatting, one place to change configuration, and shared `defaultMeta` (like `service` name) on every line. The `format.combine(...)` pipeline runs each formatter in order — here adding a timestamp, expanding `Error` objects to include their stack, then serializing to JSON. The `errors({ stack: true })` formatter is important: without it, a logged `Error` becomes `{}` because its properties aren't enumerable.
Log levels in Winston
JS
// Winston's default levels (npm style), lowest number = highest severity:
// error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6
// A logger at level 'info' emits error, warn, info — but NOT debug.
logger.debug('this is hidden when level is info')
// Define custom levels if you need them:
const logger = winston.createLogger({
levels: { fatal: 0, error: 1, warn: 2, info: 3, debug: 4 },
level: 'info',
})Transports — route logs to multiple destinations
JS
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
// Console — pretty colours in dev, JSON in prod:
new winston.transports.Console(),
// All logs at 'info'+ to one file:
new winston.transports.File({ filename: 'logs/combined.log' }),
// Only errors to a separate file (per-transport level):
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
],
})Each transport has its own level and format — that's Winston's core strength
A transport is a destination. Because each one carries its own `level` and `format`, you can send *everything* to the console as readable text, ship `info`+ as JSON to a file or log platform, and route only `error`+ to a dedicated error file or an alerting service like Sentry — all from a single `logger.error(...)` call. This flexibility is why Winston is favoured when logs must fan out to many places. The trade-off is overhead: this machinery makes Winston measurably slower than [Pino](/nodejs/pino), which matters in high-throughput services.
Environment-aware formatting
JS
const isProd = process.env.NODE_ENV === 'production'
const logger = winston.createLogger({
level: isProd ? 'info' : 'debug',
format: isProd
? winston.format.combine(winston.format.timestamp(), winston.format.json())
: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(
({ level, message, timestamp, ...meta }) =>
`${timestamp} ${level}: ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`,
),
),
transports: [new winston.transports.Console()],
})# Development (colorized, human-readable):
09:21:14 info: server started {"port":3000}
# Production (JSON, machine-parseable):
{"level":"info","message":"server started","port":3000,"timestamp":"2026-06-21T09:21:14.882Z"}Child loggers for per-request context
Attach a request ID to every log line within a request
JS
import { randomUUID } from 'node:crypto'
// Express middleware — give each request its own child logger:
app.use((req, res, next) => {
req.log = logger.child({ requestId: randomUUID(), method: req.method, path: req.path })
req.log.info('request received')
next()
})
// In a handler — every line automatically carries requestId/method/path:
app.get('/orders/:id', (req, res) => {
req.log.info('fetching order', { orderId: req.params.id })
// ...
})Child loggers bind shared context once — every line inherits the requestId
`logger.child(meta)` returns a logger that automatically merges `meta` into every message it emits. Creating one child per request with a unique `requestId` means *all* logs from that request are tagged with the same ID — so in your log platform you can pull up the complete story of a single request across many log lines, even when thousands of requests are interleaved. This correlation is essential for debugging production issues and is the foundation of request tracing.
Handling uncaught errors
JS
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'logs/combined.log' })],
exceptionHandlers: [new winston.transports.File({ filename: 'logs/exceptions.log' })],
rejectionHandlers: [new winston.transports.File({ filename: 'logs/rejections.log' })],
})Log the crash, then still exit — an uncaught exception leaves the process in an unknown state
Winston's `exceptionHandlers` and `rejectionHandlers` capture `uncaughtException` and `unhandledRejection` so the fatal error gets logged before the process dies — invaluable for diagnosing crashes. But logging the error does **not** make it safe to continue: after an uncaught exception the process state is corrupt, so you should still let it exit and rely on your process manager to restart it. See [uncaught exceptions](/nodejs/uncaught-exceptions) and [graceful shutdown](/nodejs/graceful-shutdown) for the full pattern. Winston can be configured to exit after logging (`exitOnError` is true by default for these handlers).
Next
When raw throughput matters, reach for the fastest logger: [Pino](/nodejs/pino).