NodeJSPino

Pino

Pino is a logger built around one obsession: speed. It is several times faster than Winston because of two design choices — it emits JSON by default (no string formatting in the hot path) and it does the expensive work (pretty-printing, shipping to other services) in a separate worker thread or process, so your application thread is barely interrupted. For high-throughput Node services, Pino is the de-facto standard. This page covers the basics, child loggers, redaction, transports, the pino-http middleware, and the crucial "log fast, transport elsewhere" philosophy.

Setup and basics

Bash
npm install pino

logger.js

JS
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Rename default keys to match your log platform's conventions:
  formatters: {
    level: (label) => ({ level: label }),   // log level as text, not number
  },
})

// Usage — message-last OR object-first, both work:
logger.info('server started')
logger.info({ port: 3000, pid: process.pid }, 'server started')
logger.error({ err }, 'payment failed')
{"level":"info","time":1750497601004,"pid":4821,"port":3000,"msg":"server started"}
Pino's API is object-first: `logger.info({ ...fields }, 'message')`
The Pino calling convention is `logger.LEVEL(mergeObject, message)` — pass a **structured object first** with your fields, then the human-readable message string. The object's keys become top-level JSON fields you can query. Passing an `Error` as `{ err }` is special-cased: Pino serializes its message, stack, and code properly (where a naive `JSON.stringify(error)` would produce `{}`). By default Pino logs the numeric level (`30` = info); the `formatters.level` shown above converts it to the text label most log platforms expect.
Why Pino is fast — async transports
Pino does minimal work in your app thread and offloads formatting to a worker
Pino's performance comes from keeping the *logging call* cheap: it serializes to a JSON string and writes it to a stream, nothing more. All the expensive work — pretty-printing for humans, filtering, shipping to Elasticsearch or a file with rotation — happens in a **separate worker thread** via `pino.transport()`, off your application's event loop. This means heavy logging barely affects request latency, which is exactly what you want in a high-traffic API. Winston, by contrast, does formatting and transport work inline, which is more flexible but slower. If logging volume or latency is a concern, Pino wins clearly.
Pretty-printing in development

Bash
npm install --save-dev pino-pretty

JS
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Use pino-pretty ONLY in development — keep raw JSON in production:
  transport:
    process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
      : undefined,
})
# With pino-pretty (development):
[09:25:02] INFO: server started
    port: 3000
    pid: 4821
Never run `pino-pretty` in production — it's slow and defeats structured logging
`pino-pretty` transforms the JSON stream into colourful, human-readable lines — perfect for a developer's terminal. But in production it's the wrong choice twice over: it adds formatting overhead (the very cost Pino exists to avoid), and it converts machine-parseable JSON back into free text your log platform can't index. Pipe to `pino-pretty` only locally — in production, emit raw JSON to stdout and let your aggregator parse it. A common pattern is `node app.js | pino-pretty` in a dev script while production runs `node app.js` straight.
Redacting sensitive fields

JS
const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'body.password',
      'body.creditCard',
      '*.ssn',                  // any ssn at any top-level object
    ],
    censor: '[REDACTED]',       // or remove: true to delete the key entirely
  },
})

logger.info({ body: { email: 'a@x.com', password: 'hunter2' } }, 'login')
{"level":"info","time":...,"body":{"email":"a@x.com","password":"[REDACTED]"},"msg":"login"}
Built-in redaction is a key Pino feature — configure it once and forget the risk
Pino's `redact` option uses fast path-based matching to mask or remove sensitive keys *before* they're written, with negligible performance cost. Configuring it once at logger creation means you can safely log request objects without manually stripping passwords and tokens at every call site — eliminating an entire class of [secrets-in-logs](/nodejs/secrets-management) leaks. Use `censor` to mask the value (keeping the key visible for debugging) or `remove: true` to drop it entirely. Wildcards like `*.ssn` match across object shapes.
HTTP request logging with pino-http

Bash
npm install pino-http

JS
import pinoHttp from 'pino-http'
import { logger } from './logger.js'

const app = express()
app.use(pinoHttp({ logger }))

app.get('/orders/:id', (req, res) => {
  // req.log is a child logger with a per-request id already attached:
  req.log.info({ orderId: req.params.id }, 'fetching order')
  res.json({ id: req.params.id })
})
{"level":"info","time":...,"req":{"id":1,"method":"GET","url":"/orders/9981"},
 "res":{"statusCode":200},"responseTime":12,"msg":"request completed"}
`pino-http` auto-logs every request with a generated id and response time
`pino-http` is Express/Node middleware that logs each request automatically — method, URL, status code, and response time — and attaches a **child logger** at `req.log` carrying a unique request id. Use `req.log` inside your handlers so all of a request's log lines share that id and can be correlated end-to-end. This overlaps with [Morgan](/nodejs/morgan), but `pino-http` produces structured JSON and integrates with your app logger, whereas Morgan emits formatted access-log strings; in a Pino-based stack, `pino-http` is the natural choice.
Child loggers

JS
// Bind context that applies to a group of related logs:
const orderLog = logger.child({ module: 'orders', orderId: 9981 })
orderLog.info('processing started')   // includes module + orderId
orderLog.error({ err }, 'processing failed')
Pino vs Winston — choosing

Consideration

Pino

Winston

Performance

Fastest — async, JSON-first

Slower — inline formatting

Default output

Structured JSON

Configurable (often needs setup)

Transports

Worker-thread based, fewer built-in

Many built-in, very flexible routing

Ecosystem

pino-http, pino-pretty, transports

Huge transport ecosystem

Best for

High-throughput APIs, performance-sensitive

Complex multi-destination routing

Next
Concise HTTP access logs the classic way: [HTTP Logging with Morgan](/nodejs/morgan).