Blog · October 12, 2026

Node.js logging in production: pino vs winston, and what most guides miss

Which library to pick, how to set it up in five minutes, where your logs actually end up, how to follow one request end to end, and how to find out when something stops logging. With code you can copy.

1. The short answer: pino or winston

For a new project, pino. It writes JSON from the start, it's among the fastest, and today it's the most downloaded. If you already have winston and it works, keep it: configure it to write JSON with each error's stack trace and you're done.

pinowinston
Weekly npm downloads54 million28 million
FormatJSON, out of the boxWhatever you configure (JSON with format.json())
SpeedAmong the fastest: heavy work runs on another threadSlower: everything goes through its format chain
Sending elsewhereTransports on a separate threadMany transports, in the same process
HTTP requestspino-httpWith morgan or by hand
Readable in your terminalpino-pretty, in development onlyformat.prettyPrint() or colorize()
Current version10.43.19

Downloads from api.npmjs.org, week of October 5–11, 2026; versions from the npm registry, same day. Many guides still say winston is downloaded more: that's no longer true.

What about morgan? It doesn't compete with either: it only writes one line per HTTP request. If you use pino, pino-http does the same in the same format as the rest of your logs.

2. What about console.log?

In production, console.log has three problems: no levels (you can't turn off debug output without touching code), free-form text (hard to search), and no way to tell which request each line came from.

But if your app is already full of console.log, you don't have to rewrite it to get started. What matters is that those lines end up somewhere searchable. Write the new ones with pino.

3. Pino in five minutes

npm install pino
npm install --save-dev pino-pretty
// logger.js
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // readable in your terminal; plain JSON in production
  transport: process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty' },
  // what must never be written down
  redact: ['req.headers.authorization', '*.password', '*.card'],
});
import { logger } from './logger.js';

logger.info({ order: 'A-1042', amount: 640 }, 'order paid');
logger.warn({ order: 'A-1042' }, 'out of stock, ships tomorrow');

try {
  await ship(order);
} catch (err) {
  logger.error({ err, order: order.id }, 'could not ship');   // with its stack
}

// one logger per module, with its label on every line
const log = logger.child({ module: 'payments' });

Three rules that save you hours later:

4. If you already use winston

What people forget most: without format.errors({ stack: true }), errors arrive without a stack trace. Same if you wrap them in an object ({ error: err }).

import winston from 'winston';

export 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.Console()],
});

// the error as the second argument: that's how it keeps its stack
logger.error('could not ship', err);
// and data, through a child logger
logger.child({ order: 'A-1042' }).warn('out of stock');

5. Every Express request, with its id

npm install pino-http
import pinoHttp from 'pino-http';
import { randomUUID } from 'node:crypto';
import { logger } from './logger.js';

app.use(pinoHttp({
  logger,
  // keep the id your load balancer sends; otherwise, a new one
  genReqId: (req) => req.headers['x-request-id'] || randomUUID(),
}));

app.post('/orders/:id/pay', async (req, res) => {
  req.log.info({ order: req.params.id }, 'charging');   // already carries the request id
  // …
});

Each request writes one line when it finishes, with method, route, status code and duration, and everything you write through req.log carries the same id. When a customer says «it failed at 3:12», you search for that id and see everything that happened.

6. The id everywhere, without passing it around

req.log works in the route, but your charge function, three files down, has no req. Instead of passing it hand to hand, keep it in the request's context with AsyncLocalStorage, which ships with Node:

import { AsyncLocalStorage } from 'node:async_hooks';

const context = new AsyncLocalStorage();

// after pinoHttp
app.use((req, res, next) => context.run(req.log, next));

// anywhere in your code
export const log = () => context.getStore() ?? logger;

log().info({ order }, 'charge approved');   // with the id of the request that asked for it

And if the request crosses several services, the standard way to carry the same id from one to the next is the W3C traceparent header.

7. What stays out of your logs

With pino, redact removes them before writing (step 3 above). With winston, a custom format that deletes those fields.

8. Where are my logs?

Your app should write to standard output (stdout) and let whatever runs it store them. Where they end up depends on where it runs:

If it runs on…Your logs are in…
pm2pm2 logs, and files in ~/.pm2/logs/
Dockerdocker logs <container>; gone if you remove the container
systemdjournalctl -u your-service
Vercel, Railway, RenderTheir dashboard, for a limited time depending on your plan
AWS LambdaCloudWatch Logs

The problem with all of them: they live on one server or for a short time, and nobody is watching. They help you investigate something you already know happened, not find out that it happened.

9. Have something read them for you

The last missing piece, and the one almost no logging guide covers: finding out without opening them. Three things worth watching:

With Estiaje, your pino or winston logs arrive with one more line, without changing how you write them:

import { estiajeFromEnv } from '@estiaje/sdk';
const estiaje = estiajeFromEnv({ service: 'store' });

// pino: to your terminal and to Estiaje
const logger = pino(pino.multistream([
  { stream: process.stdout },
  { stream: estiaje.destino() },
]));

// winston: one more transport
logger.add(new winston.transports.Stream({ stream: estiaje.destino() }));

// still on console.log? those arrive too
estiaje.capturarConsola();

You search them by text and filter by service, level or any field. Errors arrive grouped with their stack, a service that goes quiet alerts your phone, and if you tell it which process must finish, it also alerts you about the one stuck halfway, with the money at risk. Logs are included from the Free plan.

Try Pro free for 14 days No card. Afterwards you stay on Free, with your logs and errors.

Frequently asked questions

Pino or winston?

For a new project, pino: JSON out of the box, among the fastest, and today downloaded twice as much as winston (54 vs 28 million a week on npm, October 2026). If you already use winston and it works, keep it and configure it to write JSON with each error's stack.

Is console.log bad in production?

It has no levels, writes free-form text and doesn't say which request each line came from. You don't need to rewrite your app: what matters is that those lines reach somewhere searchable, and that new ones go through a library.

Where are Node.js logs stored?

Node doesn't store them: it writes to standard output and whatever runs it stores them. With pm2, in ~/.pm2/logs/; with Docker, in docker logs; with systemd, in journalctl; on Vercel, Railway or Render, in their dashboard.

How do I use pino-pretty only in development?

Set transport: { target: 'pino-pretty' } only when NODE_ENV isn't production, and install it as a dev dependency. In production, pino writes plain JSON, which is what you want to store and search.

Downloads from api.npmjs.org and versions from the npm registry, checked October 12, 2026.

Keep reading