
Every Node.js app starts with console.log. It works fine until the day something breaks in production at 2 a.m. and you're grepping through thousands of unstructured lines trying to work out which request caused it. That's when a real logging library stops being optional.
The Node.js ecosystem has plenty of options, and picking one is harder than it looks. This post compares the main contenders and gives you a straight answer.
TL;DR
Pino is the best choice for most Node.js applications: fast, structured by default, and production-friendly.
Winston is the right pick if you need maximum flexibility, a rich transport ecosystem, or you're working in an existing Winston codebase.
Consola or LogTape are good for CLIs and libraries.
Bunyan is a legacy choice. Don't start new projects with it.
What makes a good logging library?
Before comparing tools, it helps to know what you're actually looking for:
Structured output. Logs should be JSON (or another machine-readable format) so tools like Datadog, Grafana Loki, Elasticsearch, or CloudWatch can filter and query them.
Performance. Logging happens on the hot path. A slow logger drags down your whole service, especially under load.
Log levels. You need
debug,info,warn,error(and usuallytraceandfatal), controllable at runtime through configuration.Context and child loggers. Attaching a request ID or user ID once and having it appear on every subsequent line is a huge debugging win.
Redaction. Passwords, tokens, and personal data should never reach your log storage.
Destinations and transports. Files, stdout, remote services, and so on.
TypeScript support and active maintenance. You don't want to depend on an abandoned package.
Pino: the default recommendation
Pino was built with one goal: be as fast as possible while still giving you structured JSON logs. It consistently ranks among the fastest Node.js loggers in published benchmarks, including its own.
Why it wins
Speed. Pino does minimal work on the main thread. It serializes to JSON quickly and writes straight to stdout. Heavier work, like formatting or shipping logs to a remote service, can be pushed to worker threads through its transport system.
JSON by default. No configuration needed to get production-ready output.
Child loggers. Cheap to create and ideal for per-request context.
Built-in redaction. Hide sensitive fields with a simple path list.
Great ecosystem.
pino-httpfor Express and plain Node.js HTTP servers,pino-prettyfor readable local output, and Fastify's built-in logging is powered by Pino.
Basic usage
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['password', 'req.headers.authorization'],
});
logger.info({ userId: 42 }, 'user signed in');
try {
await chargeCard(order);
} catch (err) {
logger.error({ err }, 'payment failed');
}Notice the signature: the data object comes first, then the message. This keeps your message strings constant and your data queryable, which is exactly what you want when searching logs later.
Child loggers for request context
import { randomUUID } from 'node:crypto';
app.use((req, res, next) => {
req.log = logger.child({ requestId: randomUUID() });
next();
});
app.get('/orders/:id', (req, res) => {
req.log.info({ orderId: req.params.id }, 'fetching order');
// ...
});Every line logged through req.log now carries the same requestId, so you can trace one request across your entire log stream.
Pretty logs in development
JSON is great for machines and painful for humans. In development, pipe the output through pino-pretty:
node app.js | npx pino-prettyKeep raw JSON in production and pretty output only locally.
Downsides
Fewer built-in destinations than Winston. You'll often rely on separate transport packages or ship stdout logs with your platform's agent.
The "object first, message second" argument order surprises people coming from
console.logor Winston.
Winston: the flexible veteran
Winston has been a Node.js staple since 2011 and is still hugely popular, with well over ten million weekly npm downloads. Its strength is flexibility: you can combine formats, send logs to multiple destinations at once, and customize almost everything.
Why you might choose it
Multiple transports out of the box: console, files, HTTP, and many community transports.
Composable formats for timestamps, colors, JSON, custom layouts, and more.
Huge community and documentation. Almost any problem you hit has already been answered somewhere.
Familiar API for teams that already know it.
Basic usage
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()],
});
logger.info('user signed in', { userId: 42 });Downsides
Slower than Pino. For most apps this won't matter. For high-throughput services, it can.
More configuration. The flexibility comes with more moving parts, and it's easy to end up with an over-engineered logging setup.
Bare-bones defaults. Output is JSON by default, but there's no timestamp, no destination until you add a transport, and stack traces need extra formatting.
Other options worth knowing
Bunyan. It helped popularize structured JSON logging in Node.js and shares much of Pino's philosophy. However, it hasn't had a new npm release in roughly five years. For new projects, Pino is the natural successor.
Log4js. Borrows the Java Log4j vocabulary of appenders and categories, though its own README warns that it doesn't behave the same way. It's fine if your team likes that model, but it has a smaller mindshare than Pino or Winston.
Consola. A friendly, pretty logger popular in CLIs and tooling (it's used throughout the Nuxt ecosystem). Great for developer-facing output, less suited to high-volume server logging. As of this writing its latest release (3.4.2) dates from March 2025, though it remains stable and widely used.
LogTape. A newer, dependency-free library designed to work across Node.js, Deno, Bun, and browsers. It's especially interesting for library authors, since it lets applications decide how and where library logs are handled.
debug. Not a general logger, but a lightweight way to toggle namespaced diagnostic output with an environment variable. Many popular packages use it internally.
Morgan. An HTTP request logger middleware for Express, not a general-purpose logger. If you use Pino, pino-http replaces it.
Side-by-side comparison
Library | Speed | Structured by default | Flexibility | Best for |
|---|---|---|---|---|
Pino | Excellent | Yes (JSON) | Good | Most production services |
Winston | Good | Yes (JSON) | Excellent | Complex, multi-destination setups |
Bunyan | Good | Yes (JSON) | Moderate | Legacy projects |
Log4js | Good | Needs config | Good | Teams used to Log4j |
Consola | Moderate | No | Moderate | CLIs and tooling |
LogTape | Good | Configurable | Good | Libraries and cross-runtime code |
Which one should you pick?
Building an API or microservice? Use Pino. It's fast, structured, and pairs well with Express, Fastify, and NestJS.
Need to write to several destinations with custom formats? Use Winston.
Already using Winston and it works? Keep it. Migrating a working logger rarely pays off unless performance is a real problem.
Writing a CLI tool? Use Consola.
Publishing an npm library? Consider LogTape or the
debugpackage, so you don't force a logger on your users.Running serverless? Pino's low overhead is a good fit, and stdout logging works well with platform log collection.
Logging best practices (whichever library you choose)
The library matters less than how you use it. A few habits make any logger far more useful:
Log structured data, not formatted strings. Prefer
logger.info({ orderId, total }, 'order placed')(Pino's argument order; Winston takes the object second) over interpolating values into the message.Write to stdout. Let your platform (Docker, Kubernetes, your cloud provider) collect and route logs. Don't manage log files inside your app unless you must.
Use levels deliberately.
errorshould mean something needs attention. If everything iserror, nothing is.Add correlation IDs. Attach a request or trace ID to every log line so you can follow a single request through multiple services.
Never log secrets. Use redaction for tokens, passwords, and personal data, and review what your request and response serializers include.
Log errors properly. Pass the actual error object so the stack trace is captured, instead of just
error.message.Set the level through configuration. Run at
infoin production and flip todebugwith an environment variable when investigating an issue.Pretty-print only in development. Machines should read production logs.
Flush before exit. If your process exits quickly, make sure buffered logs have been written, especially when using asynchronous transports.
Conclusion
For the majority of Node.js projects, Pino is the best logging library. It gives you structured JSON, strong performance, child loggers, and redaction with very little setup, and it has a healthy ecosystem around it.
Winston remains a solid, battle-tested choice when you need flexibility or already have it in your stack. For CLIs and libraries, lighter tools like Consola and LogTape fit better.
Whichever you choose, pick one early, wrap it in a small shared module, and use structured logging from day one. Your future self, debugging a production incident, will be grateful.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!