#Structured Logging in Go with log/slog - The Complete Guide
In this tutorial we are going to take a thorough look at log/slog — the structured logging package that landed in the standard library in Go 1.21. By the end you’ll know how to configure handlers, set log levels, attach structured attributes, carry loggers through context.Context, write your own handlers, and redact sensitive fields.
We’ll also make the case for why slog has become the default choice for new Go projects, and why it makes third-party loggers like logrus, zap and zerolog hard to justify reaching for anymore.
Why fmt.Println and log Aren’t Enough
Most Go developers start out logging like this:
log.Printf("user %s logged in from %s", userID, ipAddr)
This produces a human-readable line and nothing more. The moment your application runs anywhere other than your laptop — a container, a fleet of pods, a serverless function — those lines get shipped to a log aggregator like Loki, CloudWatch, or Datadog.
Once they’re there, you want to query them: “show me every failed login for this user in the last hour”. With a free-text string, the best you can do is a fragile substring match. The data you care about is trapped inside a sentence.
Structured logging flips this around. Instead of formatting values into a message, you log the message and the values as discrete key/value pairs:
slog.Info("user logged in", "user_id", userID, "ip", ipAddr)
Emitted as JSON, that becomes a record your aggregator can index and filter on directly. The message stays constant; the data lives in fields. That is the entire premise of structured logging, and it’s why every production logging stack expects it.
Why slog Makes logrus, zap and zerolog Obsolete
For years, structured logging in Go meant adding a dependency. logrus popularised the idea, zap and zerolog chased raw performance, and most teams picked one and moved on. Go 1.21 changed the calculus by putting a capable structured logger in the standard library.
Here’s why that matters more than “one fewer dependency”:
- It’s in the standard library. No version churn, no supply-chain surface, no abandoned-maintainer risk. It ships and is supported alongside the language under Go’s compatibility promise.
- It defines a common interface. The
slog.Handlerinterface means libraries can emit structured logs without forcing a logging dependency on you. A library logs toslog; you decide where those records go. That decoupling was never possible when every library hard-codedlogrusorzap. - The API is small and idiomatic. If you know
fmt, you can useslogin minutes. - Performance is competitive.
slogwas designed with allocation in mind. It isn’t always the fastest in synthetic benchmarks, but it’s in the same league aszapandzerologfor realistic workloads — close enough that performance is rarely the deciding factor.
The table below summarises how slog stacks up against the popular third-party options:
| Feature | log/slog | logrus | zap | zerolog |
|---|---|---|---|---|
| In standard library | ✅ Yes | ❌ No | ❌ No | ❌ No |
| External dependencies | None | A handful | A handful | A handful |
| Structured output | ✅ | ✅ | ✅ | ✅ |
| Pluggable handler interface | ✅ | Limited | Limited | Limited |
| Performance | High | Moderate | Very high | Very high |
| Active maintenance | ✅ (Go team) | Maintenance mode | ✅ | ✅ |
| API stability guarantee | ✅ (Go 1 promise) | ❌ | ❌ | ❌ |
zap and zerolog still edge ahead on raw throughput, so if you are logging in an extremely hot path and have measured it as a bottleneck, they remain valid choices. For the other 95% of projects, slog gives you structured logging with zero dependencies and standard-library guarantees — which is exactly why it’s become the default.
Getting Started With slog
slog lives in the standard library, so there’s nothing to install. Import it and log:
package main
import "log/slog"
func main() {
slog.Info("application started")
slog.Warn("disk space low", "available_gb", 4)
slog.Error("failed to connect", "service", "postgres")
}
Running this gives you text output on standard error:
2026/06/03 12:00:00 INFO application started
2026/06/03 12:00:00 WARN disk space low available_gb=4
2026/06/03 12:00:00 ERROR failed to connect service=postgres
The package-level functions (slog.Info, slog.Warn, slog.Error, slog.Debug) write through a default logger. The arguments after the message are interpreted as alternating keys and values — "available_gb", 4 becomes the field available_gb=4. This is the loose key/value form, and it’s the quickest way to get going.
Text vs JSON Handlers
A slog.Logger doesn’t format or write anything itself — it hands a record to a Handler, and the handler decides how it looks and where it goes. The standard library ships two: TextHandler and JSONHandler.
TextHandler produces the key=value output you saw above, which is pleasant to read during local development. JSONHandler produces one JSON object per line — the format every log aggregator wants in production:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user logged in", "user_id", "u_123", "ip", "10.0.0.4")
}
This emits:
{"time":"2026-06-03T12:00:00Z","level":"INFO","msg":"user logged in","user_id":"u_123","ip":"10.0.0.4"}
slog.New wraps a handler in a *slog.Logger. We constructed a JSONHandler writing to os.Stdout, so every field becomes a top-level JSON key — exactly what a downstream system needs to index on user_id or level.
Rather than thread that logger through your whole codebase, you can register it as the process-wide default so the package-level slog.Info calls use it too:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("now using the JSON handler") // package-level call, JSON output
slog.SetDefault swaps out the logger backing every package-level function. A common pattern is to call it once in main — TextHandler for local runs, JSONHandler in production — so the rest of your code just calls slog.Info without caring which handler is active.
Log Levels and Filtering
slog defines four built-in levels: Debug, Info, Warn and Error. By default a logger emits Info and above, so Debug lines are dropped. You control the threshold with HandlerOptions:
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Debug("this now shows up", "query", "SELECT 1")
Passing HandlerOptions with Level: slog.LevelDebug lowers the threshold so debug records are no longer filtered out. The levels are ordered integers under the hood, which is what lets the handler compare them cheaply.
Often you want to change the level at runtime — for example, flipping on debug logging via an environment variable without a redeploy. That’s what slog.LevelVar is for:
var lvl slog.LevelVar // defaults to LevelInfo
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &lvl,
}))
// later, perhaps in response to a signal or config change:
lvl.Set(slog.LevelDebug)
LevelVar holds a level that can be updated atomically while the program runs. Because we passed a pointer to it in the options, every subsequent log call reads the current value — so lvl.Set(slog.LevelDebug) instantly turns on debug logging everywhere that logger is used.
Attributes, With, and Groups
The loose "key", value form is convenient but has a downside: a mismatched pair silently produces a malformed record. For code you care about, use the strongly-typed Attr constructors:
slog.Info("payment processed",
slog.String("currency", "GBP"),
slog.Int("amount_pence", 4999),
slog.Bool("captured", true),
)
slog.String, slog.Int, slog.Bool and friends build typed slog.Attr values. They’re slightly more verbose but type-safe, and as we’ll see, they also avoid an allocation on the hot path.
When several log lines share the same context — a request ID, a user — repeating those fields is tedious and error-prone. Logger.With returns a child logger that carries a fixed set of attributes:
reqLogger := logger.With(
"request_id", "req_8a3f",
"user_id", "u_123",
)
reqLogger.Info("fetching profile")
reqLogger.Info("profile returned", "duration_ms", 14)
Every line emitted through reqLogger automatically includes request_id and user_id. With returns a new logger and doesn’t mutate the original, so you can derive request-scoped loggers freely without affecting the base logger.
To nest related fields under a common namespace, use groups:
logger.Info("request completed",
slog.Group("http",
slog.String("method", "GET"),
slog.Int("status", 200),
),
)
In JSON this nests the fields under an http object: "http":{"method":"GET","status":200}. slog.Group keeps related attributes together, which makes large records far easier to read and query. Logger.WithGroup("http") does the same thing for every subsequent attribute on a child logger.
Request-Scoped Logging With context.Context
In a real service you want request-scoped attributes — a request ID, a trace ID — to appear on every log line for that request, including logs emitted deep inside functions you call. slog supports this with the Context variants and a logger stored in context.Context.
First, the Context methods. slog.InfoContext (and DebugContext, WarnContext, ErrorContext) take a context.Context as their first argument:
slog.InfoContext(ctx, "handling request", "path", r.URL.Path)
Passing the context lets handlers extract context-bound values — distributed-tracing spans, for example — at log time. The built-in handlers don’t read anything from the context by default, but custom handlers (next section) can.
The common pattern is to stash a request-scoped logger in the context at the edge of your system and pull it back out wherever you need it:
type ctxKey struct{}
// WithLogger stores a logger in the context.
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
// LoggerFrom retrieves it, falling back to the default.
func LoggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
We use an unexported ctxKey struct as the context key — the idiomatic way to avoid collisions with keys set by other packages. LoggerFrom always returns a usable logger by falling back to slog.Default(), so callers never have to nil-check.
Wiring this into HTTP middleware gives every handler a logger already tagged with the request ID:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
l := slog.Default().With("request_id", reqID)
ctx := WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The middleware derives a logger carrying the request ID and tucks it into the request context. Any downstream handler calls LoggerFrom(r.Context()) and logs with the request ID attached automatically — no manual plumbing of the field through every function signature.
Custom Handlers and LogValuer
Because slog is built around an interface, you can write your own handler when the built-in ones don’t fit — to add fields from context, change the output format, or fan out to multiple destinations. A slog.Handler implements four methods:
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
Enabled is the cheap level check called before a record is built, Handle does the actual work, and WithAttrs/WithGroup return derived handlers when someone calls With or WithGroup. A common real-world need is a handler that pulls a trace ID out of the context and adds it to every record. Rather than implementing all four methods from scratch, wrap an existing handler:
type TraceHandler struct {
slog.Handler // embed a real handler; inherit Enabled/WithAttrs/WithGroup
}
func (h TraceHandler) Handle(ctx context.Context, r slog.Record) error {
if traceID, ok := ctx.Value(traceKey{}).(string); ok {
r.AddAttrs(slog.String("trace_id", traceID))
}
return h.Handler.Handle(ctx, r)
}
By embedding slog.Handler, we inherit three of the four methods and only override Handle. Our version reads the trace ID from the context, appends it to the record with r.AddAttrs, then delegates to the wrapped handler to do the formatting and writing. Wrap any handler with it: slog.New(TraceHandler{slog.NewJSONHandler(os.Stdout, nil)}).
The other half of customisation is controlling how your own types appear in logs. Implement the LogValuer interface and slog will call it whenever a value of that type is logged. This is the cleanest way to redact secrets:
type Password string
// LogValue controls how a Password renders in logs.
func (Password) LogValue() slog.Value {
return slog.StringValue("REDACTED")
}
func main() {
pw := Password("hunter2")
slog.Info("user created", "password", pw)
// => ... password=REDACTED
}
slog recognises that Password implements LogValuer and substitutes the result of LogValue() instead of the raw value. The real password never reaches the handler, so it can’t leak into your logs — and you get this protection everywhere a Password is logged, automatically.
A Note on Performance
slog was designed so that the common case is cheap. Two things are worth knowing:
The Record type stores a small number of attributes inline rather than allocating a slice, so typical log lines avoid heap allocations. And the Enabled check happens before the record is assembled — a filtered-out Debug call does almost no work, so leaving debug logging in your code is close to free in production.
The one place allocations creep in is the loose "key", value form, where passing values through any can cause boxing. The typed constructors avoid this:
// loose form — convenient, may allocate
slog.Info("done", "count", n)
// typed Attr — avoids boxing on the hot path
slog.LogAttrs(context.Background(), slog.LevelInfo, "done", slog.Int("count", n))
slog.LogAttrs takes only slog.Attr values, which is the most allocation-efficient way to log. Reach for it in genuinely hot paths; everywhere else, the readability of the loose form wins and the difference won’t show up in a profile. As always, measure before optimising rather than guessing.
Migrating From logrus or zap
Moving to slog is mostly mechanical, because the structured model is the same. The main change is that fields become trailing key/value pairs or Attr values.
// logrus
log.WithFields(log.Fields{
"user_id": id,
"ip": ip,
}).Info("user logged in")
// zap
logger.Info("user logged in",
zap.String("user_id", id),
zap.String("ip", ip),
)
// slog
slog.Info("user logged in",
"user_id", id,
"ip", ip,
)
The shapes line up almost one-to-one: logrus.Fields and zap.String(...) both become slog key/value pairs. For libraries that accept a logger, swap the concrete type for *slog.Logger, and if you previously relied on a global logrus logger, slog.SetDefault gives you the same convenience.
Conclusion
log/slog brings structured logging into the standard library with a clean, idiomatic API and a pluggable handler model. For new projects it should be your default: you get JSON output, levels, context propagation and redaction without a single third-party dependency, all under Go’s compatibility promise.
The third-party loggers aren’t going away — zap and zerolog still win on raw throughput for the rare workload that needs it — but for the vast majority of services, slog is now the obvious choice.
What’s Next
- Go Context Tutorial — go deeper on the
context.Contextpatterns we used for request-scoped logging. - The Complete Guide to Building REST APIs in Go — wire
sloginto a real HTTP service with middleware. - The Complete Guide to Testing in Go — capture and assert on log output in your tests.
Frequently Asked Questions
What is log/slog in Go?
log/slog is the structured logging package added to Go’s standard library in Go 1.21. It logs messages alongside typed key/value attributes and can output them as text or JSON, making logs easy to query in aggregators like Loki, CloudWatch or Datadog.
How do I log JSON in Go?
Create a logger backed by a JSONHandler: slog.New(slog.NewJSONHandler(os.Stdout, nil)). Every attribute you pass becomes a field in a one-line JSON object. Call slog.SetDefault on it if you want the package-level slog.Info functions to emit JSON too.
Is slog faster than zap?
For raw throughput, zap and zerolog still edge ahead in synthetic benchmarks. slog was designed to be allocation-cheap and is competitive for realistic workloads, so unless logging is a measured bottleneck in a hot path, the difference rarely matters in practice.
Should I still use logrus?
For new projects, no. logrus is in maintenance mode, and slog covers the same structured-logging use cases from the standard library with no external dependency. Existing logrus code can be migrated incrementally, since the field-based model maps closely onto slog.
How do I add a logger to context in Go?
Store a *slog.Logger in the context with context.WithValue using an unexported key type, then retrieve it with a helper that falls back to slog.Default(). A common pattern is to attach a request-scoped logger in HTTP middleware so every downstream handler logs with the request ID already attached.
Continue Learning
An Introduction to Go Closures - Tutorial
Learn how closures work in Go with simple, practical examples. Understand lexical scoping and how closures capture and maintain their own state.
Makefiles for Go Developers
Learn how to use Makefiles in Go projects to automate builds, cross-compilation, and common dev tasks with a single `make` command.
Go Interfaces Tutorial
Learn how Go interfaces work — implicit satisfaction, defining contracts, and writing flexible, testable code without inheritance or explicit implements keywords.
Reading And Writing To Files in Go
Learn how to read and write files in Go using the os package — covering os.ReadFile, os.WriteFile, appending to existing files, and file permissions.