Raw Pino JSON is fast, but reading it in the terminal is torture.
If you’ve squinted at a wall of JSON at 2 AM, you know the pain.
A pino log formatter translates Pino’s structured JSON into readable terminal output — color, human timestamps, and reordered fields — without changing what your app actually logs.
In this post we’ll show pino-pretty for quick setup, custom serializers for safe redaction, and transports for production-grade formatting.
You’ll get step-by-step commands, common gotchas like non-serializable objects, and clear rules for keeping pretty print out of production.
Understanding Pino’s Default JSON Logs and How a Pino Log Formatter Works

Pino writes structured JSON logs because it’s fast and machines can parse it easily. Each log includes fields like level, timestamp, message, and whatever extra data you toss in. This keeps Pino crazy fast (one of the quickest Node.js loggers out there) and makes your logs ready for tools like Elasticsearch, Grafana Loki, or anything expecting structured data. Default JSON format cares about speed and machine readability, not you.
But raw JSON? It’s brutal during local debugging. A wall of {"level":30,"time":1679184000000,"pid":1234,"msg":"User logged in"} isn’t exactly friendly when you’re hunting bugs at 2 AM. That’s where a pino log formatter steps in. Formatters turn Pino’s JSON into something you can actually read. Color, rearranged fields, timestamps that look like timestamps instead of epoch milliseconds. They’re a translation layer between Pino’s structured output and your terminal.
A pino log formatter can tweak timestamps, change how levels display, reorder fields, add color. Some formatters (like pino-pretty) run as separate processes, reading Pino’s JSON from stdin and spitting formatted text to stdout. Others work inside your app. Goal’s the same: keep Pino’s speed and structure while making output bearable during development. Formatters don’t touch what Pino logs, just how you see it.
Pino’s default JSON includes these pieces:
- level – number showing severity (30 for info, 50 for error)
- time – Unix epoch timestamp in milliseconds
- pid – process ID of the Node.js process writing the log
- msg – your main message string
- additional keys – structured data you pass (userId, error stack, request ID, whatever)
Using pino-pretty as a Pino Log Formatter for Human‑Readable Output

pino-pretty is the go-to pino log formatter for development. Converts JSON logs into colorized, readable output that’s easier to scan in your terminal. Install it globally with npm install -g pino-pretty or add it as a dev dependency: npm install --save-dev pino-pretty. Global install lets you pipe any Pino app’s output through it without touching your code. Project install keeps it locked to your dependencies and makes sure everyone on the team uses the same version.
Easiest way to use pino-pretty? CLI piping. Run your app and pipe output through pino-pretty: node app.js | pino-pretty. This leaves production code alone and formats logs only when you need them. You can configure pino-pretty programmatically inside your app by setting it as a transport in Pino’s config, but that can add overhead in production if you forget to strip it out. For most devs, piping’s safer and keeps formatting separate from app logic.
pino-pretty gives you CLI flags to control formatting. --colorize enables or disables color (handy in CI environments that don’t support ANSI colors). --levelFirst puts log level before the message, easier to scan by severity. --translateTime converts Unix timestamps into readable formats like 2025-03-19 14:32:10 instead of 1679234530000. Color handling uses chalk or colorette under the hood, and pino-pretty detects terminal color support with the supports-color package. Working in CI or a terminal without ANSI? Skip --colorize or set it to false.
Here’s how to configure pino-pretty:
- Install as dev dependency:
npm install --save-dev pino-pretty - Pipe your app’s output in your dev script:
"dev": "node app.js | pino-pretty" - Add CLI flags for custom output:
node app.js | pino-pretty --colorize --levelFirst --translateTime - For programmatic use, configure Pino with a pretty transport, but only enable it in development by checking
process.env.NODE_ENV !== 'production'
Keep pino-pretty strictly in development. Pretty-printing adds CPU and memory overhead because it parses JSON and applies formatting logic. In production, always log raw JSON. Aggregators expect structured data, and pino-pretty’s formatted output breaks parsing pipelines. Raw JSON for production. Let your observability tools handle formatting downstream.
Custom Pino Log Formatter Techniques for Advanced Output Control

Custom serializers let you transform or redact specific fields before Pino writes them to JSON. Define serializer functions in your Pino config, and Pino calls them whenever those keys show up in a log entry. Want to redact a user’s email? Replace it with a hash or mask it to show only the domain. Serializers run before JSON serialization, so they’re fast and don’t break pino-pretty compatibility. Cleanest way to control what ends up in your logs without manually filtering every log call: const logger = pino({ serializers: { user: (user) => ({ ...user, email: '***REDACTED***' }) } });.
Some data types can’t serialize to JSON. Functions, circular references, certain built-in objects will cause errors or produce garbage output. Pino skips or stringifies these incorrectly by default. To handle them, filter problematic fields before logging, use custom serializers to convert them into strings with util.inspect, or use a library like flatted to handle circular references. If you’re logging complex objects, validate them or structure your logs to include only serializable stuff: primitives, strings, numbers, arrays, plain objects.
When pino-pretty and custom serializers aren’t enough, build a custom formatting pipeline. Write a transport that reads Pino’s JSON, enriches it with extra metadata (hostname, environment), reformats timestamps, writes it to stdout or a file. Custom pipelines give you full control but need more code and testing. Useful when you need to bridge Pino’s output with legacy systems or apply transformations that don’t fit into serializers or pino-pretty’s config.
| Formatter Type | Purpose | Notes |
|---|---|---|
| Custom serializers | Redact, transform, or enrich specific fields | Runs before JSON output; fast and compatible with pino-pretty |
| Custom transport | Read JSON logs and apply formatting, filtering, or routing logic | Full control; requires separate process or module; adds complexity |
| Flatted or util.inspect | Handle circular references or non-serializable objects | Converts complex data into strings; loses some structure |
Comparing Pino Log Formatter Options for Development and Production

Raw JSON is the fastest and safest option for production. Pino writes structured JSON with minimal overhead, and aggregators like Elasticsearch or Grafana Loki parse it directly. No formatting cost, no risk of breaking machine parsing, logs stay small and fast. In production, readability isn’t the goal. Ingestion speed and query performance are. Keep formatters out of the critical path.
pino-pretty’s great for development but adds CPU and memory overhead. Parsing JSON and applying formatting logic slows throughput, especially under high log volume. When you’re chasing a bug or reviewing logs in your terminal, the readability boost is worth it. But if you accidentally leave pino-pretty enabled in production? Higher resource usage, slower response times. Always gate pretty printing behind a development-only condition or keep it in your npm scripts instead of app config.
Async transports let Pino offload log writing to a separate process, reducing event loop blocking. If you need custom formatting in production (enriching logs with hostname, forwarding to multiple destinations), use an async transport. The transport reads Pino’s JSON, applies transformations, writes to files or remote endpoints without slowing your main app. This keeps Pino fast while giving you formatting flexibility. Transports add complexity though. Use them only when raw JSON piped to a log aggregator isn’t enough.
| Formatter | Best Use | Pros | Cons |
|---|---|---|---|
| Raw JSON | Production | Fastest; works with all aggregators; low overhead; structured | Hard to read in terminals; no color or formatting |
| pino-pretty | Development | Readable; colorized; customizable timestamps and order | Slows throughput; breaks machine parsing; not production-safe |
| Custom serializers | Both (with care) | Fast; runs before JSON output; compatible with pino-pretty and aggregators | Limited to field-level transforms; can’t change log structure |
| Transport formatter | Production (async) | Full control; can route to multiple destinations; doesn’t block main thread | More complex; harder to debug; requires separate process or module |
Formatter-Friendly Logging Patterns: Message + Payload, Errors, and Redaction in Pino

Pino’s default behavior expects an object as the first argument. If you log like logger.info("User logged in", { userId: 42 }), Pino ignores the second argument. Trips up developers migrating from console.log or Winston. Common workaround is %j for JSON interpolation: logger.info("User logged in %j", { userId: 42 }). But %j flattens the object into a string, breaking structured parsing and making it harder for aggregators to query specific fields. The fix? Detect when the first argument is a string and the second is an object, then merge them correctly without breaking pino-pretty or structured output.
Error serialization works smoothly with Pino’s built-in error handling and pino-pretty. When you log an error object, Pino’s serializers capture stack trace and error message. pino-pretty formats stack traces with line breaks and indentation, easy to scan. To preserve this, always log errors as part of the log object: logger.error({ err }, 'Unhandled error'). If you try to log errors as standalone arguments or use %j, you risk flattening the stack into an unreadable string. Pino and pino-pretty both expect errors under keys like err or fields configured in errorLikeObjectKeys.
Redacting sensitive fields is straightforward with Pino’s redact option. Specify paths to redact, and Pino replaces their values with [Redacted] before writing JSON. Happens before formatting, so pino-pretty and aggregators never see the sensitive data. For example, pino({ redact: ['user.email', 'password'] }) masks those fields in every log entry. Redaction’s safer than filtering in application code because it’s enforced at the logger level and can’t be accidentally bypassed.
Practices for formatter-friendly logging:
- Log structured objects with named keys instead of relying on string interpolation
- Use Pino’s error serializers by logging errors as
{ err }instead of standalone arguments - Enable redaction for sensitive fields like passwords, tokens, email addresses
- Skip %j unless you’re certain the output won’t be parsed by aggregators
- Test log output with pino-pretty locally and validate JSON structure for production
- Use child loggers to add consistent context (like
moduleorrequestId) without repeating it in every call
Transport-Level Pino Log Formatting: Files, Streams, and Log Aggregators

Transports read Pino’s JSON output and forward it to files, remote systems, other destinations. They run in separate processes or worker threads, so they don’t block your app’s event loop. Critical for high-throughput logging. If Pino had to wait for disk I/O or network calls every time you logged something, your app would crawl. Transports handle async delivery and let you route logs to multiple destinations without impacting performance. Formatting typically happens after Pino writes JSON, so transports receive raw structured logs and can apply transformations before writing them elsewhere.
When you send logs to aggregators like Elasticsearch or Grafana Loki, always use raw JSON. These tools expect structured data with consistent field names and types. If you pipe pino-pretty output into an aggregator, parsing fails because pretty-printed logs are plain text with no structure. Aggregators rely on JSON to index logs, run queries, build dashboards. Pretty formatting’s for humans, not machines. If you need readable logs locally and structured logs in production, use a multistream setup that writes raw JSON to one destination and pipes pretty-printed output to another.
Multistream setups let you combine multiple transports. Write raw JSON to a file and pipe formatted output to stdout for local debugging. Pino’s multistream transport makes this easy. Define an array of streams, each with its own destination and formatting config. One stream uses pino-pretty, another writes to a log file, a third forwards JSON to a remote logging service. This keeps your local debugging smooth while ensuring production logs stay structured and ingestion-ready.
Async transports reduce event loop blocking by offloading I/O to worker threads or separate processes. Pino’s built-in transports like pino/file handle this automatically. Configure the transport in your logger setup, and Pino streams logs to it without waiting for writes to complete. Important for high-volume apps where synchronous file writes would introduce latency. Transports add complexity though. You need to manage separate processes, handle transport failures, ensure logs don’t get lost if a transport crashes. For most apps, piping raw JSON to stdout and letting a process manager or container runtime handle log forwarding is simpler and more reliable.
Debugging and Testing Log Output When Working With Pino Log Formatters

Most common issue? Missing logs, usually from transport misconfiguration or incorrect piping. If logs aren’t showing up, check your transport destination config. Make sure file paths are writable, network endpoints are reachable, piping syntax is correct. Using pino-pretty? Verify it’s installed and in your PATH if installed globally. For programmatic setups, confirm the transport’s enabled and configured with the right options. Add a test log at startup to confirm the logger works: logger.info({ startup: true }, 'Logger initialized').
Performance problems often trace back to pretty printing in production or synchronous transports. If your app slows under load, check whether pino-pretty or a custom formatter is enabled in production. Strip it out and test again. For transport issues, confirm you’re using async transports and not writing logs synchronously to disk. Profile log calls with Node’s built-in profiler or use Pino’s benchmarks to compare raw JSON output against formatted output. In most cases, switching to raw JSON resolves performance concerns.
Version mismatches between Pino and pino-pretty can cause formatting glitches or broken output. Pino’s JSON schema occasionally changes between major versions, and pino-pretty needs to stay in sync. If you see garbled logs or missing fields after upgrading Pino, check pino-pretty’s version and update it. Same applies to custom transports. Make sure they’re compatible with your Pino version. Lock versions in package.json and test upgrades in staging before deploying.
Testing formatter output:
- Write a test that captures log output to a buffer or file, then parse the JSON and validate field names, types, structure.
- For pino-pretty, run your app with pretty formatting enabled and visually inspect output for readability, color support, correct timestamp formatting.
- Use snapshot tests to lock expected log output and catch unintended formatting changes during refactoring.
- In CI, disable pino-pretty and validate raw JSON output to ensure formatters don’t break structured logging in production.
Mock loggers simplify testing. Create a Pino instance that writes to an in-memory stream or test file, then assert on the logged JSON. Libraries like pino-test or simple streams let you capture logs without touching stdout. Isolates your tests from real log destinations and makes assertions faster and more reliable. Mock the logger in your tests, log some messages, parse the output and check for expected fields and values.
Final Words
We covered Pino’s default JSON logs, how pino-pretty turns them into human-friendly output, building custom formatters and serializers, transport implications, and basic testing and debugging tips.
You got practical rules: use pretty printing only in development, keep JSON for ingestion, redact sensitive fields, and log message+payload safely so formatters don’t break.
Pick the simplest pino log formatter that fits your pipeline, test formatting in CI, and prefer JSON in production to avoid parsing headaches. Small changes now save time later.
FAQ
Q: What does Pino output by default and why?
A: Pino outputs structured JSON logs by default for speed and reliable machine parsing, keeping CPU overhead low and making logs easy to ingest into systems like Elasticsearch or Loki.
Q: How does a Pino log formatter change that output?
A: A Pino log formatter changes that output by transforming JSON fields—timestamps, level, and message—into readable text, colorized output, or alternate structures while still using the original JSON data as input.
Q: When should I use pino-pretty versus raw JSON?
A: You should use pino-pretty for local development to get colorized, readable logs, and stick to raw JSON in production to preserve performance and compatibility with log ingestion tools.
Q: How do I install and run pino-pretty quickly?
A: To install and run pino-pretty, install as a dev dependency, then pipe logs into it (node app.js | pino-pretty), and use flags like –colorize, –levelFirst, or –translateTime to customize output.
Q: How do custom serializers work and when should I use them?
A: Custom serializers transform or redact specific object fields before they become JSON, so use them to remove secrets, format errors, or flatten complex objects without breaking structured logging.
Q: How do I log a message plus payload safely with Pino?
A: To log a message plus payload safely with Pino, pass an object as the first arg ({ payload }) and the message as the second, or use pino’s object-first pattern to avoid losing structured fields.
Q: How can I redact sensitive fields without breaking log structure?
A: You can redact sensitive fields without breaking structure by using Pino’s redact option or serializers to remove or replace fields at serialization time, preserving valid JSON for ingestion.
Q: How do transports affect formatting and external ingestion?
A: Transports affect formatting by sending either raw JSON or transformed output; for external ingestion (Elastic/Loki) always send JSON via async transports, while local pretty streams can remain separate for readability.
Q: How should I test and debug Pino formatter output in CI and locally?
A: To test and debug Pino formatter output, mock the logger or snapshot JSON output in CI, run pino-pretty locally for visual checks, and verify transport configs and version compatibility when issues appear.
