Log Slog #

Logging is an almost universal need in every production application — but it’s not just about printing text to the console. In distributed systems, logs must be searchable, filterable, and analyzable efficiently: that means logs must be structured, have consistent levels, and carry enough context to trace problems. The log/slog package arrived in Go 1.21 as the official solution for this need. Before slog, developers had to choose between the very simple built-in log package or external libraries like zap or zerolog. Now, slog provides performant structured logging directly from the standard library — with a clean API, level support, JSON or text output, and full customization through the Handler interface.

An Overview of the log/slog Package #

flowchart TD
    App["Application Code"] --> Logger["slog.Logger"]

    Logger --> Level["Level Filter\nDebug / Info / Warn / Error"]
    Level --> Handler["slog.Handler"]

    Handler --> TH["TextHandler\nhuman-readable text output"]
    Handler --> JH["JSONHandler\nmachine-readable JSON output"]
    Handler --> CH["Custom Handler\nyour own implementation"]

    TH --> Stdout["os.Stdout\nos.Stderr"]
    JH --> Stdout
    CH --> Any["File, Network,\nCloud Logging, etc."]

    Logger --> Attr["Attributes\nKey-Value pairs"]
    Logger --> Group["Groups\nattribute namespaces"]
    Logger --> Ctx["Context\nrequest-scoped attrs"]

    style App fill:#4f86c6,color:#fff
    style Logger fill:#e8f5e9
    style Handler fill:#e3f2fd
    style TH fill:#fff3e0
    style JH fill:#fff3e0
    style CH fill:#f3e5f5

Quick Start — The Default Logger #

The slog package provides top-level functions that use the default logger — easy to use without any configuration:

package main

import (
    "log/slog"
    "os"
)

func main() {
    // Top-level functions — use the default logger
    slog.Info("application started")
    slog.Debug("this doesn't appear by default — the minimum level is Info")
    slog.Warn("high memory usage", "percent", 85)
    slog.Error("database connection failed", "host", "localhost", "port", 5432)

    // Output (TextHandler, text format):
    // time=2024-03-15T14:30:00.000Z level=INFO msg="application started"
    // time=2024-03-15T14:30:00.001Z level=WARN msg="high memory usage" percent=85
    // time=2024-03-15T14:30:00.002Z level=ERROR msg="database connection failed" host=localhost port=5432
}

The default logger uses a TextHandler with output to os.Stderr and a minimum level of Info. For production applications, always create your own logger with explicit configuration.


Handlers — TextHandler and JSONHandler #

Handlers determine how logs are written — where and in what format. Go provides two built-in handlers:

flowchart LR
    subgraph Text["TextHandler"]
        T1["Human-readable text format"]
        T2["time=... level=... msg=... key=val"]
        T3["Ideal for development\nand CLI tools"]
    end

    subgraph JSON["JSONHandler"]
        J1["Machine-friendly JSON format"]
        J2["{time:..., level:..., msg:..., key:val}"]
        J3["Ideal for production\nand cloud logging"]
    end

    subgraph Config["HandlerOptions"]
        C1["Level — filter the minimum level"]
        C2["AddSource — include file:line"]
        C3["ReplaceAttr — transform attributes"]
    end

    Config --> Text
    Config --> JSON
import (
    "log/slog"
    "os"
)

// TextHandler — for development
func makeDevLogger() *slog.Logger {
    handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level:     slog.LevelDebug, // show all levels
        AddSource: true,            // include the file name and line number
    })
    return slog.New(handler)
}

// JSONHandler — for production
func makeProdLogger() *slog.Logger {
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo, // only Info and above
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // Rename the time attribute from "time" to "timestamp"
            if a.Key == slog.TimeKey && len(groups) == 0 {
                return slog.Attr{Key: "timestamp", Value: a.Value}
            }
            // Rename the level from "level" to "severity" (GCP convention)
            if a.Key == slog.LevelKey && len(groups) == 0 {
                return slog.Attr{Key: "severity", Value: a.Value}
            }
            return a
        },
    })
    return slog.New(handler)
}

func main() {
    // Set as the global default logger
    logger := makeProdLogger()
    slog.SetDefault(logger)

    // Now slog.Info(), etc. use the new logger
    slog.Info("server started", "port", 8080, "env", "production")

    // JSON output:
    // {"timestamp":"2024-03-15T14:30:00Z","severity":"INFO","msg":"server started","port":8080,"env":"production"}
}

Log Levels #

slog supports four built-in levels, and custom levels can be added:

flowchart LR
    Debug["Debug\n-4\nDetailed information\nfor debugging"] --> Info["Info\n0\nNormal events\n(default minimum)"]
    Info --> Warn["Warn\n4\nAbnormal condition\nbut still running"]
    Warn --> Error["Error\n8\nFailures needing\nimmediate attention"]

    style Debug fill:#e3f2fd
    style Info fill:#e8f5e9
    style Warn fill:#fff3e0
    style Error fill:#fce4ec
logger := slog.Default()

// The four standard levels
logger.Debug("loading configuration", "path", "/etc/app.yaml")
logger.Info("server running", "addr", ":8080")
logger.Warn("disk almost full", "percent_used", 92)
logger.Error("failed to save data", "error", err)

// Check whether a level is active before building expensive messages
if logger.Enabled(context.Background(), slog.LevelDebug) {
    // This expensive operation only runs if Debug is active
    state := getDetailedState() // expensive to compute
    logger.Debug("detailed state", "state", state)
}

// Custom levels — ints between the standard levels
const LevelTrace = slog.Level(-8)  // lower than Debug
const LevelNotice = slog.Level(2)  // between Info and Warn
const LevelFatal = slog.Level(12)  // higher than Error

logger.Log(context.Background(), LevelTrace, "very detailed trace")
logger.Log(context.Background(), LevelFatal, "fatal error, application stopping")

// Changing the level dynamically (without restarting)
var levelVar slog.LevelVar // zero value = LevelInfo
levelVar.Set(slog.LevelDebug) // change to Debug at runtime

handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: &levelVar, // pointer to the LevelVar
})
dynamicLogger := slog.New(handler)
dynamicLogger.Debug("this now appears")

levelVar.Set(slog.LevelWarn) // change again
dynamicLogger.Info("this no longer appears")

Attributes — Structured Key-Values #

The main strength of slog is its ability to include structured attributes with every log message. There are several ways to add attributes:

logger := slog.Default()

// Way 1: alternating key-value (most concise)
logger.Info("order created",
    "id", 42,
    "total", 150000.50,
    "item_count", 3,
)

// Way 2: slog.Attr (more explicit, faster)
logger.Info("order created",
    slog.Int("id", 42),
    slog.Float64("total", 150000.50),
    slog.Int("item_count", 3),
)

// Way 3: slog.Any for any type
logger.Info("user logged in",
    slog.Any("user", user),
    slog.Any("ip", net.ParseIP("192.168.1.1")),
)

// Available attribute types
slog.String("name", "Budi")
slog.Int("age", 30)
slog.Int64("id", int64(12345678901))
slog.Uint64("bytes", uint64(1024))
slog.Float64("score", 98.5)
slog.Bool("active", true)
slog.Time("created", time.Now())
slog.Duration("elapsed", 250*time.Millisecond)
slog.Any("error", err)
slog.Any("data", map[string]any{"key": "val"})

// Errors — convention: use the key "error" or "err"
if err != nil {
    logger.Error("operation failed",
        "error", err, // will call err.Error() automatically
        "operation", "saveProduct",
        "id", productID,
    )
}

Groups — Grouping Attributes #

Groups create namespaces for related attributes, producing a cleaner structure in JSON output:

// Without groups — flat attributes
logger.Info("request received",
    "method", "POST",
    "path", "/api/products",
    "ip", "192.168.1.1",
    "user_agent", "Mozilla/5.0",
    "user_id", 42,
    "user_name", "Budi",
)
// JSON output: {"msg":"request received","method":"POST","path":"/api/products",...}

// With groups — structured attributes
logger.Info("request received",
    slog.Group("http",
        slog.String("method", "POST"),
        slog.String("path", "/api/products"),
        slog.String("ip", "192.168.1.1"),
        slog.String("user_agent", "Mozilla/5.0"),
    ),
    slog.Group("user",
        slog.Int("id", 42),
        slog.String("name", "Budi"),
    ),
)
// JSON output:
// {
//   "msg": "request received",
//   "http": {"method":"POST","path":"/api/products","ip":"...","user_agent":"..."},
//   "user": {"id":42,"name":"Budi"}
// }

Loggers with Fixed Attributes — With #

logger.With() creates a new logger that always includes certain attributes in every log — useful for adding consistent context like the service name, version, or request ID:

// A base logger
base := slog.New(slog.NewJSONHandler(os.Stdout, nil))

// A logger with service context — these attributes appear in EVERY log
serviceLogger := base.With(
    slog.String("service", "order-service"),
    slog.String("version", "1.2.3"),
    slog.String("env", "production"),
)

serviceLogger.Info("server started", "port", 8080)
// {"service":"order-service","version":"1.2.3","env":"production","msg":"server started","port":8080}

serviceLogger.Error("DB connection failed", "error", err)
// {"service":"order-service","version":"1.2.3","env":"production","msg":"DB connection failed","error":"..."}

// A per-request logger — add a request ID
func apiHandler(w http.ResponseWriter, r *http.Request) {
    requestID := r.Header.Get("X-Request-ID")
    if requestID == "" {
        requestID = generateRequestID()
    }

    // A logger for this specific request — inherits attributes from serviceLogger
    log := serviceLogger.With(
        slog.String("request_id", requestID),
        slog.String("method", r.Method),
        slog.String("path", r.URL.Path),
    )

    log.Info("request received")

    result, err := processRequest(r)
    if err != nil {
        log.Error("request failed", "error", err)
        http.Error(w, "internal error", 500)
        return
    }

    log.Info("request succeeded", "status", 200)
    json.NewEncoder(w).Encode(result)
}

WithGroup — Fixed Namespaces #

// All subsequent attributes go into the "db" group
dbLogger := base.WithGroup("db")
dbLogger.Info("query executed",
    slog.String("query", "SELECT * FROM users"),
    slog.Duration("duration", 45*time.Millisecond),
    slog.Int("rows", 10),
)
// {"msg":"query executed","db":{"query":"SELECT...","duration":"45ms","rows":10}}

Logging with Context #

slog supports logging integrated with context.Context — allowing middleware to store attributes in the context and all subsequent logs to include them automatically:

sequenceDiagram
    participant MW as Middleware
    participant Handler as HTTP Handler
    participant Service as Service
    participant Repo as Repository

    MW->>MW: Create a logger with request_id, user_id
    MW->>MW: Store the logger in the context
    MW->>Handler: ctx with the logger
    Handler->>Handler: log := LoggerFromCtx(ctx)
    Handler->>Handler: log.Info("process request")
    Handler->>Service: Service(ctx, ...)
    Service->>Service: log := LoggerFromCtx(ctx)
    Service->>Service: log.Info("validate input")
    Service->>Repo: Repo(ctx, ...)
    Repo->>Repo: log := LoggerFromCtx(ctx)
    Repo->>Repo: log.Info("execute query")

    Note over Handler,Repo: All logs automatically have\nthe same request_id and user_id
type contextKey string

const keyLogger contextKey = "logger"

// Store a logger in the context
func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context {
    return context.WithValue(ctx, keyLogger, logger)
}

// Get a logger from the context — falls back to the default if absent
func LoggerFromCtx(ctx context.Context) *slog.Logger {
    if logger, ok := ctx.Value(keyLogger).(*slog.Logger); ok {
        return logger
    }
    return slog.Default()
}

// Middleware: add a request-scoped logger to the context
func slogMiddleware(base *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            requestID := r.Header.Get("X-Request-ID")
            if requestID == "" {
                requestID = generateRequestID()
            }

            // A logger with request attributes
            log := base.With(
                slog.String("request_id", requestID),
                slog.String("method", r.Method),
                slog.String("path", r.URL.Path),
                slog.String("remote_addr", r.RemoteAddr),
            )

            // Store it in the context
            ctx := ContextWithLogger(r.Context(), log)

            start := time.Now()
            rw := &responseWriter{ResponseWriter: w, statusCode: 200}
            next.ServeHTTP(rw, r.WithContext(ctx))

            // Log after the request finishes
            log.Info("request finished",
                slog.Int("status", rw.statusCode),
                slog.Duration("duration", time.Since(start)),
            )
        })
    }
}

// Usage in a service — no need to pass the logger as a parameter
func serviceFindBook(ctx context.Context, id int) (*Book, error) {
    log := LoggerFromCtx(ctx)
    log.Debug("searching for a book", slog.Int("id", id))

    book, err := repoFindBook(ctx, id)
    if err != nil {
        log.Error("failed to find the book", slog.Int("id", id), slog.Any("error", err))
        return nil, err
    }

    log.Debug("book found", slog.String("title", book.Title))
    return book, nil
}

Custom Handlers #

For logging needs that TextHandler or JSONHandler can’t satisfy, implement the slog.Handler interface:

type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}

Example: A Multi-Handler #

// A handler that sends logs to several destinations at once
type MultiHandler struct {
    handlers []slog.Handler
}

func NewMultiHandler(handlers ...slog.Handler) *MultiHandler {
    return &MultiHandler{handlers: handlers}
}

func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool {
    for _, handler := range h.handlers {
        if handler.Enabled(ctx, level) {
            return true
        }
    }
    return false
}

func (h *MultiHandler) Handle(ctx context.Context, r slog.Record) error {
    var errs []error
    for _, handler := range h.handlers {
        if handler.Enabled(ctx, r.Level) {
            if err := handler.Handle(ctx, r.Clone()); err != nil {
                errs = append(errs, err)
            }
        }
    }
    return errors.Join(errs...)
}

func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    handlers := make([]slog.Handler, len(h.handlers))
    for i, handler := range h.handlers {
        handlers[i] = handler.WithAttrs(attrs)
    }
    return &MultiHandler{handlers: handlers}
}

func (h *MultiHandler) WithGroup(name string) slog.Handler {
    handlers := make([]slog.Handler, len(h.handlers))
    for i, handler := range h.handlers {
        handlers[i] = handler.WithGroup(name)
    }
    return &MultiHandler{handlers: handlers}
}

// Usage: log to stdout (text) AND a file (JSON)
func makeMultiLogger() *slog.Logger {
    logFile, _ := os.OpenFile("app.log",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)

    textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    })
    jsonHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })

    return slog.New(NewMultiHandler(textHandler, jsonHandler))
}

Example: A Sampling Handler #

// A handler that only logs some Debug messages to reduce volume
type SamplingHandler struct {
    handler slog.Handler
    rate    int // log 1 of N Debug messages
    counter atomic.Int64
}

func (h *SamplingHandler) Enabled(ctx context.Context, level slog.Level) bool {
    if level > slog.LevelDebug {
        return h.handler.Enabled(ctx, level)
    }
    // For Debug: only enabled 1 of N times
    return h.counter.Add(1)%int64(h.rate) == 0
}

func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error {
    return h.handler.Handle(ctx, r)
}

func (h *SamplingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &SamplingHandler{
        handler: h.handler.WithAttrs(attrs),
        rate:    h.rate,
    }
}

func (h *SamplingHandler) WithGroup(name string) slog.Handler {
    return &SamplingHandler{
        handler: h.handler.WithGroup(name),
        rate:    h.rate,
    }
}

Migrating from log to log/slog #

If you have code using the old log package, migrating to slog is easy to do gradually:

import (
    "log"
    "log/slog"
    "os"
)

// The old log package — unstructured
log.Printf("server started on port %d", 8080)
log.Printf("error: %v", err)

// slog — structured
slog.Info("server started", "port", 8080)
slog.Error("operation failed", "error", err)

// Redirect old log output to slog (for gradual migration)
// All log.Printf calls will go through slog at Info level
slogHandler := slog.NewJSONHandler(os.Stdout, nil)
slogLogger := slog.New(slogHandler)
slog.SetDefault(slogLogger)

// log.Default() now writes to slog
log.SetOutput(slog.NewLogLogger(slogLogger.Handler(), slog.LevelInfo).Writer())

Production Usage Patterns #

A Complete Production Logger Setup #

package main

import (
    "log/slog"
    "os"
)

func setupLogger(env, version string) *slog.Logger {
    var level slog.Level
    switch env {
    case "production":
        level = slog.LevelInfo
    case "staging":
        level = slog.LevelDebug
    default: // development
        level = slog.LevelDebug
    }

    opts := &slog.HandlerOptions{
        Level:     level,
        AddSource: env != "production", // source only in non-prod
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // Format the time as a Unix timestamp for parsing efficiency
            if a.Key == slog.TimeKey && len(groups) == 0 {
                return slog.Int64("ts", a.Value.Time().Unix())
            }
            return a
        },
    }

    var handler slog.Handler
    if env == "production" || env == "staging" {
        handler = slog.NewJSONHandler(os.Stdout, opts)
    } else {
        handler = slog.NewTextHandler(os.Stdout, opts)
    }

    return slog.New(handler).With(
        slog.String("service", "myapp"),
        slog.String("version", version),
        slog.String("env", env),
    )
}

func main() {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }

    logger := setupLogger(env, "1.2.3")
    slog.SetDefault(logger)

    logger.Info("application started",
        slog.String("go_version", runtime.Version()),
        slog.Int("pid", os.Getpid()),
    )
}

Logging Operation Performance #

// A decorator to measure and log operation duration
func withLog(ctx context.Context, operation string, fn func() error) error {
    log := LoggerFromCtx(ctx)
    log.Debug("start " + operation)

    start := time.Now()
    err := fn()
    duration := time.Since(start)

    if err != nil {
        log.Error("failed "+operation,
            slog.Duration("duration", duration),
            slog.Any("error", err),
        )
        return err
    }

    log.Info("finished "+operation,
        slog.Duration("duration", duration),
    )
    return nil
}

// Usage
func serviceProcessPayment(ctx context.Context, p Payment) error {
    return withLog(ctx, "process payment", func() error {
        if err := validatePayment(ctx, p); err != nil {
            return err
        }
        return savePayment(ctx, p)
    })
}

Structured Error Logging #

// A helper for logging errors with rich context
func logError(ctx context.Context, msg string, err error, attrs ...slog.Attr) {
    log := LoggerFromCtx(ctx)

    // Collect the error attributes
    allAttrs := []slog.Attr{slog.Any("error", err)}

    // Add a stack trace if the error supports it
    type stackTracer interface {
        StackTrace() []string
    }
    if st, ok := err.(stackTracer); ok {
        allAttrs = append(allAttrs,
            slog.Any("stack_trace", st.StackTrace()))
    }

    allAttrs = append(allAttrs, attrs...)

    args := make([]any, len(allAttrs))
    for i, a := range allAttrs {
        args[i] = a
    }

    log.Error(msg, args...)
}

// Usage
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
    order, err := serviceCreateOrder(r.Context(), input)
    if err != nil {
        logError(r.Context(), "failed to create order", err,
            slog.Int("user_id", userID),
            slog.String("product", input.ProductID),
        )
        http.Error(w, "failed", 500)
        return
    }

    LoggerFromCtx(r.Context()).Info("order created successfully",
        slog.Int("order_id", order.ID),
        slog.Float64("total", order.Total),
    )
}

When to Switch to Alternatives #

Keep using log/slog if:
  ✓ Structured logging with levels for all Go 1.21+ applications
  ✓ JSON output for cloud logging (GCP, AWS CloudWatch, ELK)
  ✓ Customization via custom Handlers
  ✓ Context integration for request-scoped logging
  ✓ Wanting zero external dependencies

Consider the old log package if:
  ✗ Using Go < 1.21 and can't upgrade
  ✗ Very simple logging without structure needs

Consider external libraries if:
  ✗ Performance is very critical with extremely high log volume
     → zap (uber-go/zap) — zero-allocation, very fast
     → zerolog (rs/zerolog) — zero-allocation, chained API
  ✗ Features not yet in slog:
     → Log rotation → lumberjack
     → Built-in sampling → zap
     → Hooks for sending to Sentry/Datadog → logrus (but slower)
  ✗ The team is already familiar and the ecosystem is tied to zap/zerolog

Summary #

  • slog is the default logging choice for all Go 1.21+ projects — replacing log, logrus, and zap for common cases without external dependencies.
  • JSONHandler for production, TextHandler for development — JSON is easy for log aggregation systems to process; text is easy for humans to read in a terminal.
  • logger.With() for fixed context — create a logger with attributes that always appear (service name, version, request ID) instead of adding them manually to every log.
  • Store the logger in the context for request-scoped logging — middleware adds the request ID and user info to the logger, and all lower layers use it without passing the logger as a parameter.
  • slog.Attr is faster than alternating key-valuesslog.String("key", val) avoids reflection allocation compared to "key", val. Use Attr on hot paths.
  • logger.Enabled(ctx, level) before expensive logging operations — avoid building complex debug messages if the Debug level isn’t active.
  • LevelVar for changing levels without restarting — expose an HTTP endpoint to change the log level at runtime when debugging in production.
  • ReplaceAttr for normalization — standardize field names (e.g. timetimestamp) to be compatible with the formats expected by log aggregation systems.
  • slog.SetDefault(logger) so slog.Info() etc. use the configured logger — don’t rely on the default logger in production applications.

← Previous: Sync   Next: Sort →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact