Logging: slog and the Structured Logging Story

For years, Go’s logging story was “use the log package or pick a third-party library.” The standard log package is basic—no levels, no structure, no context. Everyone used zerolog, zap, or logrus.

Then Go 1.21 shipped log/slog. Finally, structured logging in the standard library. And it’s actually good.

The Old Way: log Package

Go’s original log package:

import "log"

log.Println("server starting")
log.Printf("listening on port %d", 8080)
log.Fatal("failed to connect")  // logs and calls os.Exit(1)

Output:

2025/01/04 10:30:00 server starting
2025/01/04 10:30:00 listening on port 8080

No levels. No structure. Just text. Fine for simple programs, inadequate for production.

The New Way: slog

Go 1.21’s log/slog package:

import "log/slog"

slog.Info("server starting")
slog.Info("listening", "port", 8080)
slog.Error("failed to connect", "error", err, "host", hostname)

Default text output:

2025/01/04 10:30:00 INFO server starting
2025/01/04 10:30:00 INFO listening port=8080
2025/01/04 10:30:00 ERROR failed to connect error="connection refused" host="db.example.com"

Or JSON:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
{"time":"2025-01-04T10:30:00Z","level":"INFO","msg":"listening","port":8080}

Log Levels

slog has four levels:

slog.Debug("detailed debugging info")
slog.Info("normal operation")
slog.Warn("something unexpected")
slog.Error("something failed")

Set the minimum level:

opts := &slog.HandlerOptions{
    Level: slog.LevelDebug,  // show Debug and above
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

Structured Attributes

Add key-value pairs to log entries:

// Alternating key-value pairs
slog.Info("user created", "user_id", userID, "email", email)

// Or use slog.Attr for type safety
slog.Info("user created",
    slog.Int("user_id", userID),
    slog.String("email", email),
    slog.Time("created_at", time.Now()),
)

Logger with Context

Add attributes that appear in every log entry:

logger := slog.Default().With(
    "service", "user-api",
    "version", "1.2.3",
)

logger.Info("request received")
// {"time":"...","level":"INFO","msg":"request received","service":"user-api","version":"1.2.3"}

Or create child loggers:

func handleRequest(r *http.Request) {
    requestID := r.Header.Get("X-Request-ID")
    log := slog.Default().With("request_id", requestID)
    
    log.Info("processing request")
    // ... later
    log.Info("request complete")  // both have request_id
}

Comparing to ILogger

C#’s ILogger:

_logger.LogInformation("User created: {UserId} {Email}", userId, email);

Go’s slog:

slog.Info("user created", "user_id", userID, "email", email)

Similar structured logging. Different syntax.

FeatureILoggerslog
Structured loggingYesYes
Log levels6 (Trace-Critical)4 (Debug-Error)
ScopesBeginScope()With()
DI integrationBuilt-inManual
ProvidersMany built-inHandlers (fewer)
Message templates{Named} placeholdersKey-value pairs
Log categoriesType-basedManual grouping

Using slog with HTTP Context

Pass loggers through context:

type ctxKey struct{}

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

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

Middleware to add request context:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        logger := slog.Default().With(
            "request_id", r.Header.Get("X-Request-ID"),
            "method", r.Method,
            "path", r.URL.Path,
        )
        
        ctx := WithLogger(r.Context(), logger)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// In handlers
func handler(w http.ResponseWriter, r *http.Request) {
    log := FromContext(r.Context())
    log.Info("processing")  // includes request context
}

Custom Handlers

slog uses handlers for output. Write your own:

type PrettyHandler struct {
    slog.Handler
    w io.Writer
}

func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error {
    // Custom formatting
    fmt.Fprintf(h.w, "[%s] %s: %s\n",
        r.Time.Format("15:04:05"),
        r.Level,
        r.Message,
    )
    r.Attrs(func(a slog.Attr) bool {
        fmt.Fprintf(h.w, "  %s=%v\n", a.Key, a.Value)
        return true
    })
    return nil
}

Or wrap existing handlers for filtering, sampling, etc.

Third-Party Integration

slog plays well with existing libraries:

Output to zerolog:

// zerolog adapter available

Output to zap:

// zapslog adapter available

You can use slog’s API with your preferred backend.

Performance Considerations

slog is designed for production:

// Avoid allocation if level disabled
if slog.Default().Enabled(ctx, slog.LevelDebug) {
    slog.Debug("expensive operation", "data", expensiveComputation())
}

Or use LogAttrs for zero-allocation logging:

slog.Default().LogAttrs(ctx, slog.LevelInfo, "event",
    slog.String("key", value),
    slog.Int("count", n),
)

The Honest Take

slog is a welcome addition. Not as mature as the third-party options, but good enough for most uses.

What slog does well:

  • Standard library (no dependencies)
  • Good API design
  • Structured by default
  • Handler abstraction for extensibility

What ILogger does better:

  • More log levels
  • Richer ecosystem
  • DI integration
  • Better provider options (Application Insights, Seq, etc.)
  • Message templates with semantic meaning

What third-party Go loggers do better:

  • zerolog: extremely fast, zero allocation
  • zap: battle-tested, rich features
  • Both: more production-proven

The verdict: For new projects, slog is a solid choice. It’s in the standard library, it’s structured, it works.

For high-performance logging or specific output requirements, zerolog or zap might still be better.

If you’re coming from ILogger, you’ll find slog familiar but simpler. Fewer features, but the core functionality is there.


Next up: database access with database/sql—raw SQL, connection pooling, and missing your ORM.