How to Use zerolog for High-Performance Logging in Go

Connect pgx to zerolog using the pgx-zerolog adapter to enable high-performance structured JSON logging for PostgreSQL operations.

The throughput bottleneck

Your API handles a thousand requests a second. Suddenly, response times spike under load. You check the logs and find nothing useful, or worse, the logging itself is choking the CPU. Standard Go logging prints plain text, formats strings eagerly, and allocates memory for every call. When throughput matters, that overhead adds up fast.

You need a logger that treats output as a data pipeline instead of a string formatting exercise. zerolog builds log entries as structured fields and serializes them to JSON only when the log level is actually enabled. The library avoids reflection entirely. It uses a method-chaining API that defers work until the final Msg call. If the log level is disabled, the entire chain short-circuits. No string concatenation. No heap allocations. Just fast, structured output.

Structured logging isn't a luxury. It's the only way to parse logs at scale.

How zerolog avoids the allocation tax

Most loggers format arguments immediately. You call logger.Info("user %s logged in", name) and the runtime builds a string, allocates a buffer, and writes it out. Even if you later filter that log level out in production, the formatting work already happened. zerolog flips the order. It checks the level first. If the level is enabled, it grabs a reusable buffer from a thread-local pool. Field methods like Str, Int, and Err write directly into that buffer as raw JSON bytes. The final Msg call appends the message, adds a newline, and flushes the buffer to the configured io.Writer.

The compiler catches type mismatches early because each field method expects a specific type. Pass a string to Int() and you get cannot use "three" (untyped string constant) as int value in argument. This strictness keeps the pipeline fast and predictable.

The chain defers work. The buffer does the heavy lifting.

Minimal setup

Here is the simplest way to spin up a zerolog instance and log a structured event.

package main

import (
	"os"
	"github.com/rs/zerolog"
)

// main initializes the logger and demonstrates basic field chaining.
func main() {
	// Write to stdout by default. Swap os.Stderr for production error routing.
	logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
	// Set global level. Info and above will print. Debug stays silent.
	zerolog.SetGlobalLevel(zerolog.InfoLevel)

	// Chain fields. Nothing formats until Msg() is called.
	logger.Info().Str("user", "alice").Int("attempts", 3).Msg("login successful")
}

The With() method creates a context builder. Anything added to that builder automatically attaches to every subsequent log call. Timestamp() injects the current time as an epoch or ISO string. Logger() finalizes the builder and returns a ready-to-use logger instance. When you call logger.Info(), it returns an event object. The event checks the global level. If enabled, it prepares the buffer. Each field method writes a JSON key-value pair directly into the buffer. Msg() appends the message field, closes the JSON object, and writes the line.

Run the program and you get clean JSON on standard output. The structure is machine-readable. Log aggregators like Elasticsearch or Loki can index fields without regex parsing.

Walking through the chain

The method-chaining API looks verbose at first glance. The verbosity is intentional. It makes the unhappy path visible and forces you to declare exactly what data belongs in each log line. You cannot accidentally log a sensitive password because you have to explicitly call Str("password", hashed) or skip it entirely.

Consider an HTTP handler. You want to track request duration, status code, and client IP. You build the logger once at startup, then attach request-specific fields per handler.

package main

import (
	"net/http"
	"os"
	"time"

	"github.com/rs/zerolog"
)

// handleRequest logs HTTP metadata and simulates processing time.
func handleRequest(w http.ResponseWriter, r *http.Request) {
	// Attach request-scoped fields to a new logger instance.
	reqLogger := logger.With().
		Str("method", r.Method).
		Str("path", r.URL.Path).
		Str("remote", r.RemoteAddr).
		Logger()

	start := time.Now()
	// Simulate work. Replace with actual handler logic.
	time.Sleep(50 * time.Millisecond)

	// Log completion with duration. Fields chain from the parent logger.
	reqLogger.Info().
		Dur("duration_ms", time.Since(start)).
		Int("status", 200).
		Msg("request handled")

	w.WriteHeader(http.StatusOK)
}

var logger = zerolog.New(os.Stdout).With().Timestamp().Logger()

The With() builder copies the parent logger's context and layers new fields on top. This avoids repeating method, path, and remote on every log line inside the handler. The Dur() method converts a time.Duration to milliseconds automatically. If you forget to import a package and you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. Go's strict import rules keep the dependency graph clean.

Database drivers speak their own dialect. Adapters translate without slowing you down.

Wiring a database driver

External packages rarely use zerolog natively. They expose their own logging interfaces. The pgx PostgreSQL driver, for example, routes connection events and query traces through its own pgx.LogLevel interface. You bridge the gap with pgx-zerolog. The adapter implements the driver's interface and forwards events into your zerolog pipeline without reflection overhead.

Here is how you wire it up in a production-ready configuration.

package main

import (
	"context"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx-zerolog"
	"github.com/rs/zerolog"
)

// main configures pgx with zerolog tracing and establishes a connection.
func main() {
	// Base logger with timestamp and caller info for stack traces.
	logger := zerolog.New(os.Stdout).With().Timestamp().Caller().Logger()
	// Adapter bridges pgx's tracing interface to zerolog's structured pipeline.
	pgxLogger := pgxzerolog.NewLogger(logger)

	config, err := pgx.ParseConfig(os.Getenv("DATABASE_URL"))
	if err != nil {
		logger.Fatal().Err(err).Msg("invalid database configuration")
	}
	// Route pgx connection and query logs into the zerolog stream.
	config.Logger = pgxLogger
	config.LogLevel = pgx.LogLevelWarn

	ctx := context.Background()
	conn, err := pgx.ConnectConfig(ctx, config)
	if err != nil {
		logger.Fatal().Err(err).Msg("failed to connect to database")
	}
	defer conn.Close(ctx)
}

The adapter takes your zerolog.Logger and returns a value that satisfies pgx.Logger. When pgx needs to log a connection state change or a slow query, it calls the adapter. The adapter maps pgx log levels to zerolog levels and writes the event. You get consistent JSON output across your entire stack.

Two conventions matter here. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The defer conn.Close(ctx) line ensures the connection releases resources even if the function panics. Go's error handling style prefers explicit checks over silent failures. The community accepts the if err != nil boilerplate because it makes the unhappy path visible.

A crashed process with unflushed logs is a mystery you cannot solve.

Pitfalls and runtime surprises

zerolog buffers output for speed. If your process receives a SIGKILL or panics without a recovery handler, the buffer might not flush. You lose the final log lines. Call logger.Sync() before exiting, or run your service as a long-lived daemon where the OS handles graceful shutdown.

Over-logging debug messages in production creates memory pressure. The buffer pool is efficient, but writing megabytes of JSON to disk or a network socket blocks the goroutine. Filter at the source. Set zerolog.SetGlobalLevel(zerolog.WarnLevel) in production and reserve debug output for local development.

logger.Fatal() logs the message and calls os.Exit(1). It does not return. If you need to clean up resources, use logger.Error() and return the error to a caller that handles shutdown. Mixing Fatal with deferred cleanup creates dead code that never runs.

Passing the wrong type to a field method triggers a compile-time error. The compiler rejects this with cannot use x (untyped int constant) as string value in argument. This is a feature. It prevents silent data corruption in your logs.

Trust the buffer. Respect the level. Flush before you exit.

Choosing your logging strategy

Use zerolog when you need structured JSON logs with zero allocation overhead and strict type safety. Use the standard log package when you are writing a small CLI tool and prefer plain text output without external dependencies. Use slog when you want a standard library structured logger that balances simplicity with basic JSON support. Use a custom io.Writer wrapper when you need to route logs to multiple destinations like files, HTTP endpoints, and message queues simultaneously.

Pick the logger that matches your output format, not your ego.

Where to go next