Logging in Node.js with Winston: Levels, Transports, Formats, Structured Logs and Child Loggers
Key takeaways
Winston is a versatile logging library for Node.js. It supports multiple transports, custom formats, and is used by millions of applications for production logging.
Introduction
Winston is a simple and universal logging library for Node.js. It’s designed to be flexible and extensible, supporting multiple transports (outputs) and formats.
This post starts from what console.log cannot do in a growing service and builds a Winston setup step by step: log levels, console and file transports, formats and structured JSON logs, error logging with stack traces, child loggers for request context, log rotation, and a production configuration, plus what not to put in logs.
Why Winston?
console.log (basic):
console.log('User logged in:', userId);
console.error('Database connection failed:', error);
These calls have no severity you can filter on, always go to the same place, and produce free-form text that is hard to search or parse later.
Winston:
logger.info('User logged in', { userId });
logger.error('Database connection failed', error);
The gap between console.log and a real logger isn’t really about output formatting — it’s that console.log writes to stdout with no concept of severity, destination, or structure, so as an application grows past a toy script, you end up needing to answer questions console.log was never designed to answer: “show me only errors from the last hour,” “ship warnings to a monitoring service but keep debug noise local,” “parse this log programmatically instead of eyeballing it.” Winston’s actual job is separating those three concerns — level (how severe), transport (where it goes), and format (how it’s shaped) — so each can be configured independently instead of hardcoded into every log call.
Installation
npm install winston
Basic Setup
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'app.log' }),
],
});
logger.info('Application started');
logger.warn('Low memory warning');
logger.error('Database connection failed');
Note that createLogger needs at least one transport to actually produce output — a logger created with no transports array writes none of your messages; Winston only prints an [winston] Attempt to write logs with no transports warning to the console, which is easy to mistake for a real log line when copying a partial example. format.json() here means every log line is written as a JSON object rather than a human-readable string; that’s deliberate for the file transport (structured logs are what log aggregation tools like ELK or Datadog expect to parse), even though it makes console output harder to read at a glance during local development — which is why the “Multiple Transports” section below typically pairs a plain/colorized format for the console with JSON for files.
Log Levels
Winston uses npm log levels by default:
{
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
silly: 6
}
const logger = winston.createLogger({
level: 'info', // Only log 'info' and above (warn, error)
});
logger.error('Critical error'); // Logged
logger.warn('Warning message'); // Logged
logger.info('Info message'); // Logged
logger.debug('Debug details'); // NOT logged (below 'info')
The numbering here is the part worth internalizing: lower numbers mean higher priority, and setting level: 'info' (2) means “log this level and everything numerically lower (more severe)” — error (0) and warn (1) both pass, while http (3) and anything below it are filtered out. This inverted-severity numbering trips up people expecting “higher number = more important”; it comes from the original syslog severity convention Winston’s npm level set is modeled on. Setting the level too permissively in production (leaving it at debug or silly) is a common real-world mistake — it floods log storage with noise that makes genuinely important warnings harder to find, and can meaningfully inflate log-storage costs on a busy service.
Transports
Console Transport
new winston.transports.Console({
level: 'debug',
format: winston.format.simple(),
});
File Transport
new winston.transports.File({
filename: 'error.log',
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5,
});
new winston.transports.File({
filename: 'combined.log',
});
maxsize and maxFiles here aren’t optional polish — a File transport with no size cap will grow error.log indefinitely as long as the process runs, and on a long-lived server that’s a real way to eventually fill a disk and take the whole machine down. This basic rotation (Winston rolling over to a new numbered file once maxsize is hit, keeping only the most recent maxFiles) is the minimum safety net; the Log Rotation section further down covers winston-daily-rotate-file, a more production-appropriate approach that rotates by calendar day rather than by size.
Multiple Transports
const logger = winston.createLogger({
transports: [
// Console for development
new winston.transports.Console({
level: 'debug',
format: winston.format.simple(),
}),
// Error file
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
// Combined file
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
This is where the level/transport separation from the introduction becomes concrete: each transport can carry its own level, independent of the logger’s own default. The error-file transport here only receives error-severity logs regardless of what else the logger emits, while the combined-file transport (no level set) inherits the logger’s overall level and receives everything that passes it. This is the standard production pattern — a narrow, easy-to-scan error log for alerting/on-call, alongside a broader combined log for full context when investigating an incident.
Formats
Built-in Formats
const { format } = winston;
// JSON format
format.json();
// Simple format
format.simple();
// Pretty print
format.prettyPrint();
// Timestamp
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' });
// Colorize (for console)
format.colorize();
// Align
format.align();
Each of these formats is a small, focused transformation, and that’s the design worth understanding — Winston formats are meant to be composed rather than picked one-at-a-time, which is exactly what format.combine() in the next section is for. format.colorize() is specifically for terminal output (it injects ANSI color codes) and should never be applied to a file transport — colorized log files fill up with unreadable escape-code garbage when opened in a plain text viewer or ingested by a log-parsing tool that doesn’t strip ANSI codes.
Combining Formats
const logger = winston.createLogger({
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
format.splat(),
format.json()
),
});
Order matters inside format.combine() — formats run left to right, each one transforming the log object the previous one produced, so format.timestamp() has to come before anything that wants to read or print the timestamp field, and format.errors({ stack: true }) (which extracts a proper stack trace out of an Error object) needs to run before the final serialization step (format.json()) or the stack trace won’t make it into the structured output. format.splat() enables printf-style %s/%d interpolation in log messages (logger.info('User %s logged in', username)) — it’s easy to skip since it’s not obviously load-bearing until you actually use that call style and the interpolation silently doesn’t happen without it.
Custom Format
const customFormat = format.printf(({ level, message, timestamp, ...meta }) => {
return `${timestamp} [${level}]: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
}`;
});
const logger = winston.createLogger({
format: format.combine(
format.timestamp(),
customFormat
),
transports: [new winston.transports.Console()],
});
format.printf is the escape hatch for when the built-in formats don’t produce exactly the line shape you want — the callback receives the fully-combined log object (after timestamp and any other formats earlier in the chain have already added their fields) and returns the final string. The rest-parameter destructuring ({ level, message, timestamp, ...meta }) is what separates the “known” fields Winston always attaches from any extra metadata a caller passed (logger.info('msg', { userId, ip })), letting the custom format print that extra context as a trailing JSON blob without hardcoding which metadata keys might show up.
Structured Logging
// Bad: string interpolation
logger.info(`User ${userId} logged in from ${ip}`);
// Good: structured data
logger.info('User logged in', {
userId,
ip,
userAgent: req.headers['user-agent'],
timestamp: new Date(),
});
// Even better: consistent format
logger.info('User login', {
event: 'user.login',
userId,
metadata: {
ip,
userAgent: req.headers['user-agent'],
},
});
String interpolation baked directly into the message (`User ${userId} logged in from ${ip}`) throws away the one advantage a real logger has over console.log: with structured fields, userId and ip become independently queryable — a log search tool can filter “every login from this IP” or aggregate “logins by user” without parsing free-text messages with regex. The tradeoff is worth naming explicitly: structured logging asks you to think about the event first (“user logged in”) and treat the specifics as attached data, rather than writing a one-off sentence per call site — a small habit change that pays off enormously once logs need to be searched or aggregated at scale rather than just read in a terminal during development.
Error Logging
const logger = winston.createLogger({
format: format.combine(
format.timestamp(),
format.errors({ stack: true }), // Include stack traces
format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
],
});
// Log error with stack trace
try {
throw new Error('Something went wrong');
} catch (error) {
logger.error('Operation failed', error); // pass the Error itself as meta
// logger.error(error); // or log it directly
}
// message: "Operation failed Something went wrong", plus a stack field
How you pass the error decides whether the stack survives. logger.error(error) hands Winston the Error itself, and format.errors({ stack: true }) copies its message and stack onto the log entry. logger.error('Operation failed', error) also works: when the metadata argument is an Error, Winston appends its message to yours and copies its stack. What does not work is wrapping it, logger.error('Operation failed', { error }), which is the form most people write. format.errors only looks at the top-level entry, not at nested fields, and JSON.stringify-based serialization drops an Error’s message and stack because they are not enumerable properties, so the entry ends up with "error":{} and no stack at all, exactly when you need it during an incident. If you want the error under its own key, serialize it yourself: { error: { message: err.message, stack: err.stack } }.
Child Loggers
const logger = winston.createLogger({
format: format.json(),
transports: [new winston.transports.Console()],
});
// Create child logger with default metadata
const userLogger = logger.child({ service: 'user-service' });
const authLogger = logger.child({ service: 'auth-service' });
userLogger.info('User created', { userId: 123 });
// Output: { level: 'info', message: 'User created', service: 'user-service', userId: 123 }
authLogger.warn('Failed login attempt', { ip: '192.168.1.1' });
// Output: { level: 'warn', message: 'Failed login attempt', service: 'auth-service', ip: '192.168.1.1' }
Child loggers solve the “I have to remember to attach service: 'auth-service' to every single log call in this module” problem: logger.child({...}) returns a new logger that automatically merges its bound metadata into every call, while still sharing the parent’s transports and format configuration — no need to reconfigure output destinations per module. This is the idiomatic way to add per-request or per-module context (a request ID, a tenant ID, a service name) in a larger application without manually threading that value into every single log call by hand.
Production Setup
const winston = require('winston');
const { format } = winston;
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
format.splat(),
format.json()
),
defaultMeta: {
service: 'my-app',
environment: process.env.NODE_ENV,
},
transports: [
// Error logs
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5,
}),
// All logs
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 5242880,
maxFiles: 5,
}),
],
exceptionHandlers: [
new winston.transports.File({ filename: 'logs/exceptions.log' }),
],
rejectionHandlers: [
new winston.transports.File({ filename: 'logs/rejections.log' }),
],
});
// Development: also log to console
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: format.combine(
format.colorize(),
format.simple()
),
}));
}
module.exports = logger;
Two details here are what separate this from the earlier, simpler examples and make it genuinely production-appropriate. First, level: process.env.LOG_LEVEL || 'info' and environment: process.env.NODE_ENV in defaultMeta let the same deployed code behave differently per environment (verbose in staging, quiet in production) and tag every log with which environment it came from — important once logs from dev, staging, and prod all flow into the same aggregation tool. Second, exceptionHandlers/rejectionHandlers catch uncaught exceptions and unhandled promise rejections that would otherwise crash the process with only a stack trace printed to stderr (easy to lose if stderr isn’t captured) — routing them through Winston ensures even a fatal, unanticipated crash gets a structured, searchable log entry before the process goes down.
Express Integration
const express = require('express');
const winston = require('winston');
const expressWinston = require('express-winston');
const app = express();
// Log all requests
app.use(expressWinston.logger({
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/requests.log' }),
],
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
meta: true, // Log request/response metadata
msg: 'HTTP {{req.method}} {{req.url}}',
expressFormat: true,
colorize: false,
}));
// Your routes
app.get('/', (req, res) => {
res.send('Hello World');
});
// Log errors
app.use(expressWinston.errorLogger({
transports: [
new winston.transports.File({ filename: 'logs/error.log' }),
],
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
}));
app.listen(3000);
express-winston isn’t Winston itself — it’s middleware that automatically logs every incoming request/response using a Winston logger instance you provide, so you get consistent request logging (method, URL, status, response time) without writing a logger.info(...) call inside every route handler. The ordering matters: expressWinston.logger needs to be registered before your routes (so it sees every request going in), while expressWinston.errorLogger needs to be registered after your routes but before any final error-handling middleware, since Express only routes an error to errorLogger once something earlier in the chain has actually thrown or called next(error).
Log Rotation
npm install winston-daily-rotate-file
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const logger = winston.createLogger({
transports: [
new DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d', // Keep logs for 14 days
}),
],
});
winston-daily-rotate-file rotates on a calendar schedule (datePattern: 'YYYY-MM-DD') rather than purely on size, which matters for log retention policy — maxFiles: '14d' means “keep 14 days of logs and delete older ones automatically,” a far more intuitive retention story than the earlier File transport’s numbered-file rotation, where “how much history do I actually have” depends on log volume and isn’t obvious at a glance. zippedArchive: true compresses rotated-out files, which matters more than it sounds for a busy service — JSON log lines compress extremely well (repeated key names, similar structure), which is real disk savings on a server retaining weeks of logs.
Real-World Example
// logger.js
const winston = require('winston');
const { format } = winston;
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const level = () => {
const env = process.env.NODE_ENV || 'development';
return env === 'development' ? 'debug' : 'warn';
};
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
};
winston.addColors(colors);
const consoleFormat = format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
format.colorize({ all: true }),
format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`,
),
);
const fileFormat = format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
format.errors({ stack: true }),
format.splat(),
format.json(),
);
const transports = [
new winston.transports.Console({ format: consoleFormat }),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: fileFormat,
}),
new winston.transports.File({
filename: 'logs/all.log',
format: fileFormat,
}),
];
const logger = winston.createLogger({
level: level(),
levels,
transports,
});
module.exports = logger;
This example ties the whole guide together into a shape worth reusing directly: a custom level set (http as its own tier between info and debug, so HTTP-access logging can be switched on without application-level debug output), an environment-driven level() function so local development sees everything down to debug while production only sees warn and above, and millisecond timestamps (SSS in the fecha pattern Winston uses; the ms seen in many copies of this snippet actually prints minutes and seconds again), and separate console/file formats — colorized and human-readable for the terminal, structured JSON for files that a log aggregator will ingest. Centralizing this in a single logger.js module that every other file requires is the standard pattern; it guarantees every part of the app logs through the same configuration instead of each file inventing its own Winston setup.
// Usage
const logger = require('./logger');
logger.debug('Debug message');
logger.http('HTTP request');
logger.info('Info message');
logger.warn('Warning message');
logger.error('Error message', new Error('Something broke'));
Keeping secrets out of the logs
Logged credentials are a recurring source of real leaks: log files and log platforms are usually retained longer and access-controlled more loosely than the database the data came from. Not writing password into a log call is the easy part. The harder part is data that reaches the logger indirectly, which is where Winston gives you two places to intervene.
const { format } = winston;
// Custom format: mask known sensitive keys before serialization
const redact = format((info) => {
for (const key of ['password', 'token', 'authorization', 'cookie']) {
if (key in info) info[key] = '[REDACTED]';
}
return info;
});
const logger = winston.createLogger({
format: format.combine(redact(), format.json()),
transports: [new winston.transports.Console()],
});
logger.info('Login attempt', { userId: 123, password: 'hunter2' });
// {"level":"info","message":"Login attempt","userId":123,"password":"[REDACTED]"}
A format created with format(fn) sees every log entry before it is written, so it is the one place to enforce masking for the whole app. This version only checks top-level keys; a password nested inside { body: req.body } passes straight through, so either log specific fields instead of whole objects, or make the function walk nested objects.
The second source is request logging. express-winston with meta: true records request headers by default, which includes Authorization and Cookie, so every bearer token and session cookie ends up in logs/requests.log. Pass headerBlacklist: ['authorization', 'cookie'] to expressWinston.logger() (or narrow requestWhitelist) before turning it on in production.
Related Articles
- PM2 | Production Process Manager for Node.js
- Express.js: Node.js Web Framework and REST
- Nodemailer | Send Emails from Node.js
- Passport.js