morgan vs pino-http vs express-winston: HTTP Request Logging in Node.js (2026)
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.