Skip to main content

morgan vs pino-http vs express-winston: HTTP Request Logging in Node.js (2026)

·PkgPulse Team

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

PackageWeekly DownloadsFormatPerformanceProduction Ready
morgan~3.5MText/Apache CLF⚠️ (no structured JSON)
pino-http~1.3MJSON (structured)✅ Fastest
express-winston~500KJSON/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

Featuremorganpino-httpexpress-winston
FormatText/CLFJSONJSON/text
Performance✅ Fast✅ Fastest✅ Good
Request IDsManual✅ Built-in✅ Manual
CorrelationManual✅ req.logManual
Structured JSON
Log aggregator support✅ Native
Redaction⚠️ Limited
Custom log levels
Multiple transportsVia 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.

Compare logging and monitoring packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.