Go 1.25's slog Package: A Practical Guide to Structured Logging

For years, Go developers have navigated the journey from simple fmt.Println statements during early development to more robust logging solutions in production. While effective for simple debugging, this approach quickly breaks down under the complexity of modern systems. The release of Go 1.25 marks a pivotal moment in this evolution. The introduction of the new slog package is a game-changer for observability, providing a powerful, performant, and standardized way to produce machine-readable logs directly from the standard library.

Traditional, unstructured logging presents significant challenges at scale. Log messages formatted as simple strings, often using log.Printf, are difficult for automated systems to parse reliably. Extracting specific information, like a user ID or trace identifier, requires fragile regular expressions. As applications grow and generate terabytes of log data, querying and analyzing these unstructured text blobs becomes inefficient, costly, and sometimes impossible. This hinders our ability to effectively monitor applications, debug issues, and create meaningful alerts.

Enter slog, Go's new official package for structured logging. It was designed from the ground up to address the shortcomings of traditional logging by establishing a clear, key-value format. Long-awaited by the community, its inclusion in the standard library eliminates the need to choose from a wide array of third-party libraries, providing a common foundation for the entire Go ecosystem.

This article serves as a practical, hands-on guide for professional developers looking to adopt slog in their Go 1.25 projects. We will cover everything from the fundamental concepts of structured logging to advanced patterns for integrating slog into complex applications, complete with real-world code examples.

What is Structured Logging and Why Should You Care?

The Limitations of Traditional Logging

In production environments, using log.Printf(\"User %s failed to process order %d\", user, orderID) creates a human-readable but machine-hostile log entry. Imagine you need to find all failed orders for a specific user across a distributed system generating millions of log lines per minute. You would have to construct a complex and brittle regex to parse the string. If a developer later changes the log message to log.Printf(\"Order %d processing failed for user %s\", orderID, user), your parsing logic breaks instantly.

This fragility makes automated log analysis a nightmare. Building dashboards, creating alerts for specific error conditions (e.g., 'alert if user X has more than 5 failed orders in a minute'), or simply searching for all logs related to a single request becomes an arduous task. Unstructured text lacks the explicit context that machines need to operate reliably.

The Power of Key-Value Pairs

Structured logging solves this problem by enforcing a consistent, machine-parsable format. Instead of a free-form string, it records information as a collection of key-value pairs, often serialized as JSON. The previous log message would be captured like this: {\"level\":\"ERROR\", \"msg\":\"Order processing failed\", \"user_id\":\"jane.doe\", \"order_id\":12345}.

The benefits of this approach are immediate and profound. Logs become a rich source of queryable data. You can now easily run queries like level=ERROR AND user_id=\"jane.doe\" in your log management system. This enables powerful features:

  • Machine-Parsable: Logs can be reliably ingested and indexed by tools like Datadog, Splunk, and the Elastic Stack without custom parsing rules.
  • Easy to Query: Finding related events, calculating metrics, and debugging complex issues becomes trivial.
  • Richer Context: You can attach detailed contextual information (e.g., request IDs, latency metrics, application version) to every log message, providing a complete picture of an event.

Meet slog: Go's New Standard Logging Library

Core Concepts: Logger, Handler, and Record

The slog package is built on three fundamental concepts that work together to provide a flexible and efficient logging pipeline:

  • Logger: This is the primary user-facing API. Your application code interacts with a slog.Logger instance to emit log messages at different severity levels (e.g., logger.Info(), logger.Error()). It's the 'front door' to the logging system.
  • Handler: A slog.Handler is the backend processor. It's responsible for taking a log event, formatting it into a specific output format (like text or JSON), and writing it to a destination (such as os.Stdout, a file, or a network connection). This separation of concerns allows you to change your logging backend without changing your application code.
  • Record: A slog.Record is the immutable data structure that represents a single logging event. It captures the timestamp, log level, message, and all the associated key-value attributes. The Logger creates a Record and passes it to the Handler for processing.

Built-in Handlers: TextHandler vs. JSONHandler

slog ships with two essential built-in handlers to cover the most common use cases. The TextHandler is optimized for human readability, making it ideal for local development.

package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n)\n\nfunc main() {\n\tlogger := slog.New(slog.NewTextHandler(os.Stdout, nil))\n\tlogger.Info(\"Server starting\", \"port\", 8080)\n}\n// Output:\n// time=2023-10-27T10:00:00.000-07:00 level=INFO msg=\"Server starting\" port=8080

For production environments, the JSONHandler is the recommended choice. It outputs logs in a structured JSON format that can be easily ingested and parsed by log aggregation platforms.

package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n)\n\nfunc main() {\n\tlogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))\n\tlogger.Info(\"Server starting\", \"port\", 8080)\n}\n// Output:\n// {\"time\":\"2023-10-27T10:00:00.000-07:00\",\"level\":\"INFO\",\"msg\":\"Server starting\",\"port\":8080}

Understanding Logging Levels

slog provides four standard logging levels, each with a corresponding method on the Logger:

  • Debug: For detailed information useful during development and troubleshooting.
  • Info: For routine information about the application's operation.
  • Warn: For potentially harmful situations that do not constitute an error.
  • Error: For error events that might still allow the application to continue running.

You can control log verbosity by setting a minimum level on the handler. Any log message below this level will be discarded, which is crucial for managing log volume in production.

package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n)\n\nfunc main() {\n\topts := &slog.HandlerOptions{\n\t\tLevel: slog.LevelInfo, // Set the minimum log level to Info\n\t}\n\n\tlogger := slog.New(slog.NewTextHandler(os.Stdout, opts))\n\n\t// This log will be discarded because its level (Debug) is below the minimum (Info).\n\tlogger.Debug(\"Connecting to database...\", \"db_host\", \"localhost\")\n\n\t// This log will be printed.\n\tlogger.Info(\"Database connection successful.\")\n}\n// Output:\n// time=2023-10-27T10:00:00.000-07:00 level=INFO msg=\"Database connection successful.\"

Practical slog Usage: From Basics to Best Practices

Your First Structured Log

Writing a structured log is straightforward. The slog package provides a default logger, so you can start logging immediately without any setup. The logging methods, like Info, accept a message string followed by a sequence of alternating keys and values.

Keys must be strings, while values can be of any type. For convenience and performance, it's best to stick to common types.

package main\n\nimport (\n\t\"log/slog\"\n)\n\nfunc main() {\n\t// Use the default logger, which writes text to stderr.\n\tslog.Info(\n\t\t\"User logged in\",\n\t\t\"username\", \"jane.doe\",\n\t\t\"ip_address\", \"192.168.1.100\",\n\t\t\"success\", true,\n\t)\n}\n// Output:\n// time=... level=INFO msg=\"User logged in\" username=jane.doe ip_address=192.168.1.100 success=true

Adding Rich Context with Attributes and Groups

While alternating keys and values works, slog provides a more explicit and type-safe way to define attributes using the slog.Attr type. You can use helpers like slog.String(), slog.Int(), slog.Duration(), and slog.Bool() to create attributes.

To keep logs organized, you can use slog.Group to nest related attributes under a common key. This is particularly useful for adding complex, structured context, such as details about an incoming HTTP request.

package main\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n)\n\nfunc main() {\n\tslog.Info(\"Processed HTTP request\",\n\t\t// Group related request attributes together\n\t\tslog.Group(\"http_request\",\n\t\t\tslog.String(\"method\", http.MethodGet),\n\t\t\tslog.String(\"path\", \"/api/users\"),\n\t\t\tslog.Int(\"status_code\", http.StatusOK),\n\t\t),\n\t\tslog.Duration(\"duration\", time.Millisecond*55),\n\t)\n}\n// JSON Output:\n// {\"time\":...,\"level\":\"INFO\",\"msg\":\"Processed HTTP request\",\"http_request\":{\"method\":\"GET\",\"path\":\"/api/users\",\"status_code\":200},\"duration\":\"55ms\"}

Creating and Using Custom Logger Instances

While the default package-level logger is convenient, in larger applications it's a best practice to create and pass around specific Logger instances. This improves testability and allows you to add contextual attributes that persist across multiple log calls.

The logger.With() method is the key to this pattern. It returns a new Logger that automatically includes the provided attributes in every subsequent log message. This is perfect for adding request-scoped context, such as a trace or request ID.

package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n)\n\nfunc main() {\n\t// Create a base logger for our application.\n\tbaseLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))\n\n\t// In an HTTP handler, you would create a request-specific logger.\n\trequestLogger := baseLogger.With(\"request_id\", \"trace-12345\")\n\n\trequestLogger.Info(\"Handling user request\")\n\t// ... some business logic ...\n\trequestLogger.Warn(\"User has no permissions\", \"user_id\", \"jane.doe\")\n}\n// Output:\n// {\"time\":...,\"level\":\"INFO\",\"msg\":\"Handling user request\",\"request_id\":\"trace-12345\"}\n// {\"time\":...,\"level\":\"WARN\",\"msg\":\"User has no permissions\",\"request_id\":\"trace-12345\",\"user_id\":\"jane.doe\"}

Advanced slog: Integrating with Your Application

Integrating slog with context.Context

In modern Go web services, context.Context is the standard way to carry request-scoped values, cancellation signals, and deadlines across API boundaries. It's the perfect place to store a request-specific logger.

A common and powerful pattern is to use a middleware to create a logger with a unique request ID, embed it into the context, and then retrieve it in your downstream handlers and services. This ensures that all logs related to a single request can be easily correlated, which is invaluable for debugging.

package main\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n)\n\ntype contextKey string\n\nconst loggerKey = contextKey(\"logger\")\n\n// WithLogger stores a logger in the context.\nfunc WithLogger(ctx context.Context, l *slog.Logger) context.Context {\n\treturn context.WithValue(ctx, loggerKey, l)\n}\n\n// LoggerFromContext retrieves a logger from the context.\n// If no logger is found, it returns the default logger.\nfunc LoggerFromContext(ctx context.Context) *slog.Logger {\n\tif l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {\n\t\treturn l\n\t}\n\treturn slog.Default()\n}\n\n// In a middleware:\n// requestID := ... // generate a unique ID\n// logger := slog.Default().With(\"request_id\", requestID)\n// r = r.WithContext(WithLogger(r.Context(), logger))\n// next.ServeHTTP(w, r)\n\n// In a handler:\n// func myHandler(w http.ResponseWriter, r *http.Request) {\n// \t logger := LoggerFromContext(r.Context())\n// \t logger.Info(\"Processing request...\")\n// }

Writing a Custom Handler (A Brief Overview)

For advanced use cases, you can implement the slog.Handler interface to create custom logging behavior. A handler needs to implement three methods: Enabled(...), Handle(...), and WithAttrs(...). This allows you to create handlers that can filter, modify, or route log records in any way you see fit.

For example, you could write a custom handler that:

  • Redacts sensitive information (like passwords or API keys) from log attributes before passing the record to another handler.
  • Sends Error level logs to an external alerting service like Sentry or PagerDuty while sending other levels to standard output.
  • Batches log records and sends them to a cloud logging service (like CloudWatch or Google Cloud Logging) in a performant way.

The handler interface is composable, meaning you can wrap existing handlers to add new functionality. This makes it a powerful tool for tailoring slog to your application's specific needs without reinventing the wheel.

Migrating from log, zerolog, or zap

For projects currently using the standard log package or popular third-party libraries like zerolog or zap, migrating to slog is a worthwhile investment. The best approach is often a gradual one. Start by using slog for all new services and packages. For existing code, identify the most critical parts of your application and migrate their logging first.

To ease the transition, you can create a compatibility layer. For example, the log package can be configured to route its output to an slog handler using log.SetOutput(logger.Writer()). For zerolog or zap, you can create a thin wrapper that implements their logger interface but calls slog under the hood.

By standardizing on slog, you reduce your project's dependency tree and align with the future of Go's standard library. This brings long-term benefits in terms of maintenance, performance, and a shared understanding across the developer community.

Conclusion: Embrace the New Standard for Go Logging

The slog package is more than just another logging library; it's a new standard for observability in Go. By embracing structured, key-value logging, it provides a robust foundation for building observable, debuggable, and maintainable systems. Its thoughtful design emphasizes performance, flexibility through its handler interface, and ease of use.

As a core part of the standard library, slog makes best-practice structured logging accessible to every Go developer out of the box. It lowers the barrier to entry and provides a common vocabulary and toolset that will benefit the entire ecosystem, from library authors to application developers.

We encourage you to experiment with the slog package in your next Go 1.25 project. Start simple, explore the power of contextual attributes, and discover how much easier it is to understand your application's behavior when your logs are as structured as your code. Share your experiences and help build a community of practice around this fantastic addition to Go.

At ToolShelf, we believe in building professional, privacy-first tools. That's why our tools work locally in your browser—your data never leaves your device, providing security through isolation.

Explore more articles on modern development practices on our blog and check out our suite of offline-first developer tools.

Stay secure & happy coding,
— ToolShelf Team