TL;DR
morgan is the classic Express HTTP access logger — simple, zero-config, good for development. pino-http is the production choice — it integrates with the Pino logger (the fastest Node.js logger) and produces structured JSON logs optimized for log aggregation platforms like Datadog, Grafana Loki, or CloudWatch. express-winston wraps Winston (the most popular application logger) with HTTP middleware — use it if you're already using Winston and want a consistent log format. For production APIs in 2026, pino-http is the default.
Key Takeaways
- morgan: ~3.5M weekly downloads — simple, text-based HTTP logs, great for development
- pino-http: ~1.3M weekly downloads — structured JSON, fastest, integrates with Pino logger
- express-winston: ~500K weekly downloads — Winston-based, flexible output targets
- Morgan is for development; pino-http is for production
- pino-http produces structured logs that work natively with log aggregators
- Request IDs and correlation IDs are built into pino-http, manual in morgan
Download Trends
| Package | Weekly Downloads | Format | Performance | Production Ready |
|---|---|---|---|---|
morgan | ~3.5M | Text/Apache CLF | ✅ | ⚠️ (no structured JSON) |
pino-http | ~1.3M | JSON (structured) | ✅ Fastest | ✅ |
express-winston | ~500K | JSON/text | ✅ | ✅ |
morgan
morgan is the de facto standard HTTP request logger for Express — simple, minimal, and human-readable.
Basic setup
import express from "express"
import morgan from "morgan"
const app = express()
// Development — colorized, concise:
app.use(morgan("dev"))
// GET /api/packages 200 42ms - 1234b
// Common Apache CLF format (for log parsers):
app.use(morgan("common"))
// ::1 - - [09/Mar/2026:10:00:00 +0000] "GET /api/packages HTTP/1.1" 200 1234
// Combined (includes referrer + user-agent):
app.use(morgan("combined"))
// ::1 - - [09/Mar/2026:10:00:00 +0000] "GET /api/packages HTTP/1.1" 200 1234 "-" "Mozilla/5.0..."
// Tiny — minimal output:
app.use(morgan("tiny"))
// GET /api/packages 200 1234 - 42ms
// Short — similar to dev without colors:
app.use(morgan("short"))
Custom format
import morgan, { TokenIndexer } from "morgan"
// Define custom tokens:
morgan.token("request-id", (req) => req.headers["x-request-id"] as string || "N/A")
morgan.token("user-id", (req) => (req as any).user?.id || "anonymous")
morgan.token("body-size", (req) => {
const contentLength = req.headers["content-length"]
return contentLength ? `${contentLength}B` : "-"
})
// Custom format string:
const format = ":request-id :user-id :method :url :status :response-time ms :body-size"
app.use(morgan(format))
// abc-123 user-456 GET /api/packages 200 42 ms -
// Custom function format:
app.use(
morgan((tokens, req, res) => {
return [
tokens["request-id"](req, res),
tokens.method(req, res),
tokens.url(req, res),
tokens.status(req, res),
tokens["response-time"](req, res), "ms",
tokens.res(req, res, "content-length"), "B",
].join(" ")
})
)
Writing logs to file
import morgan from "morgan"
import { createWriteStream } from "fs"
import { join } from "path"
// Write access logs to file (rotation via external tool like logrotate):
const accessLogStream = createWriteStream(
join(__dirname, "logs/access.log"),
{ flags: "a" } // Append mode
)
// Only log to file in production:
if (process.env.NODE_ENV === "production") {
app.use(morgan("combined", { stream: accessLogStream }))
} else {
app.use(morgan("dev"))
}
Skipping routes
// Skip health check endpoints from logs:
app.use(
morgan("combined", {
skip: (req, res) => {
// Don't log health checks:
if (req.url === "/health" || req.url === "/ping") return true
// Don't log static assets:
if (req.url.startsWith("/static")) return true
// Only log errors in production:
// if (process.env.NODE_ENV === "production") return res.statusCode < 400
return false
},
})
)
pino-http
pino-http is HTTP request logging middleware for the Pino logger — produces structured JSON logs at maximum performance.
Basic setup
import express from "express"
import pinoHttp from "pino-http"
import pino from "pino"
// Production logger config:
const logger = pino({
level: process.env.LOG_LEVEL || "info",
...(process.env.NODE_ENV !== "production" && {
// Pretty print in development only:
transport: {
target: "pino-pretty",
options: { colorize: true },
},
}),
})
// Attach HTTP middleware:
const httpLogger = pinoHttp({ logger })
app.use(httpLogger)
// JSON output in production:
// {
// "level": 30,
// "time": 1709978400000,
// "pid": 12345,
// "hostname": "server-1",
// "req": {
// "id": 1,
// "method": "GET",
// "url": "/api/packages",
// "query": {},
// "params": {},
// "headers": { "user-agent": "...", "host": "..." },
// "remoteAddress": "::1",
// "remotePort": 54321
// },
// "res": {
// "statusCode": 200,
// "headers": { "content-type": "application/json" }
// },
// "responseTime": 42,
// "msg": "request completed"
// }
Request ID correlation
import pinoHttp from "pino-http"
import { randomUUID } from "crypto"
const httpLogger = pinoHttp({
// Custom request ID generator:
genReqId: (req, res) => {
// Use existing header if present (from load balancer):
const existing = req.headers["x-request-id"] as string
if (existing) return existing
const id = randomUUID()
res.setHeader("X-Request-ID", id) // Return ID in response header too
return id
},
// Include request ID in all log lines from this request:
customSuccessMessage: (req, res) => {
return `${req.method} ${req.url} ${res.statusCode}`
},
// Customize which request fields are logged:
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: req.url,
// Don't log Authorization header:
headers: { ...req.headers, authorization: "[REDACTED]" },
}),
},
})
app.use(httpLogger)
// Access the request logger in route handlers:
app.get("/api/packages/:name", async (req, res) => {
// req.log is a child logger with request context (id, method, url):
req.log.info({ packageName: req.params.name }, "Fetching package data")
const data = await fetchPackageData(req.params.name)
req.log.info({ found: true, score: data.healthScore }, "Package data fetched")
res.json(data)
})
// All logs from this request share the same request ID — easy to correlate
Custom log levels by status code
const httpLogger = pinoHttp({
// Log 4xx as warnings, 5xx as errors:
customLogLevel: (req, res, err) => {
if (res.statusCode >= 500 || err) return "error"
if (res.statusCode >= 400) return "warn"
if (res.statusCode >= 300) return "silent" // Don't log redirects
return "info"
},
// Redact sensitive fields from logs:
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"req.body.password",
"res.headers["set-cookie"]",
],
censor: "[REDACTED]",
},
// Skip health check endpoints:
autoLogging: {
ignore: (req) => req.url === "/health" || req.url === "/ping",
},
})
express-winston
express-winston wraps Winston for HTTP request logging — best if you're already using Winston as your application logger.
Basic setup
import express from "express"
import expressWinston from "express-winston"
import winston from "winston"
const app = express()
// Request logging middleware (before routes):
app.use(
expressWinston.logger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({
filename: "logs/access.log",
format: winston.format.json(),
}),
],
format: winston.format.combine(
winston.format.json()
),
meta: true, // Include request/response metadata
expressFormat: true, // Use Express/morgan-like format string
colorize: true, // Color the output (console only)
ignoreRoute: (req) => req.url === "/health",
})
)
// Routes...
app.get("/api/packages", handler)
// Error logging middleware (after routes):
app.use(
expressWinston.errorLogger({
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "logs/errors.log" }),
],
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
})
)
Multi-target logging (console + Datadog + file)
import expressWinston from "express-winston"
import winston from "winston"
import { DatadogTransport } from "winston-datadog"
app.use(
expressWinston.logger({
transports: [
// Development console (colorized):
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
silent: process.env.NODE_ENV === "production",
}),
// Production JSON file:
new winston.transports.File({
filename: "logs/http-access.log",
format: winston.format.json(),
maxsize: 10 * 1024 * 1024, // 10MB rotation
maxFiles: 5,
}),
// Datadog HTTP logs:
new DatadogTransport({
ddClientConf: {
authMethods: { apiKeyAuth: process.env.DATADOG_API_KEY! },
},
ddServerConf: { site: "datadoghq.com" },
service: "pkgpulse-api",
source: "nodejs",
}),
],
})
)
Feature Comparison
| Feature | morgan | pino-http | express-winston |
|---|---|---|---|
| Format | Text/CLF | JSON | JSON/text |
| Performance | ✅ Fast | ✅ Fastest | ✅ Good |
| Request IDs | Manual | ✅ Built-in | ✅ Manual |
| Correlation | Manual | ✅ req.log | Manual |
| Structured JSON | ❌ | ✅ | ✅ |
| Log aggregator support | ❌ | ✅ Native | ✅ |
| Redaction | ❌ | ✅ | ⚠️ Limited |
| Custom log levels | ❌ | ✅ | ✅ |
| Multiple transports | ❌ | Via Pino | ✅ Winston |
| Pretty dev output | ✅ Colorized | ✅ pino-pretty | ✅ Colorize |
| Skip routes | ✅ | ✅ autoLogging | ✅ ignoreRoute |
When to Use Each
Choose morgan if:
- Local development where human-readable output is preferred
- Simple Express apps where structured JSON isn't needed
- You want zero-configuration logging with minimal setup
- Legacy codebases that already use morgan
Choose pino-http if:
- Production APIs sending logs to Datadog, Grafana Loki, CloudWatch, or similar
- Structured JSON logs are required by your ops team
- You need request ID correlation across microservices
- Performance matters — pino is the fastest Node.js logger
Choose express-winston if:
- You're already using Winston as your application logger and want consistent log formatting
- You need multi-target transport (file + console + cloud simultaneously)
- You want Winston's ecosystem (transports for Datadog, Splunk, CloudWatch, etc.)
Methodology
Download data from npm registry (weekly average, February 2026). Performance benchmarks from Pino's benchmark suite. Feature comparison based on morgan v1.10.x, pino-http v10.x, and express-winston v4.x.
Log Aggregation and Structured Output
The difference between morgan and pino-http becomes most visible when logs reach a log aggregation platform. Morgan's text output — GET /api/packages 200 42ms — requires regex parsing before Datadog, Grafana Loki, or Splunk can query it. This works for simple queries ("show me all 500 errors") but breaks down for correlating requests with application logs, filtering by specific user IDs, or building dashboards that join HTTP metrics with business events.
Pino's JSON output is designed for these platforms from the start. Every log line is machine-readable JSON with consistent field names: req.id, res.statusCode, responseTime, req.url. Log aggregators index these fields automatically, making queries like "show me all requests from user X that returned 4xx" trivial. The structured format also enables log sampling — you can configure Datadog to sample info level HTTP logs at 10% while capturing all warn and error level logs at 100%, reducing cost without losing signal on failures.
express-winston's multi-transport architecture solves a different problem: writing the same log event to multiple destinations simultaneously. A typical production configuration writes to the console (for local development), a rotating file (for audit trail), and directly to a log service API. Winston transports handle the fan-out, including retry and buffering for the external service transport. This is operationally more complex than pino's approach — pino prefers writing all logs to stdout and letting the infrastructure (Docker log driver, Kubernetes fluentd sidecar) route to destinations. Both patterns work in production; pino's approach is simpler for container-native deployments while express-winston's is more flexible for traditional server deployments.
Request ID Propagation Across Services
In microservice architectures, request ID correlation is critical for tracing a single user request across multiple service boundaries. Pino-http generates or propagates request IDs automatically with the genReqId callback, and the resulting req.log child logger attaches that ID to every subsequent log line from the same request. When your route handler calls a database query helper, an external API client, or an email service, passing req.log as the logger ensures all logs share the same reqId field.
Morgan doesn't support this natively. To achieve the same with morgan, you need a separate request-id middleware that sets res.locals.requestId or augments req, then a custom morgan token that reads it. This works but requires explicit wiring that pino-http provides automatically.
Express-winston's request ID handling depends on your Winston configuration. You can include request context in log metadata using the dynamicMeta option, which extracts fields from req and res at log time:
expressWinston.logger({
dynamicMeta: (req, res) => ({
requestId: req.headers['x-request-id'] || 'no-id',
userId: (req as any).user?.id,
}),
})
The incoming x-request-id header convention — where load balancers and API gateways set the header and services propagate it — works with all three loggers, but pino-http is the only one that propagates it to child loggers automatically.
Security Implications of HTTP Request Logging
HTTP access logs frequently contain sensitive user data, and the logging configuration directly affects your application's security and compliance posture. Request headers often include authentication tokens (Authorization: Bearer ...), session cookies, and API keys that must be redacted before logs are written to any persistent store or shipped to a log aggregation service. Failing to redact these values means your log storage becomes a secondary attack surface — an attacker who gains read access to your logs also gains access to valid authentication credentials for all logged sessions.
Pino-http's redact option is the most ergonomic solution to this problem. The configuration accepts an array of dot-notation paths to redact from the logged object: ["req.headers.authorization", "req.headers.cookie", "req.body.password"]. Pino replaces these values with the configured censor string ("[REDACTED]") before serialization, so the sensitive data never enters the log output at all. This approach is more reliable than post-processing logs to remove sensitive values because the redaction happens at the source rather than potentially failing in a downstream pipeline step.
Production Log Volume and Cost Management
HTTP access logs grow faster than most developers anticipate. A service handling 1,000 requests per minute with verbose logging (full headers, request bodies, response bodies) can generate several gigabytes per day, which at typical log storage prices of $0.10-0.50/GB/month becomes a non-trivial operating cost. All three libraries support log filtering strategies to manage this volume without losing the signal.
Pino-http's autoLogging.ignore option is the most flexible for production cost control. You can filter by path (skip health checks), by status code (skip 2xx and 3xx in verbose environments, only log 4xx and 5xx in production), by request duration (only log slow requests above a threshold), or by any combination. Pino's level system means info logs can be sampled at 10% while warn and error logs are always captured — a significant cost reduction for high-traffic APIs where 90% of successful request logs carry zero diagnostic value.
Morgan's filtering is limited to the skip option, which is a boolean predicate. It does not support sampling or level-based filtering — you either log the request or you do not. For production APIs with strict log budgets, morgan's binary filtering is usually insufficient, which is one of the strongest practical reasons to use pino-http in production even if morgan is acceptable in development. express-winston's ignoreRoute option is similarly binary, though Winston's transport system allows writing only certain log levels to specific destinations — for example, only writing warn and error levels to the expensive cloud log service while writing all levels to a cheaper local file.
Compare logging and monitoring packages on PkgPulse →
See also: Pino vs Winston and Express vs NestJS, helmet vs cors vs express-rate-limit.