What if your logs are lying to you—telling partial or unreadable stories when you need answers fast?
Winston log format controls exactly what your Node.js logs look like and what they include.
I walk through core formatters—timestamp, json, printf, colorize, errors, and metadata—explain why order matters, and show per-transport setups.
You’ll see short examples, quick fixes for big objects and stacks, plus tips to keep console output human-friendly and file logs machine-friendly.
By the end you’ll be able to tweak the Winston log format so logs help, not hinder.
Core Concepts of Winston Log Formatting and How to Apply Them

const { createLogger, format, transports } = require('winston');
const { combine, timestamp, json, printf, colorize } = format;
const logger = createLogger({
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
json()
),
transports: [
new transports.Console({
format: combine(colorize(), printf(({ timestamp, level, message }) =>
`${timestamp} ${level}: ${message}`
))
}),
new transports.File({ filename: 'app.log' })
]
});
Winston log formatting controls how your log entries look and what data they include. When you call logger.info('User logged in'), Winston takes that message plus any metadata and transforms it into the final output. The formatting system passes a log info object (with level, message, timestamp, and extra fields) through formatters you define. Each formatter can add fields, change existing data, or reshape the structure before your transport writes it out.
The info object moves left to right through whatever pipeline you build with format.combine(). Add timestamp() first and the timestamp field exists before printf() sees it. Add metadata() before json() and extra fields get grouped under a metadata key in the JSON. Each formatter is just a small function that grabs the info object, tweaks it, passes it along.
Winston includes these built-in formatters:
- json() outputs the info object as JSON, perfect for files and log aggregation
- simple() gives you minimal “level: message” strings without metadata or timestamps
- printf(template) lets you write a custom string layout by defining a function that formats fields exactly how you want
- combine(…formatters) chains multiple formatters together so you can stack timestamps, colors, and custom layouts
Those four cover most situations. Winston also has colorize() for console colors, timestamp() for adding time fields, metadata() for grouping extra properties, and splat() for printf style string interpolation.
Using Winston format.printf to Build Custom Output Layouts

format.printf() receives the full info object and returns a string. Inside your printf function you destructure { level, message, timestamp, ...meta } and build the final line however you need. This is where you decide whether to show metadata inline, serialize it as JSON, or skip it. When you pass objects as metadata (like logger.info('Got response', { response: apiResponse })), you have to stringify them inside printf or use format.metadata() to capture them in a predictable key.
Errors need special attention. Include format.errors({ stack: true }) before your printf so the stack trace lands in the info object. Then inside printf check for info.stack and tack it onto your output string. Skip this and the stack stays hidden, you lose debugging context. Metadata should get JSON stringified if you want nested objects preserved. JSON.stringify(meta) works, but watch for circular references and giant payloads that bloat logs.
Designing a printf Template
Start with the fields you always want: timestamp, level, message. Then decide whether to show metadata inline or on its own line. A bare-bones template looks like ${timestamp} ${level}: ${message}, giving you clean one-liners. Got metadata? Add a conditional: ${Object.keys(meta).length ? ' ' + JSON.stringify(meta) : ''} so empty metadata doesn’t clutter output. For multi-line readability, insert newlines or indentation for stack traces: ${stack ? '\n' + stack : ''}. Keep the template short enough that it doesn’t slow down logging in tight loops.
Combining Winston Formatters with format.combine

format.combine() chains formatters in the order you list them. Put timestamp() first if you want the time available later. Put metadata() before printf() so your printf function can grab info.metadata. Put colorize() before printf() if you want ANSI codes baked into the final string, or last if you only want color on the level field. Order matters because each formatter changes the info object and passes it forward.
format.splat() turns on printf style interpolation like logger.info('User %s logged in', username). Add it early in the combine chain so the message gets expanded before printf or json sees it. Timestamps can use ISO strings by default or custom formats via timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }). The format string follows standard date tokens, so you can show milliseconds, UTC offsets, or shortened forms.
Common ordering patterns:
combine(timestamp(), metadata(), json())gives you structured logs with time and grouped extra fieldscombine(timestamp(), colorize(), printf())produces human-readable console output with colors and timecombine(errors({ stack: true }), timestamp(), json())yields JSON logs with error stacks intactcombine(splat(), timestamp(), printf())gives you interpolated messages with timestamps in a custom layout
Timestamping and Colorizing Winston Logs

format.timestamp() drops a timestamp field into the info object. By default it uses ISO 8601, but you can pass { format: 'YYYY-MM-DD HH:mm:ss' } for custom layout or { format: () => Date.now() } to log epoch milliseconds. Timezone handling follows the system timezone unless you set up moment or date-fns formatters to force UTC. Shipping logs to a centralized store? Stick with ISO or epoch so parsers don’t guess formats. Reading logs locally during development? A readable format like 2026-03-26 14:32:01 works fine.
format.colorize() wraps ANSI color codes around the level field. Error in red, warn in yellow, info in green, debug in blue. This only makes sense for console output. File transports should skip colorization because ANSI codes show up as raw escape sequences in text editors and log viewers. Combine colorize with printf for development, but exclude it from file or remote transports. Colorization helps you scan console logs quickly during local debugging but adds nothing once logs leave the terminal.
Handling Metadata, Full Objects, and Error Serialization with Winston

format.metadata({ fillExcept: ['message', 'level', 'timestamp'] }) collects all extra fields on the info object into info.metadata. Log logger.info('Event', { userId: 123, sessionId: 'abc' }) and metadata captures { userId, sessionId } so printf or json can include them without mixing with core fields. Keeps your output structured and prevents custom properties from vanishing when you use json().
To log full objects in winston, pass the object as metadata: logger.info('API response received', { response: apiResponse }). Using printf? Destructure ...meta and stringify it. Using json()? The full object shows up in the JSON output under the key you gave. Watch out for giant objects. Logging a 2 MB response every request fills disk fast. Consider logging only the status code and a few key fields, or set a max depth when stringifying.
format.errors({ stack: true }) makes sure Error instances include their stack trace in the info object. Without this formatter, logger.error(new Error('Boom')) only logs the message and you lose the call stack. Add errors() early in your combine chain so the stack field exists before json() or printf() formats the output. For sensitive fields like passwords or tokens, write a custom formatter that deletes or masks those keys before serialization. Safe stringify libraries like fast-safe-stringify handle circular references and stop crashes when objects reference themselves.
| Field | Description |
|---|---|
| metadata | Groups extra properties via metadata({ fillExcept: […] }) so printf and json include them without cluttering core fields. |
| stack | Captured by errors({ stack: true }); includes full stack trace when logging Error instances. |
| Masked fields | Custom formatter deletes or redacts sensitive keys (password, token) before final serialization to prevent leaking credentials. |
Winston Log Format Differences: Console vs File Transports

Console transports benefit from human-readable formatting. Use combine(colorize(), timestamp(), printf()) so you see color-coded levels, readable timestamps, and messages without extra JSON noise. During local development you want to scan logs quickly without opening a JSON viewer. Printf gives you one line per log with only the fields you care about, like 2026-03-26 14:32:01 info: User logged in.
File transports should use format.json() because machines parse JSON reliably. Structured JSON logs work with Elasticsearch, Splunk, and other log aggregation tools that expect consistent key names. JSON Lines (ndjson) is a variant where each line is a separate JSON object, making it easy to stream and ingest without parsing the entire file at once. Logstash compatible formats are JSON based and include @timestamp and @version fields. Shipping logs to a centralized system? Use json() on your file transport and skip printf.
Output comparison:
- Console: colorized printf with timestamp and message—
2026-03-26 14:32:01 info: User logged in - File: JSON object per line—
{"timestamp":"2026-03-26T14:32:01.123Z","level":"info","message":"User logged in"} - JSONL/Logstash: Same as file but with @timestamp and @version keys for compatibility with specific ingestion pipelines
Configuring Winston Transports and Per-Transport Formats

createLogger({ format, transports }) accepts a global format that applies to all transports by default, but each transport can override its own format. This lets you send colorized printf to the console and JSON to a file without writing two separate loggers. Over 30 transports exist (file, console, HTTP, daily-rotate-file, Elasticsearch, MongoDB, S3, MySQL, and more), and each respects its own format option if you give it one. Typical configs assign different levels per transport so errors go to an error log while debug messages stay in console.
Daily-rotate-file transports can have their own formats and rotation rules. Rotate daily at midnight, compress old logs, and use JSON format for all entries. You pass the same format option to the transport constructor. Need separate formats for separate files? One human-readable audit log, one JSON metrics log? Create two file transports with different formats and filenames.
Configuration checklist: set a default level on the logger, add timestamp and metadata to the global format, then override per transport. For console, combine colorize and printf. For file, use json. For HTTP or remote transports, use json with additional fields like hostname or environment. This pattern keeps your logger definition clean and stops you from repeating format logic.
Example: Per-Transport Format Overrides
const logger = createLogger({
level: 'debug',
format: combine(timestamp(), metadata({ fillExcept: ['message', 'level', 'timestamp'] })),
transports: [
new transports.Console({
format: combine(
colorize(),
printf(({ timestamp, level, message, metadata }) =>
`${timestamp} ${level}: ${message} ${metadata ? JSON.stringify(metadata) : ''}`
)
)
}),
new transports.File({
filename: 'app.log',
level: 'info',
format: json()
})
]
});
Console shows colorized lines with readable timestamps. File writes JSON starting at info level and skips debug messages. This split’s common in production: console for real-time monitoring, file for auditing and indexing.
Logging Correlation IDs, Request IDs, and Trace Fields

Add request IDs by passing them as metadata: logger.info('Request completed', { reqId: req.id }). Using Express or similar middleware? Attach a unique ID at the start of each request and include it in every log call for that request. Lets you grep logs by request ID and see the full lifecycle of a single API call. Correlation IDs connect logs across services. Generate one ID at the ingress point and pass it through headers so every downstream service logs with the same correlation ID.
OpenTelemetry trace IDs and span IDs fit the same pattern. Using an OpenTelemetry SDK? Extract the trace ID from the active span and add it to your log metadata: { traceId: span.spanContext().traceId }. The traceparent header in W3C Trace Context format can be parsed and logged so you can tie logs to distributed traces in observability tools. Add these fields via a custom formatter or middleware that injects them into every log call automatically, so you don’t have to remember them manually.
Formatting Logs for Production and Development Environments

Development uses console with colorize and printf so you can read logs in your terminal without extra tools. Production uses JSON on file or remote transports because centralized log systems need structured data. Environment variables like LOG_FORMAT control which format the logger uses at runtime. Check process.env.LOG_FORMAT and pick combine chains based on the value: if it’s json, skip colorize and printf. If it’s pretty, use colorize and printf.
You can also switch formats based on process.env.NODE_ENV. When NODE_ENV === 'production', set the global format to json() and the console format to simple() or skip console entirely. When NODE_ENV === 'development', use colorize and printf on console and skip file transports to cut disk I/O. This pattern keeps production logs machine-readable while giving developers a readable local experience.
| Environment | Console Format | File Format |
|---|---|---|
| Development | colorize + printf for human-readable output | Disabled or simple JSON for quick debugging |
| Test | Silent or simple without colors | JSON for test result logs |
| Production | Disabled or JSON (no colorize) | JSON with timestamp and metadata for ingestion |
Comparing Winston Formatting to Bunyan and Pino

Bunyan and Pino are JSON-first loggers. Every log line they produce is a JSON object by default, which makes them fast and consistent for machine parsing. Bunyan ships with a CLI (bunyan command) that pretty-prints JSON logs on the fly, so you can pipe production JSON logs through the CLI for human-readable output during debugging. Pino takes the same approach but cranks speed up. It’s one of the fastest Node.js loggers because it skips complex formatting and writes JSON directly.
Winston gives you more flexibility. You can use JSON when you need it and printf when you want readable output without external tools. The tradeoff? Slightly lower performance because printf and combine add overhead. Need structured logs and maximum speed? Pino’s a good pick. Want built-in pretty printing and a batteries-included CLI? Bunyan works well. Want flexible formatting pipelines with per-transport customization and don’t mind a small performance hit? Winston fits.
Final Words
You jumped straight into a compact example using combine, timestamp, json and printf, then traced how info objects flow through Winston’s formatter pipeline.
From printf templates and formatter ordering to timestamps, colorize, metadata, error serialization, transports, per-transport overrides, correlation IDs, environment switches, and a quick Bunyan/Pino comparison — each section gives a practical nugget you can apply.
Apply these patterns and your winston log format will be consistent and machine-friendly in prod while staying readable in dev. You’re set to ship clearer, more actionable logs.
FAQ
Q: What is Winston log formatting and how do info objects flow through the formatter pipeline?
A: Winston log formatting is a pipeline that passes an info object through chained formatters (format.combine). Each formatter reads or modifies fields like level, message, metadata, and timestamp, then forwards the updated info.
Q: When should I use json() vs printf() in Winston?
A: Use json() for machine-readable structured logs (ingestion, logstash, ndjson). Use printf() for human-friendly single-line console output during development or quick debugging.
Q: How do I set up a simple combined formatter example?
A: You set up a simple combined formatter with format.combine(timestamp(), format.errors({stack:true}), json()) for structured output, or combine(timestamp(), printf(…)) for readable console logs.
Q: How does format.printf receive fields and include metadata or error stacks?
A: format.printf receives the full info object, including level, message, timestamp, metadata, and spread fields; JSON.stringify nested metadata and enable format.errors({stack:true}) to include error stacks.
Q: Why does the order of format.combine matter?
A: The order matters because formatters run sequentially; earlier transforms change what later ones see. Put timestamp and errors before printf, and splat before printf for interpolation.
Q: How do I configure timestamp() formats and colorize() for console logs?
A: You configure timestamp({format:’YYYY-MM-DD HH:mm:ss’}) or use ISO strings; colorize() adds ANSI codes for console readability. Use colorize only for console, not for file transports.
Q: How do I log full objects, handle circular refs, and mask sensitive fields?
A: You log full objects by passing them as metadata; use a safe stringify to handle circular refs and proactively mask or remove sensitive fields before logging to avoid leaking secrets.
Q: What’s the difference between console, file, and JSONL/Logstash formats?
A: Console usually uses colorize + printf for human readability; file transports prefer json() for structured storage; JSONL/Logstash uses newline-delimited JSON for reliable ingestion and parsing.
Q: Can transports have their own formats and how do I override them?
A: Transports can override the logger’s global format by setting transport.format. This is useful for daily-rotate-file or when you want different layouts or levels per transport.
Q: How do I add correlation IDs, request IDs, or trace fields to Winston logs?
A: You add correlation or request IDs as metadata on each log call or via request-scoped logger middleware. Include OpenTelemetry trace IDs to connect logs across services.
Q: How should I choose formats for development, testing, and production?
A: Choose colorize+printf for fast dev debugging, compact JSON for test parsing, and full ndjson JSON in production for ingestion. Toggle with an env var like LOG_FORMAT at runtime.
Q: How does Winston’s formatting compare to Bunyan and Pino?
A: Winston is flexible with printf and combine for custom layouts; Bunyan and Pino are JSON-first. Pino prioritizes performance, while Bunyan includes built-in pretty-printing tools.
