Skip to content

2026-10-05 / zSender

pino logs your Error as {} unless the key is err

Two of our error paths wrote a log line every time they failed, and every one of those lines said the same thing about the error: "error":{}. The level was right, the message we passed was there, the timestamp was there. The error object, the one field we needed, was an empty pair of braces. Nothing crashed and nothing warned. The cause is one short default in pino, a JSON logger for Node.js, together with a property of JavaScript errors that is easy to forget.

What pino does with an Error#

Here is the whole behaviour in one script, run with pino 9.14.0 on Node.js 26.7.0:

js
import pino from 'pino'

Error.stackTraceLimit = 0 // keeps the stack field to one line here

const logger = pino({ base: undefined, timestamp: false })
const e = new Error('boom')

logger.error({ error: e }, 'under error')
logger.error({ err: e }, 'under err')
logger.error(e, 'as the first argument')

It prints:

json
{"level":50,"error":{},"msg":"under error"}
{"level":50,"err":{"type":"Error","message":"boom","stack":"Error: boom"},"msg":"under err"}
{"level":50,"err":{"type":"Error","message":"boom","stack":"Error: boom"},"msg":"as the first argument"}

The same Error produces a full record under err and an empty object under error. Passing the error itself as the first argument also works, because pino puts it under err for you.

The reason is in pino's defaults. In pino.js at v9.14.0, the default options set errorKey: 'err' and a serializers object with exactly one entry, err, which is the error serializer from pino-std-serializers. When pino writes a line, it walks the top-level keys of the object you passed. For each key it applies a serializer if one is registered under that name, or the error serializer if the key equals errorKey. Every other object goes to JSON.stringify as it is.

The documentation says the same. The serializers option is documented with the default {err: pino.stdSerializers.err}, and the section on errors says they can be passed as the first parameter or as the err property of the merging object. A key named error, e or exception is just another property to pino. It also only looks at top-level keys, so an error nested deeper, as in { cause: { inner: e } }, comes out as {"cause":{"inner":{}}} even with the default serializer in place.

Why the object is empty#

JSON.stringify(new Error('boom')) returns {} in Node.js, with or without pino.

The ECMAScript specification serializes an object by taking its own enumerable properties, through the EnumerableOwnProperties step of SerializeJSONObject. An Error has very few of those. The Error constructor creates message with CreateNonEnumerableDataPropertyOrThrow, so it is an own property that is not enumerable. name is not on the instance at all: the specification defines Error.prototype.name as "Error", and the instance inherits it. stack is not part of the standard. MDN notes that engines put it in different places. On Node.js 26.7.0, Object.getOwnPropertyDescriptor(new Error('boom'), 'stack') returns a getter and a setter with enumerable: false, so it is an own, non-enumerable accessor property on each instance.

We checked this directly on Node.js 26.7.0. For new Error('boom'), Object.getOwnPropertyNames returns stack and message, Object.keys returns an empty array, and name is not an own property. With no enumerable own properties to list, the serializer writes {}.

pino's error serializer exists to work around exactly this. It reads message and stack directly, adds a type taken from the constructor name, and copies any other enumerable properties it finds. It only runs when pino decides the key is an error key.

How it hid from us#

Both paths were places where silence was the design. One was a webhook receiver that must always answer 200, whatever happens inside it. The other was a health check, whose job is to report a failure as a status, not to throw. In both, a catch block logged the error under the key error and moved on.

So each failure produced a line at the error level, with the text we passed and an empty error field. That shape is easy to miss: the line exists, it has the right level, and nothing in it looks broken until you need the detail and find that there is none. For these two paths, that line was the only place the error's details could have been, and it held none.

They logged {} for as long as they had that catch. We found it on 2026-09-20.

The fix#

There are three ways to get the full error, and each was checked with the same probe.

Use the key pino expects:

js
logger.error({ err: e }, 'health check failed')

Or keep your key and register the error serializer for it:

js
const logger = pino({
  serializers: { error: pino.stdSerializers.err },
})

Or tell pino your key is the error key:

js
const logger = pino({ errorKey: 'error' })

With either option, { error: e } logs {"type":"Error","message":"boom","stack":"Error: boom"}. With errorKey: 'error' the err key still works too, because the default err serializer stays registered.

We changed both call sites to err, since that is what pino documents and what other code reading our logs will expect. Whichever you pick, pick one for the whole codebase. A rule that depends on remembering a key name at each catch is the rule that failed here.

A test that catches it#

A test that only checks the function returned normally passes whether the error was logged well or not. The test has to read the log line. pino's type definitions describe a destination as any object with a write method that takes a string, so a test can collect the lines in an array:

js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import pino from 'pino'

function checkHealth(logger) {
  try {
    throw new Error('connection refused')
  } catch (e) {
    logger.error({ error: e }, 'health check failed')
  }
}

test('a caught error reaches the log with its message', () => {
  const lines = []
  const logger = pino({}, { write: (line) => lines.push(line) })

  checkHealth(logger)

  assert.equal(lines.length, 1)
  assert.match(lines[0], /connection refused/)
})

As written, with the error key, this test fails on the assertion, and the failure shows the line it got:

text
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /connection refused/. Input:

The input it prints contains "error":{},"msg":"health check failed"}. Change the key to err and the same test passes. In your own code, checkHealth is whichever function owns the catch, and the logger is the one it receives in production.

The assertion is deliberately on the error's own message, not on the text we passed to the logger. The text we wrote was in every broken line. The error's message was the part that was missing, so it is the part the test has to look for.

Sources#

Try it on the free plan.

Get an API key

1,000 emails a month free, no card. Pro is $29 a month for 50,000.