Pino Logger - Node.js Logging Guide with Examples (2026)

Updated May 13, 202619 min read

Logging is the process of recording timestamped, immutable records of discrete events within a system, enabling developers to diagnose failures, audit behaviour, and maintain operational visibility in production environments. Node.js ships with the built-in console a module, which provides methods like console.log(), console.error(), console.warn(), and console.info().

However, these methods fall short in several practical ways such as, there are no log levels with filterable severity, no structured JSON output, no log rotation or file management, and no way to route logs to external destinations. As a result, the developer ecosystem relies heavily on Node.js logging libraries such as Pino logger, Winston logger, and Bunyan logger.

In this article we will explore Pino Logger in depth. We will start with basic setup and work our way through logging levels, child loggers, transports, to error handling in your application.

What is Pino Logger?

Pino logger is a fast, low-overhead Node.js logging library, built around a simple principle that, "Logging should never slow down an application". Using Pino loggers transport module, you can move log processing to a separate worker thread, keeping the main thread’s CPU usage low even during traffic spikes. Pino ships with following core features out-of-the-box:

  1. Structured logging - Log records in machine readable JSON format.
  2. Logging levels - Use logging levels to set verbosity of logs.
  3. Child loggers - Attach permanent context to your logs without repeating metadata in every log call.
  4. Custom serializers - Use it to customize specific objects (like requests, responses, or errors) into cleaner, secured, and structured format before they are logged.
  5. Transports - Use them to lower the load on main thread (asynchronous logging).

These features are discussed in dept with examples in upcoming sections. Before we move on to them, let's see Pino logger in action.

Demo - How to Set Up and use Pino Logger?

Prerequisites

Make sure you have the latest stable version of Node.js installed. You can verify your version by running node -v in your terminal.

Install and Set Up Pino

Step 1: Initialize a new Node.js project in a folder

mkdir pino-demo && cd pino-demo
npm init -y

Step 2: Install Pino logger using npm command

npm install pino --save

Step 3: Create a index.js file and set up a Pino logger instance to log messages

index.js
const pino = require('pino');
const logger = pino();

logger.info('Pino logger is running');

This code initializes Pino and logs an info message. By default, the logs are output to the console in JSON format.

Step 4: Run and veiw putput

Output
{"level":30,"time":1711440000000,"pid":12345,"hostname":"your-machine","msg":"Pino logger is running"}

This JSON-first approach is intentional. This type of structured logs are easy to parse, filter, and ingest into log analysis tools. Following is the breakdown of above Pino JSON log:

The structure of a Pino JSON log entry with labeled fields: level (numeric log severity), time (Unix timestamp in milliseconds), pid (process ID of the Node.js program), hostname (machine where the program is running), and msg (log message content).
Breakdown of a default Pino log entry.

Pretty Printing Pino Logs (pino-pretty)

When working in development or debugging an incident, you can use the pino-pretty to format the logs in a more readable way. Following are the steps to use it.

Step 1: Install pino-pretty

npm install pino-pretty --save-dev

Step 2: Pipe output through pino-pretty

node index.js | npx pino-pretty
Output
[10:30:00.000] INFO: Pino logger is running

For the full list of configuration options, including custom colour themes, message formatting, and timestamp handling, check out the official pino-pretty documentation.

Logging HTTP Requests

Pino Logger can be integrated with HTTP servers to automatically log incoming requests and outgoing responses. This is particularly useful for monitoring, debugging, and analyzing the performance and behaviour of web applications.

Using pino-http Middleware

You can log HTTP requests and responses using thepino-http module, which integrates Pino with HTTP servers like Express. This middleware logs details about each request and response, including HTTP method, URL, status code, and response time. Follow below steps to implement it.

Step 1: Install the pino-http module

npm install pino-http --save

Step 2: Integrate pino-http into your application

Here’s an example using Express:

app.js
const express = require('express')
const pino = require('pino')
const pinoHttp = require('pino-http')

const logger = pino()
const httpLogger = pinoHttp({ logger })

const app = express()

// Use Pino HTTP middleware to log all I/O HTTP requests automatically
app.use(httpLogger)

app.get('/', (req, res) => {
  res.send('Hello, world!')
})

app.listen(3000, () => {
  logger.info('Server is running on port 3000')
})

Step 3: Run application and generate the logs

node app.js
for i in {1..10}; do curl http://localhost:3000/; done
Output
{"level":30,"time":1774947711963,"pid":12345,"hostname":"your-machine","msg":"Server is running on port 3000"}

{"level":30,"time":1774947742994,"pid":12345,"hostname":"your-machine","req":{"id":1,"method":"GET","url":"/","query":{},"params":{},"headers":{"host":"localhost:3000","connection":"keep-alive",....},"remoteAddress":"::1","remotePort":63275},"res":{"statusCode":200,"headers":{"x-powered-by":"Express",...}},"responseTime":6,"msg":"request completed"}

Configuring Pino Logger for Production

Now that you have the basic Pino logger setup working, let's confirgure it for production.

Pino Log Levels

Log levels control how much detail your application writes to its logs. Each log message has a severity, and Pino only writes messages at the configured level or higher. For example, in production, you might set the level to info or warn so debug messages do not clutter your logs. During development, you can use debug or trace to see more detail while you test and troubleshoot.

Pino logger supports six log levels in order of decreasing severity. Each level has a corresponding numeric value: fatal is 60, trace is 10. By default, Pino logs at info (30), which means debug and trace messages are silently ignored unless you explicitly lower the threshold.

LevelNumeric ValueUse Case
trace10Fine-grained debugging (variable values, loop iterations)
debug20Diagnostic information for development
info30Normal operational messages (server started, request handled)
warn40Unexpected situations that are not errors
error50Errors that need attention but don't crash the process
fatal60Unrecoverable errors that will crash the process
logLevels.js
const pino = require('pino');

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

logger.fatal('Application crashed — unrecoverable');
logger.error('Failed to connect to database');
logger.warn('Deprecated API endpoint hit');
logger.info('Server started on port 3000');
logger.debug('Request payload: %o', { userId: 42 });
logger.trace('Entering function parseToken()');

Output:

Pino log output after changing LOG_LEVEL

Using process.env.LOG_LEVEL lets you change verbosity at deploy time without making any changes to code. Set it to debug in staging, warn in production.

Transports

A transport is a separate worker thread that receives log data from the main thread and processes it independently, and ensure that log routing never blocks application. You can use Pino Transports for formatting logs, writting them to a file, forwarding them to log management tools, or routing them to multiple destinations.

You configure transports using pino.transport(), passing either a single target or multiple targets. Each target is an npm package (or an absolute path) that defines where and how logs are delivered.

transport.js
const pino = require('pino');

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

logger.info('This goes to both the file and the console');

Once you run the transport.js file, you will see a log in the console, and a new file named app.log will be created with the log.

Child Loggers

In applications with multiple modules or concurrent requests, logs need context. Adding details such as the request, module, or user associated with a log makes it easier to debug issues and analyze incidents.

Pino child loggers help you add this context without repeating it in every log statement. A child logger inherits the parent logger’s configuration and automatically includes the fields you define in each log entry.

You create a child logger with logger.child() by passing an object of key-value pairs.

childLogger.js
const pino = require('pino');
const logger = pino();

// Create a child logger with module context
const authLogger = logger.child({ module: 'auth' });

authLogger.info('User login attempted');
// {"level":30,"time":...,"module":"auth","msg":"User login attempted"}

// Create a request-scoped child logger
function handleRequest(req) {
  const reqLogger = logger.child({ requestId: req.id, userId: req.userId });

  reqLogger.info('Processing request');
  // {"level":30,"time":...,"requestId":"abc-123","userId":42,"msg":"Processing request"}
}
Output
{"level":30,"time":1774874841855,"pid":55188,"hostname":"your-machine","module":"auth","msg":"User login attempted"}
{"level":30,"time":1774874841856,"pid":55188,"hostname":"your-machine","requestId":"abc-123","userId":42,"msg":"Processing request"}

This pattern is especially useful in Express or Fastify middleware. You can create a child logger for each request, add a unique requestId, and attach the logger to the request object.

From there, any log written while handling that request will include the same requestId. If an issue occurs in production, you can filter logs by that ID and trace the full path of the request across your application.

Built-In and Custom Serializers

Serializers help you define how specific fields are transformed before they are written to the log output. They are passed as functions under the serializers option, where each key maps to a field name in the logged object.

...
  serializers: {
    // Functions
  },
...

They are used when you want to log objects such as HTTP requests, responses, or errors without writing the entire raw object to your logs. They let you control which fields are included in the log output, so the logs stay readable and only contain the details you need.

Pino logger provides ready-made serializer functions for three common objects:

  1. pino.stdSerializers.req takes the raw incoming message object and returns only { method, url, headers, remoteAddress, remotePort }.
  2. pino.stdSerializers.res takes the raw ServerResponse and returns only { statusCode, headers }.
  3. pino.stdSerializers.err takes an Error object and returns { type, message, stack }.
builtInSerializer.js
const http = require('http');
const pino = require('pino');

const logger = pino({
  serializers: {
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
    err: pino.stdSerializers.err,
  },
});

const server = http.createServer((req, res) => {
  // Log the request using built-in serializer
  logger.info({ req }, 'Incoming request');

  res.statusCode = 200;
  res.end('Hello World');

  // Log the response using built-in serializer
  logger.info({ res }, 'Request completed');
});

server.listen(3000, () => {
  logger.info('Server running on port 3000');
});

Run the file builtInSerializer.js, then hit http://localhost:3000/ in your browser.

Output
{"level":30,"time":1711440000000,"pid":12345,"hostname":"your-machine","msg":"Server running on port 3000"}
{"level":30,"time":1711440001000,"pid":12345,"hostname":"your-machine","req":{"method":"GET","url":"/","headers":{"host":"localhost:3000",...},"remoteAddress":"::1","remotePort":54321},"msg":"Incoming request"}
{"level":30,"time":1711440001001,"pid":12345,"hostname":"your-machine","res":{"statusCode":200,"headers":{}},"msg":"Request completed"}

Notice that the req field only includes method, url, headers, remoteAddress, and remotePort. Without a serializer, logging the raw req object would include hundreds of internal Node.js properties. You can test this by removing req: pino.stdSerializers.req from the config and running the example again. The difference should be easy to spot.

The built-in serializer removes most of the internal Node.js fields, but it still logs all request headers. That includes headers such as cookie, which may contain session tokens, tracking IDs, or other sensitive data that should not end up in your logs.

This is where a custom serializer helps. It works the same way as the built-in serializer, but you decide which fields to keep. For example, the custom req serializer below logs only the request method, URL, remote address, and selected headers such as host.

customSerializer.js
const http = require('http');
const pino = require('pino');

const logger = pino({
  serializers: {
    req: (req) => ({
      method: req.method,
      url: req.url,
      remoteAddress: req.remoteAddress,
      // only pick the headers you actually need
      headers: {
        host: req.headers.host,
      },
    }),
  },
});

const server = http.createServer((req, res) => {
  // Log the request using built-in serializer
  logger.info({ req }, 'Incoming request');

  res.statusCode = 200;
  res.end('Hello World');

});

server.listen(3000, () => {
  logger.info('Server running on port 3000');
});
Output
{"level":30,"time":1774939188325,"pid":12345,"hostname":"your-machine","msg":"Server running on port 3000"}
{"level":30,"time":1774939190368,"pid":12345,"hostname":"your-machine","req":{"method":"GET","url":"/","headers":{"host":"localhost:3000"}},"msg":"Incoming request"}
{"level":30,"time":1774939190402,"pid":12345,"hostname":"your-machine","req":{"method":"GET","url":"/favicon.ico","headers":{"host":"localhost:3000"}},"msg":"Incoming request"}

Redaction

Redaction is a built-in security feature in Pino. It lets you hide sensitive fields such as passwords, tokens, credit card numbers, and API keys before they are written to your logs. Unlike serializers, which control how specific objects are logged, redaction works at the logger level. You provide a list of field paths, and Pino replaces the matching values with [Redacted] in every log entry.

redact.js
const pino = require('pino');

const logger = pino({
  redact: ['password', 'creditCard', 'headers.cookie', 'headers.authorization'],
});

logger.info({
  username: 'Olly',
  password: 'super-secret',
  creditCard: '4111-1111-1111-1111',
}, 'User signup');
// {"level":30,"time":...,"username":"olly","password":"[Redacted]","creditCard":"[Redacted]","msg":"User signup"}
Output
{"level":30,"time":1774940121873,"pid":12345,"hostname":"your-machine","username":"Olly","password":"[Redacted]","creditCard":"[Redacted]","msg":"User signup"}

You can also change the placeholder value or remove the field entirely instead of showing [Redacted]:

customRedacted.js
const pino = require('pino');

const logger = pino({
  redact: {
    paths: ['password', 'creditCard'],
    censor: '**',       // replaces with '**' instead of '[Redacted]'
    // remove: true,         // uncomment to remove the field entirely from the output
  },
});

logger.info({
  username: 'Olly',
  password: 'super-secret',
  creditCard: '4111-1111-1111-1111',
}, 'User signup');
Output
{"level":30,"time":1774940321495,"pid":12345,"hostname":"your-machine","username":"Olly","password":"**","creditCard":"**","msg":"User signup"}

The difference between serializers and redaction lies in scope: serializers transform a specific field's shape, while redaction blanks out sensitive values wherever they appear. In practice, you'll often use both together.

Asynchronous Logging

Asynchronous logging reduces the time your application spends writing logs. Instead of writing each log line to the destination immediately, Pino can buffer log messages in memory and flush them in larger chunks. This is useful when writing logs to a file, especially in applications that produce a high volume of logs. Fewer write operations means less work on the main thread.

You can enable asynchronous writes by using pino.destination() with sync: false:

asyncLogging.js
const pino = require('pino');

const logger = pino(
  pino.destination({
    dest: './app.log',
    minLength: 4096,  // buffer 4KB before writing
    sync: false,
  })
);

logger.info('Server started');
logger.info({ userId: 42 }, 'User logged in');
logger.error('Something went wrong');

// periodically flush the buffer during low-traffic periods
setInterval(() => {
  logger.flush();
}, 10000).unref();

With this setup, Pino buffers log messages and writes them in batches once the buffer reaches the configured size. This reduces the number of file write operations. The trade-off is that buffered logs may be lost if the process crashes before they are flushed. Pino flushes logs during normal shutdown, but a hard crash or forced termination can still drop messages that are still in memory.

For log processing that needs to run outside the main thread, such as formatting, sending logs to another service, or using multiple outputs, use pino.transport() instead. That is covered in the transports section.

Integrating Pino with Fastify

Fastify has built-in Pino integration and does not require a separate dedicated Pino integration package. However, it’s disabled by default, and you need to enable it when creating a Fastify instance.

Step 1: Install Fastify using npm

npm install fastify --save

Step 2: Enable pino logger and create a basic server

fastifyApp.js
const fastify = require('fastify')({
  logger: true,
});

fastify.get('/', async (request, reply) => {
  request.log.info('Handling root route');
  return { hello: 'world' };
});

fastify.listen({ port: 3000 }, (err) => {
  if (err) {
    fastify.log.error(err);
    process.exit(1);
  }
});

Passing { logger: true } enables Pino with its default configuration, with log level set to info, JSON output to stdout, and standard serializers for req, res, and err objects.

Fastify automatically logs every incoming request and outgoing response, and attaches a request-scoped child logger to request.log with a unique reqId for tracing. fastify.log is the server-level logger available outside of request context. In the above code, we use it to log startup errors before the server is ready to handle requests.

Step 3: Run and check http://localhost:3000/

node fastifyApp.js
Output
{"level":30,"time":1774948769144,"pid":12345,"hostname":"your-machine","msg":"Server listening at http://127.0.0.1:3000"}
{"level":30,"time":1774948773716,"pid":12345,"hostname":"your-machine","reqId":"req-1","req":{"method":"GET","url":"/","host":"localhost:3000","remoteAddress":"::1","remotePort":63608},"msg":"incoming request"}
{"level":30,"time":1774948773717,"pid":12345,"hostname":"your-machine","reqId":"req-1","msg":"Handling root route"}
{"level":30,"time":1774948773721,"pid":12345,"hostname":"your-machine","reqId":"req-1","res":{"statusCode":200},"responseTime":4.095916986465454,"msg":"request completed"}

Centralizing and Monitoring Logs in an Observability Backend

Everything we have configured so far writes logs to stdout or a local file. That works on a single server, but production applications typically run across multiple containers or instances. When something breaks, you need to check logs from the right container, on the right server, at the right time. If that container has already been killed and restarted, those logs are gone.

Centralizing your logs solves this. Instead of writing only to local destinations, you configure an exporter that sends logs to a backend over the network in real time. All your logs end up in one place, searchable across every service, and they persist regardless of what happens to individual containers.

For this guide, we will use SigNoz as our observability backend and OpenTelemetry as the export layer. OpenTelemetry is the open-source standard for collecting telemetry data, and SigNoz is an OpenTelemetry-native observability platform that lets you search, filter, and correlate your logs with traces and metrics in a single interface.

The setup uses OpenTelemetry auto-instrumentation, which automatically captures Pino logs along with trace correlation and HTTP request data. You don't need to change your logging code. Your existing logger.info(), logger.error() calls continue to work as-is.

Install the required packages:

npm install --save @opentelemetry/api @opentelemetry/auto-instrumentations-node

Set the environment variables pointing to your SigNoz instance and run your application:

export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.<region>.signoz.cloud:443"
export OTEL_NODE_RESOURCE_DETECTORS="env,host,os"
export OTEL_SERVICE_NAME="<service_name>"
export OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=<your-ingestion-key>"
export NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register"
node index.js

After the app starts, Pino logs should appear in SigNoz.

SigNoz Logs Explorer showing filtered log entries for the consumer-svc-2 service, with severity levels, deployment environment, and service name filters visible in the left sidebar, and timestamped Kafka consumer log lines in the main panel.
Viewing Node.js application logs in SigNoz Logs Explorer

The complete walkthrough, including deployment options for VMs, Docker, Kubernetes, and Windows, along with the advanced code-level instrumentation approach, is available in the official SigNoz guide:

Send logs from Node.js Pino logger to SigNoz using OpenTelemetry.

Get Started with SigNoz

You can choose between various deployment options in SigNoz. The easiest way to get started with SigNoz is SigNoz Cloud. We offer a 30-day free trial account with access to all features.

Those with data privacy concerns who can't send their data outside their infrastructure can sign up for either the enterprise self-hosted or BYOC offering.

Those who have the expertise to manage SigNoz themselves, or who just want to start with a free self-hosted option, can use our community edition.

Conclusion

In Pino's official benchmarks, 10,000 basic log operations complete in ~115ms, compared to ~270ms for Winston and ~377ms for Bunyan. Combined with structured JSON output, worker-thread transports, and child loggers for request context, Pino is a strong default for production Node.js services.

To get the most out of Pino, pair it with an observability platform. Using OpenTelemetry auto-instrumentation you can send structured logs directly to SigNoz Cloud, where they become searchable and correlated with traces and metrics, so logs can be queried alongside related traces and metrics.

FAQs

What is Pino in Node.js?

Pino is a high-performance, low-overhead logging library for Node.js that outputs structured JSON logs. It is designed to minimize the impact of logging on application performance by deferring heavy processing like log formatting and transport to worker threads. Pino supports six log levels (trace, debug, info, warn, error, fatal), child loggers to add contextual metadata, and a transport system to route logs to files, remote services, or observability platforms. It is roughly 2.4x faster than Winston in benchmarks, making it one of the most popular choices for production Node.js applications.

Which is better, Pino or Winston?

Pino is better suited for performance-sensitive applications. According to Pino's official benchmarks, it completes 10,000 basic log operations in about 115ms, while Winston takes roughly 270ms for the same workload, making Pino about 2.4x faster for basic logging. Pino achieves this by outputting minimal JSON synchronously and offloading all formatting and transport work to separate worker threads. The Pino README claims it is "over 2.4x faster than alternatives" in many real-world cases, particularly when accounting for HTTP request throughput.

Winston offers more built-in flexibility for formatting and in-process transports, and its API may feel more familiar if you're coming from other logging ecosystems. It has a larger plugin ecosystem for direct integrations, such as logging to databases or cloud services.

For most production Node.js services handling significant traffic, Pino is usually the better fit because logging overhead can affect response latency under load. You can pair Pino with pino-pretty during development to get a readable output without sacrificing production performance.

Library10K Basic Logs10K Object LogsRelative Speed
Pino~115ms~119msBaseline (fastest)
Winston~270ms~273ms~2.4x slower
Bunyan~377ms~410ms~3.3x slower

What does Pino do?

Pino is a Node.js logging library that generates structured JSON log output with minimal performance overhead. When you call a Pino log method like logger.info(), it serializes your log data into a single JSON line containing the log level, a high-resolution timestamp, the process ID, hostname, and your message or data object. This JSON output can then be piped to files, forwarded to log aggregation services, or sent to observability platforms like SigNoz via OpenTelemetry transports. Pino's core design principle is that log processing should happen outside the main application thread.

What are the benefits of Pino logger?

The benefits of Pino logger are high throughput (10,000+ logs/second), low CPU and memory overhead, structured JSON output that is machine-parseable, and asynchronous transport support via worker threads. Pino also provides child loggers for attaching request-scoped context, built-in redaction for sensitive fields like passwords and tokens, and native integration with frameworks like Express, Fastify, and NestJS through companion packages like pino-http. Its structured JSON format makes it particularly well-suited for centralized log management with observability tools, where logs need to be queried, filtered, and correlated with traces and metrics.

Was this page helpful?

Your response helps us improve this page.

Tags
JavaScriptLogging