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:#f3e5f5Quick 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 --> JSONimport (
"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:#fce4eclogger := 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_idtype 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 #
slogis the default logging choice for all Go 1.21+ projects — replacinglog,logrus, andzapfor common cases without external dependencies.JSONHandlerfor production,TextHandlerfor 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.Attris faster than alternating key-values —slog.String("key", val)avoids reflection allocation compared to"key", val. UseAttron hot paths.logger.Enabled(ctx, level)before expensive logging operations — avoid building complex debug messages if the Debug level isn’t active.LevelVarfor changing levels without restarting — expose an HTTP endpoint to change the log level at runtime when debugging in production.ReplaceAttrfor normalization — standardize field names (e.g.time→timestamp) to be compatible with the formats expected by log aggregation systems.slog.SetDefault(logger)soslog.Info()etc. use the configured logger — don’t rely on the default logger in production applications.