Node.js console.dir() Method: A Deep, Practical Debugging Guide

When I debug Node.js services, I almost never want a flat string. I want structure: nested objects, hidden properties, and the shape of data as it flows through a handler. That is where console.dir() earns a permanent spot in my toolbox. It prints a structured view of an object so you can scan the tree and spot the exact branch that looks off. If you have ever stared at a single-line console.log() output that squashes everything together, you already know the pain this avoids.

In this guide, I will walk you through how console.dir() behaves in Node.js, how it compares to console.log(), and how I use it in real services. I will show patterns that are safe for production logs, highlight mistakes that waste time, and cover edge cases like circular references or huge objects. You will also see how the method ties into modern 2026 workflows, including AI-assisted debugging. By the end, you will know when console.dir() is the best tool, how to control its output, and how to keep your logs readable without losing critical detail.

What console.dir() really prints in Node.js

console.dir() prints a structured representation of an object’s properties, including nested properties, using Node’s inspection rules. Under the hood, it uses the same engine as util.inspect(), which means you can expect consistent formatting across your CLI, REPL, and logs. The output shows property names, nesting, and types in a readable tree-like layout. In other words, you see the shape, not just a string.

A key detail: console.dir() does not serialize to JSON. That matters because JSON drops functions, symbols, and many non-enumerable properties. console.dir() can show those when you ask it to, and it will show circular references in a readable way rather than crashing.

You should also know that console.dir() returns undefined. It is a side-effect tool for visibility, not data transformation. I treat it as a window into state, not part of core logic.

Here is a short, runnable example that I use when I want to see nested state:

const session = {

user: {

id: ‘u_4815‘,

name: ‘Riley Chen‘,

roles: [‘editor‘, ‘billing‘],

},

request: {

method: ‘POST‘,

path: ‘/api/invoices‘,

headers: {

‘x-trace-id‘: ‘trace-91d‘,

},

},

};

console.dir(session);

The output shows nested properties in a clearer, multi-line layout than a plain log line. When I need even more control, I pass options.

console.dir() vs console.log(): how I choose

I pick between these two based on whether I need structure or narrative. console.log() is for progress messages and quick values. console.dir() is for inspecting object shape and depth.

Here is how I decide in practice:

Traditional approach

Modern approach

console.log() everything and scroll

Use console.log() for events, console.dir() for structured inspection

Print JSON strings

Use console.dir() to preserve functions, symbols, and non-enumerable properties

Dump entire objects without limits

Pass depth and size options to keep output readableIf I need a concise event record, I still use console.log(). For example, when an HTTP request starts, I log a single line with method, path, and status. But when something breaks inside business logic, I switch to console.dir() to see internal state without losing structure.

I also think about audience. If the output is for a human in a terminal, I lean on console.dir(). If the output is for a log parser, I create a minimal JSON object and send it through a structured logger instead. That helps me keep the right tool for the right layer.

The console.dir() signature and what it accepts

The signature is simple: console.dir(object, options). In practice, I treat the first argument as the thing I want to inspect and the second as an optional configuration object that mirrors util.inspect().

Because the options are an object, it is easy to start small and extend later. I usually start with depth and maxArrayLength, then add showHidden only if I know I am dealing with classes or library objects.

Example:

const order = {

id: ‘ord_732‘,

items: [{ sku: ‘sku-1‘ }, { sku: ‘sku-2‘ }, { sku: ‘sku-3‘ }],

customer: { id: ‘c_902‘, name: ‘Dionne‘ },

};

console.dir(order, { depth: 3, maxArrayLength: 2 });

The key is that I can dial the output up or down without changing the object itself. That gives me the same data but a better view of it.

Options that change the output (and why they matter)

Node.js lets you pass an options object to console.dir(). These are the same options used by util.inspect(). I rely on a few of them regularly:

  • depth: Limits how deep the object tree is shown. The default is usually 2. I increase it when I want to see deeper objects.
  • colors: Adds terminal colors to types and values. Good for local work, but I keep it off in production logs.
  • showHidden: Shows non-enumerable properties. I use this when debugging class instances or built-ins.
  • maxArrayLength: Limits how many array entries are printed. Helps with large lists.
  • maxStringLength: Limits string length. Helps when payloads contain large blobs or HTML.
  • breakLength and compact: Control line wrapping. I tune these when output is too wide.
  • sorted: Sorts keys for stable output, which makes diffs easier to read.
  • getters: Controls whether getters are invoked or shown as placeholders. I keep this off unless I know getters are safe.
  • showProxy: Shows proxy internals, which can help when debugging validation or ORM layers.

Example with options:

const build = {

id: ‘build-204‘,

steps: Array.from({ length: 50 }, (_, i) => ({

name: step-${i + 1},

status: ‘queued‘,

})),

metadata: {

git: { branch: ‘main‘, sha: ‘8d2f1c‘ },

env: { node: ‘22.x‘, region: ‘us-east-1‘ },

},

};

console.dir(build, {

depth: 4,

maxArrayLength: 5,

maxStringLength: 120,

colors: true,

compact: false,

sorted: true,

});

This view keeps the object readable while still showing me enough of each section to understand its structure.

Understanding depth, traversal, and output stability

Depth is the option I think about the most. It is the difference between a useful tree and a flood of noise. When I set depth to a low number, I get a high-level map of the object. When I increase it, I can see the internal leaves. When I set it too high or leave it unlimited, I can drown in output.

I often start with depth: 2 for a request context and then go deeper for specific sub-objects. If I am unsure, I log a shallow view first, then log a deeper view of the exact branch I care about. This incremental approach keeps my logs focused and faster to read.

Output stability also matters. If I am trying to compare two runs, I will use sorted: true so the key order does not change across objects. This is especially helpful when the object was built from maps or sets where insertion order varies.

console.dir() vs JSON: the mental model I use

I keep a simple model in my head:

  • JSON is for machines.
  • console.dir() is for humans.

JSON enforces a strict format and is great for structured logs, but it hides functions, symbols, and non-enumerable properties. It also breaks on cycles. console.dir() is forgiving and expressive, which makes it perfect for debugging but not ideal for long-term storage.

When I see teammates using JSON.stringify() to debug, I usually suggest switching to console.dir() for one quick look. It tends to surface shape problems immediately: missing keys, incorrect nesting, or unexpected prototypes.

Practical patterns I use in real services

When I work on Node.js APIs, I use console.dir() in three recurring situations: data validation, error triage, and local test runs.

1) Validation failures

If a validator rejects input, I want to see the payload and its derived fields in one view. That means I inspect both the raw body and the parsed object.

function validateInvoice(payload) {

const invoice = {

accountId: payload.accountId,

lineItems: payload.lineItems || [],

total: Number(payload.total),

};

if (!invoice.accountId || Number.isNaN(invoice.total)) {

console.dir({ payload, invoice }, { depth: 3 });

throw new Error(‘Invalid invoice payload‘);

}

return invoice;

}

I keep this in local and test environments. In production, I add filters to avoid logging sensitive data.

2) Error triage

When a request fails, I log the error object with a structured view so I can see custom fields like code, cause, or details.

try {

await chargeCard(order);

} catch (error) {

console.dir({

name: error.name,

message: error.message,

code: error.code,

cause: error.cause,

details: error.details,

}, { depth: 4 });

throw error;

}

That structure avoids a common mistake where the main error message prints, but the nested details object never gets surfaced.

3) Local test runs

When I run a single test locally, I sometimes add a temporary console.dir() so I can see why a mock differs from expected output. I remove it right after I learn what I need. This saves time without making the test output noisy long-term.

I also like to attach a short label before the output so I know where it came from. A quick pattern I use:

console.log(‘debug: invoice mismatch‘);

console.dir({ expected, actual }, { depth: 4, maxArrayLength: 10 });

It is a small trick, but it keeps my logs skimmable when I am moving fast.

Inspecting errors, causes, and aggregates

Errors in Node.js are not just strings. They can include nested causes, custom fields, and sometimes lists of sub-errors. I use console.dir() to surface those deeper parts, especially when dealing with error aggregation in concurrency-heavy code.

Here is a pattern I use for errors with causes:

try {

await fetchFromPartner();

} catch (error) {

console.dir({

name: error.name,

message: error.message,

cause: error.cause,

stack: error.stack,

}, { depth: 3, maxStringLength: 200 });

throw error;

}

If I am handling a batch process, I sometimes receive a collection of errors. In those cases, I log only the summary and the first few items. I do not need the entire list in one go.

if (result.errors?.length) {

console.dir({

errorCount: result.errors.length,

firstErrors: result.errors.slice(0, 3),

}, { depth: 4 });

}

The lesson here is that I treat errors like data structures, not just text. console.dir() lets me do that without writing a custom formatter each time.

Working with Maps, Sets, Buffers, and typed arrays

Objects are only part of the story. In Node.js, I often handle Map, Set, Buffer, and typed arrays. console.dir() handles these well, but the output can still be noisy if I do not control it.

For Map and Set, I care about how many entries exist and whether the keys look right. I usually log a small sample:

const features = new Map([
[‘newCheckout‘, true],

[‘betaUser‘, false],

[‘region‘, ‘us-east-1‘],

]);

console.dir(features, { depth: 2 });

For Buffer and typed arrays, I rarely need the whole thing. I will log its length and a slice instead:

const payload = Buffer.from(‘example payload data‘);

console.dir({

byteLength: payload.byteLength,

preview: payload.subarray(0, 12),

});

This avoids dumping large binary content while still telling me if the content looks plausible.

Edge cases: circular references, classes, and proxies

I have seen three patterns where console.dir() earns its keep: circular references, class instances with private state, and objects wrapped in proxies.

Circular references

A JSON stringifier fails on circular references, but console.dir() can show them safely.

const team = { name: ‘Payments‘ };

const lead = { name: ‘Amina‘, team };

team.lead = lead;

console.dir(team, { depth: 3 });

You will see a marker for the circular link instead of a crash, which helps pinpoint the relationship that caused the cycle.

Class instances

Class instances often hide interesting fields behind non-enumerable properties. If you are debugging a library object, showHidden can surface data that console.log() never shows.

class CacheEntry {

constructor(key, value) {

Object.defineProperty(this, ‘_key‘, { value: key, enumerable: false });

this.value = value;

}

}

const entry = new CacheEntry(‘session-98‘, { expiresAt: Date.now() + 3600000 });

console.dir(entry, { showHidden: true, depth: 3 });

Proxies

If you are using a proxy-based validation library or ORM, the object may not behave like a plain object. console.dir() still helps, but you should keep depth limited to avoid heavy access of getters. I often combine getters: false with a low depth to avoid triggering expensive computations.

Custom inspectors for your own classes

One of the most powerful techniques I use is a custom inspector. When I own the class, I can define how it should appear in inspection output. This is especially valuable for domain models, tokens, or objects with sensitive fields.

Node supports a custom inspect symbol that util.inspect() recognizes. I use it to return a redacted, friendly view of the object.

const util = require(‘util‘);

class ApiKey {

constructor(raw) {

this.raw = raw;

this.createdAt = new Date();

}

[util.inspect.custom]() {

return {

type: ‘ApiKey‘,

preview: this.raw.slice(0, 4) + ‘…‘,

createdAt: this.createdAt,

};

}

}

const key = new ApiKey(‘sklive1234567890‘);

console.dir(key, { depth: 2 });

This makes console.dir() safer and more consistent across my codebase. It also keeps secrets out of logs by default.

Real-world scenario: debugging an HTTP handler

Here is a more realistic example I use in production-like APIs. The goal is to inspect the incoming request body and the derived model, but only in a debug mode.

function shouldDebug() {

return process.env.DEBUG_PAYMENTS === ‘1‘;

}

function sanitize(body) {

return {

...body,

cardNumber: body.cardNumber ? ‘[redacted]‘ : undefined,

cvv: body.cvv ? ‘[redacted]‘ : undefined,

};

}

async function handleCharge(req, res) {

const payload = req.body;

const model = {

userId: payload.userId,

amount: Number(payload.amount),

currency: payload.currency || ‘USD‘,

};

if (shouldDebug()) {

console.log(‘debug: incoming payment‘);

console.dir({ payload: sanitize(payload), model }, { depth: 3 });

}

// normal processing here

res.status(200).json({ ok: true });

}

The shouldDebug() guard keeps the output off in normal operation. The sanitize() helper ensures the output is safe even if I accidentally enable debug in a staging environment.

Handling huge objects without drowning in output

Large objects are the fastest way to make console.dir() painful. I handle this with three tactics:

1) Cut down the object before logging

2) Limit depth and array length

3) Use a preview shape rather than the full object

Example:

function previewJob(job) {

return {

id: job.id,

status: job.status,

steps: job.steps?.slice(0, 3),

metadata: job.metadata,

};

}

console.dir(previewJob(job), { depth: 3, maxArrayLength: 3 });

I treat this as a habit. If I cannot read the output in 10 seconds, it is too big.

Performance and safety notes from the field

console.dir() can be expensive on large objects. The cost is usually small for a shallow object, but it climbs as depth and size increase. In local work, I do not worry about this. In production, I treat it as a sharp tool.

For a typical medium-sized object, I might see a small pause in the low single-digit milliseconds when using a moderate depth. For large datasets or deep graphs, the pause can grow into the tens of milliseconds or more. That is enough to affect tail latency in hot paths.

Safety matters as much as speed. Many objects include secrets: access tokens, passwords, or personal data. I remove or mask those fields before printing. A small helper makes that easy:

function maskSensitive(input) {

return {

...input,

password: input.password ? ‘[redacted]‘ : undefined,

token: input.token ? ‘[redacted]‘ : undefined,

};

}

const loginAttempt = {

email: ‘[email protected]‘,

password: ‘p@ssw0rd‘,

token: ‘api_123‘,

};

console.dir(maskSensitive(loginAttempt), { depth: 2 });

Production logging strategy: how I keep it safe

I do not treat console.dir() as a production logging tool. I treat it as a debugging tool that I can enable when I need it. In production, I favor structured logs through a proper logging library with strict redaction.

My production pattern looks like this:

  • Normal logs: structured JSON with explicit fields
  • Debug logs: optional, gated by env flags
  • console.dir() usage: only when I can isolate it to a single request or a controlled staging environment

This keeps production logs small, safe, and parseable. It also prevents accidental exposure of large or sensitive objects.

If I need deep visibility in production, I usually capture a specific object, sanitize it, and emit a single, structured record with the fields I know are safe. Then, if I need to inspect more, I do it locally with console.dir() using a reproduction script.

Common mistakes and how I avoid them

Mistake 1: Expecting JSON

console.dir() is not JSON output. It is a visual inspection format. If you need JSON, use JSON.stringify() with a safe replacer. I use console.dir() only when a human will read the output.

Mistake 2: Forgetting depth limits

If you inspect a large object with unlimited depth, output becomes noise. I set depth explicitly when I expect nested graphs.

Mistake 3: Logging sensitive data

I have seen teams leak credentials by dumping entire request objects. I always mask or select fields when output might live in logs.

Mistake 4: Leaving debug output in production

Temporary console.dir() calls are fine in local work, but I remove them before shipping. A quick search for console.dir( before commit saves me from noisy logs.

Mistake 5: Using it as a data tool

console.dir() is for inspection, not transformation. Do not feed its output into logic or tests.

Alternative approaches and how I choose among them

console.dir() is not the only option. I use other approaches depending on the goal:

  • console.log() for narrative progress and quick values
  • JSON.stringify() when I need strict JSON for external tools
  • util.inspect() directly when I need a string, not immediate output
  • Third-party pretty printers when I want colored output in development

Here is an example of using util.inspect() when I want a string that I can attach to a log record:

const util = require(‘util‘);

const summary = util.inspect(order, { depth: 3, colors: false });

logger.info({ orderSummary: summary });

I use this in cases where I want the inspection output to be part of a structured log rather than a direct console print.

Using console.dir() in AI-assisted workflows

In 2026, most teams use AI-assisted tooling during debugging and code review. I treat console.dir() as a bridge between runtime reality and AI analysis. When I capture a structured object in logs or a local run, I can paste that output into an assistant and ask targeted questions about data shape or possible anomalies.

For example, if a payment object has inconsistent fields between regions, I dump two versions with console.dir() and compare. The output is structured enough for a quick diff, and it keeps non-enumerable or special fields visible when that matters.

I also pair console.dir() with modern observability tools. I might print a compact view locally, then turn the same shape into a structured log event in production. The method helps me decide which fields deserve formal logging and which are purely for debug sessions.

A practical checklist I use before logging

Before I add a console.dir() during a debug session, I ask myself a few quick questions:

  • Do I need the object’s shape or just one value?
  • Can I limit depth to avoid noise?
  • Is there any sensitive data inside this object?
  • Can I log a preview instead of the full object?
  • Will I remember to remove this before shipping?

If I can answer those in a way that keeps logs safe and readable, I go ahead. If not, I choose a smaller, structured log or I run the inspection locally.

Small exercises that make the method stick

If you want to build muscle memory, I recommend a quick three-step practice:

1) Take a failing test and use console.dir() with depth: 2 to understand the object shape.

2) Increase depth to 4 on just the nested branch you care about.

3) Create a tiny maskSensitive() helper and practice redacting fields.

This turns console.dir() from a curiosity into a reliable habit.

Closing thoughts and next steps

When I want to understand an object fast, console.dir() is my first stop. It is clear, structured, and safe for complex data models as long as I keep depth and size in check. I recommend it for local debugging, for tricky validation failures, and for any time you suspect that an object’s shape is not what you expect. The method is easy to forget because console.log() is everywhere, but once you use console.dir() on a nested object, you rarely go back.

If you want to put this into practice, start by adding a single console.dir() to a failing test and tune depth until the output tells the full story. Then try a second example where you log a class instance with showHidden so you can see the real state. Finally, add a small helper for masking secrets so you can safely inspect data even when running an integration test with real credentials.

That sequence builds a habit: you learn to read object shape, control output, and keep logs safe. In my experience, that habit shortens debug sessions and makes you a stronger engineer because you see the system as it actually is, not as you assume it to be.

Scroll to Top