Logging in Go has come a long way. For years, the community relied on the simple standard log Copy package or turned to powerful third-party libraries like zap and zerolog . With the introduction of log/slog in Go 1.21 , the language now has a native, high-performance, structured logging solution designed to be the new standard. slog Copy isn’t just another logger; it’s a new foundation that provides a common API (the frontend) that separates logging logic from the final output, which is controlled by various logging implementations (the backend). This guide will take you through slog Copy from its fundamentals to advanced patterns, showing you how to make logging a useful signal for observing your applications. The log/slog Copy package is built around three core types: the Logger Copy , the Handler Copy , and the Record Copy . The Logger Copy is the frontend you’ll interact with, the Handler Copy is the backend that does the actual logging work, and the Record Copy is the data passed between them. A Record Copy represents a single log event. It contains all the necessary information about the event including: The time of the event. The severity level ( INFO Copy , WARN Copy , etc.). , , etc.). The log message. All structured key-value attributes. Essentially, a Record Copy is the raw data for each log entry before it’s formatted. A Handler Copy is an interface that’s responsible for processing Records. It’s the engine that determines how and where logs are written. It’s responsible for: Formatting the Record Copy into a specific output, like JSON or plain text. into a specific output, like JSON or plain text. Writing the formatted output to a destination like the console or a file. The log/slog Copy package includes built-in concrete TextHandler Copy and JSONHandler Copy implementations, but you can create custom handlers to meet any requirement. This interface is what makes slog Copy so flexible. The Logger Copy is the entry point for creating logs, and it’s what provides the user-facing API with methods like Info() Copy , Debug() Copy , and Error() Copy . When you call one of these methods, the Logger Copy creates a Record Copy with the message, level, and attributes you provided. It then passes that Record Copy to its configured Handler Copy for processing. Here’s how the entire process works: go Copy 1 2 3 4 5 logger := slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) logger . Info ( "user logged in" , "user_id" , 123 ) Since the JSONHandler Copy is configured to log to the stdout Copy , this yields: json Copy 1 { "time" : "..." , "level" : "INFO" , "msg" : "user logged in" , "user_id" : 123 } The slog.Logger Copy type offers a flexible API that’s designed to handle various logging scenarios, from simple messages to complex, context-aware events. Let’s explore its key methods below. The most common way to log is through the four level-based methods: Debug() Copy , Info() Copy , Warn() Copy , and Error() Copy which correspond to a specific severity level: plain text Copy 1 logger.Info("an info message") json Copy output 1 { "time" : "..." , "level" : "INFO" , "msg" : "an info message" } slog Copy also provides a context-aware version for each level, such as InfoContext() Copy . These variants accept a context.Context Copy type as their first argument, allowing context-aware handlers (if configured) to extract and log values carried within the context: go Copy 1 logger . InfoContext ( context . Background ( ) , "an info message" ) Note that slog Copy ’s context-aware methods will not automatically pull values from the provided context when using the built-in handlers. You must use a context-aware handler for this pattern to work. For more programmatic control or when using custom levels, you can use the generic Log() Copy and LogAttrs() Copy methods, which require you to specify the level explicitly: go Copy 1 2 logger . Log ( context . Background ( ) , slog . LevelInfo , "an info message" ) logger . LogAttrs ( context . Background ( ) , slog . LevelInfo , "an info message" ) After choosing a level and a log message for an event, the next step is to add contextual attributes which allow you to enrich your log entries with structured, queryable data. slog Copy provides a few ways to do this. The most convenient way is to pass them as a sequence of alternating keys and values after the log message: go Copy 1 logger . Info ( "incoming request" , "method" , "GET" , "status" , 200 ) json Copy output 1 2 3 4 5 6 7 { "time" : "..." , "level" : "INFO" , "msg" : "incoming request" , "method" : "GET" , "status" : 200 } This convenience comes with a significant drawback. If you provide an odd number of arguments (e.g., a key without a value), slog Copy doesn’t panic or return an error. Instead, it silently creates a broken log entry by pairing the value-less field with a special !BADKEY Copy key: go Copy 1 2 logger . Warn ( "permission denied" , "user_id" , 12345 , "resource" ) json Copy output 1 2 3 4 { [ ... ] , "!BADKEY" : "resource" } This silent failure is an API footgun that can corrupt your logging data, and you might only discover the problem during a critical incident when your observability tools fail you. To guarantee correctness, you must use the strongly-typed slog.Attr Copy helpers. They makes it impossible to create an unbalanced pair by catching errors at compile time: go Copy 1 2 3 4 5 logger . Warn ( "permission denied" , slog . Int ( "user_id" , 12345 ) , slog . String ( "resource" , "/api/admin" ) , ) While slightly more verbose, using slog.Attr Copy is the only right way to log in Go. It ensures your logs are always well-formed, reliable, and safe from runtime surprises. While using slog.Attr Copy is the safer approach, there’s nothing stopping anyone from using the simpler key, value Copy style in a different part of the codebase. The solution is to make this best practice into an automated, enforceable rule using a linter. For slog Copy , the best tool for this is sloglint . You’ll typically integrate it into your development environment and CI/CD pipeline through golangci-lint : yaml Copy .golangci.yml 1 2 3 4 5 6 7 8 9 linters : default : none enable : - sloglint settings : sloglint : attr-only : true By adding this simple check, you will guarantee that every log statement in your project adheres to the safest and most consistent style, preventing !BADKEY Copy occurrences across the entire project. slog Copy operates with four severity levels . Internally, each level is just an int Copy , and the gaps between them are intentional to leave room for custom levels: slog.LevelDebug Copy (-4) (-4) slog.LevelInfo Copy (0) (0) slog.LevelWarn Copy (4) (4) slog.LevelError Copy (8) All loggers are configured to log at slog.LevelInfo Copy by default, meaning that DEBUG Copy messages will be suppressed: go Copy 1 2 3 4 logger . Debug ( "a debug message" ) logger . Info ( "an info message" ) logger . Warn ( "a warning message" ) logger . Error ( "an error message" ) json Copy output 1 2 3 { "time" : "2025-07-17T10:32:26.364917642+01:00" , "level" : "INFO" , "msg" : "an info message" } { "time" : "2025-07-17T10:32:26.364966625+01:00" , "level" : "WARN" , "msg" : "a warning message" } { "time" : "2025-07-17T10:32:26.36496905+01:00" , "level" : "ERROR" , "msg" : "an error message" } If you have some expensive operations to prepare some data before logging, you’ll want to check logger.Enabled() Copy to confirm if the desired log level is active before performing the expensive work: go Copy 1 2 3 4 if logger . Enabled ( context . Background ( ) , slog . LevelDebug ) { logger . Debug ( "operation complete" , "data" , getExpensiveDebugData ( ) ) } This simple check ensures that expensive operations only run when their output is guaranteed to be logged, thus preventing an unnecessary performance hit. You can control the minimum level that will be processed through slog.HandlerOptions Copy : go Copy 1 2 3 4 handler := slog . NewJSONHandler ( os . Stdout , & slog . HandlerOptions { Level : slog . WarnLevel , } ) logger := slog . New ( handler ) To set the level based on an environmental variable, you may use this pattern: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func getLogLevelFromEnv ( ) slog . Level { levelStr := os . Getenv ( "LOG_LEVEL" ) switch strings . ToLower ( levelStr ) { case "debug" : return slog . LevelDebug case "warn" : return slog . LevelWarn case "error" : return slog . LevelError default : return slog . LevelInfo } } func main ( ) { logger := slog . New ( slog . NewJSONHandler ( os . Stdout , & slog . HandlerOptions { Level : getLogLevelFromEnv ( ) , } ) ) } For production services where you might need to change log verbosity without a restart, slog provides the slog.LevelVar Copy type. It is a dynamic container for the log level that allows you to change it concurrently and safely at any time with Set() Copy .: go Copy 1 2 3 4 5 6 7 8 var logLevel slog . LevelVar logLevel . Set ( getLogLevelFromEnv ( ) ) logger := slog . New ( slog . NewJSONHandler ( os . Stdout , & slog . HandlerOptions { Level : & logLevel , } ) ) For even greater control of severity levels on a per-package basis, you can use the slog-env package which provides a handler that allows setting the log level via the GO_LOG Copy environmental variable: go Copy 1 logger := slog . New ( slogenv . NewHandler ( slog . NewJSONHandler ( os . Stderr , nil ) ) ) Let’s say your program defaults to the INFO Copy level and you're seeing the following logs: json Copy 1 2 3 { "time" : "..." , "level" : "INFO" , "msg" : "main: an info message" } { "time" : "..." , "level" : "WARN" , "msg" : "main: a warning message" } { "time" : "..." , "level" : "ERROR" , "msg" : "main: an error message" } You can enable DEBUG Copy messages with: sh Copy 1 GO_LOG = debug ./myapp json Copy 1 2 3 4 5 { "time" : "..." , "level" : "DEBUG" , "msg" : "app: a debug message" } { "time" : "..." , "level" : "DEBUG" , "msg" : "main: a debug message" } { "time" : "..." , "level" : "INFO" , "msg" : "main: an info message" } { "time" : "..." , "level" : "WARN" , "msg" : "main: a warning message" } { "time" : "..." , "level" : "ERROR" , "msg" : "main: an error message" } You can then raise the minimum level for the main Copy package alone with: sh Copy 1 GO_LOG = debug,main = error go run main.go The DEBUG Copy logs still show up for other packages, but package main Copy is now raised to the ERROR Copy level: json Copy 1 2 { "time" : "..." , "level" : "DEBUG" , "msg" : "app: a debug message" } { "time" : "..." , "level" : "ERROR" , "msg" : "main: an error message" } If you’re missing a log level like TRACE Copy or FATAL Copy , you can easily create them by defining new constants: go Copy 1 2 3 4 const ( LevelTrace = slog . Level ( - 8 ) LevelFatal = slog . Level ( 12 ) ) To use these custom levels, you must use the generic logger.Log() Copy method: go Copy 1 logger . Log ( context . Background ( ) , LevelFatal , "database connection lost" ) However, their default output name isn’t ideal ( DEBUG-4 Copy , ERROR+4 Copy ): json Copy 1 { "time" : "..." , "level" : "ERROR+4" , "msg" : "database connection lost" } You can fix this by providing a ReplaceAttr() Copy function in your HandlerOptions Copy to map the level’s integer value to a custom string: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 opts := & slog . HandlerOptions { ReplaceAttr : func ( groups [ ] string , a slog . Attr ) slog . Attr { if a . Key == slog . LevelKey { level := a . Value . Any ( ) . ( slog . Level ) switch level { case LevelTrace : a . Value = slog . StringValue ( "TRACE" ) case LevelFatal : a . Value = slog . StringValue ( "FATAL" ) } } return a } , } You’ll see a more normal output now: json Copy 1 { "time" : "..." , "level" : "FATAL" , "msg" : "database connection lost" } Note that the ReplaceAttr() Copy is called once for every attribute on every log, so always keep its logic as fast as possible to avoid performance degradation. The Handler Copy is the backend of the logging system that’s responsible for taking a Record Copy , formatting it, and writing it to a destination. A key feature of slog Copy handlers is their composability. Since handlers are just interfaces, it’s easy to create “middleware” handlers that wrap other handlers. This allows you to build a processing pipeline to enrich, filter, or modify log records before they are finally written. You’ll see some examples of this pattern as we go along. The log/slog Copy package ships with two built-in handlers: JSONHandler Copy , which formats logs as JSON . , which formats logs as JSON . TextHandler Copy , which formats logs as key=value Copy pairs. go Copy 1 2 3 4 5 jsonLogger := slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) textLogger := slog . New ( slog . NewTextHandler ( os . Stdout , nil ) ) jsonLogger . Info ( "database connected" , "db_host" , "localhost" , "port" , 5432 ) textLogger . Info ( "database connected" , "db_host" , "localhost" , "port" , 5432 ) plain text Copy output 1 2 {"time":"...","level":"INFO","msg":"database connected","db_host":"localhost","port":5432} time=... level=INFO msg="database connected" db_host=localhost port=5432 This article will focus primarily on JSON logging since its the de facto standard for production logging. You can configure the behavior of the built-in handlers using slog.HandlerOptions Copy , and you’ve already seen this approach for setting the Level Copy and using ReplaceAttrs Copy to provide custom level names. The final option is AddSource Copy , which automatically includes the source code file, function, and line number in the log output: go Copy 1 2 3 4 5 6 opts := & slog . HandlerOptions { AddSource : true , } logger := slog . New ( slog . NewJSONHandler ( os . Stdout , opts ) ) logger . Warn ( "storage space is low" ) json Copy output 1 2 3 4 5 6 7 8 9 10 { "time" : "..." , "level" : "WARN" , "source" : { "function" : "main.main" , "file" : "/path/to/your/project/main.go" , "line" : 15 } , "msg" : "storage space is low" } While source information is handy to have, it comes with a performance penalty because slog must call runtime.Caller() Copy to get the source code information, so keep that in mind. That’s pretty much all you can do to customize the built-in handlers. To go farther than this, you’ll need to utilize third-party handlers created by the community or create a custom one by implementing the Handler interface . Some notable handlers you might find useful include: slog-sampling : A handler for dropping repetitive log entries. : A handler for dropping repetitive log entries. slog-json : Uses the JSON v2 library (coming in Go v1.25) for improved correctness and performance. : Uses the JSON v2 library (coming in Go v1.25) for improved correctness and performance. tint : Writes colorized logs to the console for development environment. : Writes colorized logs to the console for development environment. slog-multi : Provides advanced composition patterns for fanout, buffering, conditional routing, failover, and more. One notable behavior of the built-in handlers is that they do not de-duplicate keys which can cause unpredictable or undefined behavior in telemetry pipelines and observability tools: go Copy 1 2 3 jsonLogger := slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) childLogger := jsonLogger . With ( "app" , "my-service" ) childLogger . Info ( "User logged in" , slog . String ( "app" , "auth-module" ) ) json Copy 1 2 3 4 5 6 { "time" : "..." , "level" : "INFO" , "msg" : "User logged in" , "app" : "auth-module" } There’s currently no consensus on the “correct” behavior, though the relevant GitHub issue remains open and could still evolve. For now, if de-duplication is needed, you must use a third-party “middleware” handler, like slog-dedup , to fix the keys before they are written. It supports various strategies, including overwriting, ignoring, appending, and incrementing the duplicate keys. For example, you could overwrite duplicate keys as follows: go Copy 1 jsonLogger := slog . New ( slogdedup . NewOverwriteHandler ( slog . NewJSONHandler ( os . Stdout , nil ) , nil ) ) json Copy output 1 2 3 4 5 6 { "time" : "..." , "level" : "INFO" , "msg" : "User logged in" , "app" : "auth-module" } The best practice for modern applications is often to log to stdout Copy or stderr Copy , and allow the runtime environment to manage the log stream. However, if your application needs to write directly to a file, you can simply pass an *os.File Copy instance to the slog Copy handler: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 logFile , err := os . OpenFile ( "app.log" , os . O_CREATE | os . O_WRONLY | os . O_APPEND , 0666 ) if err != nil { panic ( err ) } defer logFile . Close ( ) logger := slog . New ( slog . NewJSONHandler ( logFile , nil ) ) logger . Info ( "Starting server..." , "port" , 8080 ) logger . Warn ( "Storage space is low" , "remaining_gb" , 15 ) logger . Error ( "Database connection failed" , "db_host" , "10.0.0.5" ) For managing the rotation of log files, you can use the standard logrotate utility or the lumberjack package. Choosing how to make a logger available across your application is a key architectural decision. This involves trade-offs between convenience, testability, and explicitness. While there’s no single “right” answer, understanding the common patterns will help you select the best approach for your project. This guide explores the three most common patterns for contextual logging in Go: using a global logger, embedding the logger in the context, and passing the logger explicitly as a dependency. Using the global logger via slog.Info() Copy is a convenient approach as it avoids the need to pass a logger instance through every function call. You only need to configure the default logger once at the entry point of the program, and then you’re free to use it anywhere in your application: go Copy 1 2 3 4 5 6 7 8 9 10 11 func main ( ) { slog . SetDefault ( slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) ) doSomething ( ) } func doSomething ( ) { slog . Info ( "doing something" ) } When you want to log contextual attributes across scopes, you only need to use the context.Context Copy type to wrap the attributes and then use the Context Copy variants of the level methods accordingly. This requires the use of a context-aware handler, and there are a few of these already created by the community. One example is slog-context which allows you place slog attributes into the context and have them show up anywhere that context is used. Here’s a detailed example showing this pattern: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 package main import ( "log/slog" "net/http" "os" "github.com/google/uuid" slogctx "github.com/veqryn/slog-context" ) const ( correlationHeader = "X-Correlation-ID" ) func requestID ( next http . Handler ) http . Handler { return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) { ctx := r . Context ( ) correlationID := r . Header . Get ( correlationHeader ) if correlationID == "" { correlationID = uuid . New ( ) . String ( ) } ctx = slogctx . Prepend ( ctx , slog . String ( "correlation_id" , correlationID ) ) r = r . WithContext ( ctx ) w . Header ( ) . Set ( correlationHeader , correlationID ) next . ServeHTTP ( w , r ) } ) } func requestLogger ( next http . Handler ) http . Handler { return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) { slog . InfoContext ( r . Context ( ) , "incoming request" , slog . String ( "method" , r . Method ) , slog . String ( "path" , r . RequestURI ) , slog . String ( "referrer" , r . Referer ( ) ) , slog . String ( "user_agent" , r . UserAgent ( ) ) , ) next . ServeHTTP ( w , r ) } ) } func hello ( w http . ResponseWriter , r * http . Request ) { slog . InfoContext ( r . Context ( ) , "hello world!" ) } func main ( ) { h := slogctx . NewHandler ( slog . NewJSONHandler ( os . Stdout , nil ) , nil ) slog . SetDefault ( slog . New ( h ) ) mux := http . NewServeMux ( ) mux . HandleFunc ( "/" , hello ) wrappedMux := requestID ( requestLogger ( mux ) ) http . ListenAndServe ( ":3000" , wrappedMux ) } The requestID() Copy middleware intercepts every incoming request, generates a unique correlation_id Copy , and uses slogctx.Prepend() Copy to attach this ID as a logging attribute to the request’s context. The requestLogger() Copy middleware and the final hello() Copy handler both use slog.InfoContext() Copy . They don’t need to know about the correlation_id Copy explicitly; they just pass the request’s context to the global logger. When slog.InfoContext() Copy is called, the configured slogctx.Handler Copy intercepts the call, inspects the provided context, finds the correlation_id Copy attribute, and automatically adds it to the log record before it’s written out by the JSONHandler Copy : json Copy output 1 2 { "time" : "..." , "level" : "INFO" , "msg" : "incoming request" , "correlation_id" : "59230d79-a206-44e3-a02c-e7acf5bad28d" , "method" : "GET" , "path" : "/" , "referrer" : "" , "user_agent" : "curl/8.5.0" } { "time" : "..." , "level" : "INFO" , "msg" : "hello world!" , "correlation_id" : "59230d79-a206-44e3-a02c-e7acf5bad28d" } This pattern ensures that every log statement related to a single HTTP request is tagged with the same correlation_id Copy , making it possible to connect a set of logs to a single request. Another common pattern is placing the logger itself in a context.Context Copy instance. You can also use the slog-context Copy package to implement this pattern: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 package main import ( "log/slog" "net/http" "os" "github.com/google/uuid" slogctx "github.com/veqryn/slog-context" ) const ( correlationHeader = "X-Correlation-ID" ) func requestID ( next http . Handler ) http . Handler { return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) { ctx := r . Context ( ) correlationID := r . Header . Get ( correlationHeader ) if correlationID == "" { correlationID = uuid . New ( ) . String ( ) } ctx = slogctx . With ( ctx , slog . String ( "correlation_id" , correlationID ) ) r = r . WithContext ( ctx ) w . Header ( ) . Set ( correlationHeader , correlationID ) next . ServeHTTP ( w , r ) } ) } func requestLogger ( next http . Handler ) http . Handler { return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) { logger := slogctx . FromCtx ( r . Context ( ) ) logger . Info ( "incoming request" , slog . String ( "method" , r . Method ) , slog . String ( "path" , r . RequestURI ) , slog . String ( "referrer" , r . Referer ( ) ) , slog . String ( "user_agent" , r . UserAgent ( ) ) , ) next . ServeHTTP ( w , r ) } ) } func ctxLogger ( logger * slog . Logger , next http . Handler ) http . Handler { return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) { ctx := slogctx . NewCtx ( r . Context ( ) , logger ) r = r . WithContext ( ctx ) next . ServeHTTP ( w , r ) } ) } func hello ( w http . ResponseWriter , r * http . Request ) { logger := slogctx . FromCtx ( r . Context ( ) ) logger . Info ( "hello world!" ) } func main ( ) { h := slogctx . NewHandler ( slog . NewJSONHandler ( os . Stdout , nil ) , nil ) logger := slog . New ( h ) mux := http . NewServeMux ( ) mux . HandleFunc ( "/" , hello ) wrappedMux := ctxLogger ( logger , requestID ( requestLogger ( mux ) ) ) http . ListenAndServe ( ":3000" , wrappedMux ) } Here, The outermost middleware, ctxLogger() Copy , takes the application’s base logger and uses slogctx.NewCtx() Copy to place it into the request’s context. This makes the logger available to all subsequent handlers. Next, the requestID Copy middleware retrieves the logger from the context. It then uses slogctx.With Copy to create a new child logger that includes the correlation_id Copy . This new, more contextual logger is then placed back into the context, replacing the base logger. Any subsequent middleware or handler, like requestLogger() Copy and hello() Copy , can now retrieve the fully contextualized child logger using slogctx.FromCtx() Copy . They can log messages without needing to know anything about the correlation_id Copy ; it’s automatically included because it’s part of the logger instance that was retrieved. The result is exactly the same as before: json Copy output 1 2 { "time" : "..." , "level" : "INFO" , "msg" : "incoming request" , "correlation_id" : "59230d79-a206-44e3-a02c-e7acf5bad28d" , "method" : "GET" , "path" : "/" , "referrer" : "" , "user_agent" : "curl/8.5.0" } { "time" : "..." , "level" : "INFO" , "msg" : "hello world!" , "correlation_id" : "59230d79-a206-44e3-a02c-e7acf5bad28d" } What happens if you use slogctx.FromCtx() Copy but there’s no associated logger? The default logger ( slog.Default() Copy ) will be returned. This approach treats the logger as a formal dependency, which is provided to components either through function parameters or as a field in a struct. The logger is provided once when the struct is created, and all its methods can then access it via the receiver: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 type UserService struct { logger * slog . Logger db * sql . DB } func NewUserService ( logger * slog . Logger , db * sql . DB ) * UserService { return & UserService { logger : logger . With ( slog . String ( "component" , "UserService" ) ) , db : db , } } func ( s * UserService ) CreateUser ( ctx context . Context , user * User ) { l := s . logger . With ( slog . Any ( "user" , user ) ) l . InfoContext ( ctx , "creating new user" ) l . InfoContext ( ctx , "user created successfully" ) } For context-aware logging, you would then rely on adding attributes to the context with slogctx.Prepend() Copy as shown earlier. slog Copy ’s design encourages handlers to read contextual values from a context.Context Copy . This makes putting the Logger Copy instance itself in the context unnecessary, and thus not recommended. The initial slog Copy proposal originally included helper functions like slog.NewContext() Copy and slog.FromContext() Copy for adding and retrieving the logger from the context, but they were removed from the final version due to strong community opposition from the “anti-pattern” camp. The key decision is thus between two patterns: using a global logger or using dependency injection. The former is extremely convenient but adds a hidden dependency that’s hard to test, while the latter is more verbose but makes dependencies explicit, resulting in highly testable and flexible code. You can use sloglint Copy to enforce whatever style you choose to implement throughout your codebase, so do check out the full list of options that it provides. The LogValuer Copy interface provides a powerful mechanism for controlling how your custom types appear in log output. This becomes particularly important when dealing with sensitive data, complex structures, or when you want to provide consistent representation of domain objects across your logging. The interface is elegantly simple: go Copy 1 2 3 type LogValuer interface { LogValue ( ) slog . Value } When slog Copy encounters a value that implements LogValuer Copy , it calls the LogValue() Copy method instead of using the default representation. This gives you complete control over what information appears in your logs. Consider an application where you frequently log user information. Without implementing LogValuer Copy , logging a User Copy struct directly might expose more information than intended: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 type User struct { ID string Email string FirstName string LastName string PasswordHash string CreatedAt time . Time LastLogin time . Time IsActive bool } func main ( ) { logger := slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) user := & User { ID : "user-123" , Email : "[email protected]" , FirstName : "John" , LastName : "Doe" , PasswordHash : "encrypted-password-hash" , CreatedAt : time . Now ( ) , LastLogin : time . Now ( ) . Add ( - 24 * time . Hour ) , IsActive : true , } logger . Info ( "user operation" , slog . Any ( "user" , user ) ) } json Copy output 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "time" : "2025-07-17T17:18:22.090974193+01:00" , "level" : "INFO" , "msg" : "user operation" , "user" : { "ID" : "user-123" , "Email" : "[email protected]" , "FirstName" : "John" , "LastName" : "Doe" , "PasswordHash" : "encrypted-password-hash" , "CreatedAt" : "2025-07-17T17:18:22.090965054+01:00" , "LastLogin" : "2025-07-16T17:18:22.090965107+01:00" , "IsActive" : true } } By implementing LogValuer Copy , you can control exactly what information appears. For example, you can limit it to just the id Copy : go Copy 1 2 3 4 5 6 func ( u * User ) LogValue ( ) slog . Value { return slog . GroupValue ( slog . String ( "id" , u . ID ) , ) } This now produces clean, controlled output that hides all sensitive or unnecessary fields: json Copy output 1 2 3 4 5 6 7 8 { "time" : "2024-01-15T10:30:45.123Z" , "level" : "INFO" , "msg" : "User operation" , "user" : { "id" : "user-123" } } If you add a new field later on, it won’t be logged until you specifically add it to the LogValue() Copy method. While this adds some extra work, it guarantees that sensitive data won’t be accidentally logged. Error logging in slog Copy requires thoughtful consideration of what information will be most valuable during debugging. Unlike simple string-based logging, structured error logging allows you to capture rich context alongside the error itself. The most straightforward approach uses slog.Any() Copy to log error values: go Copy 1 2 3 4 err := errors . New ( "payment gateway unreachable" ) if err != nil { logger . Error ( "Payment processing failed" , slog . Any ( "error" , err ) ) } You’ll see the error message accordingly: json Copy 1 2 3 4 5 6 { "time" : "2025-07-17T17:25:05.356666995+01:00" , "level" : "ERROR" , "msg" : "Payment processing failed" , "error" : "payment gateway unreachable" } If you’re using a custom error type, you can implement the LogValuer Copy interface to enrich your error logs: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 type PaymentError struct { Code string Message string Cause error } func ( pe PaymentError ) Error ( ) string { return pe . Message } func ( pe PaymentError ) LogValue ( ) slog . Value { return slog . GroupValue ( slog . String ( "code" , pe . Code ) , slog . String ( "message" , pe . Message ) , slog . String ( "cause" , pe . Cause . Error ( ) ) , ) } func main ( ) { logger := slog . New ( slog . NewJSONHandler ( os . Stdout , nil ) ) causeErr := errors . New ( "network timeout" ) err := PaymentError { Code : "GATEWAY_UNREACHABLE" , Message : "Failed to reach payment gateway" , Cause : causeErr , } logger . Error ( "Payment operation failed" , slog . Any ( "error" , err ) ) } json Copy output 1 2 3 4 5 6 7 8 9 10 { "time" : "2025-07-17T17:25:05.356666995+01:00" , "level" : "ERROR" , "msg" : "Payment processing failed" , "error" : { "code" : "GATEWAY_UNREACHABLE" , "message" : "Failed to reach payment gateway" , "cause" : "network timeout" } } This approach provides structured error information that’s much more valuable than simple error strings when analyzing failures in production systems. You can go even farther by capturing the structured stack trace of an error in your logs. You’ll need to integrate with a third-party package like go-errors or go-xerrors to achieve this: go Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package main import ( "context" "log/slog" "os" xerrors "github.com/mdobak/go-xerrors" ) func replaceAttr ( _ [ ] string , a slog . Attr ) slog . Attr { if err , ok := a . Value . Any ( ) . ( error ) ; ok { if trace := xerrors . StackTrace ( err ) ; len ( trace ) > 0 { errGroup := slog . GroupValue ( slog . String ( "msg" , err . Error ( ) ) , slog . Any ( "trace" , formatStackTrace ( trace ) ) , ) a . Value = errGroup } } return a } func formatStackTrace ( trace xerrors . Callers ) [ ] map [ string ] any { frames := trace . Frames ( ) s := make ( [ ] map [ string ] any , len ( frames ) ) for i , v := range frames { s [ i ] = map [ string ] any { "func" : v . Function , "source" : v . File , "line" : v . Line , } } return s } func main ( ) { h := slog . NewJSONHandler ( os . Stdout , & slog . HandlerOptions { ReplaceAttr : replaceAttr , } ) logger := slog . New ( h ) ctx := context . Background ( ) err := xerrors . New ( "something happened" ) logger . ErrorContext ( ctx , "image uploaded" , slog . Any ( "error" , err ) ) } json Copy output 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { "time" : "2025-07-18T09:16:14.870855023+01:00" , "level" : "ERROR" , "msg" : "image uploaded" , "error" : { "msg" : "something happened" , "trace" : [ { "func" : "main.main" , "line" : 46 , "source" : "/home/ayo/dev/dash0/demo/golang-slog/main.go" } , { "func" : "runtime.main" , "line" : 283 , "source" : "/home/ayo/.local/share/mise/installs/go/1.24.2/src/runtime/proc.go" } , { "func" : "runtime.goexit" , "line" : 1700 , "source" : "/home/ayo/.local/share/mise/installs/go/1.24.2/src/runtime/asm_amd64.s" } ] } } While slog Copy was designed with performance in mind, it consistently benchmarks as slower than some highly optimized third-party libraries such as zerolog and zap. While absolute numbers may vary based on the specific benchmark conditions, the relative rankings have been shown to be consistent: Package Time % Slower Objects allocated zerolog 380 ns/op +0% 1 allocs/op zap 656 ns/op +73% 5 allocs/op zap (sugared) 935 ns/op +146% 10 allocs/op slog (LogAttrs) 2479 ns/op +552% 40 allocs/op slog 2481 ns/op +553% 42 allocs/op logrus 11654 ns/op +2967% 79 allocs/op This performance profile is not an accident but a result of deliberate design choices. The Go team’s own analysis revealed that their optimization efforts were focused on the most common logging patterns they observed in open-source projects where calls with five or fewer attributes accounted for over 95% of use cases. Only you can decide if this performance gap is relevant for your use case. If you need bridge this gap for a high-throughput or latency-sensitive use case, you have two practical options: Retain slog Copy as the frontend API and wire it to a high-performance third-party logging handler for modest gains. Ditch slog Copy entirely and log directly with zerolog or zap to squeeze out every last nanosecond. As always, ensure to run your own benchmarks before committing either way. Once your Go application is producing high-quality, structured logs with slog Copy , the next step is to get them off individual servers and into a centralized observability pipeline. Centralizing your logs transforms them from simple diagnostic records into a powerful, queryable dataset. More importantly, it allows you to correlate slog entries with other critical telemetry signals, like distributed traces and metrics, to get a complete picture of your system’s health. Modern observability platforms can ingest the structured JSON output from slog’s JSONHandler Copy . They provide powerful tools for searching, creating dashboards, and alerting on your log data. To unlock true correlation, however, your logs must share a common context (like a TraceID Copy ) with your traces. The standard way to achieve this is by integrating slog with OpenTelemetry using the otelslog bridge. A full demonstration is beyond the scope of this guide, but you can consult the official OpenTelemetry documentation to learn how to configure the log bridge accordingly. Once your OpenTelemetry-enriched log data is fed into an OpenTelemetry-native platform like Dash0 , your slog Copy entries will appear alongside traces and metrics in a unified view, giving you end-to-end visibility into every request across your distributed system. The introduction of log/slog Copy was a pivotal moment for the Go ecosystem that finally acknowledged the need for robust tooling to support building highly observable systems right out of the box. Throughout this guide, we’ve journeyed from the core concepts of Logger Copy , Handler Copy , and Record Copy to patterns for contextual and error logging. While the API has a few rough edges and isn’t the most elegant, its establishment reduces the fragmentation of past approaches and provides the Go community with a consistent, shared language for structured logging. By treating logging not as an afterthought but as a fundamental signal for observability , you’ll transform your services from opaque black boxes into systems that are transparent, diagnosable, and easier to troubleshoot. Thanks for reading!