Deep Dive and Migration Guide to Go 1.21+'s Structured Logging with slog
Grace Collins
Solutions Engineer · Leapcell

Introduction: Elevating Go Logging with slog
For years, Go's standard log package has served as a foundational, albeit simplistic, solution for application logging. While functional for basic needs, the lack of structured logging capabilities often led to challenges in log parsing, analysis, and debugging, especially in complex, distributed systems. Developers frequently turned to third-party libraries like Zerolog, Zap, or Logrus to gain the benefits of structured, machine-readable logs.
The landscape of logging in Go significantly evolved with the introduction of slog in Go 1.21. slog is a new, opinionated structured logging package that became part of the standard library, offering a robust, performant, and extensible solution directly out-of-the-box. This addition marks a pivotal moment for Go developers, providing a first-party, idiomatic way to achieve high-quality structured logging without external dependencies. This article delves into slog, comparing it with established third-party libraries, and offers a practical guide for migrating existing Go applications to leverage its capabilities. The goal is to illuminate its advantages and equip developers with the knowledge to adopt slog effectively, streamlining their logging infrastructure and improving operational observability.
Understanding Structured Logging with slog
Before diving into comparisons and migration, let's establish a common understanding of key terms and slog's foundational design.
Structured Logging: Unlike traditional, human-readable log messages that are often free-form strings, structured logging outputs logs in a machine-readable format, typically JSON or key-value pairs. Each log entry is a discrete object containing fields like timestamp, level, message, and additional contextual data. This format makes logs easily parsable, queryable, and aggregatable by logging systems (e.g., Elasticsearch, Splunk), significantly enhancing observability and debugging.
slog Core Components:
slog.Logger: The primary logging interface. It's safe for concurrent use and provides methods likeInfo,Warn,Error, andDebugfor different log levels. Importantly, it accepts variadic arguments ofslog.Attrto add structured data.slog.Handler: An interface that defines how log records are processed and outputted.slogprovides built-in handlers for JSON (slog.JSONHandler) and text (slog.TextHandler), and custom handlers can be implemented. Handlers are where the actual formatting and writing of logs occur.slog.Attr: Represents a key-value attribute pair that can be added to log records.slog.Any,slog.String,slog.Int, etc., are functions to createslog.Attrinstances.- Log Levels:
slogdefines standard log levels:slog.LevelDebug,slog.LevelInfo,slog.LevelWarn,slog.LevelError. Custom levels can also be introduced.
Basic slog Usage
Let's start with a simple example of slog in action:
package main import ( "log/slog" "os" ) func main() { // Default logger uses TextHandler and writes to os.Stderr slog.Info("Hello, world!") // Create a new JSON logger that writes to stdout jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, // Log all levels from Debug upwards })) jsonLogger.Info("User logged in", slog.String("user_id", "123"), slog.Int("session_id", 456), slog.Any("custom_data", map[string]string{"source": "web"}), ) jsonLogger.Error("Failed to process request", slog.String("method", "GET"), slog.String("path", "/api/v1/data"), slog.Int("status", 500), slog.String("error", "database connection failed"), ) }
Running this code would produce output similar to this:
time=2023-10-27T10:00:00.000Z level=INFO msg="Hello, world!"
{"time":"2023-10-27T10:00:00.000Z","level":"INFO","msg":"User logged in","user_id":"123","session_id":456,"custom_data":{"source":"web"}}
{"time":"2023-10-27T10:00:00.000Z","level":"ERROR","msg":"Failed to process request","method":"GET","path":"/api/v1/data","status":500,"error":"database connection failed"}
Notice how slog.Info with its default TextHandler produces key-value pairs, while slog.JSONHandler outputs valid JSON objects, making advanced log analysis much simpler.
Comparing slog with Existing Solutions
Many Go developers are familiar with third-party structured logging libraries. Let's compare slog with some prominent ones:
-
logstandard library:- Pros: Always available, simple API.
- Cons: No structured logging, difficult to parse, performance can be an issue for very high throughput.
slogvslog:slogis a direct, structured upgrade. It offers performance, flexibility, and machine-readability thatlogentirely lacks.
-
Zerolog:
- Pros: Extremely fast, zero-allocation per log event (when used correctly), fluent API, highly configurable.
- Cons: Opinionated (primarily JSON output), can be slightly less ergonomic for beginners than
slog's variadicAttrapproach. slogvs Zerolog: Zerolog often edges outslogin raw performance for extremely high-throughput scenarios due to its meticulous design for zero allocations. However,slogoffers a similar structured output with the benefit of being part of the standard library, meaning no external dependencies and potentially better long-term integration with the Go ecosystem.slog'sAttrmodel can feel more "Go-native" to some.
-
Zap:
- Pros: Very high performance, supports both structured (JSON/console) and untyped logs, highly flexible, strong emphasis on performance through pooling.
- Cons: More complex API than others, especially for advanced configurations.
slogvs Zap: Both are performance-oriented. Zap, similar to Zerolog, can achieve slightly better performance in some benchmarks due to aggressive optimization and pooling.slogprovides a standard-library alternative that offers excellent performance for most applications, along with a simpler, more idiomatic API, making it a compelling choice for many projects.
-
Logrus:
- Pros: Features rich, extensible, supports various formatters and hooks, widely adopted.
- Cons: Slower than Zap/Zerolog for high volumes, less emphasis on purely structured fields by default, can have higher memory allocations.
slogvs Logrus:slogoffers superior performance and a more explicitly structured approach from the ground up. While Logrus is feature-rich,slog's performance and standard library status make it a more modern and often preferred choice for new Go projects aiming for structured logging.
In essence, slog bridges the gap between the simplicity of the standard log package and the advanced capabilities of third-party structured loggers, offering a balanced solution that is performant, extensible, and natively supported. For most applications, slog's performance is more than adequate, removing the need for external logging dependencies.
Migration Guide to slog
Migrating to slog involves identifying your current logging patterns and gracefully transitioning them to slog's API.
Step 1: Default Logger (Optional, for quick wins)
The easiest partial migration is to replace log with slog for basic Info level logs. slog provides a default logger accessible via package-level functions.
Before (using log):
import "log" log.Println("Operation started") log.Printf("Processing item %d", itemID)
After (using slog default logger):
import ( "log/slog" ) slog.Info("Operation started") slog.Info("Processing item", slog.Int("item_id", itemID))
This is a quick way to get some structured logging, but for full control, you'll want to create your own slog.Logger instances.
Step 2: Initialize a Global/Contextual Logger
For larger applications, it's best to initialize one or more slog.Logger instances and pass them around or make them globally available (with caution).
// main.go or initialization logic import ( "log/slog" "os" ) var appLogger *slog.Logger func init() { // Configure global logger for JSON output and Info level appLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, AddSource: true, // Adds file and line number })) slog.SetDefault(appLogger) // Optionally set as default, but prefer passing explictly } // In your application code: func processOrder(orderID string) { appLogger.Info("Processing order", slog.String("order_id", orderID), slog.String("status", "pending"), ) // ... appLogger.Warn("Potential issue with order", slog.String("order_id", orderID), slog.String("reason", "low stock"), ) }
When passing slog.Logger instances, consider using Go's context.Context to carry loggers, especially in request-scoped operations.
import ( "context" "log/slog" ) type contextKey string const loggerKey contextKey = "logger" // WithLogger returns a new context with the provided logger. func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { return context.WithValue(ctx, loggerKey, logger) } // FromContext returns the logger from the context, or the default logger if not found. func FromContext(ctx context.Context) *slog.Logger { if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok { return logger } return slog.Default() // Fallback to default } // Example usage in an HTTP handler func MyHandler(w http.ResponseWriter, r *http.Request) { requestLogger := appLogger.With(slog.String("request_id", generateRequestID())) ctx := WithLogger(r.Context(), requestLogger) log := FromContext(ctx) log.Info("Incoming request", slog.String("method", r.Method), slog.String("path", r.URL.Path), ) // ... rest of handler logic }
Step 3: Map Log Levels
Ensure your application's current log levels (e.g., debug, info, warn, error) are correctly mapped to slog.Level constants.
| Old Level (e.g., Logrus) | slog.Level |
|---|---|
| DebugLevel | slog.LevelDebug |
| InfoLevel | slog.LevelInfo |
| WarnLevel | slog.LevelWarn |
| ErrorLevel | slog.LevelError |
| FatalLevel / PanicLevel | slog.LevelError (and then os.Exit(1) / panic) |
slog does not have Fatal or Panic levels directly. For these, you would typically log an Error level message and then explicitly call os.Exit(1) or panic(). You could also create a wrapper function for convenience.
Step 4: Convert Dynamic Fields to slog.Attr
This is the most critical part of moving to structured logging. Identify variable data points in your log messages and convert them into slog.Attr key-value pairs.
Before (Logrus with Fields):
import "github.com/sirupsen/logrus" logrus.WithFields(logrus.Fields{ "user_id": userID, "order_id": orderID, }).Info("Order placed")
After (using slog Attr):
import "log/slog" logger.Info("Order placed", slog.String("user_id", userID), slog.String("order_id", orderID), )
slog offers various functions (slog.String, slog.Int, slog.Bool, slog.Duration, slog.Time, slog.Any) to create Attrs. Use slog.Any for arbitrary types, but be mindful of performance implications as it uses reflection.
Step 5: Handle Contextual Logging (With Method)
slog's Logger.With method is excellent for adding persistent attributes to a logger that will be applied to all subsequent log calls made with that derived logger.
Before (Zerolog with With()):
import "github.com/rs/zerolog" reqLogger := zerolog.New(os.Stdout).With().Str("request_id", reqID).Logger() reqLogger.Info().Msg("Request started")
After (using slog.Logger.With):
import "log/slog" reqLogger := appLogger.With(slog.String("request_id", reqID)) reqLogger.Info("Request started")
Step 6: Custom Handlers and Extensibility
If you rely on custom formatters or hooks in third-party libraries, you'll need to re-implement this functionality using slog.Handler interface.
// Example of a custom handler that wraps JSONHandler and adds a custom field type customHandler struct { slog.Handler serviceName string } func NewCustomHandler(h slog.Handler, serviceName string) *customHandler { return &customHandler{h, serviceName} } func (h *customHandler) Handle(ctx context.Context, r slog.Record) error { // Prepend a service_name attribute to all records r.Add(slog.String("service_name", h.serviceName)) return h.Handler.Handle(ctx, r) } // Usage: func main() { jsonHandler := slog.NewJSONHandler(os.Stdout, nil) logger := slog.New(NewCustomHandler(jsonHandler, "my-go-service")) logger.Info("Application started") // Output will include "service_name":"my-go-service" }
This extensibility allows you to integrate slog with observability platforms, distributed tracing IDs, and other custom requirements.
Conclusion: Embrace slog for Modern Go Logging
The introduction of slog in Go 1.21 is a significant enhancement to the language's standard library, providing a native, high-performance, and structured logging solution that was previously only available through third-party packages. By offering a robust API for structured data, contextual logging, and handler extensibility, slog empowers Go developers to build more observable and maintainable applications. Migrating to slog standardizes logging practices within the Go ecosystem, reduces external dependencies, and streamlines log analysis, ultimately leading to more efficient development and operations. Embracing slog is a clear step towards modern, idiomatic Go programming.

