| |

Node.js 67 🟢 Structured Logging Strategies with Pino and Winston

⏱️ Estimated read: 14 min | |

Structured logging is the practice of emitting log entries as machine-readable objects—typically JSON—rather than unstructured text. The difference matters at scale. A console.log("user logged in") line is readable by a human but nearly useless to a log aggregator that needs to filter by user ID, count errors per endpoint, or correlate entries across services. A structured log entry carries named fields: {"level":"info","userId":"abc-123","event":"login","timestamp":"2026-10-17T12:00:00.000Z"}. Every field is queryable, every entry is consistent, and every downstream system—Elasticsearch, Loki, Datadog—can index it without parsing regexes.

Node.js has two dominant structured logging libraries, and they make opposite trade-offs. Winston prioritizes flexibility: it has a plugin architecture with a large transport ecosystem, composable format pipelines, and built-in support for log querying and profiling . Pino prioritizes performance: it serializes JSON 5–10x faster than Winston by avoiding synchronous string formatting in the hot path and offloading I/O to worker threads . Both produce structured output, both support log levels, and both work with Express, Fastify, and any other Node.js framework. The right choice depends on whether you need maximum flexibility or maximum throughput.

Key point: Winston’s format-and-transport pipeline gives you control over every aspect of log output at the cost of synchronous serialization; Pino’s direct JSON serialization and worker-thread transports give you speed and non-blocking I/O at the cost of a smaller transport ecosystem and numeric log levels by default. Both are production-grade; choose based on whether your bottleneck is flexibility or throughput.


Why structured logging requires a library

The console.log ceiling. console.log has no levels, no structure, no redaction, and no routing. It writes to stdout as a plain string, and every downstream system that wants to filter by severity or search by field has to parse the string. A logging library provides the primitives that console.log lacks: severity levels that can be filtered, structured fields that can be indexed, redaction rules that prevent secrets from leaking, and transports that route logs to files, HTTP endpoints, or log aggregators .

The serialization cost problem. Every log line must be converted from a JavaScript object to a string before it can be written. Winston performs this serialization synchronously in the calling thread. For a service handling 10,000 requests per second, each with several log lines, the serialization cost compounds into measurable request latency . Pino avoids this by using custom fast serializers that write JSON directly, and by moving transport I/O to a worker thread so the main event loop is never blocked by a slow disk or a down log aggregator .

The transport failure problem. If your logging library writes to a file synchronously and the disk fills up, or writes to an HTTP endpoint and the aggregator goes down, the application’s event loop stalls. Pino’s transport system runs in a separate worker thread. When the transport is slow or unavailable, the main thread continues processing requests, and the log lines buffer in the worker thread’s queue. Winston’s transports run in the main thread by default, which means a slow transport can back up into the application .

The redaction problem. Logs are a common source of credential leaks. A logger.info(req) call that dumps raw request headers exposes bearer tokens, cookies, and API keys to anyone with access to the log store. Both libraries support redaction, but the configuration differs. Pino uses a redact option with path patterns like req.headers.authorization and *.password . Winston uses format functions that can filter or mask fields before serialization . The redaction must be configured deliberately; neither library redacts by default.

The child logger problem. In a request-scoped context, every log line for a request should carry the same request ID. Creating a new logger per request with the same base configuration is wasteful. Both libraries provide child loggers that inherit the parent’s configuration and permanently bind additional fields. Pino implements child loggers as prototype chain extensions, which makes creating 10,000 child loggers per second have negligible overhead . Winston’s child loggers are also cheap, but the implementation differs.


a. Winston: the flexibility choice

Winston’s architecture is built around three decoupled concepts: levels, formats, and transports . A logger instance is configured with a minimum level, a format pipeline, and one or more transports. Each transport can have its own level and its own format, which allows sending detailed logs to the console and only errors to a file.

The format pipeline is Winston’s defining feature. format.combine() chains transformations: timestamp() adds a timestamp field, errors({ stack: true }) includes stack traces for Error objects, json() serializes the final object, and colorize() adds ANSI colors for console output . A production configuration typically uses timestamp(), errors({ stack: true }), and json() for file transports, while a development configuration adds a console transport with colorize() and simple().

const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    )
  }));
}

The transport ecosystem is Winston’s second strength. Beyond console and file, there are transports for Elasticsearch, CloudWatch, Datadog, Sentry, Slack, and dozens of other destinations . For log rotation, winston-daily-rotate-file rotates logs by date or size and automatically deletes older files . This breadth means that for almost any output destination, a Winston transport already exists.

The cost is configuration complexity. The format pipeline, the transport options, and the exception handling have many combinations, and getting the production configuration right—JSON output, error stack traces, no duplicate console output, proper file rotation—requires careful reading of the documentation . Winston also has a larger dependency tree than Pino, pulling in logform, triple-beam, and readable-stream .


b. Pino: the performance choice

Pino’s architecture is optimized for the hot path. It writes newline-delimited JSON directly to a stream, using custom serializers instead of a format pipeline. The core logger has no transport configuration; transports are separate modules that run in a worker thread . This separation is what makes Pino fast: the main thread writes a JSON line to a stream, and the worker thread handles delivery to the destination.

The basic setup is minimal:

const pino = require('pino');
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: {
    paths: ['req.headers.authorization', '*.password', '*.token'],
    censor: '[REDACTED]'
  }
});

logger.info({ userId: 'abc-123' }, 'User logged in');
// {"level":30,"time":1697654321000,"pid":1234,"hostname":"...","userId":"abc-123","msg":"User logged in"}

The output includes level, time, pid, and hostname automatically. The level is numeric by default: 30 is info, 40 is warn, 50 is error . This is efficient for machines but less readable for humans. The formatters.level option can convert numeric levels to strings, but it adds a small overhead .

The redact option is Pino’s mechanism for preventing sensitive data from reaching the log output. It accepts an array of path patterns using dot notation and wildcards. req.headers.authorization redacts the authorization header, *.password redacts any password field one level deep, and users[*].token redacts tokens inside an array of user objects . The redaction uses fast-redact internally and has near-zero overhead . The censor option customizes the replacement string; the remove: true option deletes the field entirely .

Child loggers are Pino’s mechanism for request-scoped context. logger.child({ requestId: 'abc-123' }) returns a new logger that includes the request ID in every log line. The implementation is a prototype chain extension, not a copy, which is why creating child loggers is cheap . Nested children accumulate bindings: a child of a child includes both the parent’s bindings and its own .

const requestLogger = logger.child({ requestId: 'abc-123' });
requestLogger.info('Processing request');
// {"level":30,"requestId":"abc-123","msg":"Processing request"}

const userLogger = requestLogger.child({ userId: 'user-456' });
userLogger.info('User action');
// {"level":30,"requestId":"abc-123","userId":"user-456","msg":"User action"}

For HTTP request logging, pino-http provides middleware that logs every request and response with method, URL, status code, and duration, and attaches a child logger to req.log . The genReqId option allows forwarding an existing X-Request-Id header or generating a new one, which is essential for correlating logs across services .

The transport system is where Pino’s performance advantage becomes operational. pino.transport({ target: 'pino/file', options: { destination: '/var/log/app.log' } }) writes to a file in a worker thread, so a slow disk does not block the event loop . For development, pino-pretty formats the JSON output for human readability, but it should never be used in production because it adds significant overhead and defeats the purpose of structured logging . The pino-roll transport provides file rotation by size or time .


c. Choosing between them and the production configuration

The decision is not about which library is “better.” It is about which trade-off matches the application. Pino is the correct choice for high-throughput services where logging latency matters—API gateways, real-time services, and anything handling thousands of requests per second. The published benchmarks show Pino processing 30,000+ log lines per second versus Winston’s ~6,000, and the worker-thread transport architecture means that a slow log destination cannot stall the application . Fastify uses Pino as its default logger for exactly these reasons .

Winston is the correct choice when the output requirements are complex. If you need to route logs to multiple destinations with different formats, filter fields with custom logic, or use a niche transport that Pino does not support, Winston’s pipeline gives you the control . The larger transport ecosystem means that for almost any destination, someone has already written the transport. Winston’s built-in profiling (logger.profile('request')) and querying support are also features that Pino does not match .

For production, both libraries require the same disciplines. Never log raw request or response objects. Log a curated set of fields instead. A logger.info(req) call that dumps headers is how bearer tokens end up in the log store . Configure redaction explicitly. Neither library redacts by default. Pino’s redact option and Winston’s format filters must be configured before the first log line is written. Use child loggers for request context. A global logger that logs without a request ID is nearly useless for debugging a specific request. Avoid pino-pretty in production. It is a development tool that adds overhead and produces unstructured output . Set the log level appropriately. debug in production floods the log store with noise; info is the standard production level, with error and warn reserved for problems .


Complete Example Session

The following session builds a production-ready logger with Pino, including redaction, child loggers, and a file transport, then shows the equivalent Winston configuration.

// ============================================
// PART 1: PINO PRODUCTION CONFIGURATION
// ============================================
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: {
    service: 'api',
    version: process.env.APP_VERSION || 'unknown'
  },
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      '*.password',
      '*.token',
      '*.apiKey'
    ],
    censor: '[REDACTED]'
  },
  transport: {
    target: 'pino/file',
    options: { destination: '/var/log/api/app.log' }
  }
});

// ============================================
// PART 2: REQUEST-SCOPED CHILD LOGGER
// ============================================
const requestLogger = logger.child({ requestId: 'req-abc-123' });
requestLogger.info({ userId: 'user-456' }, 'Processing login');
// Output includes service, version, requestId, userId, and msg

// ============================================
// PART 3: REDACTION IN ACTION
// ============================================
requestLogger.info({
  user: { name: 'Alice', password: 'secret123' },
  token: 'eyJhbGciOi...'
}, 'User created');
// password and token are replaced with [REDACTED]

// ============================================
// PART 4: ERROR LOGGING
// ============================================
try {
  throw new Error('Database connection failed');
} catch (err) {
  requestLogger.error({ err }, 'Operation failed');
}
// err is serialized with type, message, stack, and cause chain

// ============================================
// PART 5: WINSTON EQUIVALENT
// ============================================
const winston = require('winston');

const winstonLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: '/var/log/api/error.log', level: 'error' }),
    new winston.transports.File({ filename: '/var/log/api/combined.log' })
  ]
});

const winstonRequestLogger = winstonLogger.child({ requestId: 'req-abc-123' });
winstonRequestLogger.info('Processing login', { userId: 'user-456' });

The five parts cover Pino’s production configuration with redaction and file transport, a request-scoped child logger, redaction of sensitive fields, error serialization, and the Winston equivalent. What the session demonstrates is that both libraries produce structured JSON, but Pino’s configuration is more compact because it avoids the format pipeline, while Winston’s configuration is more explicit about each transformation.


Quick Reference

Library Comparison

AspectWinstonPino
Primary strengthFlexibilityPerformance
SerializationSynchronous format pipelineDirect JSON, custom serializers
Transports80+ community transportsSmaller ecosystem, worker-thread
Log levelsString by defaultNumeric by default
RedactionFormat filtersredact option
Child loggerslogger.child()logger.child() (prototype chain)
Pretty printingBuilt-in colorize()pino-pretty (dev only)
Default in FastifyNoYes

Log Levels (Pino Numeric)

LevelNumericPurpose
fatal60Critical system failure
error50Application error
warn40Warning
info30General information
debug20Debugging
trace10Detailed tracing

Redaction Paths (Pino)

PatternMatches
passwordTop-level password field
*.passwordpassword one level deep
req.headers.authorizationNested path
users[*].tokentoken inside array elements

Production Checklist

ItemWinstonPino
JSON outputformat.json()Default
Error stacksformat.errors({ stack: true }){ err } key
RedactionFormat filterredact option
File transporttransports.Filepino/file transport
Rotationwinston-daily-rotate-filepino-roll
Dev prettyformat.colorize()pino-pretty

Best Practices

✅ Do This:

pino({ redact: ['*.password', 'req.headers.authorization'] })  // ✅ redact from day one
logger.child({ requestId })                                     // ✅ request-scoped
logger.info({ userId }, 'msg')                                  // ✅ structured fields
pino({ transport: { target: 'pino/file' } })                    // ✅ worker-thread I/O

❌ Don’t Do This:

logger.info(req)                                                // ❌ dumps headers, tokens
pino({ transport: { target: 'pino-pretty' } })                  // ❌ production overhead
logger.info(`User ${userId} logged in`)                         // ❌ unstructured string
// no redaction configured                                      // ❌ secrets leak

Common Pitfalls

PitfallWhy It HappensFix
Secrets in logslogger.info(req) or no redactionConfigure redact or curate fields
Pino logs unreadableNumeric levels, JSON outputUse pino-pretty in development only
Slow logging under loadWinston synchronous serializationUse Pino for high-throughput services
Missing request contextGlobal logger, no childlogger.child({ requestId })
Log files grow unboundedNo rotationwinston-daily-rotate-file or pino-roll
Pino transport blocksUsed sync destinationUse pino.transport() worker thread

Real-World Examples

1. Pino with redaction

pino({ redact: ['req.headers.authorization', '*.password'] })

2. Pino child logger

logger.child({ requestId: 'abc-123' })

3. Pino error logging

logger.error({ err }, 'Operation failed')

4. Pino file transport

pino({ transport: { target: 'pino/file', options: { destination: '/var/log/app.log' } } })

5. Winston multiple transports

transports: [new winston.transports.File({ filename: 'error.log', level: 'error' })]

6. Winston format pipeline

format.combine(timestamp(), errors({ stack: true }), json())

7. Winston daily rotation

new winston.transports.DailyRotateFile({ filename: 'app-%DATE%.log', datePattern: 'YYYY-MM-DD' })

8. Pino with pino-http

app.use(require('pino-http')({ logger }))

9. Winston child logger

winstonLogger.child({ requestId: 'abc-123' })

10. Pino pretty in development

pino({ transport: { target: 'pino-pretty' } })

Visual

Winston Architecture

┌──────────────────────────────────────────────┐
│  logger.info({ userId }, 'msg')             │
│         │                                    │
│         ▼                                    │
│  FORMAT PIPELINE (synchronous)               │
│  timestamp() → errors() → json()            │
│         │                                    │
│         ▼                                    │
│  TRANSPORT (main thread)                     │
│  File / Console / HTTP                       │
└──────────────────────────────────────────────┘

Pino Architecture

┌──────────────────────────────────────────────┐
│  logger.info({ userId }, 'msg')             │
│         │                                    │
│         ▼                                    │
│  DIRECT JSON SERIALIZATION                   │
│  (custom fast serializers)                   │
│         │                                    │
│         ▼                                    │
│  STREAM (main thread)                        │
│         │                                    │
│         ▼                                    │
│  WORKER THREAD                               │
│  pino/file / pino-pretty / custom            │
└──────────────────────────────────────────────┘

Child Logger Bindings

┌──────────────────────────────────────────────┐
│  logger.child({ requestId: 'abc' })          │
│         │                                    │
│         ▼                                    │
│  requestLogger.info({ userId: '456' }, 'x')  │
│         │                                    │
│         ▼                                    │
│  {"requestId":"abc","userId":"456",...}      │
│                                              │
│  Nested children accumulate:                 │
│  requestLogger.child({ module: 'auth' })     │
│  → {"requestId":"abc","module":"auth",...}   │
└──────────────────────────────────────────────┘

Redaction Paths

┌──────────────────────────────────────────────┐
│  redact: ['*.password', 'req.headers.authorization'] │
│         │                                    │
│         ▼                                    │
│  { password: 'secret', user: { name: 'A' } } │
│         │                                    │
│         ▼                                    │
│  { password: '[REDACTED]', user: { name: 'A' } } │
│                                              │
│  Wildcards match at the specified depth      │
└──────────────────────────────────────────────┘

Summary

ItemValue
Structured loggingJSON output with named fields
Winston strengthFlexibility, 80+ transports, format pipeline
Pino strengthPerformance, 5–10x faster serialization
Winston serializationSynchronous in main thread
Pino serializationDirect JSON, worker-thread transports
Pino default levelsNumeric (30 = info)
Redaction (Pino)redact option with path patterns
Redaction (Winston)Format filters
Child loggersRequest-scoped context in both
Production pretty-printNever (use JSON)

Key takeaways:

  • Structured logging is not optional at scale. Plain strings cannot be filtered, counted, or correlated. JSON fields can .
  • Winston’s flexibility comes at a cost. The format pipeline and transport ecosystem give you control over every aspect of output, but serialization happens synchronously in the main thread .
  • Pino’s performance comes from avoiding the hot path. Direct JSON serialization and worker-thread transports mean logging does not block the event loop .
  • Redaction is not automatic. Both libraries require explicit configuration. A logger.info(req) call without redaction leaks credentials .
  • Child loggers provide request context. A request ID bound to a child logger appears in every subsequent log line without repeating it .
  • pino-pretty is for development only. It defeats Pino’s performance advantage and produces unstructured output .
  • The choice depends on your bottleneck. If throughput matters, Pino. If output routing complexity matters, Winston.

Remember: Structured logging is the foundation of observability. Winston and Pino both produce JSON, both support levels and redaction, and both work in production. The difference is where they spend their complexity budget: Winston spends it on flexibility, Pino spends it on speed. Choose Winston when you need to route logs to multiple destinations with custom transformations. Choose Pino when you need logging to be invisible in the hot path. In either case, configure redaction before your first production deployment, use child loggers for request context, and never log raw request objects. The library you choose matters less than the discipline you apply to what you log.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!