mrkeyoor.com_
Wed 05 Aug 05:05 UTC
npmUtilsupdated 05 Aug 2026

pino

Pino is a very low overhead JSON logger for Node.js. It writes newline-delimited JSON to stdout and deliberately does as little as possible in your process: pretty printing, shipping to log services and other processing are pushed into separate worker threads or processes called transports. The project's benchmarks show it over 5x faster than alternatives in many cases, which is the whole reason it exists. It is the default logger inside Fastify.

Verdict

The right default for Node services that log JSON to a collector: fastest mainstream option, actively maintained, and the transport model keeps log processing off your event loop. If you want batteries-included formatting and file rotation in one config, winston will annoy you less.

API stability4/5Core API (logger, child, levels, redact) has been steady across majors; majors land every one to two years (v8 2022, v9 2024, v10 2025) and mostly drop old Node versions, but that still forces upgrades.
Docs4/5Thorough markdown docs for API, transports, redaction, browser and async logging, plus a documented LTS policy, but the material is split across many files and the transport model takes a couple of reads.
Maintenance5/5Pushed August 2026, 159 open issues on an 18k-star project, active team including Matteo Collina, and an open-governance contributor model with a written LTS policy.
Ecosystem5/5Default logger in Fastify, documented integrations for Express, Hapi, Koa, Nest and Hono, pino-pretty and pino-http companions, and a long transports list in the ecosystem docs.

Use it if

  • You run high-throughput HTTP services where logger overhead actually shows up in latency and requests per second
  • Your logs go to a collector (Datadog, Loki, Elasticsearch) that wants structured JSON, not formatted strings
  • You use Fastify, where pino is already the built-in logger, or Express/Koa via pino-http
  • You want child loggers to stamp requestId or tenant context onto every line cheaply
Skip it if

Setup reality

npm install pino and one line gives you a working JSON logger. The friction is everything around it: pino-pretty is a separate install for dev output, transports run in worker threads so their options must be serializable (no functions, no closures), logs buffered in a transport can be lost on a hard crash unless you set up sync destinations for fatal paths, and levels are numbers in the output (30 = info) which trips up log queries until you map them. Express integration means adding pino-http too.

Patterns

Create a logger and log structured databasic-logger

import pino from 'pino';

const logger = pino();
logger.info({ userId: 42 }, 'user logged in');

Merge object first, message second; the arguments are reversed from what console.log habits expect.

Stamp context onto every log linechild-logger

const child = logger.child({ requestId: req.id });
child.info('processing');
// {"level":30,...,"requestId":"req-1","msg":"processing"}

Children are cheap by design; create one per request instead of passing metadata to every call.

Readable logs in developmentpretty-dev-logs

npm i -D pino-pretty

const logger = pino(
  process.env.NODE_ENV !== 'production'
    ? { transport: { target: 'pino-pretty', options: { colorize: true } } }
    : {}
);

pino-pretty is a separate package on purpose; do not ship it as the production output format, collectors want the raw JSON.

Control level from the environmentset-log-level

const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
logger.debug('hidden unless LOG_LEVEL=debug');

Output shows numeric levels (10 trace, 20 debug, 30 info, 40 warn, 50 error, 60 fatal); use formatters.level if your collector needs label strings.

Redact sensitive fields before they hit diskredact-secrets

const logger = pino({
  redact: {
    paths: ['req.headers.authorization', '*.password', 'user.email'],
    censor: '[redacted]',
  },
});

Redaction paths match the merged log object, not your variable names; wildcards cover one level, not deep nesting.

Log an Error with stack tracelog-errors

try {
  await task();
} catch (err) {
  logger.error({ err }, 'task failed');
}

Use the key err exactly; the built-in error serializer only kicks in for that key and expands message, stack and type.

Request logging in Expresshttp-logging

import express from 'express';
import pinoHttp from 'pino-http';

const app = express();
app.use(pinoHttp());
app.get('/', (req, res) => {
  req.log.info('inside handler');
  res.send('ok');
});

pino-http is a separate install; it attaches req.log with request context and logs a completion line per response.

Write logs to a file off the event loopfile-transport

const logger = pino({
  transport: {
    target: 'pino/file',
    options: { destination: '/var/log/app.log', mkdir: true },
  },
});

Transports run in a worker thread, so options must survive structured clone: plain JSON only, no functions.

Send different levels to different targetsmultiple-transports

const logger = pino({
  transport: {
    targets: [
      { target: 'pino/file', options: { destination: '/var/log/app.log' }, level: 'info' },
      { target: 'pino/file', options: { destination: '/var/log/error.log' }, level: 'error' },
    ],
  },
});

Each target gets its own level floor; a fatal crash can still lose buffered lines, so keep fatal paths synchronous where it matters.

Trim noisy objects before loggingcustom-serializers

const logger = pino({
  serializers: {
    user: (u) => ({ id: u.id, plan: u.plan }),
  },
});
logger.info({ user }, 'checkout started');

Serializers run only for their exact top-level key; anything else is logged as-is, including 10KB ORM entities if you let them through.

Alternatives

PackageRegistryPick it when
winstonnpmYou want flexible in-process transports and formatting at the cost of speed
consolanpmYou are building a CLI and want pretty, human-first output rather than JSON throughput
debugnpmYou just want namespaced dev tracing toggled by an env var, not production logging