Beginner

Go Logging with Zerolog: Zero-Allocation Structured Logs

Set up zerolog in a Go application for fast, structured JSON logging. Ship logs to a centralized platform and add request context with middleware.

LogFlow TeamAugust 19, 202612 minGoZerolog

Go's standard library log package writes plain text with no structure, no levels, and no fields. The newer slog package (Go 1.21+) is better but still basic. For production workloads, zerolog is the fastest structured logger in the Go ecosystem — zero allocation on common paths means no GC pressure even under high throughput.

This tutorial sets up zerolog in a Go HTTP service with request middleware, error tracking, and log shipping.

Prerequisites

  • Go 1.21+
  • A LogFlow account (sign up free)
  • Your LogFlow API key

Step 1 — Install zerolog

go get github.com/rs/zerolog

Step 2 — Configure the global logger

package main

import (
	"os"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
)

func initLogger() {
	// Production: JSON output
	// Development: pretty console output
	if os.Getenv("ENV") == "development" {
		log.Logger = log.Output(zerolog.ConsoleWriter{
			Out:        os.Stdout,
			TimeFormat: time.RFC3339,
		})
	} else {
		zerolog.TimeFieldFormat = time.RFC3339Nano
		log.Logger = zerolog.New(os.Stdout).
			With().
			Timestamp().
			Str("service", "my-api").
			Logger()
	}

	// Set global log level
	level := os.Getenv("LOG_LEVEL")
	switch level {
	case "debug":
		zerolog.SetGlobalLevel(zerolog.DebugLevel)
	case "warn":
		zerolog.SetGlobalLevel(zerolog.WarnLevel)
	case "error":
		zerolog.SetGlobalLevel(zerolog.ErrorLevel)
	default:
		zerolog.SetGlobalLevel(zerolog.InfoLevel)
	}
}

Production output:

{"level":"info","service":"my-api","time":"2026-08-19T09:23:15.442Z","message":"Server started","port":8080}

Step 3 — HTTP request logging middleware

Log every request with method, path, status code, and duration:

package middleware

import (
	"net/http"
	"time"

	"github.com/rs/zerolog/log"
)

func RequestLogger(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		// Wrap ResponseWriter to capture status code
		wrapped := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}

		// Pass trace ID from header or generate one
		traceID := r.Header.Get("X-Trace-ID")
		if traceID == "" {
			traceID = generateID()
		}
		w.Header().Set("X-Trace-ID", traceID)

		// Add logger with request context to the request
		logger := log.With().
			Str("traceId", traceID).
			Str("method", r.Method).
			Str("path", r.URL.Path).
			Logger()

		// Store logger in context
		ctx := logger.WithContext(r.Context())
		next.ServeHTTP(wrapped, r.WithContext(ctx))

		duration := time.Since(start)

		logger.Info().
			Int("status", wrapped.statusCode).
			Dur("durationMs", duration).
			Str("clientIp", r.RemoteAddr).
			Msg("Request completed")
	})
}

type statusWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *statusWriter) WriteHeader(code int) {
	w.statusCode = code
	w.ResponseWriter.WriteHeader(code)
}

Step 4 — Use context loggers in handlers

The middleware stores a logger in the request context. Use it in handlers to automatically include trace ID and request info:

func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
	logger := zerolog.Ctx(r.Context())

	var order Order
	if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
		logger.Warn().Err(err).Msg("Invalid order payload")
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	logger.Info().
		Str("orderId", order.ID).
		Float64("total", order.Total).
		Str("currency", order.Currency).
		Msg("Order created")

	// Process order...

	if err := processPayment(order); err != nil {
		logger.Error().
			Err(err).
			Str("orderId", order.ID).
			Str("provider", "stripe").
			Msg("Payment processing failed")
		http.Error(w, "Payment failed", http.StatusInternalServerError)
		return
	}

	logger.Info().
		Str("orderId", order.ID).
		Msg("Order completed successfully")

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(order)
}

Every log from this handler automatically includes the traceId, method, and path set by the middleware.

Step 5 — Ship logs to LogFlow

Option A — LogFlow SDK via HTTP

Create a writer that sends logs to LogFlow's API:

package logshipper

import (
	"bytes"
	"encoding/json"
	"net/http"
	"sync"
	"time"
)

type LogFlowWriter struct {
	apiKey   string
	endpoint string
	buffer   []map[string]interface{}
	mu       sync.Mutex
	client   *http.Client
}

func NewLogFlowWriter(apiKey string) *LogFlowWriter {
	w := &LogFlowWriter{
		apiKey:   apiKey,
		endpoint: "https://api.getlogflow.com/v1/logs",
		client:   &http.Client{Timeout: 5 * time.Second},
	}
	go w.flushLoop()
	return w
}

func (w *LogFlowWriter) Write(p []byte) (n int, err error) {
	var entry map[string]interface{}
	if err := json.Unmarshal(p, &entry); err != nil {
		return 0, err
	}

	w.mu.Lock()
	w.buffer = append(w.buffer, entry)
	w.mu.Unlock()

	return len(p), nil
}

func (w *LogFlowWriter) flushLoop() {
	ticker := time.NewTicker(1 * time.Second)
	for range ticker.C {
		w.flush()
	}
}

func (w *LogFlowWriter) flush() {
	w.mu.Lock()
	if len(w.buffer) == 0 {
		w.mu.Unlock()
		return
	}
	batch := w.buffer
	w.buffer = nil
	w.mu.Unlock()

	payload := make([]map[string]interface{}, len(batch))
	for i, entry := range batch {
		payload[i] = map[string]interface{}{
			"level":   entry["level"],
			"message": entry["message"],
			"service": entry["service"],
			"attributes": entry,
		}
	}

	body, _ := json.Marshal(payload)
	req, _ := http.NewRequest("POST", w.endpoint, bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+w.apiKey)
	req.Header.Set("Content-Type", "application/json")
	w.client.Do(req)
}

Wire it into zerolog with a multi-writer:

func initLogger() {
	consoleWriter := os.Stdout
	logflowWriter := logshipper.NewLogFlowWriter(os.Getenv("LOGFLOW_API_KEY"))

	multi := zerolog.MultiLevelWriter(consoleWriter, logflowWriter)

	log.Logger = zerolog.New(multi).
		With().
		Timestamp().
		Str("service", "my-api").
		Logger()
}

Option B — Pipe stdout to a log forwarder

If changing the application code isn't feasible, pipe the process output to a forwarder like Vector or Fluent Bit that sends JSON lines to LogFlow's HTTP endpoint.

Step 6 — Error recovery middleware

Catch panics and log them instead of crashing silently:

func RecoveryMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				logger := zerolog.Ctx(r.Context())
				logger.Error().
					Interface("panic", rec).
					Bytes("stack", debug.Stack()).
					Msg("Panic recovered")

				http.Error(w, "Internal server error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Step 7 — Wire everything together

func main() {
	initLogger()

	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders", CreateOrderHandler)
	mux.HandleFunc("GET /health", HealthHandler)

	handler := middleware.RequestLogger(
		RecoveryMiddleware(mux),
	)

	log.Info().Int("port", 8080).Msg("Server started")
	http.ListenAndServe(":8080", handler)
}

Why zerolog?

Logger Allocations per log JSON output Structured fields
log (stdlib) 1-2 No No
slog (Go 1.21+) 0-1 Yes Yes
zap 0 Yes Yes
zerolog 0 Yes Yes

Zerolog and zap both achieve zero allocations. Zerolog's API is more fluent (log.Info().Str("key", "val").Msg("text")), while zap uses a field-list style (zap.Info("text", zap.String("key", "val"))). Both are excellent choices. This tutorial uses zerolog for its chainable API.

Next steps

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free