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!