Skip to content
Node.js nd libraries 5 min read

Logging with Winston & Pino

Logging is how a running service tells you what it’s doing, and console.log stops being enough the moment you ship to production. A real logging library gives you severity levels, structured (machine-readable) output, multiple destinations, and contextual metadata that ties log lines back to a request or user. In the Node.js ecosystem two libraries dominate this space: Winston, the flexible workhorse built around pluggable transports, and Pino, a low-overhead logger that emits JSON extremely fast. This page compares the two and shows how to use log levels, structured JSON, transports, and child loggers.

Winston vs Pino at a glance

Both produce structured logs and support levels and child loggers, but they optimize for different things. Winston is endlessly configurable — you compose formats and ship to many transports in one logger. Pino is opinionated and fast: it writes newline-delimited JSON to a stream and offloads pretty-printing and routing to separate processes.

ConcernWinstonPino
Primary goalFlexibility & transportsThroughput & low overhead
Default outputConfigurable (text or JSON)JSON (NDJSON)
PerformanceGoodExcellent (5-10x faster)
FormattingIn-process formatsOut-of-process (pino-pretty)
TransportsBuilt-in, many community onespino.transport() worker threads
Child loggersYesYes (very cheap)

Rule of thumb: choose Pino for high-throughput services where logging cost matters, and Winston when you need rich in-process formatting or unusual transport combinations.

Log levels

Levels let you classify the importance of a message and filter at runtime. Both libraries default to standard severities. Winston uses npm levels (error, warn, info, http, verbose, debug, silly); Pino uses fatal, error, warn, info, debug, trace. In both, setting a level threshold suppresses everything less severe.

import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL ?? 'info',
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});

logger.info('server started', { port: 3000 });
logger.debug('this is hidden when level is info');
logger.error('db connection failed', { code: 'ECONNREFUSED' });

Output:

{"level":"info","message":"server started","port":3000}
{"level":"error","message":"db connection failed","code":"ECONNREFUSED"}

The debug line never appears because the threshold is info. Flip LOG_LEVEL=debug and it shows up — no code change required.

Structured JSON logging

Structured logs are objects, not strings, so log aggregators (Loki, Elasticsearch, Datadog) can index and query individual fields. The key practice is to pass metadata as a separate object rather than interpolating it into the message. Pino is JSON-first, which is why it pairs so well with log shipping pipelines.

import { pino } from 'pino';

const logger = pino({
  level: 'info',
  // rename fields and stamp ISO timestamps
  timestamp: pino.stdTimeFunctions.isoTime,
});

logger.info({ userId: 42, plan: 'pro' }, 'user logged in');

Output:

{"level":30,"time":"2026-06-14T10:22:01.503Z","userId":42,"plan":"pro","msg":"user logged in"}

Pino encodes levels numerically (info is 30) for speed; the included pino-pretty tool decodes them for human reading during development. Note the first argument is the metadata object and the second is the message — a common source of confusion when coming from console.log.

Transports and destinations

A transport decides where logs go. Winston attaches transports directly to the logger, and a single logger can fan out to several at once.

import winston from 'winston';

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json(),
  ),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

logger.warn('disk usage high', { percent: 92 });

Here info and above land in combined.log and the console, while only error lines reach error.log.

Pino keeps the main thread fast by running transports in a worker thread via pino.transport(). This pattern moves pretty-printing or file rotation off the hot path.

import { pino } from 'pino';

const transport = pino.transport({
  targets: [
    { target: 'pino/file', options: { destination: './app.log' }, level: 'info' },
    { target: 'pino-pretty', options: { colorize: true }, level: 'debug' },
  ],
});

const logger = pino({ level: 'debug' }, transport);
logger.info('using async worker-thread transports');

In production, leave Pino writing raw JSON to stdout and let your platform (Docker, Kubernetes, systemd) collect it. Avoid running pino-pretty in production — it adds overhead and breaks machine parsing.

Child loggers with context

Child loggers inherit their parent’s configuration and add fixed metadata to every line they emit. This is the cleanest way to attach a request ID, user ID, or module name to all logs from a given scope, without repeating yourself on each call.

import { pino } from 'pino';
import { randomUUID } from 'node:crypto';

const logger = pino();

function handleRequest(req) {
  const reqLog = logger.child({ requestId: randomUUID(), path: req.path });

  reqLog.info('request received');
  // ... do work ...
  reqLog.info({ status: 200 }, 'request completed');
}

handleRequest({ path: '/checkout' });

Output:

{"level":30,"requestId":"8f3c...","path":"/checkout","msg":"request received"}
{"level":30,"requestId":"8f3c...","path":"/checkout","status":200,"msg":"request completed"}

Every line carries the same requestId, so you can trace one request end-to-end. Winston exposes the same idea through logger.child({ ... }). If you use CommonJS, the imports become const winston = require('winston') and const { pino } = require('pino') — the APIs are otherwise identical.

Best practices

  • Log structured objects, not concatenated strings, so fields stay queryable in your aggregator.
  • Make the level configurable via an environment variable (LOG_LEVEL) instead of hardcoding it.
  • Use child loggers to attach request/correlation IDs and propagate them through your handlers.
  • Never log secrets, tokens, or full request bodies; use Pino’s redact or a Winston format to strip sensitive fields.
  • In production, write JSON to stdout and let the platform collect it — reserve pretty-printing for local development.
  • Prefer Pino for high-throughput APIs; reach for Winston when you need many in-process formats or unusual transports.
  • Always handle the logger’s error events and flush logs on shutdown so nothing is lost.
Last updated June 14, 2026
Was this helpful?