cheat sheet

debug

Package-level reference for debug on npm — namespace pattern, DEBUG env enable, browser vs Node, and production wrapping.

debug

What it is

debug is the tiny namespaced logger that Express, Mocha, Socket.IO, and most of the Node ecosystem used as their standard "verbose mode" before structured loggers (pino, winston) became mainstream. You call const log = debug("app:db") to create a namespaced logger, then log("connected to %s", url). By default, nothing prints. Setting DEBUG=app:* in the environment enables the matching namespaces.

It's been the lowest-common-denominator instrumentation library since 2011. Almost every package in your node_modules already uses it — DEBUG=* will produce a torrent of output from your transitive deps, much of it useful for diagnosing why something hangs in production.

Install

bash
# npm / pnpm / yarn / bun
npm install debug
pnpm add debug
yarn add debug
bun add debug

Output: runtime dep. ~3 KB gzipped — tiny.

bash
# TypeScript declarations are separate
npm install --save-dev @types/debug

Output: DefinitelyTyped declarations. Always install for TS projects.

bash
# Browser bundle — same package, conditional export selects the right impl
import debug from "debug";   // works in bundlers; uses localStorage in browsers

Output: browser builds use localStorage.debug to enable namespaces (since cookies are sandboxed). Same API, different transport.

Versioning & Node support

  • Current major line is 4.x (stable since 2018 — feature-complete, security maintained). Minor versions land bug fixes.
  • Pure JS; runs on Node 6+, browsers (via localStorage), Bun, Deno, Cloudflare Workers.
  • Dual ESM + CJS publishing via conditional exports since 4.3.
  • Always a runtime dependency — your code calls debug("ns") at runtime.
  • Strict semver; major bumps are rare (last was 2018).

Package metadata

  • Maintainer: Josh Junon (@Qix-) + the debug-js org (originally TJ Holowaychuk)
  • Project home: github.com/debug-js/debug
  • Docs: github.com/debug-js/debug#readme
  • npm: npmjs.com/package/debug
  • License: MIT
  • First released: February 2011
  • Downloads: ~250 million per week — top-5 package on npm by download count

Peer dependencies & extras

PackagePurpose
@types/debugTS declarations. Always install for TS projects.
msTime-elapsed formatting (e.g. +25ms between calls). Transitive dep of debug.
pino / winston / bunyanStructured loggers. Pair with debug (or replace it) for prod logging.
dotenvLoad DEBUG=... from a .env file at dev time.

Alternatives

LibraryTrade-off
console.log + a project-wide flagFree. Doesn't have namespace filtering or browser interop.
pinoStructured logger, JSON output, ~5× faster than winston. Pick for production logs. Pair with debug for tracing-style dev output.
winstonVeteran structured logger, transports for files/HTTP/etc. Heavier. Slowly being displaced by pino.
bunyanOlder structured logger, JSON-first. Largely succeeded by pino.
consolaModern logger by UnJS — pretty output for CLIs, supports debug-style namespacing.
diaryTiny isomorphic logger (~700 bytes). Modern alt to debug for tree-shake-sensitive bundles.

Common gotchas

  1. DEBUG is read once at module load. Setting process.env.DEBUG = "app:*" after importing debug does nothing — namespaces are computed eagerly. To toggle at runtime, use debug.enable("app:*") / debug.disable().
  2. debug writes to stderr, not stdout. This is deliberate (stdout = data, stderr = diagnostics). Piping CLI output to a file won't capture debug logs unless you redirect 2>&1.
  3. Namespace wildcards are glob-like, not regex. DEBUG=app:* matches app:db, app:db:slow, etc. Use -app:noisy to subtract (DEBUG=app:*,-app:noisy).
  4. debug calls cost CPU even when disabled. The check is O(1), but if the args are expensive (log("got %j", buildHugeObject())), buildHugeObject still runs. Wrap with if (log.enabled) for hot paths.
  5. Browser usage requires bundler config. Bundlers pick the browser entry; localStorage.debug = "app:*" enables namespaces. Set in DevTools console.
  6. No log levels — debug is one level. If you need info/warn/error discrimination, use a real logger (pino, winston). debug is just on-or-off per namespace.

Real-world recipes

Namespace pattern — one logger per module

typescript
// src/db.ts
import debug from "debug";
const log = debug("myapp:db");

export async function connect(url: string) {
  log("connecting to %s", url);
  // ...
  log("connected");
}
typescript
// src/api.ts
import debug from "debug";
const log = debug("myapp:api");
const error = debug("myapp:api:error");

export function handle(req, res) {
  log("%s %s", req.method, req.url);
  if (failed) error("handler failed: %o", err);
}

Output: with DEBUG=myapp:*:

text
  myapp:db connecting to postgres://… +0ms
  myapp:db connected +25ms
  myapp:api GET /users +1s

Each call shows the namespace, the message, and the elapsed time since the last log in that namespace.

Enable via DEBUG env var

bash
# Enable a specific namespace
DEBUG=myapp:db node ./server.js

# Wildcard — all myapp:*
DEBUG=myapp:* node ./server.js

# All packages — incredibly noisy
DEBUG=* node ./server.js

# Exclude noisy namespaces
DEBUG=myapp:*,-myapp:noisy node ./server.js

# Multiple patterns
DEBUG=myapp:*,express:* node ./server.js

Output: standard shell-env activation. The variable is read once when debug is imported.

In production:

bash
# Cloudflare Worker / Vercel — set via dashboard env
DEBUG=myapp:critical:*

# Docker
docker run -e DEBUG=myapp:* myimage

Output:

text
myapp:auth login ok +0ms
myapp:db query SELECT 1 +2ms

Multiple namespaces in one module

typescript
import debug from "debug";

const log = debug("myapp:worker");
const trace = debug("myapp:worker:trace");
const slow = debug("myapp:worker:slow");

async function process(job) {
  log("job %s start", job.id);
  trace("payload: %j", job.payload);

  const t0 = Date.now();
  const result = await heavyWork(job);
  const elapsed = Date.now() - t0;
  if (elapsed > 1000) slow("job %s took %dms", job.id, elapsed);

  log("job %s done in %dms", job.id, elapsed);
  return result;
}

Output: with DEBUG=myapp:worker,myapp:worker:slow:

text
  myapp:worker job 42 start +0ms
  myapp:worker job 42 done in 1500ms +1.5s
  myapp:worker:slow job 42 took 1500ms +0ms

trace is silent (not enabled); slow only fires on long jobs. Sub-namespaces let you turn the volume up incrementally.

Production wrap — gate debug behind a feature flag

typescript
// src/lib/log.ts
import debug from "debug";

// Auto-enable namespaces in production for critical paths
if (process.env.NODE_ENV === "production" && !process.env.DEBUG) {
  debug.enable("myapp:critical:*,myapp:error:*");
}

export function makeLogger(ns: string) {
  return debug(`myapp:${ns}`);
}

Output: ensures error tracing is on even when the env var isn't set; lets the operator override with DEBUG=... if they need more.

Formatters — %o, %O, %j, %s, %d

typescript
import debug from "debug";
const log = debug("myapp:fmt");

log("string: %s", "alice");           // string: alice
log("number: %d", 42);                // number: 42
log("json:   %j", { a: 1, b: [2] });  // json:   {"a":1,"b":[2]}
log("object: %o", { a: 1, b: [2] });  // object: { a: 1, b: [ 2 ] }    (util.inspect, single-line)
log("Object: %O", { a: 1, b: [2] });  // Object: { a: 1, b: [ 2 ] }    (util.inspect, multi-line)

Output:

text
  myapp:fmt string: alice +0ms
  myapp:fmt number: 42 +1ms
  myapp:fmt json:   {"a":1,"b":[2]} +0ms
  myapp:fmt object: { a: 1, b: [ 2 ] } +1ms

%o is most useful — readable and short. %O for deeper inspection. %j for valid JSON output (handy when piping to log analysers).

Custom formatter

typescript
import debug from "debug";

// Register a custom %h formatter — hex of a Buffer
debug.formatters.h = (v: any) => v.toString("hex");

const log = debug("myapp:bytes");
log("hash: %h", Buffer.from("hello"));

Output:

text
  myapp:bytes hash: 68656c6c6f +0ms

Adds domain-specific formatters without touching the call sites.

Browser usage

typescript
// bundled via webpack/vite/esbuild
import debug from "debug";
const log = debug("ui:cart");

log("added %d item", count);
javascript
// in DevTools console
localStorage.debug = "ui:*";
location.reload();

Output: in the browser console, with colour-coded namespaces. The bundler picks the browser entry automatically.

Production deployment

Strategy: keep debug, layer pino on top

debug is great for tracing-style "what's happening" diagnostics. For structured production logs, layer a real logger:

typescript
import debug from "debug";
import { pino } from "pino";

const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
const dlog = debug("myapp:internal");

export function info(msg: string, data?: object) { logger.info(data, msg); dlog(msg); }
export function error(msg: string, err: unknown) { logger.error({ err }, msg); dlog("ERROR: %s %o", msg, err); }

This way:

  • Pino logs everything (info+) to stdout as JSON for log aggregators.
  • debug traces fire only when DEBUG=myapp:* is set.

Disabling debug entirely

bash
DEBUG="" node server.js          # off
unset DEBUG && node server.js    # off

Output: (none — debug traces suppressed)

debug calls cost ~50 ns each when disabled — negligible for normal apps. For very hot paths (millions of calls/sec), guard with if (log.enabled):

typescript
const log = debug("myapp:hot");
if (log.enabled) log("processed %d in %dms", count, elapsed);

Edge runtime compatibility

debug works on Cloudflare Workers, Vercel Edge, Bun, Deno. The browser entry is used; localStorage is not available on Workers — instead, use debug.enable("ns") at startup.

typescript
// worker entry
import debug from "debug";
debug.enable(env.DEBUG ?? "myapp:critical:*");

Performance tuning

Disabled-check cost

debug checks enabled per call — O(1) on a precompiled regex set. For ≤1M calls/sec, no concern. Above that, gate with log.enabled.

Sync writes

debug writes synchronously to process.stderr. In tight loops this can dominate runtime — same as console.log. If you're logging millions of lines, pipe to /dev/null or disable.

util.inspect cost in %o / %O

util.inspect on a deeply-nested object can be 100 µs+. For hot paths, prefer %s with pre-formatted strings.

Tree-shakability

debug is not tree-shakable beyond what you import (it's a single export). For very-small bundles, consider diary (~700 bytes) or roll your own 10-line wrapper.

Version migration guide

debug has been on 4.x since 2018. The migration most teams confront is from older 2.x/3.x versions.

v3 → v4

  • Node ≥ 6 required.
  • debug.coerce removed. Use formatters instead.
  • Browser bundle smaller — dropped legacy IE shims.
  • API otherwise unchanged.
typescript
// v3 — works in v4 too
import debug from "debug";
const log = debug("myapp");

Newer features (4.1+)

  • debug.enable(ns) and debug.disable() — toggle at runtime.
  • log.extend("sub") — chained namespace creation.
typescript
const root = debug("myapp");
const db = root.extend("db");    // namespace: "myapp:db"
const slow = db.extend("slow");  // namespace: "myapp:db:slow"

When to stay on ^4

Forever, basically. debug is feature-complete. There's no v5 announced; it would likely be a breaking-change rewrite for ESM-first.

Security considerations

debug itself has had no notable CVEs. The risk is what you log.

  1. Don't log secrets. log("auth token: %s", token) puts secrets in stderr — which may be persisted by your log aggregator, console pipe, or CI tooling.
  2. Don't log full request bodies in production. PII, credit cards, addresses. Sanitise before logging.
  3. DEBUG=* floods logs. Don't set this in production — every transitive dep using debug will fire, swamping log volume and cost.
  4. debug formatter functions run user code. debug.formatters.x = fn; fn(arg) can throw. Guard with try/catch for production formatters.
  5. No PII redaction. Unlike pino's redact option, debug doesn't sanitise — that's your job. For PII-heavy paths, prefer pino with redaction.

Testing & CI integration

typescript
import { describe, it, expect, beforeEach } from "vitest";
import debug from "debug";

describe("logger", () => {
  const captured: string[] = [];
  beforeEach(() => {
    captured.length = 0;
    debug.log = (msg: string) => captured.push(msg);
    debug.enable("test:*");
  });

  it("emits namespaced output", () => {
    const log = debug("test:foo");
    log("hello %s", "world");
    expect(captured[0]).toMatch(/test:foo hello world/);
  });
});

Output: debug.log is the sink — override it to capture. Combined with debug.enable(...), tests can assert on log content without spawning subprocesses.

For CI, set DEBUG=myapp:critical:* in failing-test reruns to get extra context.

Ecosystem integrations

ToolIntegration
express / koaAlready use debug internally — DEBUG=express:* shows routing
socket.ioDEBUG=socket.io:* shows handshake/event flow
mochaDEBUG=mocha:* for test runner internals
mongoosemongoose.set("debug", true) — separate from debug, but inspired by it
pinoPair: pino for structured logs, debug for traces
winstonSame — pair, not replace
dotenv.env file → DEBUG=... at dev time
pm2Forwards process.env.DEBUG to spawned children
nodemonAuto-restart preserves DEBUG=... from parent shell

Troubleshooting common errors

  • No output despite DEBUG=*DEBUG was set after debug imported. Restart the process or call debug.enable("*") after setting.
  • Output goes to stderr, not stdout — by design. Redirect with 2>logs.txt or 2>&1.
  • Wrong namespace appears — usually a typo or unintended substring match. DEBUG=app matches app:foo and myapp (substring). Use DEBUG=app:* for clarity.
  • Browser: no outputlocalStorage.debug = "*" then refresh. Bundler must pick the browser entry (most do automatically).
  • Cannot find module 'debug' in ESM — use import debug from "debug", not require.
  • Hot loop is slowdebug calls cost CPU. Gate with if (log.enabled) log(...).
  • CI logs are noisyDEBUG= (empty) in the CI env disables everything; or unset.

When NOT to use this

  • You need structured logs for log aggregation. Use pino (JSON output, fast, redaction). debug is plain text.
  • You need log levels (info/warn/error). Use pino/winston/bunyan. debug is on-or-off per namespace, one level.
  • You're building a CLI with pretty user-facing output. Use chalk directly, or consola. debug is for developer diagnostics, not user UI.
  • Bundle size is critical. diary (~700B) or a hand-rolled 10-line console.log wrapper is smaller. debug is tiny but not the smallest.
  • You want PII redaction built in. Use pino with redact: ["token", "*.password"].

See also