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
# npm / pnpm / yarn / bun
npm install debug
pnpm add debug
yarn add debug
bun add debug
Output: runtime dep. ~3 KB gzipped — tiny.
# TypeScript declarations are separate
npm install --save-dev @types/debug
Output: DefinitelyTyped declarations. Always install for TS projects.
# 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
| Package | Purpose |
|---|---|
@types/debug | TS declarations. Always install for TS projects. |
ms | Time-elapsed formatting (e.g. +25ms between calls). Transitive dep of debug. |
pino / winston / bunyan | Structured loggers. Pair with debug (or replace it) for prod logging. |
dotenv | Load DEBUG=... from a .env file at dev time. |
Alternatives
| Library | Trade-off |
|---|---|
console.log + a project-wide flag | Free. Doesn't have namespace filtering or browser interop. |
| pino | Structured logger, JSON output, ~5× faster than winston. Pick for production logs. Pair with debug for tracing-style dev output. |
| winston | Veteran structured logger, transports for files/HTTP/etc. Heavier. Slowly being displaced by pino. |
| bunyan | Older structured logger, JSON-first. Largely succeeded by pino. |
| consola | Modern logger by UnJS — pretty output for CLIs, supports debug-style namespacing. |
| diary | Tiny isomorphic logger (~700 bytes). Modern alt to debug for tree-shake-sensitive bundles. |
Common gotchas
DEBUGis read once at module load. Settingprocess.env.DEBUG = "app:*"after importingdebugdoes nothing — namespaces are computed eagerly. To toggle at runtime, usedebug.enable("app:*")/debug.disable().debugwrites tostderr, notstdout. This is deliberate (stdout = data, stderr = diagnostics). Piping CLI output to a file won't capture debug logs unless you redirect2>&1.- Namespace wildcards are glob-like, not regex.
DEBUG=app:*matchesapp:db,app:db:slow, etc. Use-app:noisyto subtract (DEBUG=app:*,-app:noisy). debugcalls cost CPU even when disabled. The check is O(1), but if the args are expensive (log("got %j", buildHugeObject())),buildHugeObjectstill runs. Wrap withif (log.enabled)for hot paths.- Browser usage requires bundler config. Bundlers pick the browser entry;
localStorage.debug = "app:*"enables namespaces. Set in DevTools console. - No log levels — debug is one level. If you need
info/warn/errordiscrimination, use a real logger (pino, winston). debug is just on-or-off per namespace.
Real-world recipes
Namespace pattern — one logger per module
// 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");
}
// 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:*:
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
# 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:
# Cloudflare Worker / Vercel — set via dashboard env
DEBUG=myapp:critical:*
# Docker
docker run -e DEBUG=myapp:* myimage
Output:
myapp:auth login ok +0ms
myapp:db query SELECT 1 +2ms
Multiple namespaces in one module
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:
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
// 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
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:
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
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:
myapp:bytes hash: 68656c6c6f +0ms
Adds domain-specific formatters without touching the call sites.
Browser usage
// bundled via webpack/vite/esbuild
import debug from "debug";
const log = debug("ui:cart");
log("added %d item", count);
// 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:
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.
debugtraces fire only whenDEBUG=myapp:*is set.
Disabling debug entirely
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):
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.
// 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.coerceremoved. Use formatters instead.- Browser bundle smaller — dropped legacy IE shims.
- API otherwise unchanged.
// v3 — works in v4 too
import debug from "debug";
const log = debug("myapp");
Newer features (4.1+)
debug.enable(ns)anddebug.disable()— toggle at runtime.log.extend("sub")— chained namespace creation.
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.
- 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. - Don't log full request bodies in production. PII, credit cards, addresses. Sanitise before logging.
DEBUG=*floods logs. Don't set this in production — every transitive dep usingdebugwill fire, swamping log volume and cost.debugformatter functions run user code.debug.formatters.x = fn;fn(arg)can throw. Guard with try/catch for production formatters.- No PII redaction. Unlike pino's
redactoption,debugdoesn't sanitise — that's your job. For PII-heavy paths, prefer pino with redaction.
Testing & CI integration
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
| Tool | Integration |
|---|---|
express / koa | Already use debug internally — DEBUG=express:* shows routing |
socket.io | DEBUG=socket.io:* shows handshake/event flow |
mocha | DEBUG=mocha:* for test runner internals |
mongoose | mongoose.set("debug", true) — separate from debug, but inspired by it |
pino | Pair: pino for structured logs, debug for traces |
winston | Same — pair, not replace |
dotenv | .env file → DEBUG=... at dev time |
pm2 | Forwards process.env.DEBUG to spawned children |
nodemon | Auto-restart preserves DEBUG=... from parent shell |
Troubleshooting common errors
- No output despite
DEBUG=*—DEBUGwas set afterdebugimported. Restart the process or calldebug.enable("*")after setting. - Output goes to stderr, not stdout — by design. Redirect with
2>logs.txtor2>&1. - Wrong namespace appears — usually a typo or unintended substring match.
DEBUG=appmatchesapp:fooandmyapp(substring). UseDEBUG=app:*for clarity. - Browser: no output —
localStorage.debug = "*"then refresh. Bundler must pick the browser entry (most do automatically). Cannot find module 'debug'in ESM — useimport debug from "debug", notrequire.- Hot loop is slow —
debugcalls cost CPU. Gate withif (log.enabled) log(...). - CI logs are noisy —
DEBUG=(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).debugis plain text. - You need log levels (info/warn/error). Use
pino/winston/bunyan.debugis on-or-off per namespace, one level. - You're building a CLI with pretty user-facing output. Use
chalkdirectly, orconsola.debugis for developer diagnostics, not user UI. - Bundle size is critical.
diary(~700B) or a hand-rolled 10-lineconsole.logwrapper is smaller.debugis tiny but not the smallest. - You want PII redaction built in. Use
pinowithredact: ["token", "*.password"].
See also
- Concept: async — tracing async flow with debug
- Concept: api — request-ID logging at API boundaries
- JavaScript: node runtime — process.env semantics, stderr vs stdout