Ever look at production logs and realize you can’t tell which error belongs to which user or request? Most teams ship Winston with default settings, then waste hours manually reconstructing context when things break. Custom formatters let you structure logs the way your actual debugging workflow needs them, whether that’s sanitized JSON for compliance, color-coded terminal output for local dev, or request-traced entries that make incident response actually manageable. This guide walks through practical formatter implementations you can copy directly into your codebase.
Essential Winston Formatter Configuration with Working Examples

const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...metadata }) => {
let msg = `${timestamp} [${level}]: ${message}`;
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata)}`;
}
return msg;
})
)
}),
new winston.transports.File({
filename: 'app.log',
format: winston.format.json()
})
]
});
logger.info('Application started', { userId: 12345, environment: 'production' });
Winston formatters are transformation functions that control how your log entries show up in different outputs. They’re basically processors that grab raw log data (level, message, metadata) and turn it into whatever structure you need. Could be a colorful string for your terminal or a JSON object for your log aggregation tools. Each formatter gets the log object, does its thing, then passes it along to the next one.
The combine method wraps multiple formatters together and runs them in sequence. One formatter’s output becomes the next one’s input. In that example up there, combine first slaps on a timestamp, then deals with error stack traces, enables string interpolation with splat, and finally converts everything to JSON. This happens at the logger level, but individual transports can do their own thing with separate format configs.
The printf method gives you full control by letting you write a custom template function. You get access to timestamp (when it’s there), level (error, warn, info, whatever), message (your actual text), and any extra metadata you passed. That template function shown above just strings them together: ${timestamp} [${level}]: ${message} spits out something like “2024-07-01 14:23:45 [info]: Application started”.
The json format turns log entries into structured JSON objects that log analysis tools can actually parse. Colorize adds those ANSI color codes to level names so they pop visually in terminals. The timestamp config takes a format parameter using standard date patterns. ‘YYYY-MM-DD HH:mm:ss’ gets you ISO-style dates, or you can pass a function that returns whatever format you want.
Built-in Winston Formatters and Their Configuration

Winston comes with six ready-to-go formatting functions that cover most common scenarios without writing custom code.
json converts log entries into machine-readable JSON with properties for level, message, timestamp, and metadata. Perfect for log aggregation systems.
simple produces plain text in the format level: message without timestamps or colors. Good for basic file logging.
colorize adds ANSI color codes to log levels. Red for error, yellow for warn, green for info. Makes terminal output way easier to scan.
printf takes a template function that receives the log info object and returns whatever custom string format you want.
timestamp adds a timestamp property to the log info object. You can configure it with custom date formats or functions.
combine wraps multiple formatters into one that executes them in order.
The timestamp formatter takes an options object where you specify the format parameter. Use timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }) for standard date-time strings. Or pass a function like timestamp({ format: () => new Date().toISOString() }) for ISO format. Skip the format option entirely and Winston defaults to ISO 8601. The timestamp gets added as a property to the info object, so subsequent formatters can use it.
The combine method creates a pipeline where each formatter receives output from the previous one and modifies the info object. When you write combine(timestamp(), colorize(), printf(...)), Winston runs timestamp first (adds a timestamp property), then colorize (modifies the level property with color codes), then printf (reads all available properties to build the final string). Order matters because later formatters can only see properties that earlier ones added.
const customFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message }) => {
return `${timestamp} ${level}: ${message}`;
})
);
const logger = winston.createLogger({
level: 'debug',
format: customFormat,
transports: [new winston.transports.Console()]
});
You can pass as many formatters to combine as you want. Common patterns include pairing timestamp with printf for readable logs, combining json with timestamp for structured storage, or stacking colorize, timestamp, and printf for dev environments where visual scanning beats machine parsing.
Creating Advanced Custom Formatters for Specific Use Cases

Custom formatters become necessary when you need conditional logic, data sanitization, or complex transformations that printf templates can’t handle. Stripping sensitive data, adding request context, reformatting nested objects, implementing business-specific log structures. All that needs custom formatter functions.
const sanitizingFormatter = winston.format((info) => {
// Create a copy to avoid mutating original
const sanitized = { ...info };
// Remove sensitive fields
if (sanitized.password) delete sanitized.password;
if (sanitized.token) delete sanitized.token;
if (sanitized.apiKey) sanitized.apiKey = '***REDACTED***';
// Sanitize nested objects
if (sanitized.user && sanitized.user.ssn) {
sanitized.user = { ...sanitized.user, ssn: '***-**-****' };
}
return sanitized;
})();
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
sanitizingFormatter,
winston.format.json()
),
transports: [new winston.transports.File({ filename: 'secure.log' })]
});
The transform function gets an info object with several standard properties. The level property holds the severity string (error, warn, info, debug, whatever). Message contains your main log text. Timestamp appears if you’ve added the timestamp formatter earlier. Any metadata you passed during logging shows up as additional properties. The splat property contains string interpolation arguments when using %s or %d style formatting. Your transform function must return the modified info object. Or return false to suppress the log entirely.
Security-conscious apps should scrub passwords, auth tokens, API keys, credit card numbers, social security numbers, and other personally identifiable information before logs hit storage. That example above checks for common sensitive field names and either deletes them or replaces values with redacted placeholders. Run this formatter first in your combine chain, before any formatters that serialize or output data. That ensures sensitive info never makes it to disk or remote logging services.
Configuring Formatters for Different Winston Transports

Each transport in a Winston logger can specify its own format property that overrides the global logger format. So you can send human-readable colored output to your terminal while simultaneously writing machine-parseable JSON to files. This transport-level configuration means you’re not stuck with one formatting strategy across all destinations.
const logger = winston.createLogger({
level: 'debug',
format: winston.format.json(), // Default fallback
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
new winston.transports.File({
filename: 'application.log',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
}),
new winston.transports.File({
filename: 'errors.log',
level: 'error',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
)
})
]
});
Use transport-specific formatting when different consumers need different output structures. During local dev, your console benefits from colorized, readable text you can scan quickly. But your file-based logs should use JSON for grep-ability and integration with log analysis tools. Production environments typically send JSON everywhere since centralized logging systems like Elasticsearch, Splunk, or CloudWatch parse structured data way more efficiently than freeform text. Set a global format as a fallback for any transports that don’t specify their own, then override where needed.
Working with Metadata and Context in Log Formatters

Metadata in Winston is any additional properties you pass beyond the main message string. User IDs, request IDs, session tokens, or business context that helps you trace logs back to specific transactions or users. This contextual stuff doesn’t belong in the message string because it needs to be structured and searchable when you’re debugging production issues.
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({ timestamp, level, message, userId, requestId, ...rest }) => {
let log = `${timestamp} [${level}] ${message}`;
if (userId) log += ` | User: ${userId}`;
if (requestId) log += ` | Request: ${requestId}`;
if (Object.keys(rest).length > 0) {
log += ` | ${JSON.stringify(rest)}`;
}
return log;
})
),
transports: [new winston.transports.Console()]
});
// Log with metadata
logger.info('User login successful', {
userId: 'usr_12345',
requestId: 'req_abc-def-ghi',
ipAddress: '192.168.1.100'
});
// Output: 2024-07-01 14:23:45 [info] User login successful | User: usr_12345 | Request: req_abc-def-ghi | {"ipAddress":"192.168.1.100"}
When you call logger.info('message', { key: 'value' }), Winston merges that metadata object into the info object your formatters receive. Inside printf or custom formatters, you can destructure specific properties like { userId, requestId } or use the spread operator ...rest to grab all additional metadata. This pattern lets you create consistent log formats where certain fields always appear in predictable spots while variable metadata gets tacked on.
The splat formatter enables printf-style string interpolation using %s for strings and %d for numbers. When you log logger.info('User %s logged in from %s', 'john_doe', '192.168.1.1'), the splat formatter converts this into message: 'User john_doe logged in from 192.168.1.1' before other formatters see it. Add splat early in your combine chain if you’re using this style. But structured metadata is generally more useful for production logging since it keeps data typed and searchable.
Structured logging means keeping consistent metadata field names across your application. Every user-related log includes userId. Every HTTP request includes requestId. Every database operation includes queryDuration. This consistency makes it trivial to filter all logs for a specific user or trace a request through your entire system by searching for a single request ID across services.
Formatting Error Stack Traces and Exception Handling

Error objects contain valuable stack trace information that gets lost when you naively convert them to strings. You need special handling in formatters to preserve the full debugging context. Without explicit error formatting, you’ll see [object Object] or just the error message without knowing where in your code the error actually happened.
const errorStackFormat = winston.format.printf(({ timestamp, level, message, stack }) => {
if (stack) {
return `${timestamp} [${level}]: ${message}\nStack trace:\n${stack}`;
}
return `${timestamp} [${level}]: ${message}`;
});
const logger = winston.createLogger({
level: 'error',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
errorStackFormat
),
transports: [new winston.transports.File({ filename: 'errors.log' })]
});
try {
throw new Error('Database connection failed');
} catch (error) {
logger.error('Failed to process request', error);
}
The winston.format.errors({ stack: true }) formatter automatically extracts the stack property from Error objects and adds it to the info object, making it accessible in later formatters. Without this formatter, you’d need to manually check if the message parameter is an Error instance and extract the stack yourself. Place errors() early in your combine chain so printf and other formatters can access the stack property.
Configure uncaught exception handling by adding an exceptionHandlers array to your logger options, specifying transports that should receive these critical errors. Use exceptionHandlers: [new winston.transports.File({ filename: 'exceptions.log' })] to capture crashes before your app exits. Handle unhandled promise rejections by adding rejectionHandlers: [new winston.transports.File({ filename: 'rejections.log' })] to prevent silent failures in async code.
Production-Ready Formatter Best Practices and Performance

Production formatter configuration balances human readability during incidents with machine parseability for automated analysis. While keeping performance overhead minimal since every log statement runs your formatter code. Complex string operations in high-throughput services can add measurable latency.
Use JSON format for production file and remote transports. Log aggregation systems parse structured data orders of magnitude faster than freeform text.
Apply colorize formatter only to console transports in dev environments. ANSI color codes add unnecessary bytes to files and break parsing in centralized logging systems.
Avoid expensive operations like deep object cloning, regex matching, or synchronous I/O inside formatter functions. They run on every log statement.
Cache formatter instances by assigning winston.format.combine(...) to a constant rather than creating new formatters on each logger creation.
Minimize metadata by logging only essential context fields. Remove large objects or data structures that inflate log size without adding debugging value.
Winston’s formatters add approximately 0.0005ms per log event according to filesystem logging benchmarks. That’s 0.0005% of a typical 100ms HTTP response time. This minimal overhead means formatter choice rarely impacts app performance under normal logging volumes. The memory overhead of active logging shows roughly 50% performance reduction compared to no logging, but that cost comes primarily from I/O operations and transport buffering rather than formatter execution.
Switch formatters based on environment variables to use verbose colorized output during dev and compact JSON in production. Set up configuration like format: process.env.NODE_ENV === 'production' ? winston.format.json() : winston.format.combine(winston.format.colorize(), winston.format.simple()) to automatically adjust formatting based on deployment context. This pattern ensures developers get readable console output while production systems send structured logs to centralized aggregation services.
Avoid formatter patterns that do string concatenation in loops, recursive object traversal, or synchronous file reads to look up formatting rules. Keep formatters pure and deterministic, transforming only the info object properties without side effects or external dependencies. Test formatter performance by logging 10,000 entries and measuring execution time to identify bottlenecks before they hit production throughput.
Final Words
Winston formatters give you complete control over how your logs look and what information they contain.
You can use built-in options like json and colorize for quick setup, or build custom formatters when you need sanitization, conditional logic, or complex transformations. The combine method lets you chain multiple formatters together, and transport-specific configuration means your console logs can look different from your file logs.
Getting your winston log formatter right means faster debugging, cleaner production logs, and fewer headaches when something breaks at 2 AM.
Start with the basics, add metadata when you need context, and keep performance in mind for production workloads.
FAQ
What is a Winston formatter and how does it work?
A Winston formatter is a transformation function that controls the structure and display of log entries in Winston. Formatters receive log data (level, message, metadata) and return formatted output, allowing you to customize how logs appear in different destinations like console or files.
How do you combine multiple Winston formatters together?
You combine multiple Winston formatters together using the winston.format.combine() method, which chains formatters in sequence. Each formatter receives the output from the previous one, allowing you to stack timestamp, colorize, and custom printf formatters to build complex log formats.
What is the printf formatter method in Winston?
The printf formatter method in Winston creates custom format templates using available tokens like timestamp, level, message, and metadata. You pass a callback function to printf that receives the info object and returns a formatted string using template literals or string concatenation.
What are the built-in formatters available in Winston?
The built-in formatters available in Winston include json (structured output), simple (basic text), colorize (colored terminal output), printf (custom templates), timestamp (date/time stamps), and combine (chains multiple formatters). Each formatter serves a specific formatting purpose and can be configured independently.
How do you configure timestamp format in Winston?
You configure timestamp format in Winston by passing options to winston.format.timestamp(), such as { format: 'YYYY-MM-DD HH:mm:ss' } for custom patterns or using the default ISO format. You can also pass a function that returns a formatted date string for complete control.
When should you create a custom Winston formatter?
You should create a custom Winston formatter when you need sanitization of sensitive data, conditional formatting logic, or complex transformations beyond basic printf patterns. Custom formatters use the winston.format() function with a transform callback that modifies the info object before returning it.
How do you apply different formatters to different Winston transports?
You apply different formatters to different Winston transports by setting the format property at the transport level, which overrides the global logger format. This lets you use colorized output for console during development and JSON format for file logs in production.
What is metadata in Winston logs and how do you format it?
Metadata in Winston logs is additional contextual information beyond level and message, such as userId, requestId, or custom properties. You access metadata in formatters through the info object, and can format it using template literals or JSON.stringify for structured output.
How do you format error stack traces in Winston?
You format error stack traces in Winston by checking for the error.stack property in a custom printf formatter or using the built-in winston.format.errors({ stack: true }) formatter. This ensures full stack traces appear in your logs when logging error objects.
What are the performance impacts of Winston formatters?
The performance impacts of Winston formatters are minimal, with filesystem logging taking approximately 0.0005ms per event. Complex formatters with heavy string operations or synchronous transformations can add overhead, so production environments should use efficient JSON formatters and avoid unnecessary string concatenation.
Should you use JSON or human-readable format in production?
You should use JSON format in production because it produces machine-readable structured logs suitable for parsing, analysis, and log aggregation systems. Reserve human-readable formats with colorization for local development where terminal readability matters more than automated processing.
How does the splat formatter work in Winston?
The splat formatter in Winston enables string interpolation using placeholders like %s, %d, and %j in log messages. It processes additional arguments passed to logging methods and replaces placeholders with formatted values, similar to printf-style formatting in other languages.
How do you handle uncaught exceptions in Winston formatters?
You handle uncaught exceptions in Winston formatters by configuring the exceptionHandlers property on your logger with appropriate transports and formats. This captures unhandled errors before the process crashes, logging them with full stack traces to console or file.
What is the difference between global and transport-specific format configuration?
The difference between global and transport-specific format configuration is that global format applies to all transports unless overridden, while transport-specific format affects only that transport. Transport-level format properties take precedence over global logger format settings.
How do you optimize Winston formatter performance for high-volume logging?
You optimize Winston formatter performance for high-volume logging by using JSON format to avoid string concatenation, caching formatter instances, minimizing metadata fields, and setting appropriate log levels. Avoid expensive operations like regex or synchronous I/O inside format functions.
