Skip to main content

Logging in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Good logging is the difference between a service that’s observable and one that’s a black box. Go has had the basic log package since day one, but the ecosystem push toward structured logging — where each log entry is a machine-parseable key-value map rather than a free-form string — led to log/slog landing in Go 1.21 as the new standard.

This guide covers the progression from log to slog, why structured logging matters in production, and when third-party libraries like zap are worth the dependency.

For context see Go error handling, Go monitoring with Prometheus, and Go best practices.

The Basic log Package

For scripts, CLIs, and early-stage services, the standard log package is sufficient. It’s zero-setup:

import "log"

func main() {
    log.Println("server starting")          // 2026/08/29 14:30:01 server starting
    log.Printf("listening on :%d", 8080)    // 2026/08/29 14:30:01 listening on :8080
    log.Fatal("cannot bind port")           // logs then calls os.Exit(1)
}

log.Fatal and log.Panic are useful for startup failures where continuing makes no sense. For runtime errors, return them — don’t Fatal.

To add a prefix that identifies the component:

logger := log.New(os.Stdout, "[api] ", log.LstdFlags|log.Lshortfile)
logger.Println("request received")  // [api] 2026/08/29 14:30:01 handler.go:42: request received

The limitation of log is that every entry is an unstructured string. When you search logs in a log management system (Datadog, Loki, CloudWatch), you’re parsing text with regex rather than filtering on structured fields. That breaks as volume grows.

Structured Logging with log/slog (Go 1.21+)

slog outputs JSON (or logfmt) with typed attributes — each field is a key-value pair that survives ingestion into any log aggregator:

import "log/slog"

func main() {
    // Default handler writes to stderr in text format
    slog.Info("server starting", "port", 8080, "env", "production")
    // time=2026-08-29T14:30:01Z level=INFO msg="server starting" port=8080 env=production

    // JSON handler for production — parseable by log aggregators
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    logger.Info("user created",
        slog.Int("user_id", 42),
        slog.String("email", "[email protected]"),
    )
    // {"time":"2026-08-29T14:30:01Z","level":"INFO","msg":"user created","user_id":42,"email":"[email protected]"}
}

The typed attribute functions (slog.Int, slog.String, slog.Duration, etc.) preserve types in the JSON output — important when you’re querying logs with something like user_id > 1000 rather than user_id == "1000".

Log Levels

slog has four levels: Debug, Info, Warn, Error. Control the minimum level via HandlerOptions:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,  // suppress Debug messages
}))

logger.Debug("cache miss", "key", key)  // not emitted at Info level
logger.Info("request", "method", r.Method, "path", r.URL.Path)
logger.Warn("slow query", "duration", d, "query", sql)
logger.Error("db failed", "err", err)

In development, run at LevelDebug. In production, LevelInfo or LevelWarn depending on log volume costs.

Adding Persistent Context with With

Often you want every log entry in a function or request to include a common set of fields — a request ID, a user ID, a trace ID. Use logger.With(...) to create a child logger with those fields pre-attached:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    log := slog.Default().With(
        slog.String("request_id", r.Header.Get("X-Request-ID")),
        slog.String("method", r.Method),
        slog.String("path", r.URL.Path),
    )

    user, err := getUser(r.Context())
    if err != nil {
        log.Error("failed to get user", "err", err)
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    log = log.With(slog.Int("user_id", user.ID))  // extend with more context
    log.Info("processing request")
    // Every entry below here includes request_id, method, path, user_id automatically
}

With is cheap — it returns a new logger that shares the same handler, just with extra attributes. It’s the idiomatic way to correlate all log entries for a request.

Context-Aware Logging

For distributed systems where trace IDs and span IDs live in context.Context, propagate them into logs by storing the logger in the context:

type contextKey struct{}

func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
    return context.WithValue(ctx, contextKey{}, logger)
}

func FromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}

Middleware sets up the request logger and stores it in context:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        reqLog := slog.Default().With(
            slog.String("request_id", generateRequestID()),
            slog.String("remote_addr", r.RemoteAddr),
        )
        ctx := WithLogger(r.Context(), reqLog)

        srw := &statusResponseWriter{ResponseWriter: w}
        next.ServeHTTP(srw, r.WithContext(ctx))

        reqLog.Info("request completed",
            slog.Int("status", srw.status),
            slog.Duration("duration", time.Since(start)),
        )
    })
}

Business logic retrieves the logger from context — no global state, no logger passed as a parameter through every function:

func (s *OrderService) PlaceOrder(ctx context.Context, order *Order) error {
    log := FromContext(ctx)
    log.Info("placing order", slog.Int("order_id", order.ID))
    // ...
}

Third-Party Libraries

When to Use zap

zap from Uber is significantly faster than slog because it avoids allocations on the hot path — it uses zap.Field values that are stack-allocated structs rather than any interfaces:

import "go.uber.org/zap"

logger, _ := zap.NewProduction()
defer logger.Sync()

logger.Info("user created",
    zap.Int("user_id", 42),
    zap.String("email", "[email protected]"),
    zap.Duration("latency", time.Since(start)),
)

zap.NewProduction() configures JSON output with sampling (drops redundant log entries above a rate), which is important for high-throughput services logging every request. zap.NewDevelopment() gives human-readable output for local work.

The tradeoff: zap is a dependency, the API is more verbose, and the performance gap over slog has narrowed significantly in recent Go versions. For most services, slog is the right default. Reach for zap when profiling shows logging is in the top 5% of CPU usage.

zerolog

zerolog uses a builder pattern and achieves the lowest allocation rate of any Go logging library through method chaining:

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

log.Info().
    Int("user_id", 42).
    Str("email", "[email protected]").
    Msg("user created")

The builder pattern ensures the chain is compiled away when the log level is disabled — no allocations at all for suppressed messages. It’s the right choice for extremely high-throughput services (>100k req/s) where logging overhead is measurable.

What Not to Log

Secrets and credentials — passwords, API keys, tokens, private keys. If a log aggregator is compromised or logs are stored unencrypted, these become a breach. Scrub them from error messages too:

// ❌ Leaks credentials into logs
log.Printf("connecting to %s with password %s", dsn, password)

// ✅ Log what matters without the secret
log.Printf("connecting to db at %s", sanitizeDSN(dsn))

Full request/response bodies by default — they can contain PII (names, addresses, payment info) and can be large. Log a subset of fields or body size, not the full payload.

Excessive debug noise in production — each log entry has a cost: CPU to serialize it, bandwidth to ship it, storage to retain it, money to query it. Use LevelWarn or LevelError in production hot paths and enable LevelDebug only when actively investigating.

Log Rotation and Shipping

The application should write to stdout/stderr — container runtimes and systemd capture these automatically. Avoid writing to files directly; instead, let your infrastructure (fluentd, promtail, Vector) tail stdout and ship to your aggregator.

For CLI tools or services that must write to files, use lumberjack for rotation:

import "gopkg.in/natefinish/lumberjack.v2"

logger := slog.New(slog.NewJSONHandler(&lumberjack.Logger{
    Filename:   "/var/log/app.log",
    MaxSize:    100, // MB before rotation
    MaxBackups: 5,
    MaxAge:     30,  // days
    Compress:   true,
}, nil))

Summary

  • Use log/slog for all new Go services (Go 1.21+) — structured JSON output, typed attributes, and context-aware logging built in
  • Log at Info in production; suppress Debug — use HandlerOptions.Level to control minimum level
  • Use logger.With(...) to attach request-scoped fields (request ID, user ID) to a child logger
  • Store the logger in context.Context and retrieve it in business logic — avoids global state and makes tests easier
  • Never log secrets, tokens, or full request bodies; sanitize before logging
  • Reach for zap only when profiling confirms logging overhead is significant; slog is sufficient for most services

Resources

Comments

👍 Was this article helpful?