For years, every Go service I wrote started with the same decision: which logging library this time? Since Go 1.21 that decision has a boring, excellent default — log/slog ships with the standard library, speaks JSON out of the box, and needs no dependency at all. This post is the tour I wish I had when I migrated my first service to it.
Why Structured Logs Beat Formatted Strings
Here is a line the old log package might produce:
2026/08/04 10:15:02 user 42 checkout failed after 1.2s: payment declined
It reads fine. Now try answering “how many checkouts failed for users on the EU cluster last Tuesday between 14:00 and 15:00?” You are writing a regular expression.
The same event as structured data:
{"time":"2026-08-04T10:15:02.113Z","level":"ERROR","msg":"checkout failed",
"user_id":42,"duration_ms":1204,"reason":"payment declined","cluster":"eu"}
Now it is a query. Every log aggregator — Loki, Elasticsearch, CloudWatch, BigQuery — indexes those fields directly. The point of structured logging is not prettier output; it is that your logs become a queryable dataset.
The Smallest Useful Setup
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// Make it the package-level default so slog.Info et al. use it too.
slog.SetDefault(logger)
slog.Info("service started", "port", 8080, "env", "production")
}
Output:
{"time":"2026-08-04T10:15:02.09Z","level":"INFO","msg":"service started","port":8080,"env":"production"}
The variadic arguments are alternating keys and values. If you prefer something the compiler can check, use typed attributes instead:
slog.Info("service started",
slog.Int("port", 8080),
slog.String("env", "production"),
)
Both forms are fine, and you can mix them. The typed form costs a little more typing and saves you from the classic odd-number-of-arguments bug, where a stray value ends up under the key !BADKEY.
For local development, swap the handler and keep everything else:
var handler slog.Handler
if os.Getenv("ENV") == "development" {
handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
}
slog.SetDefault(slog.New(handler))
Levels, and Changing Them Without a Redeploy
slog has four built-in levels — Debug (-4), Info (0), Warn (4), Error (8) — as plain integers, so you can define your own in between if you really need to.
The more useful trick is a LevelVar, which lets you change the level at runtime:
var logLevel = new(slog.LevelVar) // defaults to Info
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})))
// Flip to debug on demand — from a signal handler, an admin endpoint,
// or a config watcher.
http.HandleFunc("/debug/level", func(w http.ResponseWriter, r *http.Request) {
var l slog.Level
if err := l.UnmarshalText([]byte(r.FormValue("level"))); err != nil {
http.Error(w, "bad level", http.StatusBadRequest)
return
}
logLevel.Set(l)
slog.Warn("log level changed", "level", l)
})
}
Being able to turn on debug logging for two minutes on a misbehaving pod, without a deploy, has saved me more time than any other logging feature. Put that endpoint behind authentication — the router access permission patterns I wrote about earlier work well for exactly this kind of internal route.
Attaching Context With With
logger.With returns a new logger that carries the given attributes on every subsequent call. This is how you stop repeating yourself:
// Once, at construction:
type Worker struct {
log *slog.Logger
}
func NewWorker(id int, queue string) *Worker {
return &Worker{
log: slog.Default().With("worker_id", id, "queue", queue),
}
}
func (w *Worker) process(job Job) {
// Every line from this worker carries worker_id and queue automatically.
w.log.Info("job started", "job_id", job.ID)
// ...
w.log.Info("job finished", "job_id", job.ID, "duration_ms", 42)
}
With does the attribute formatting work once, at call time, rather than on every log line — so a long-lived logger built with With is cheaper than passing the same attributes repeatedly.
Use WithGroup when you want to namespace a block of fields:
log := slog.Default().WithGroup("http")
log.Info("request", "method", "GET", "path", "/users")
// {"level":"INFO","msg":"request","http":{"method":"GET","path":"/users"}}
Or group inline, for one call:
slog.Info("upstream call",
slog.String("service", "billing"),
slog.Group("response",
slog.Int("status", 502),
slog.Duration("latency", 1200*time.Millisecond),
),
)
Request-Scoped Logging
The pattern that pays off most in a web service: put a logger carrying the request ID into the request context, then let every layer below pull it out. If you have not used context.Context for this kind of request-scoped data before, understanding Golang context covers the rules — including why the key must be an unexported type.
package logging
import (
"context"
"log/slog"
)
type ctxKey struct{}
// Into returns a copy of ctx carrying logger.
func Into(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, logger)
}
// From returns the logger stored in ctx, or the default logger.
func From(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
The middleware that fills it in — a close cousin of the handler-wrapping in my Go middleware example:
func Middleware(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 = uuid.NewString()
}
log := slog.Default().With(
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
)
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r.WithContext(logging.Into(r.Context(), log)))
log.Info("request completed",
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
// statusRecorder remembers the status code written by the handler.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Now any function that already takes a context.Context can log with full request context and no extra parameters:
func (s *Store) User(ctx context.Context, id int64) (*User, error) {
logging.From(ctx).Debug("loading user", "user_id", id)
// ...
}
Every line from that request — across every layer — shares a request_id. Tracing one user’s bad afternoon becomes a single filter in your log viewer.
Redacting Secrets With ReplaceAttr
HandlerOptions.ReplaceAttr runs on every attribute before it is written. It is the right place to enforce policy centrally instead of trusting each call site.
var sensitive = map[string]bool{
"password": true, "token": true, "authorization": true,
"api_key": true, "secret": true, "set-cookie": true,
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true, // include file:line — useful for errors
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if sensitive[strings.ToLower(a.Key)] {
return slog.String(a.Key, "[REDACTED]")
}
// Rename "time" to "timestamp" to match the rest of our pipeline.
if a.Key == slog.TimeKey && len(groups) == 0 {
a.Key = "timestamp"
}
return a
},
})
For types you control, implementing LogValuer is even better — the value redacts itself wherever it is logged:
type Password string
// LogValue implements slog.LogValuer so a Password never reaches a log sink.
func (Password) LogValue() slog.Value {
return slog.StringValue("[REDACTED]")
}
slog.Info("login attempt", "user", "alex", "password", Password("hunter2"))
// {"level":"INFO","msg":"login attempt","user":"alex","password":"[REDACTED]"}
LogValuer is also how you log a struct compactly. Give your User type a LogValue that returns a group with just the ID and role, and you never accidentally dump an entire record — with its email address and hashed password — into a log line.
Logging Errors Properly
Log the error value, not a formatted string, and let the handler decide how to render it:
if err != nil {
slog.Error("checkout failed", "err", err, "user_id", userID)
return fmt.Errorf("checkout: %w", err)
}
Two habits worth keeping:
- Log once, at the boundary. If you log here and return the error, every caller up the stack logs it again. That is the same rule I covered in error handling in Go, and
slogdoes not change it. - Do not log
context.Canceledas an error. A user closing a tab is not an incident.
switch {
case errors.Is(err, context.Canceled):
slog.Debug("client disconnected", "path", r.URL.Path)
case err != nil:
slog.Error("request failed", "err", err, "path", r.URL.Path)
}
Bridging Libraries That Use the Old log Package
Dependencies still writing to the standard log package do not have to break your JSON output:
// Route everything from the standard logger through slog at Info level.
slog.SetLogLoggerLevel(slog.LevelInfo)
// Or hand a specific component its own *log.Logger backed by slog.
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError),
}
That second line is worth adding to every server you write — http.Server logs connection errors through ErrorLog, and by default they land on stderr as unstructured text that your aggregator will not parse.
Performance Notes
slog is designed so the common path allocates very little, but a few habits matter:
-
Guard genuinely expensive debug work behind
Enabled:if slog.Default().Enabled(ctx, slog.LevelDebug) { slog.Debug("payload", "body", expensiveDump(req)) } -
Prefer
LogAttrsin hot paths — it takes typed attributes and skips theanyboxing:slog.LogAttrs(ctx, slog.LevelInfo, "cache hit", slog.String("key", key), slog.Int("size", len(val)), ) -
Build the per-request logger once with
With, not per log line.
None of this matters at ten requests per second. All of it matters in a hot loop — the same way caching with Ristretto only pays off once you are actually calling the expensive thing often.
Checklist
- One handler, configured once in
main, installed withslog.SetDefault. - JSON in production, text locally.
- A
LevelVarso you can raise verbosity without a redeploy. - A request-scoped logger in the context, carrying the request ID.
ReplaceAttrorLogValuerfor anything secret.- Errors logged once, at the boundary, as values.
http.Server.ErrorLogwired throughslog.
Conclusion
log/slog removed the last dependency I used to add reflexively to every new Go service. It is fast enough, it is in the standard library, and its handler interface means you can change output format or destination without touching a single call site. If you are still logging with fmt.Sprintf, the migration is mostly mechanical — and the first time you filter a week of logs by request_id, you will not want to go back.
It pays off especially well for anything token-billed, where a handful of numeric fields per call is the only record of what a feature costs — calling an LLM from Go covers which ones to log.