Introduction

Pino is a blazing-fast logging library for Node.js, built for structured logs and high performance. However, one common frustration developers face is that Pino does not natively support logging a message alongside an object the way console.log does.

Note: this is my approach and a solution that works well for me. If you have any suggestions, improvements, or use a different method in your projects, I’d love to hear your thoughts!

Migrating to Pino: Common Pitfalls and Issues

When migrating an existing codebase to Pino—whether from Winston, another logging framework, or even the raw console API—you may expect it to work similarly. However, Pino handles logging differently, requiring adjustments to your log statements.

1️⃣ See for example, logs that work in console or Winston but fail in Pino

console.log('User created', { userId: 123 });
logger.info('User created', { userId: 123 }); // Winston

Expected Output:

INFO  [12:30:45]  User created { userId: 123 }

Pino Output (Incorrect):

INFO  [12:30:45]  User created

🚨 The second argument is ignored because Pino expects an object as the first argument.


2️⃣ Using %j to Force JSON Logging

logger.debug('User created %j', { userId: 123 });

🚨 While this works, it flattens the JSON into a string:

DEBUG  [12:30:45]  User created {"userId": 123}

This prevents structured log processors from parsing the data properly.


3️⃣ Moving Messages Inside JSON

To properly log both the message and object, you might be forced to rewrite logs like this:

logger.info({ msg: 'User created', userId: 123 });

✅ This works with structured logging but requires changing every log statement in your codebase.


4️⃣ The %j Issue With Errors

If you log errors using %j:

logger.debug('hello %j', { error: new Error('some crazy error') });

🚨 The error loses its stack trace and serializes incorrectly:

DEBUG  [12:30:45]  hello {"error":{}}

With our fix:

logger.debug('hello', { error: new Error('some crazy error') });

✅ Logs correctly in dev mode with pino-pretty:

DEBUG  [12:30:45]  hello 
{
  "error": {
    "type": "Error",
    "message": "some crazy error",
    "stack": "Error: some crazy error
 at file.js:12:34"
  }
}

Fixed Behavior: Error Serializer Works in Dev Mode

With our fix:

logger.debug('hello', new Error('some crazy error'));

🎉 Logs correctly in development environments with pino-pretty:

DEBUG  [12:30:45]  hello 
{
  "error": {
    "type": "Error",
    "message": "some crazy error",
    "stack": "Error: some crazy error
 at file.js:12:34"
  }
}
  • Preserves the error stack trace.
  • Works seamlessly in pino-pretty and JSON logs.

The Right Fix: Supporting (message, payload) While Preserving Pino's Structure

To fix this behavior, we need to:

  • Detect when the first argument is a string and second is an object.
  • Ensure %s, %d, and %j placeholders still work correctly.
  • Preserve Pino’s structured JSON logging.
  • Ensure compatibility with pino-pretty, transports, and serializers.

Let's fix your logger.ts file with just a few lines

import pino, { LogFn, Logger, LoggerOptions } from 'pino';

const isDevelopment = process.env.NEXT_PUBLIC_NODE_ENV === 'development' || process.env.NODE_ENV === 'development';

const pinoConfig: LoggerOptions = {
  level: process.env.LOG_LEVEL || 'debug',
  // We recommend using pino-pretty in development environments only
  ...(isDevelopment
    ? {
        transport: {
          target: 'pino-pretty',
          options: { colorize: true, translateTime: 'HH:MM:ss Z', sync: true },
        },
      }
    : {}),
  hooks: { logMethod },
  timestamp: pino.stdTimeFunctions.isoTime,
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
  },
};

const logger = pino(pinoConfig);

function logMethod(this: Logger, args: Parameters<LogFn>, method: LogFn) {
  // If two arguments: (message, payload) -> Format correctly
  if (args.length === 2 && typeof args[0] === 'string' && typeof args[1] === 'object' && !args[0].includes('%')) {
    const payload = { msg: args[0], ...args[1] };

    // If the object is an Error, serialize it properly
    if (args[1] instanceof Error) {
      payload.error = payload.error ?? args[1];
    }

    method.call(this, payload);
  } else {
    // any other amount of parameters, or order of parameters will be considered here
    method.apply(this, args);
  }
}

export default logger;

How This Fix Works

(Message, Payload) Now Works as Expected

logger.info('User created', { userId: 123 });

➡️ Logs correctly:

INFO  [12:30:45]  User created { userId: 123 }

Before: ❌ Second argument ignored

Now: ✅ Structured properly.


Preserves String Interpolation

When a string interpolation is present in the message, we fallback to the default behavior of Pino to handle string interpolations. See pino docs for more details: https://github.com/pinojs/pino/blob/main/docs/api.md#logmethod

logger.info('User %s created with ID %d', 'John', 123);

➡️ Logs:

INFO  [12:30:45]  User John created with ID 123

Ensures Error Serialization Works

logger.error('Something went wrong', new Error('Oops!'));

➡️ Logs:

ERROR [12:30:45]  Something went wrong { error: { msg: 'Oops!', stack: '...' } }

Before: ❌ Pino didn't include the error in the logs
Now: ✅ Uses Pino's error serializer.


Why I believe This Fix is The Right Approach

  • Respects Pino’s structured logging (doesn’t mutate logs).
  • Fully supports pino-pretty formatting.
  • Doesn’t break child loggers, transports, or serializers.
  • Makes Pino more intuitive without hacks.

Final Thoughts

With this fix:

  • Pino behaves the way you expect.
  • Messages and objects log together properly.
  • Pino remains fast, structured, and developer-friendly.
  • If you have a big codebase based on console logging API or Winston, your transition to Pino will be very smooth.

Happy coding!