Compare

evlog vs Pino: request context and migration

Compare request context, serializers, transports, and migration costs before moving a Pino application to evlog.

Your Pino setup already ships JSON logs. The next decision is how your application assembles request context. evlog provides an accumulator and a lifecycle for that context. Pino provides JSON logging, bindings, serializers, and transports that you can keep if they already fit your application.

Compare the work you maintain

ConcernevlogPino
Request contextcreateLogger() and typed set() calls accumulate fields until emit()child() attaches bindings to records. The application can accumulate an object and log it once.
Adding bindingsset() merges fields into the pending eventsetBindings() adds bindings after creation. It does not overwrite existing keys and can introduce duplicate keys.
Error fieldscreateError() carries why, fix, and linkError serializers, including pino.stdSerializers.err, shape error records.
ExtensibilityPlugins and enrichers hook into the event lifecyclePer-key serializers, formatters, and transports
LevelsFour severities, global minLevel, sampling for wide eventsBuilt-in and custom levels, mutable logger level
DeliveryDrain adapters and configurable batching/retryDestinations and transports, including worker-thread transports

Pino documents these contracts in its API reference. A worker transport moves transport processing and delivery to a worker. The logger still performs the initial JSON serialization on the calling thread. See Pino transports.

Compare one completed operation

Both libraries can emit one structured record for an operation. Install pino or evlog and run the corresponding TypeScript file with Node.js 24. The decline argument exercises the failure path. These examples simulate the same checkout decision without calling a payment provider.

With Pino, the application owns the accumulated context and emits it in finally:

checkout-pino.ts
import pino from 'pino'

const logger = pino()
const context = { orderId: 'order-123', amount: 2999, outcome: 'pending' }
let failure: Error | undefined

try {
  if (process.argv.includes('decline')) throw new Error('Payment declined')
  context.outcome = 'paid'
} catch (error) {
  failure = error instanceof Error ? error : new Error(String(error))
  context.outcome = 'declined'
  process.exitCode = 1
} finally {
  if (failure) logger.error({ ...context, err: failure }, 'checkout completed')
  else logger.info(context, 'checkout completed')
}

With evlog, the logger owns the accumulator and selects error severity after error():

checkout-evlog.ts
import { createLogger, initLogger } from 'evlog'

initLogger({ env: { service: 'checkout' }, pretty: false })
const log = createLogger({ orderId: 'order-123', amount: 2999, outcome: 'pending' })

try {
  if (process.argv.includes('decline')) throw new Error('Payment declined')
  log.set({ outcome: 'paid' })
} catch (error) {
  log.error(error instanceof Error ? error : new Error(String(error)))
  log.set({ outcome: 'declined' })
  process.exitCode = 1
} finally {
  log.emit()
}

Each version emits one record on either path. Their envelopes differ: Pino uses a numeric level and its error serializer, while evlog adds its event metadata and duration. evlog's wide-event model becomes useful when several layers contribute fields and framework integrations own the request lifecycle. Pino's pino-http also provides automatic HTTP request logging.

Migrate one handler before changing the whole application

Inventory the fields your dashboards query, custom levels, serializers, and transport configuration. Map them explicitly before switching output. Pino serializers do not transfer unchanged into evlog plugins, and a custom transport may need a new drain.

Keep the existing Pino path for unrelated handlers while you validate one evlog integration. Avoid emitting duplicate records for the same request through both paths unless you deliberately account for that in queries and ingestion costs. Test successful requests, thrown errors, shutdown, and redaction using the same assertions about required fields.

Read benchmark conditions before choosing on speed

The repository's historical comparison uses silent evlog event construction, Pino JSON writes to /dev/null, and different output counts in the request-lifecycle scenario. It does not establish an equivalent-output throughput ranking. The Performance reference records the workloads and how to measure your own configuration.

Keep Pino when its serializers, ecosystem integrations, or worker transports already solve your problem. Choose evlog when its context accumulator, structured errors, and framework lifecycle remove application code you would otherwise maintain. For other choices, use evlog vs other loggers.