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=3msA 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:
methodpathorroutestatusduration_msrequest_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:
- production output uses a machine-readable handler when required by the deployment platform;
- important attributes use consistent names and types;
- log levels have a documented meaning;
- request or trace identifiers are attached where they help correlate events;
- secrets and unnecessary personal data are excluded;
- high-volume paths do not emit excessive info-level events;
- errors are not redundantly logged at every application layer; and
- 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.