Apps Artificial Intelligence Backends CSS DevOps Go JavaScript Laravel Linux MongoDB MySQL PHP Python Rust Svelte Vue

Structured Logging in Go with slog

6 min read .
Structured Logging in Go with slog

Plain text logs are easy to print but difficult to query reliably. Once an application runs across multiple processes or containers, operators usually need to filter events by fields such as HTTP status, request path, customer ID, or latency rather than search arbitrary strings.

Go’s standard library includes log/slog for structured logging. It was added in Go 1.21, so the examples in this article require Go 1.21 or newer.

What structured logging changes

A traditional log message often embeds data inside prose:

request completed method=GET path=/health status=204 duration=3ms

A structured logger keeps the event message and its attributes separate. A JSON handler can produce an event such as:

{"time":"2026-09-01T09:19:24.980235552Z","level":"INFO","msg":"request completed","method":"GET","path":"/health","status":204,"duration_ms":3}

The exact timestamp changes on every run, but the field names and value types remain predictable. Log collectors can index those fields without parsing a custom message format.

Create an application logger

For server applications, create a logger during startup and pass it to components that need logging. Avoid constructing a new logger for every request.

package main

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

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    logger.InfoContext(
        context.Background(),
        "request completed",
        "method", "GET",
        "path", "/health",
        "status", 204,
        "duration_ms", 3,
    )
}

JSONHandler is useful when logs are consumed by machines. For local development, slog.NewTextHandler exposes the same attributes in a more compact text representation.

The example above was formatted with gofmt, checked with go vet, and executed with Go 1.23.2. The runtime produced an INFO JSON event containing method, path, status, and duration_ms with the values shown in the code.

Choose stable attribute names

Treat log field names as an interface used by dashboards, alerts, and incident queries. Pick a small vocabulary and keep it consistent.

For HTTP requests, useful fields often include:

  • method
  • path or route
  • status
  • duration_ms
  • request_id

Do not alternate between names such as status, status_code, and httpStatus unless they intentionally represent different concepts.

Preserve value types

Structured logs are most useful when numbers remain numbers and booleans remain booleans. Prefer:

logger.Info("request completed", "status", 204, "cached", true)

over converting everything to strings. A logging backend can then perform numeric comparisons and aggregations without extra parsing.

Use typed attributes when they improve clarity

Key-value arguments are concise, but slog also provides typed constructors:

logger.Info(
    "request completed",
    slog.String("method", "GET"),
    slog.Int("status", 204),
    slog.Duration("duration", 37*time.Millisecond),
)

Typed attributes are especially helpful in reusable logging helpers because the intended value type is explicit.

For repeated groups of related fields, use slog.Group:

logger.Info(
    "request completed",
    slog.Group("http",
        slog.String("method", "GET"),
        slog.String("route", "/users/{id}"),
        slog.Int("status", 200),
    ),
)

Grouping can prevent naming collisions and makes the schema easier to understand in logging systems that preserve nested JSON objects.

Attach component-wide attributes once

A component often repeats the same metadata on every event. Use Logger.With instead of manually adding those fields to every call:

workerLogger := logger.With(
    "component", "email-worker",
    "queue", "notifications",
)

workerLogger.Info("job started", "job_id", "job-42")
workerLogger.Info("job completed", "job_id", "job-42")

The derived logger shares the underlying handler while automatically including the attached attributes.

This pattern works well for component names, service versions, queue names, or other values that remain stable for the logger’s lifetime.

Use log levels deliberately

slog provides the familiar Debug, Info, Warn, and Error levels.

A practical policy is:

  • Debug: detailed diagnostic information that is normally disabled in production.
  • Info: normal lifecycle events worth retaining, such as a server starting or a background job completing.
  • Warn: unexpected conditions from which the application can recover.
  • Error: operations that failed and require investigation or affect the requested work.

Do not log every successful internal function call at Info. High-volume logs increase storage cost and can hide important events in noise.

Configure the minimum level centrally

HandlerOptions.Level controls which events a handler accepts:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelWarn,
})

With this configuration, debug and info events are discarded by the handler.

If an application needs to change the threshold while running, use slog.LevelVar:

var level slog.LevelVar
level.Set(slog.LevelInfo)

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: &level,
})

The same handler can later observe a new level after level.Set is called.

Log errors as attributes

Keep the event description stable and attach the error separately:

logger.Error(
    "database query failed",
    "operation", "load_user",
    "error", err,
)

This is generally easier to query than building a different message for every error value.

Logging an error does not handle it. The function still needs to return, retry, translate, or otherwise respond to the failure according to the application’s control flow.

Context-aware logging does not automatically add context values

InfoContext, ErrorContext, and the other context-aware methods pass a context.Context to the handler. The default text and JSON handlers do not automatically extract arbitrary values such as request IDs from that context.

If a request ID is needed in every request log, one simple approach is to derive a request-scoped logger explicitly:

requestLogger := logger.With("request_id", requestID)
requestLogger.InfoContext(ctx, "request started")

More advanced applications can implement a custom slog.Handler that reads selected context values, but implicit extraction should be kept narrow and documented. Context should not become an unstructured bag of logging metadata.

Avoid logging secrets and sensitive data

Structured logging makes data easier to search, which also makes accidental disclosure more serious. Never log credentials merely because they are available as request fields or environment variables.

Common values to exclude or redact include:

  • passwords and password-reset tokens
  • API keys and bearer tokens
  • session cookies
  • database connection strings containing credentials
  • private cryptographic keys
  • sensitive personal information that is not required for operations

Prefer an allowlist of known-safe attributes over dumping entire request objects, HTTP headers, configuration structs, or environment variables.

Common pitfalls

Building messages dynamically

Avoid using the message itself as the primary place for variable data:

logger.Info(fmt.Sprintf("user %s logged in", userID))

Prefer a stable event name and a field:

logger.Info("user logged in", "user_id", userID)

Stable messages are easier to group, while the attribute remains independently searchable.

Logging the same failure at every layer

If a repository logs an error, a service logs the returned error, and an HTTP handler logs it again, one failure can create several nearly identical events. Decide which layer has enough context to record the failure usefully and avoid duplicate noise.

Using unbounded attribute values

Fields such as raw URLs, SQL statements, stack traces, or user-generated text can have extremely high cardinality. Logging systems may become expensive or difficult to query when such values are indexed indiscriminately.

Prefer normalized route patterns such as /users/{id} when the concrete identifier is not needed for the operational question.

Treating logs as metrics

Logs can answer detailed questions about individual events, but they are not a substitute for metrics. Request counts, latency distributions, queue depth, and error rates are usually better represented by a metrics system designed for aggregation.

Use logs to preserve event context and metrics to measure system behavior over time.

A practical production checklist

Before shipping structured logging, verify that:

  1. production output uses a machine-readable handler when required by the deployment platform;
  2. important attributes use consistent names and types;
  3. log levels have a documented meaning;
  4. request or trace identifiers are attached where they help correlate events;
  5. secrets and unnecessary personal data are excluded;
  6. high-volume paths do not emit excessive info-level events;
  7. errors are not redundantly logged at every application layer; and
  8. dashboards and alerts depend on stable fields rather than parsing message text.

Final thoughts

log/slog provides enough structure for many Go services without requiring a third-party logging API. The biggest improvement, however, comes from designing logs as operational data rather than decorated print statements.

Use stable event messages, explicit attributes, predictable value types, intentional levels, and strict rules around sensitive information. Those habits make logs substantially more useful when a production incident eventually requires answering not just what failed, but which requests were affected and under what conditions.

Related Posts

chevron-up