Structured Logging with slog
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27.
A log line is read by two audiences: a person tailing a terminal during development and a program (a log shipper, a
search index, an alerting rule) in production. Free-form text such as lent 9780134685991 to ada after 3 retries
serves the first well and the second badly, because the program has to parse prose to recover the book, the member
and the retry count. Structured logging keeps the message and the facts separate: a constant message plus
key-value pairs that a handler renders as key=value text or as one JSON object per line. Go 1.21 added the
standard library package log/slog for exactly this, and it is now the default choice for new code. This page covers
the old log package (to read and bridge it), the whole of slog, how to write and test your own handler, how to log
with a context.Context, performance, the go vet check and the practices that matter in production.
It builds on Interfaces (slog.Handler is an interface you implement
and wrap), Structs and Embedding (wrapping a handler by
embedding it) and Context (request-scoped values and the …Context
logging methods). The related pages are Error Handling (log an
error once, where it is handled), net/http Clients and Servers
(request logging middleware), Testing,
Formatting, vet, fix and Static Analysis,
encoding/json (how JSONHandler renders values),
Command-Line Programs (where to send logs and exit codes) and
Profiling, Tracing and Diagnostics (logs are one of
three signals next to metrics and traces). Other ecosystems solve the same problem with a facade plus a back end:
Spring Boot logging (SLF4J and Logback with MDC) and the log and tracing
crates in Rust debugging and diagnostics; Python’s
logging module is in the Python standard library tour.
Every example was compiled, vetted with go vet and run with Go 1.27.2 unless it is explicitly labelled otherwise;
the output shown is the real output. Log records carry a timestamp, which changes on every run, so the examples remove
it with a ReplaceAttr function (explained in Customizing the output with HandlerOptions); real programs keep it.
The go.mod of the example module is:
module example.com/shelf
go 1.27
The log package and its limits
The log package is the original standard-library logger. A *log.Logger writes lines to an io.Writer; each line
is an optional prefix, optional header fields chosen by flag bits, and the text produced by Print, Printf or
Println. The package-level functions use a default logger that writes to standard error with the flags
log.LstdFlags (date and time).
| Flag or function | Effect |
|---|---|
|
Date |
|
Report the time in UTC instead of the local zone. |
|
Add |
|
Put the prefix before the message instead of at the start of the line. |
|
A new logger. |
|
Print, then call |
|
Print, then |
A Logger serializes its writes, so one logger can be shared by many goroutines and each line is written whole.
package main
import (
"log"
"os"
)
func main() {
l := log.New(os.Stdout, "shelf: ", 0)
l.Println("catalogue loaded", 3, "books")
l.Printf("lent %s to %s", "978-0-13-468599-1", "ada")
l.SetFlags(log.Lmsgprefix)
l.SetPrefix("[loans] ")
l.Print("returned late")
l.SetFlags(log.Lshortfile)
l.Print("with a file position")
// log.Fatal prints and then calls os.Exit(1): deferred calls do not run.
// log.Panic prints and then panics.
}
shelf: catalogue loaded 3 books
shelf: lent 978-0-13-468599-1 to ada
[loans] returned late
[loans] main.go:18: with a file position
Source: log: package documentation
This is enough for a small tool. It is not enough for a service, for four reasons that motivated slog:
-
Levels do not exist.
Printhas no severity; programs invented[DEBUG]prefixes andif debug {…}guards. -
The line is prose. The facts are interpolated into a format string, so a consumer must parse them back out, and adding a field changes the format.
-
No structure beyond the line: nothing groups the fields of a request, and there is no standard place for a request ID or a trace ID.
-
One destination, one format. Choosing JSON, a second sink or a different level per sink means wrapping the logger yourself or importing a third-party library (zap, zerolog and logrus were the common choices), after which every dependency of the program logs differently.
The log package is not deprecated. It remains right for throwaway programs and for code that must stay free of
slog's concepts, and a great deal of existing code (including parts of the standard library, such as net/http's
error log) still writes through it. slog is designed to coexist: The default logger and the log bridge shows
how both directions are connected.
Why slog, and how it is organized
The design is described in the Go blog post Structured Logging with slog and was proposed as issue 56345. Its central idea is the split into a front end that application code calls and a back end that decides what happens to the data, with a small, well-specified record type in between:
-
slog.Loggeris the front end. It has the convenience methodsDebug,Info,WarnandError, their…Contextforms, the generalLogandLogAttrs, and the derivation methodsWithandWithGroup. ALoggeris immutable and safe for concurrent use;Withreturns a new one. -
slog.Handleris the back end, an interface with four methods (Enabled,Handle,WithAttrs,WithGroup). The standard library suppliesTextHandler,JSONHandler,MultiHandler(Go 1.26) andDiscardHandler(Go 1.24). -
slog.Recordis the data passed between them: the time, the level, the message, the program counter of the call and a list of attributes. -
slog.Attris a key and aslog.Value. AValueis a small tagged union that stores strings, integers, floats, booleans, durations, times, groups and "any" without allocating for the common kinds.
The record built by a call to logger.Info("book lent", "isbn", isbn) therefore contains four built-in pieces of
data (time, level, message, source position) followed by the attributes. The names of the built-in keys are the
constants slog.TimeKey ("time"), slog.LevelKey ("level"), slog.MessageKey ("msg") and slog.SourceKey
("source").
A first logger: TextHandler and JSONHandler
slog.New(handler) builds a logger. slog.NewTextHandler(w, opts) writes key=value pairs separated by spaces, with
values quoted only when needed. slog.NewJSONHandler(w, opts) writes one JSON object per record, terminated by a newline
(the "JSON Lines" convention that log shippers expect). Both take an io.Writer and an optional *slog.HandlerOptions
(nil selects the defaults), and both hold a lock around each Write, so a record is never interleaved with another.
package main
import (
"log/slog"
"os"
)
// noTime drops the time attribute so the output is the same on every run.
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
opts := &slog.HandlerOptions{ReplaceAttr: noTime}
text := slog.New(slog.NewTextHandler(os.Stdout, opts))
text.Info("book lent", "isbn", "9780134685991", "member", "ada", "days", 14)
text.Warn("overdue", "isbn", "9780134685991", "title", "The Go Programming Language")
js := slog.New(slog.NewJSONHandler(os.Stdout, opts))
js.Info("book lent", "isbn", "9780134685991", "member", "ada", "days", 14)
js.Warn("overdue", "isbn", "9780134685991", "title", "The Go Programming Language")
}
level=INFO msg="book lent" isbn=9780134685991 member=ada days=14
level=WARN msg=overdue isbn=9780134685991 title="The Go Programming Language"
{"level":"INFO","msg":"book lent","isbn":"9780134685991","member":"ada","days":14}
{"level":"WARN","msg":"overdue","isbn":"9780134685991","title":"The Go Programming Language"}
Source: log/slog: NewTextHandler and NewJSONHandler
The same call without the ReplaceAttr option begins with time=2026-10-18T02:17:08.018+02:00 level=INFO … in text
form and {"time":"2026-10-18T02:17:08.018…+02:00","level":"INFO",…} in JSON (text uses millisecond resolution, JSON
uses nanosecond resolution). The order is always time, level, source (if enabled), message, then the attributes in the
order given, with attributes attached by With before those of the call.
The convenience methods take the message and then alternating keys and values (args ...any), exactly as the examples
show. Which handler to pick:
TextHandler |
JSONHandler |
|
|---|---|---|
Best for |
development, terminals, small tools, |
production services whose logs are collected by a machine |
Values |
|
|
Groups |
dotted keys, |
nested objects, |
Durations |
|
integer nanoseconds ( |
Times |
RFC 3339, milliseconds |
RFC 3339, nanoseconds |
Levels
A slog.Level is an int. The named levels are spaced four apart so that applications can define levels in between
(the gap was borrowed from OpenTelemetry’s severity numbers):
| Constant | Value | Typical use |
|---|---|---|
|
-4 |
Developer detail that is off in production: cache hits, SQL text, decisions. |
|
0 |
Normal events worth keeping: a request served, a book lent, the service started. |
|
4 |
Something unexpected that the program handled: a retry, a rejected input, a slow dependency. |
|
8 |
An operation failed and someone may need to act: a request that returned 5xx, a failed write. |
The minimum level a handler emits is HandlerOptions.Level, a slog.Leveler (an interface with a single Level()
method). A slog.Level is a Leveler, which fixes the threshold for the life of the handler; a *slog.LevelVar is a
Leveler whose value can be changed at run time from any goroutine, for example by an admin endpoint or a signal
handler, without rebuilding the logger. The zero value of both is LevelInfo, which is also the default when
Level is nil.
Any int is a valid level. Text and JSON output print the nearest named level plus an offset, such as
WARN+2; Level.String produces that form and Level.UnmarshalText parses it, ignoring case, which is how a
level read from a flag or an environment variable should be converted.
package main
import (
"context"
"fmt"
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
var level slog.LevelVar // zero value: LevelInfo
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: &level, ReplaceAttr: noTime})
log := slog.New(h)
log.Debug("not shown: below Info")
log.Info("shown")
level.Set(slog.LevelDebug)
log.Debug("shown now", "level", level.Level())
// Any int is a level; the text form is relative to the nearest named one.
const LevelAudit = slog.LevelWarn + 2
log.Log(context.Background(), LevelAudit, "member suspended", "member", "ada")
log.Log(context.Background(), slog.LevelError+4, "disk full")
log.Log(context.Background(), slog.LevelDebug-2, "very chatty")
for _, l := range []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError, LevelAudit} {
fmt.Println(int(l), l.String(), log.Enabled(context.Background(), l))
}
var parsed slog.Level
if err := parsed.UnmarshalText([]byte("warn+1")); err != nil {
panic(err)
}
fmt.Println("parsed", int(parsed), parsed.String())
}
level=INFO msg=shown
level=DEBUG msg="shown now" level=DEBUG
level=WARN+2 msg="member suspended" member=ada
level=ERROR+4 msg="disk full"
-4 DEBUG true
0 INFO true
4 WARN true
8 ERROR true
6 WARN+2 true
parsed 5 WARN+1
Two details in the output matter. The record logged at LevelDebug-2 is absent because it is below the minimum
(Debug), not because it is invalid. And the custom level prints as WARN+2, which most log back ends do not
understand; if a pipeline expects specific names, map them with ReplaceAttr (see below). The method form
logger.Enabled(ctx, level) asks the handler whether a record at that level would be emitted, which is how to guard
work that is expensive to prepare.
Source: log/slog: Level and LevelVar
A service normally reads its level and format from the environment once, in main. The example below turns two
environment variables into a logger (the lookup function is a parameter so that the code can be tested) and shows the
error messages for bad values.
package main
import (
"fmt"
"io"
"log/slog"
"os"
"strings"
)
// newLogger builds the process logger from SHELF_LOG_LEVEL and SHELF_LOG_FORMAT.
func newLogger(w io.Writer, getenv func(string) string) (*slog.Logger, error) {
var level slog.Level
if v := getenv("SHELF_LOG_LEVEL"); v != "" {
if err := level.UnmarshalText([]byte(v)); err != nil {
return nil, fmt.Errorf("SHELF_LOG_LEVEL: %w", err)
}
}
opts := &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{} // for stable output in this page only
}
return a
},
}
switch f := strings.ToLower(getenv("SHELF_LOG_FORMAT")); f {
case "", "text":
return slog.New(slog.NewTextHandler(w, opts)), nil
case "json":
return slog.New(slog.NewJSONHandler(w, opts)), nil
default:
return nil, fmt.Errorf("SHELF_LOG_FORMAT: unknown format %q", f)
}
}
func main() {
for _, env := range []map[string]string{
{},
{"SHELF_LOG_LEVEL": "debug", "SHELF_LOG_FORMAT": "json"},
{"SHELF_LOG_LEVEL": "warn"},
{"SHELF_LOG_LEVEL": "loud"},
{"SHELF_LOG_FORMAT": "xml"},
} {
log, err := newLogger(os.Stdout, func(k string) string { return env[k] })
if err != nil {
fmt.Println("error:", err)
continue
}
log.Debug("debug line")
log.Info("info line")
log.Warn("warn line")
}
}
level=INFO msg="info line"
level=WARN msg="warn line"
{"level":"DEBUG","msg":"debug line"}
{"level":"INFO","msg":"info line"}
{"level":"WARN","msg":"warn line"}
level=WARN msg="warn line"
error: SHELF_LOG_LEVEL: slog: level string "loud": unknown name
error: SHELF_LOG_FORMAT: unknown format "xml"
Source: log/slog: Level.UnmarshalText
Attributes: key-value arguments and Attr values
A log call accepts attributes in two forms, and they may be mixed in one call:
-
alternating keys and values (
"isbn", isbn, "days", 14): short to write, but the compiler cannot check it, and every value is boxed into anany; -
typed
slog.Attrvalues built byslog.String,slog.Int,slog.Int64,slog.Uint64,slog.Float64,slog.Bool,slog.Duration,slog.Time,slog.Any,slog.Groupandslog.GroupAttrs: more to type, checked by the compiler, and allocation-free for the basic kinds when passed toLogAttrs.
A value of any other type is wrapped by slog.Any (or implicitly, in the key-value form). Its Value.Kind() is
KindAny, and the handler decides how to render it. A type that implements LogValuer is replaced by the value its
LogValue method returns (see LogValuer: controlling and redacting how a value is logged).
package main
import (
"context"
"errors"
"log/slog"
"os"
"time"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
type Member struct {
ID int
Name string
}
func main() {
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime})
log := slog.New(h)
// Alternating keys and values.
log.Info("loan", "isbn", "9780134685991", "days", 14, "renewable", true)
// Typed attributes: no boxing, no mistakes with pairs.
log.LogAttrs(context.Background(), slog.LevelInfo, "loan",
slog.String("isbn", "9780134685991"),
slog.Int("days", 14),
slog.Duration("grace", 36*time.Hour),
slog.Float64("fine", 1.5),
slog.Time("due", time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)),
)
// Mixing both forms in one call is allowed.
log.Info("mixed", slog.Int("a", 1), "b", 2)
// Any values: structs, slices, maps, errors.
err := errors.New("catalog: invalid ISBN")
log.Error("lookup failed", "err", err, "member", Member{ID: 7, Name: "Ada"}, "tags", []string{"a", "b"})
log.Error("lookup failed", slog.Any("err", err))
}
{"level":"INFO","msg":"loan","isbn":"9780134685991","days":14,"renewable":true}
{"level":"INFO","msg":"loan","isbn":"9780134685991","days":14,"grace":129600000000000,"fine":1.5,"due":"2026-11-01T00:00:00Z"}
{"level":"INFO","msg":"mixed","a":1,"b":2}
{"level":"ERROR","msg":"lookup failed","err":"catalog: invalid ISBN","member":{"ID":7,"Name":"Ada"},"tags":["a","b"]}
{"level":"ERROR","msg":"lookup failed","err":"catalog: invalid ISBN"}
Source: log/slog: Attr and Logger.LogAttrs
LogAttrs(ctx, level, msg, attrs...) is the method that takes only Attr values; the convenience methods exist so that
the common case is short. Conventions that keep logs searchable:
-
Use a constant message and put the variable parts into attributes. Searching for
msg="book lent"then finds every lending, which a message such aslent 9780134685991 to adawould prevent. -
Pick one spelling for each key across the code base (
isbn, notisbnhere andISBNthere;err, noterrorsometimes). Aconstblock or a small set of attribute constructors helps. -
Log the error as a value,
"err", err, noterr.Error(): handlers (andReplaceAttr) then see the error itself. Anilerror is logged asnullor<nil>, so test fornilfirst. -
Keys are case sensitive and are not deduplicated: logging the same key twice emits it twice.
What the two handlers do with a value
The following run logs the same attributes through both handlers. It is worth reading line by line: the differences are the usual source of surprises when a team changes handler or log back end.
package main
import (
"errors"
"log/slog"
"os"
"time"
)
type ISBN string
func (i ISBN) String() string { return "ISBN:" + string(i) }
type Book struct {
ISBN ISBN
Title string
}
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
opts := &slog.HandlerOptions{ReplaceAttr: noTime}
for _, log := range []*slog.Logger{
slog.New(slog.NewTextHandler(os.Stdout, opts)),
slog.New(slog.NewJSONHandler(os.Stdout, opts)),
} {
var nilErr error
log.Info("values",
"dup", 1, "dup", 2,
"isbn", ISBN("9780134685991"),
"book", Book{ISBN: "9780134685991", Title: "Sample Title"},
"ptr", &Book{ISBN: "9780134685991"},
"nil_err", nilErr,
"err", errors.New("boom"),
"wait", 1500*time.Millisecond,
"bytes", []byte("hi"),
"map", map[string]int{"b": 2, "a": 1},
)
}
}
level=INFO msg=values dup=1 dup=2 isbn=ISBN:9780134685991 book="{ISBN:ISBN:9780134685991 Title:Sample Title}" ptr="&{ISBN:ISBN:9780134685991 Title:}" nil_err=<nil> err=boom wait=1.5s bytes="hi" map="map[a:1 b:2]"
{"level":"INFO","msg":"values","dup":1,"dup":2,"isbn":"9780134685991","book":{"ISBN":"9780134685991","Title":"Sample Title"},"ptr":{"ISBN":"9780134685991","Title":""},"nil_err":null,"err":"boom","wait":1500000000,"bytes":"aGk=","map":{"a":1,"b":2}}
Source: log/slog: TextHandler and JSONHandler
-
The
ISBNvalue implementsfmt.Stringer.TextHandlerprints the result ofString(),JSONHandlermarshals the underlying string, so the two outputs differ. (json.Marshalerandencoding.TextMarshalerare the interfaces to implement if the two must agree.) -
A struct renders as
%+vin text and as a JSON object in JSON; a pointer to a struct is dereferenced by JSON and printed as&{…}in text. -
A
nilerror appears as<nil>in text andnullin JSON; a non-nil error appears as itsError()string. -
A duration is
1.5sin text but1500000000(nanoseconds) in JSON; convert inReplaceAttror logd.Milliseconds()if the consumer wants a unit. -
[]byteis a quoted string in text and base64 in JSON; a map is printed with sorted keys in both.
Mismatched arguments and the BADKEY attribute
The key-value form has no type safety. If an argument that should be a key is not a string, or the list has an odd
length, slog does not panic and does not drop the data: it emits the stray value under the key !BADKEY.
package main
import (
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime}))
log.Info("odd number of arguments", "isbn")
log.Info("non-string key", 42, "value")
log.Info("fine", "isbn", "9780134685991", "copies")
}
level=INFO msg="odd number of arguments" !BADKEY=isbn
level=INFO msg="non-string key" !BADKEY=42 !BADKEY=value
level=INFO msg=fine isbn=9780134685991 !BADKEY=copies
go vet finds these mistakes at build time with its slog analyzer (see The vet slog analyzer):
e15/main.go:17:2: call to slog.Logger.Info missing a final value
e15/main.go:18:29: slog.Logger.Info arg "42" should be a string or a slog.Attr (possible missing key or value)
e15/main.go:19:2: call to slog.Logger.Info missing a final value
Source: log/slog: Attrs and Values
Groups, With and WithGroup
Attributes can be nested. A group is an attribute whose value is a list of attributes; the handler decides how to
show the nesting. TextHandler joins the names with dots, JSONHandler writes a nested object. There are three ways
to create one:
-
slog.Group(key, args ...any)builds a group attribute from key-value pairs (orAttrvalues) for a single record. -
slog.GroupAttrs(key, attrs ...Attr)(Go 1.25) does the same fromAttrvalues only. It is the more efficient form, because no arguments are boxed or scanned, and it is the one to prefer when the attributes are alreadyAttrvalues. -
logger.WithGroup(name)returns a logger whose later attributes, both those given toWithafterwards and those in each call, are all qualified byname. Use it to give a subsystem a namespace so that its keys cannot collide with other subsystems' keys.
logger.With(args ...any) returns a logger with extra attributes attached to every record. The built-in handlers
format those attributes once, when With is called, rather than on each record, which is why a request-scoped logger
created with With is cheaper than repeating the attributes in each call.
package main
import (
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
opts := &slog.HandlerOptions{ReplaceAttr: noTime}
text := slog.New(slog.NewTextHandler(os.Stdout, opts))
js := slog.New(slog.NewJSONHandler(os.Stdout, opts))
for _, l := range []*slog.Logger{text, js} {
l.Info("request",
slog.Group("http", "method", "GET", "path", "/books"),
slog.Int("status", 200))
// WithGroup qualifies every attribute that comes after it.
l.With("service", "shelfd").WithGroup("loan").With("member", "ada").Info("lent", "isbn", "9780134685991")
// An empty group is dropped; an empty key inlines the group.
l.Info("edge cases", slog.Group("empty"), slog.Group("", "inline", 1))
// GroupAttrs (Go 1.25) takes Attr values only.
l.Info("typed group", slog.GroupAttrs("book", slog.String("isbn", "9780134685991"), slog.Int("copies", 3)))
}
}
level=INFO msg=request http.method=GET http.path=/books status=200
level=INFO msg=lent service=shelfd loan.member=ada loan.isbn=9780134685991
level=INFO msg="edge cases" inline=1
level=INFO msg="typed group" book.isbn=9780134685991 book.copies=3
{"level":"INFO","msg":"request","http":{"method":"GET","path":"/books"},"status":200}
{"level":"INFO","msg":"lent","service":"shelfd","loan":{"member":"ada","isbn":"9780134685991"}}
{"level":"INFO","msg":"edge cases","inline":1}
{"level":"INFO","msg":"typed group","book":{"isbn":"9780134685991","copies":3}}
Source: log/slog: Groups and GroupAttrs
The order of With and WithGroup matters: in l.With("service", "shelfd").WithGroup("loan").With("member", "ada"),
service is at the top level and member is inside loan. The output also shows two edge-case rules from the
Handler contract: a group with no attributes is omitted, and a group with an empty key is inlined into its parent.
The default logger and the log bridge
slog.Info, slog.Warn and the other top-level functions use a package-level default logger, returned by
slog.Default(). Until the program replaces it, that logger does not use a TextHandler: its handler writes through
the standard log package, producing lines in the old format with a level word added (2022/11/08 15:28:26 INFO hello count=3),
so that a program which starts using slog keeps its old log configuration (output, prefix and flags).
slog.SetDefault(logger) replaces the default logger. It does two things: the top-level functions now use the new
logger, and the output of the package-level log functions (log.Printf and friends, including from libraries that
you do not control) is routed through the new logger’s handler, so the whole program ends up in one format. The
level of those bridged log calls is Info unless slog.SetLogLoggerLevel (Go 1.22) changes it. The opposite
direction is slog.NewLogLogger(handler, level), which returns a *log.Logger that sends every line to a handler;
use it for APIs that insist on a *log.Logger, such as http.Server.ErrorLog.
package main
import (
"log"
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
// Before SetDefault: the default handler writes through package log.
log.SetOutput(os.Stdout)
log.SetFlags(0)
slog.Info("default handler", "k", 1)
slog.Debug("dropped: the default level is Info")
// A library that still uses package log...
legacy := func() { log.Printf("legacy message from package log") }
// ...goes through the new handler once SetDefault is called.
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime})))
slog.Info("after SetDefault", "k", 2)
legacy()
log.Print("also bridged")
// SetLogLoggerLevel (Go 1.22) chooses the level used for those bridged log calls.
slog.SetLogLoggerLevel(slog.LevelWarn)
log.Print("now a warning")
// The other direction: give a library an *log.Logger that writes through a handler.
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime})
std := slog.NewLogLogger(h, slog.LevelError)
std.Printf("http: TLS handshake error from %s", "192.0.2.1:5000")
}
INFO default handler k=1
{"level":"INFO","msg":"after SetDefault","k":2}
{"level":"INFO","msg":"legacy message from package log"}
{"level":"INFO","msg":"also bridged"}
{"level":"WARN","msg":"now a warning"}
level=ERROR msg="http: TLS handshake error from 192.0.2.1:5000"
Source: log/slog: SetDefault, SetLogLoggerLevel and NewLogLogger
Calling SetDefault in main, before starting goroutines, is the usual pattern. Library code should not call it, and
should preferably accept a *slog.Logger (or take the logger from a context) instead of reaching for the default.
Passing the logger explicitly makes tests easier, because a test can give the code a logger that writes into a buffer or
to slog.DiscardHandler.
Customizing the output with HandlerOptions
TextHandler and JSONHandler share one options struct with three fields:
| Field | Meaning |
|---|---|
|
The minimum level, a |
|
Add a |
|
A function called for each non-group attribute, including the built-in |
ReplaceAttr(groups []string, a slog.Attr) slog.Attr receives the names of the groups that are open around the
attribute (always nil for the built-ins, so len(groups) == 0 identifies a top-level key) and returns the attribute
to write. Returning the zero slog.Attr{} drops it. The value has already been resolved (a LogValuer is expanded) and the
function is never called for group attributes themselves, only for their contents. The slice groups must not be
retained or modified. Typical uses are to rename keys, to change formats, to drop the time (as every example on this
page does, for stable output), to shorten the source path and to redact sensitive attributes.
The first example shortens the source location; the second renames the built-in keys to the names a log pipeline expects, lower-cases the level and redacts attributes by key, including inside groups.
package main
import (
"log/slog"
"os"
"path/filepath"
)
func shortSource(groups []string, a slog.Attr) slog.Attr {
switch {
case len(groups) == 0 && a.Key == slog.TimeKey:
return slog.Attr{}
case a.Key == slog.SourceKey:
src := a.Value.Any().(*slog.Source)
src.File = filepath.Base(src.File)
return a
}
return a
}
func main() {
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, ReplaceAttr: shortSource})
slog.New(h).Info("with source")
slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, ReplaceAttr: shortSource})).Warn("json source")
}
level=INFO source=main.go:23 msg="with source"
{"level":"WARN","source":{"function":"main.main","file":"main.go","line":24},"msg":"json source"}
Source: log/slog: HandlerOptions and Source
package main
import (
"log/slog"
"os"
"strings"
)
// renamed maps slog's built-in keys to the names a log pipeline expects,
// lower-cases levels and redacts any attribute whose key looks sensitive.
func renamed(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.TimeKey:
if len(groups) == 0 {
return slog.Attr{} // dropped here only to keep the output stable
}
case slog.MessageKey:
if len(groups) == 0 {
a.Key = "message"
}
case slog.LevelKey:
if len(groups) == 0 {
a.Key = "severity"
a.Value = slog.StringValue(strings.ToLower(a.Value.String()))
}
case "password", "token", "api_key":
a.Value = slog.StringValue("[redacted]")
}
return a
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: renamed}))
log.Warn("login", "member", "ada", "token", "not-a-real-token")
log.Info("nested", slog.Group("auth", "password", "not-a-real-password", "method", "basic"))
}
{"severity":"warn","message":"login","member":"ada","token":"[redacted]"}
{"severity":"info","message":"nested","auth":{"password":"[redacted]","method":"basic"}}
Source: log/slog: HandlerOptions
Because ReplaceAttr runs for every attribute of every record, keep it cheap: switch on the key and return early.
Redacting by key name is a safety net, not a guarantee; a secret logged under an unexpected key passes through. The
stronger control is to make the secret type refuse to be logged (next section).
Levels that slog does not name (TRACE and FATAL in OpenTelemetry’s scheme, NOTICE in syslog) are defined as constants
of type slog.Level and given names in ReplaceAttr. Note that logging at a "fatal" level only writes a record;
unlike log.Fatal, slog never exits the program.
package main
import (
"context"
"log/slog"
"os"
)
// Levels that slog does not name but many log pipelines do.
const (
LevelTrace = slog.Level(-8)
LevelNotice = slog.Level(2)
LevelFatal = slog.Level(12)
)
var levelNames = map[slog.Leveler]string{
LevelTrace: "TRACE",
LevelNotice: "NOTICE",
LevelFatal: "FATAL",
}
func replace(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.TimeKey:
if len(groups) == 0 {
return slog.Attr{}
}
case slog.LevelKey:
level := a.Value.Any().(slog.Level)
if name, ok := levelNames[level]; ok {
a.Value = slog.StringValue(name)
}
}
return a
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: LevelTrace, ReplaceAttr: replace}))
ctx := context.Background()
log.Log(ctx, LevelTrace, "entering Lend")
log.Info("an ordinary record")
log.Log(ctx, LevelNotice, "nightly overdue scan started")
log.Log(ctx, LevelFatal, "cannot open the catalogue")
}
{"level":"TRACE","msg":"entering Lend"}
{"level":"INFO","msg":"an ordinary record"}
{"level":"NOTICE","msg":"nightly overdue scan started"}
{"level":"FATAL","msg":"cannot open the catalogue"}
LogValuer: controlling and redacting how a value is logged
A type that implements slog.LogValuer (one method, LogValue() slog.Value) chooses its own representation in logs.
It serves three purposes:
-
Redaction. A
Tokentype whoseLogValuereturns a placeholder cannot leak by accident, whatever key it is logged under and whichever handler is used. -
Structure. A struct can return a group of the fields that are safe and useful (and omit, say, a password hash).
-
Laziness.
LogValueis called only when the record is actually handled, so an expensive computation does not run for a disabled level.
The handlers call Value.Resolve, which calls LogValue repeatedly (a LogValue may return another LogValuer) up to a
limit, so cycles cannot loop forever.
package main
import (
"fmt"
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
// Token is a secret that must never reach the logs.
type Token string
func (Token) LogValue() slog.Value { return slog.StringValue("REDACTED") }
// Member groups its fields and leaves out the password hash.
type Member struct {
ID int
Name string
PasswordHash string
}
func (m Member) LogValue() slog.Value {
return slog.GroupValue(
slog.Int("id", m.ID),
slog.String("name", m.Name),
)
}
// Expensive does its work only if the record is actually logged.
type Expensive struct{ n int }
var calls int
func (e Expensive) LogValue() slog.Value {
calls++
return slog.IntValue(e.n * e.n)
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime}))
log.Info("login", "member", Member{ID: 7, Name: "Ada", PasswordHash: "not-a-real-hash"}, "token", Token("not-a-real-token"))
log.Debug("below the level", "square", Expensive{9})
fmt.Println("LogValue calls after a disabled record:", calls)
log.Info("enabled", "square", Expensive{9})
fmt.Println("LogValue calls after an enabled record:", calls)
}
level=INFO msg=login member.id=7 member.name=Ada token=REDACTED
LogValue calls after a disabled record: 0
level=INFO msg=enabled square=81
LogValue calls after an enabled record: 1
Source: log/slog: LogValuer
The LogValue method must be cheap and must not log or take locks that the logging call site may already hold.
Declare it on a value receiver when the type is a value type so that both T and *T satisfy the interface.
Context-aware logging
Every output method has a form that takes a context.Context: InfoContext, WarnContext, ErrorContext,
DebugContext, plus Log and LogAttrs. The plain forms (Info, Warn, …) pass context.Background() to the
handler. The context reaches Handler.Enabled and Handler.Handle, which is the only way a handler can see values
that travel with the request: a request ID, a trace and span ID, the authenticated user. The documentation recommends
passing a context whenever one is available.
Cancellation of the context does not affect logging; a handler must still write the record, because the log is often the way to find out why something was cancelled.
There are two common designs for request-scoped fields:
-
Handler reads the context. A wrapping handler, installed once, copies fields from the context into every record. Call sites only need to use the
…Contextmethods. -
Logger travels in the context. Middleware creates
log.With("request_id", id)and stores the logger in the context (or the handler’s struct), and code retrieves it. No special handler is needed, but code must remember to fetch the logger.
The first design is shown next. Note that the wrapper embeds slog.Handler, overrides Handle, and also overrides
WithAttrs and WithGroup; without those two overrides, calling logger.With(...) returns the wrapped inner
handler and silently loses the context behaviour.
package main
import (
"context"
"log/slog"
"os"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
type ctxKey struct{}
// WithRequestID returns a context carrying the request ID.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// ContextHandler adds the request ID found in the context to every record.
type ContextHandler struct{ slog.Handler }
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(ctxKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
// WithAttrs and WithGroup must return the wrapper again, or the context
// behaviour is lost as soon as a caller uses With.
func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return ContextHandler{h.Handler.WithAttrs(attrs)}
}
func (h ContextHandler) WithGroup(name string) slog.Handler {
return ContextHandler{h.Handler.WithGroup(name)}
}
func lend(ctx context.Context, log *slog.Logger, isbn string) {
log.InfoContext(ctx, "lending", "isbn", isbn)
}
func main() {
base := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime})
log := slog.New(ContextHandler{base}).With("service", "shelfd")
ctx := WithRequestID(context.Background(), "req-42")
lend(ctx, log, "9780134685991")
lend(context.Background(), log, "9780134685991")
// Without the Context variant the handler receives context.Background().
log.Info("no context here", "isbn", "9780134685991")
}
level=INFO msg=lending service=shelfd isbn=9780134685991 request_id=req-42
level=INFO msg=lending service=shelfd isbn=9780134685991
level=INFO msg="no context here" service=shelfd isbn=9780134685991
The second design, in the shelf HTTP service, is a middleware that records the status and the duration, attaches a
request ID and passes a request-scoped logger to the handler through the request context. (The server is an
httptest.NewServer, which listens on a local port for the duration of the program; durations are dropped by
ReplaceAttr because they vary.) See net/http Clients and Servers
for the rest of the service, including timeouts and graceful shutdown.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"time"
"example.com/shelf/catalog"
)
type loggerKey struct{}
// FromContext returns the request-scoped logger, or the default logger.
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
// 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)
}
var nextID atomic.Int64
// logRequests logs one line per request and hands a request-scoped logger to the handler.
func logRequests(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
reqLog := log.With("request_id", fmt.Sprintf("req-%d", nextID.Add(1)))
ctx := context.WithValue(r.Context(), loggerKey{}, reqLog)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r.WithContext(ctx))
level := slog.LevelInfo
if rec.status >= 500 {
level = slog.LevelError
}
reqLog.LogAttrs(ctx, level, "request",
slog.Group("http",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status)),
slog.Duration("duration", time.Since(start)))
})
}
func main() {
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && (a.Key == slog.TimeKey || a.Key == "duration") {
return slog.Attr{} // these vary between runs; dropped for stable output
}
return a
},
}
log := slog.New(slog.NewJSONHandler(os.Stdout, opts)).With("service", "shelfd")
store := catalog.NewMemStore()
store.Put(catalog.Book{ISBN: "9780134685991", Title: "Effective Go (sample)"})
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
isbn, err := catalog.ParseISBN(r.PathValue("isbn"))
if err != nil {
FromContext(r.Context()).WarnContext(r.Context(), "rejected ISBN", "err", err)
http.Error(w, "invalid ISBN", http.StatusBadRequest)
return
}
b, ok := store.Get(isbn)
if !ok {
http.NotFound(w, r)
return
}
FromContext(r.Context()).DebugContext(r.Context(), "found", "isbn", isbn) // Debug: not enabled
_ = json.NewEncoder(w).Encode(b)
})
srv := httptest.NewServer(logRequests(log, mux))
defer srv.Close()
for _, path := range []string{"/books/9780134685991", "/books/9780134685992", "/books/12", "/books/9783161484100"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
panic(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
{"level":"INFO","msg":"request","service":"shelfd","request_id":"req-1","http":{"method":"GET","path":"/books/9780134685991","status":200}}
{"level":"WARN","msg":"rejected ISBN","service":"shelfd","request_id":"req-2","err":"catalog: invalid ISBN: \"9780134685992\""}
{"level":"INFO","msg":"request","service":"shelfd","request_id":"req-2","http":{"method":"GET","path":"/books/9780134685992","status":400}}
{"level":"WARN","msg":"rejected ISBN","service":"shelfd","request_id":"req-3","err":"catalog: invalid ISBN: \"12\""}
{"level":"INFO","msg":"request","service":"shelfd","request_id":"req-3","http":{"method":"GET","path":"/books/12","status":400}}
{"level":"INFO","msg":"request","service":"shelfd","request_id":"req-4","http":{"method":"GET","path":"/books/9783161484100","status":404}}
Two things in the output are worth noting. The request_id is the same on the warning and on the final request line of
the same request, which is what makes the log searchable per request. And the request line is logged after the
handler returns at Info for 2xx, 3xx and 4xx and at Error only for 5xx: a client error is not a server fault, and
logging every 404 at Error makes alerts useless.
Where traces exist, the right correlation key is the trace ID and span ID from the tracing library (OpenTelemetry’s
slog bridge and handlers do exactly this by reading the span from the context); the wrapping-handler pattern above is
how they attach it.
Logging errors
Pass the error as an attribute, usually with the key err (or error; choose one for the code base), and describe
the operation in the message: log.Error("lend failed", "isbn", isbn, "err", err). Several conventions come from
Error Handling:
-
Log an error where it is handled, once, not at every level it passes through. An error that is returned is not yet a log event; logging and returning it produces duplicate lines for one fault.
-
A wrapped error already carries its context in its message (
catalog: invalid ISBN: "12"), so the log line needs only the facts that the error does not carry. -
slog.Any("err", err)and"err", errare equivalent.JSONHandlerwriteserr.Error(); a custom type that wants structured output implementsLogValuer(for example to return the code and the retry count as a group). -
errors.Is(err, context.Canceled)is usually a normal condition (the client went away) and belongs atDebugorInfo, notError. -
An
errthat isnilis logged asnullor<nil>; only log it when there is an error to report.
Multiple destinations: NewMultiHandler
A service often wants human-readable lines on standard error and every record, as JSON, in a file or a collector; or
the same record sent to an audit sink. slog.NewMultiHandler(handlers ...Handler) (Go 1.26) returns a handler that
fans a record out to all of its handlers. Before Go 1.26 this required a hand-written handler or a third-party package.
| Method | Behaviour |
|---|---|
|
True if any inner handler is enabled for the level. The |
|
Calls |
|
Apply to every inner handler and return a new |
package main
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
)
func noTime(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
// failing is a handler whose Handle always fails.
type failing struct{ slog.Handler }
func (failing) Handle(context.Context, slog.Record) error { return errors.New("sink unavailable") }
func main() {
var audit bytes.Buffer
// Human-readable records on stdout from Info up; every record, as JSON, in a second sink.
console := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: noTime})
archive := slog.NewJSONHandler(&audit, &slog.HandlerOptions{Level: slog.LevelDebug, ReplaceAttr: noTime})
log := slog.New(slog.NewMultiHandler(console, archive)).With("service", "shelfd")
log.Debug("cache miss", "isbn", "9780134685991")
log.Info("book lent", "isbn", "9780134685991")
fmt.Print("--- archive ---\n", audit.String())
// Enabled is true when any handler is enabled; the others are skipped.
fmt.Println("enabled at Debug:", log.Enabled(context.Background(), slog.LevelDebug))
// Handle errors are joined, but a Logger discards them: call the handler to see them.
m := slog.NewMultiHandler(failing{console}, console, failing{archive})
err := m.Handle(context.Background(), slog.NewRecord(time.Time{}, slog.LevelInfo, "direct call", 0))
fmt.Println("joined error:", strings.ReplaceAll(err.Error(), "\n", " | "))
}
level=INFO msg="book lent" service=shelfd isbn=9780134685991
--- archive ---
{"level":"DEBUG","msg":"cache miss","service":"shelfd","isbn":"9780134685991"}
{"level":"INFO","msg":"book lent","service":"shelfd","isbn":"9780134685991"}
enabled at Debug: true
level=INFO msg="direct call"
joined error: sink unavailable | sink unavailable
Source: log/slog: MultiHandler
Each sink keeps its own level: the console shows only Info and above, the archive keeps Debug as well, and the
Debug record is absent from the console. Logger discards the error returned by Handle (a failing log sink should
not fail the request), so when the delivery of records matters, wrap the handler and report the failures yourself.
slog.DiscardHandler (Go 1.24) is the opposite of fan-out: a handler whose Enabled is false for every level.
Use it in tests and benchmarks, and as the default for an optional logger field. Before 1.24 the idiom was a
TextHandler that wrote to io.Discard, which still formats every record that passes the level check.
Writing a custom Handler
Writing a handler is rare: most needs (a different format, an extra field, redaction, fan-out) are met by
ReplaceAttr, LogValuer, a wrapping handler or MultiHandler. Write a complete handler when you must send records
to a new kind of sink (a message queue, a vendor API) or produce a format the built-in handlers cannot. The authoritative
reference is the handler-writing guide, A Guide to
Writing slog Handlers in the golang/example repository (also reachable as https://go.dev/s/slog-handler-guide). The
contract, from the documentation of Handler:
-
Enabled(ctx, level) boolis called first, before arguments are processed, and decides whether the record is made. -
Handle(ctx, record) erroris called only ifEnabledreturned true. Ifrecord.Timeis the zero time, omit the time; ifrecord.PCis zero, omit the source; resolve eachAttr's value; skip anAttrequal to the zeroslog.Attr{}; inline a group whose key is empty; omit a group with no attributes.Loggerignores the error returned. -
WithAttrs(attrs) Handlerreturns a new handler that has the receiver’s attributes plusattrs. The receiver must not be modified. -
WithGroup(name) Handlerreturns a new handler in which every following attribute is qualified by the group name; a call with an empty name returns the receiver. -
Any method may be called concurrently with any other, so the handler must manage its own locking. Derived handlers that share one
io.Writermust share the same mutex, which is whyLineHandlerbelow holds a*sync.Mutex. -
To modify a
Recordbefore passing it on, callRecord.Clone()first; copies of aRecordshare state.
The handler below writes one JSON object per record and follows every rule above. It records WithGroup and WithAttrs
calls in order, then replays them when a record arrives, so that attributes land in the right nested object.
package main
import (
"context"
"encoding/json"
"io"
"log/slog"
"slices"
"sync"
)
// groupOrAttrs is one WithGroup or WithAttrs call, kept in call order.
type groupOrAttrs struct {
group string // set for WithGroup
attrs []slog.Attr // set for WithAttrs
}
// LineHandler writes one JSON object per record. It is a teaching
// implementation; slog.JSONHandler is the production choice.
type LineHandler struct {
// OmitTime drops the time key; handy for deterministic output.
OmitTime bool
mu *sync.Mutex // shared by all handlers derived from the same root
w io.Writer
level slog.Leveler
goas []groupOrAttrs
}
// NewLineHandler returns a handler that writes to w at or above level.
func NewLineHandler(w io.Writer, level slog.Leveler) *LineHandler {
if level == nil {
level = slog.LevelInfo
}
return &LineHandler{mu: new(sync.Mutex), w: w, level: level}
}
func (h *LineHandler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level.Level()
}
func (h *LineHandler) withGroupOrAttrs(g groupOrAttrs) *LineHandler {
h2 := *h
h2.goas = append(slices.Clip(h.goas), g)
return &h2
}
func (h *LineHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{group: name})
}
func (h *LineHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs})
}
func (h *LineHandler) Handle(_ context.Context, r slog.Record) error {
root := map[string]any{}
if !r.Time.IsZero() && !h.OmitTime {
root[slog.TimeKey] = r.Time
}
root[slog.LevelKey] = r.Level.String()
root[slog.MessageKey] = r.Message
// Replay WithGroup/WithAttrs in order; attributes land in the innermost group.
cur := root
var openGroups []map[string]any
var names []string
for _, g := range h.goas {
if g.group != "" {
sub := map[string]any{}
openGroups = append(openGroups, cur)
names = append(names, g.group)
cur = sub
continue
}
for _, a := range g.attrs {
add(cur, a)
}
}
r.Attrs(func(a slog.Attr) bool {
add(cur, a)
return true
})
// Close the groups from the innermost outwards, dropping empty ones.
for i := len(names) - 1; i >= 0; i-- {
parent := openGroups[i]
if len(cur) > 0 {
parent[names[i]] = cur
}
cur = parent
}
b, err := json.Marshal(root)
if err != nil {
return err
}
h.mu.Lock()
defer h.mu.Unlock()
_, err = h.w.Write(append(b, '\n'))
return err
}
// add stores a into m, following the rules in the slog.Handler documentation.
func add(m map[string]any, a slog.Attr) {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return // ignore empty attributes
}
if a.Value.Kind() != slog.KindGroup {
m[a.Key] = a.Value.Any()
return
}
attrs := a.Value.Group()
if len(attrs) == 0 {
return // ignore empty groups
}
target := m
if a.Key != "" { // an empty key inlines the group
target = map[string]any{}
}
for _, ga := range attrs {
add(target, ga)
}
if a.Key != "" && len(target) > 0 {
m[a.Key] = target
}
}
var _ slog.Handler = (*LineHandler)(nil)
Source: log/slog: Handler and golang/example: slog-handler-guide
package main
import (
"log/slog"
"os"
)
func main() {
h := NewLineHandler(os.Stdout, slog.LevelDebug)
h.OmitTime = true
log := slog.New(h)
log.Debug("opened", "path", "/var/lib/shelf/books.db")
log.With("service", "shelfd").WithGroup("loan").With("member", "ada").Info("lent", "isbn", "9780134685991", slog.Group("terms", "days", 14))
}
{"level":"DEBUG","msg":"opened","path":"/var/lib/shelf/books.db"}
{"level":"INFO","loan":{"isbn":"9780134685991","member":"ada","terms":{"days":14}},"msg":"lent","service":"shelfd"}
Source: log/slog: Handler and Logger.WithGroup
This is a teaching handler. It builds a map per record (so key order is alphabetical) and re-marshals on every record;
slog.JSONHandler pre-formats With attributes, keeps insertion order and avoids allocations. Use the built-in
handlers unless you need what they cannot do.
Testing a handler with testing/slogtest
The package testing/slogtest checks a handler against the contract. slogtest.Run (Go 1.22) runs each rule as a
subtest, which makes a failure easy to locate; slogtest.TestHandler runs the same checks without subtests. Both need
a function that turns the handler’s output back into map[string]any (JSON is the easy case, since
json.Unmarshal produces exactly that, with groups as nested maps). If a handler intentionally drops an attribute
that a case checks, the results function must add its absence to the map.
package main
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
"testing/slogtest"
)
func TestLineHandler(t *testing.T) {
var buf bytes.Buffer
newHandler := func(*testing.T) slog.Handler {
buf.Reset()
return NewLineHandler(&buf, slog.LevelInfo)
}
result := func(t *testing.T) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
t.Fatalf("output is not one JSON object: %v\n%s", err, buf.Bytes())
}
return m
}
slogtest.Run(t, newHandler, result)
}
func TestLineHandlerAll(t *testing.T) {
var buf bytes.Buffer
h := NewLineHandler(&buf, slog.LevelInfo)
results := func() []map[string]any {
var ms []map[string]any
for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) {
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
t.Fatal(err)
}
ms = append(ms, m)
}
return ms
}
if err := slogtest.TestHandler(h, results); err != nil {
t.Error(err)
}
}
=== RUN TestLineHandler
=== RUN TestLineHandler/built-ins
=== RUN TestLineHandler/attrs
=== RUN TestLineHandler/empty-attr
=== RUN TestLineHandler/zero-time
=== RUN TestLineHandler/WithAttrs
=== RUN TestLineHandler/groups
=== RUN TestLineHandler/empty-group
=== RUN TestLineHandler/inline-group
=== RUN TestLineHandler/WithGroup
=== RUN TestLineHandler/multi-With
=== RUN TestLineHandler/empty-group-record
=== RUN TestLineHandler/nested-empty-group-record
=== RUN TestLineHandler/resolve
=== RUN TestLineHandler/resolve-groups
=== RUN TestLineHandler/resolve-WithAttrs
=== RUN TestLineHandler/resolve-WithAttrs-groups
=== RUN TestLineHandler/empty-PC
--- PASS: TestLineHandler (0.00s)
--- PASS: TestLineHandler/built-ins (0.00s)
--- PASS: TestLineHandler/attrs (0.00s)
--- PASS: TestLineHandler/empty-attr (0.00s)
--- PASS: TestLineHandler/zero-time (0.00s)
--- PASS: TestLineHandler/WithAttrs (0.00s)
--- PASS: TestLineHandler/groups (0.00s)
--- PASS: TestLineHandler/empty-group (0.00s)
--- PASS: TestLineHandler/inline-group (0.00s)
--- PASS: TestLineHandler/WithGroup (0.00s)
--- PASS: TestLineHandler/multi-With (0.00s)
--- PASS: TestLineHandler/empty-group-record (0.00s)
--- PASS: TestLineHandler/nested-empty-group-record (0.00s)
--- PASS: TestLineHandler/resolve (0.00s)
--- PASS: TestLineHandler/resolve-groups (0.00s)
--- PASS: TestLineHandler/resolve-WithAttrs (0.00s)
--- PASS: TestLineHandler/resolve-WithAttrs-groups (0.00s)
--- PASS: TestLineHandler/empty-PC (0.00s)
=== RUN TestLineHandlerAll
--- PASS: TestLineHandlerAll (0.00s)
PASS
ok example.com/shelf/e12 0.067s
Source: testing/slogtest
The tests are meaningful: if the check a.Equal(slog.Attr{}) that ignores empty attributes is removed from add, both
tests fail and name the broken rule.
--- FAIL: TestLineHandler (0.00s)
--- FAIL: TestLineHandler/empty-attr (0.00s)
slogtest.go:311: unexpected key "": a Handler should ignore an empty Attr ($GOROOT/src/testing/slogtest/slogtest.go:61)
--- FAIL: TestLineHandlerAll (0.00s)
handler_test.go:43: unexpected key "": a Handler should ignore an empty Attr ($GOROOT/src/testing/slogtest/slogtest.go:61)
FAIL
(The path in the last line is the toolchain’s GOROOT, abbreviated to $GOROOT.)
Wrapping slog in your own helper
The logger finds the file and line of the call by walking the stack, which gives the wrong location for a helper that
wraps slog (it reports the helper’s own line). A correct wrapper obtains the caller’s program counter with
runtime.Callers, builds the record with slog.NewRecord and passes it to the handler.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"time"
)
func shortSource(groups []string, a slog.Attr) slog.Attr {
switch {
case len(groups) == 0 && a.Key == slog.TimeKey:
return slog.Attr{}
case a.Key == slog.SourceKey:
src := a.Value.Any().(*slog.Source)
src.File = filepath.Base(src.File)
src.Function = ""
}
return a
}
// Infof is a printf-style wrapper that reports the caller's position, not its own.
func Infof(log *slog.Logger, format string, args ...any) {
if !log.Enabled(context.Background(), slog.LevelInfo) {
return
}
var pcs [1]uintptr
runtime.Callers(2, pcs[:]) // skip runtime.Callers and Infof
r := slog.NewRecord(time.Now(), slog.LevelInfo, fmt.Sprintf(format, args...), pcs[0])
_ = log.Handler().Handle(context.Background(), r)
}
// naive wraps without adjusting the program counter.
func naive(log *slog.Logger, format string, args ...any) {
log.Info(fmt.Sprintf(format, args...))
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, ReplaceAttr: shortSource}))
Infof(log, "lent %s to %s", "9780134685991", "ada")
naive(log, "lent %s to %s", "9780134685991", "ada")
}
level=INFO source=main.go:43 msg="lent 9780134685991 to ada"
level=INFO source=main.go:38 msg="lent 9780134685991 to ada"
Source: log/slog: example (Wrapping)
The first line points at the call in main (line 43); the second points at the line inside naive (38). Most code
should avoid printf-style wrappers entirely (they re-introduce unstructured messages) and call the logger directly.
Testing code that logs
Code that logs should accept a *slog.Logger (a struct field, a parameter or an option), not use the global logger,
so that tests can control and inspect the output. Two helpers cover most tests:
-
a logger writing into a
bytes.Bufferwith aJSONHandler, then decoding the lines and asserting on the fields that matter (level, message and the attributes that the operation promises), as below; -
slog.New(slog.DiscardHandler)for tests that do not care about logs and for the zero value of an optional field.
Assert on stable fields, not on whole lines: times, durations and ordering vary.
package main
import (
"context"
"log/slog"
"example.com/shelf/catalog"
)
// Lender lends books and reports what it did to its logger.
type Lender struct {
Store catalog.Store
Log *slog.Logger
}
func (l *Lender) Lend(ctx context.Context, isbn catalog.ISBN, member string) error {
b, ok := l.Store.Get(isbn)
if !ok {
l.Log.WarnContext(ctx, "lend failed", "isbn", isbn, "member", member, "reason", "unknown book")
return catalog.ErrInvalidISBN
}
l.Log.InfoContext(ctx, "book lent", "isbn", b.ISBN, "member", member)
return nil
}
func main() {}
Source: log/slog: Logger.InfoContext and Logger.WarnContext
package main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"example.com/shelf/catalog"
)
// capture returns a logger that writes JSON lines into the returned buffer.
func capture() (*slog.Logger, *bytes.Buffer) {
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
return slog.New(h), &buf
}
func records(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("bad log line %q: %v", line, err)
}
out = append(out, m)
}
return out
}
func TestLendLogsOutcome(t *testing.T) {
store := catalog.NewMemStore()
store.Put(catalog.Book{ISBN: "9780134685991", Title: "Sample"})
log, buf := capture()
l := &Lender{Store: store, Log: log}
if err := l.Lend(context.Background(), "9780134685991", "ada"); err != nil {
t.Fatal(err)
}
if err := l.Lend(context.Background(), "9783161484100", "ada"); err == nil {
t.Fatal("want an error for an unknown book")
}
got := records(t, buf)
if len(got) != 2 {
t.Fatalf("got %d records, want 2", len(got))
}
if got[0]["level"] != "INFO" || got[0]["msg"] != "book lent" {
t.Errorf("first record = %v", got[0])
}
if got[1]["level"] != "WARN" || got[1]["reason"] != "unknown book" {
t.Errorf("second record = %v", got[1])
}
}
func TestLendWithoutLogging(t *testing.T) {
l := &Lender{Store: catalog.NewMemStore(), Log: slog.New(slog.DiscardHandler)}
_ = l.Lend(context.Background(), "9780134685991", "ada") // no output, no nil pointer
}
=== RUN TestLendLogsOutcome
--- PASS: TestLendLogsOutcome (0.00s)
=== RUN TestLendWithoutLogging
--- PASS: TestLendWithoutLogging (0.00s)
PASS
ok example.com/shelf/e17 (cached)
Source: log/slog: DiscardHandler and testing
Performance
Logging is on the hot path of most services, so slog was designed to be cheap when a record is disabled and
reasonable when it is enabled. The rules that matter:
-
Arguments are always evaluated, even for a disabled level.
log.Debug("x", "v", expensive())callsexpensive()every time. Guard withlog.Enabled(ctx, slog.LevelDebug), or pass aLogValuer(the work then happens only for enabled records), or pass the value itself and let the handler format it (for example, a*url.URLinstead ofu.String()). -
LogAttrs with typed Attr values avoids boxing for strings, numbers, booleans, durations and times, and is the most efficient call. The key-value form boxes into
anywhen a value does not fit the small-value cases. -
With pre-formats. A logger derived with
WithorWithGrouphas its attributes encoded once by the built-in handlers; repeating the same attributes in every call re-encodes them each time. -
AddSource costs a runtime.Callers call per enabled record; enable it where the location is worth it.
-
DiscardHandler short-circuits in
Enabled; aTextHandleronio.Discarddoes not.
The following program measures allocations per call (testing.AllocsPerRun) for several variants against a handler
that writes to io.Discard. The numbers are from go1.27.2 on darwin/arm64 and can differ slightly by platform and
release; the relative result is the lesson.
package main
import (
"context"
"fmt"
"io"
"log/slog"
"testing"
)
type Loan struct {
Book string
Member string
Days int
}
func main() {
ctx := context.Background()
text := slog.New(slog.NewTextHandler(io.Discard, nil))
off := slog.New(slog.DiscardHandler)
l := Loan{Book: "9780134685991", Member: "ada", Days: 14}
cases := []struct {
name string
fn func()
}{
{"disabled Debug, Sprintf argument", func() { text.Debug("lend", "loan", fmt.Sprintf("%+v", l)) }},
{"disabled Debug, struct argument", func() { text.Debug("lend", "loan", l) }},
{"disabled Debug, LogAttrs", func() { text.LogAttrs(ctx, slog.LevelDebug, "lend", slog.Any("loan", l)) }},
{"enabled Info, struct argument", func() { text.Info("lend", "loan", l) }},
{"enabled Info, string attributes", func() {
text.LogAttrs(ctx, slog.LevelInfo, "lend", slog.String("book", l.Book), slog.String("member", l.Member), slog.Int("days", l.Days))
}},
{"DiscardHandler, Sprintf argument", func() { off.Info("lend", "loan", fmt.Sprintf("%+v", l)) }},
}
for _, c := range cases {
fmt.Printf("%-34s %v allocs/op\n", c.name, testing.AllocsPerRun(1000, c.fn))
}
}
disabled Debug, Sprintf argument 3 allocs/op
disabled Debug, struct argument 1 allocs/op
disabled Debug, LogAttrs 1 allocs/op
enabled Info, struct argument 2 allocs/op
enabled Info, string attributes 0 allocs/op
DiscardHandler, Sprintf argument 3 allocs/op
The Sprintf argument is the costly case, and it is paid even when the record is disabled: three allocations for a
line that is never written. The struct is boxed once by the key-value form, and slog.Any has the same cost in
LogAttrs, because a struct is not a basic kind. Plain string and int attributes through LogAttrs allocate nothing.
For microbenchmarks of logging code use b.Loop() and -benchmem; see
Benchmarks, Fuzzing and synctest.
The vet slog analyzer
go vet includes an analyzer named slog that checks calls to the key-value functions and methods of log/slog:
Debug, Info, Warn, Error, their …Context forms, Log, With, Group and the corresponding Logger
methods. It reports an argument in key position that is neither a string nor a slog.Attr, and a final key with no value. It
is not part of the small high-confidence subset that go test runs automatically (atomic, bools, buildtag,
directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv and tests), so run
go vet ./... in CI to catch mismatched arguments at build time instead of finding !BADKEY attributes in
production. The messages for the program shown earlier are:
The messages for the program shown earlier are:
e15/main.go:17:2: call to slog.Logger.Info missing a final value
e15/main.go:18:29: slog.Logger.Info arg "42" should be a string or a slog.Attr (possible missing key or value)
e15/main.go:19:2: call to slog.Logger.Info missing a final value
Run only this analyzer with go vet -slog ./.... Its implementation is in
golang.org/x/tools/go/analysis/passes/slog, and
the complete list of analyzers is on Formatting, vet, fix and Static Analysis.
Using LogAttrs and slog.Attr values removes this whole class of error at compile time, at the price of verbosity;
some teams enforce that with a linter such as sloglint (a third-party tool that is not part of Go).
Source: cmd/vet
Practices for production
-
Log to standard output or standard error, one record per line, and let the platform collect it (the twelve-factor convention; containers and Kubernetes expect it). Do not rotate files inside the process unless there is no platform. See Docker and Kubernetes for how logs are collected.
-
Use JSONHandler in production and TextHandler for development, selected by configuration, as in the environment-variable example. Keep the keys stable: dashboards and alerts depend on them.
-
Keep the message constant and the variable data in attributes; keep the attribute set small and the cardinality of message low. High-cardinality values (IDs) belong in attributes, not in metric labels.
-
Never log secrets or personal data: passwords, tokens, keys, full card numbers, request bodies. Make types that hold secrets implement
LogValuer, and keepReplaceAttrredaction as a second line of defence. -
Pick levels by who must act:
Errormeans someone may need to look;Warnmeans the program coped;Infodescribes normal operation at a volume you can afford;Debugis off by default and switchable with aLevelVar. -
Log at the edge, not in every layer: request middleware, job runners and
mainlog; library code returns errors and lets the caller decide. A library that logs should take a logger, never use the default. -
Include correlation IDs (request ID, trace ID) through the context, as above.
-
Flush and fail visibly:
slogis synchronous and unbuffered, so records are on the writer when the call returns. If you wrap a buffered writer, flush it before exit;os.Exitskips deferred calls. -
Do not use logs as metrics or as a queue. Counters and durations belong in a metrics system (see Instrumenting applications) and in traces.
Comparison with other languages
| Concept | Go (log/slog) |
Elsewhere |
|---|---|---|
Facade and back end split |
|
SLF4J plus Logback in Java, |
Request-scoped fields |
|
MDC in SLF4J/Logback, |
Levels |
four named levels, any |
TRACE to FATAL in most Java and Python frameworks; Go has no FATAL or TRACE |
Structured output |
built in ( |
a JSON layout or encoder library in Java and Python; |
Lazy arguments |
not automatic; use |
placeholders ( |
Exceptions in logs |
an |
automatic stack trace of the exception in Java and Python |
There is no stack trace in a Go log record unless the program adds one; wrap errors with context instead (see Error Handling) and use the profiling and tracing tools when a stack is needed.
Pitfalls
| Pitfall | What to do instead |
|---|---|
Odd-length or mistyped key-value arguments ( |
Rely on |
Formatting the message ( |
A constant message with attributes; format only for human text that has no fields. |
An expensive argument for a disabled level |
|
A wrapping handler that forgets |
Override both to return the wrapper again, as in the context handler above. |
Modifying a |
|
Two handlers writing to one file without a shared lock |
Share one handler, or share the mutex, as in |
Using |
Call it only in |
|
Return an error to |
Logging and returning the same error at every layer |
Return it; log once where it is handled. |
Putting the logger in a package-level variable that tests cannot replace |
Inject it, or provide a constructor option; default to |
Logging secrets or whole request bodies |
|
Different value rendering in |
Implement |
Relying on the order of attributes or on key uniqueness |
Neither is guaranteed; handlers write duplicates and consumers disagree about which wins. |
Assuming a |
|
Where the books are outdated
-
Bodner, Learning Go (1st edition) covers the
logpackage and its use for simple programs, and is written against Go 1.15, so it has no structured logging:log/slog(1.21),HandlerOptions,LogValuer,LevelVar,SetLogLoggerLevel(1.22),slog.DiscardHandler(1.24),GroupAttrs(1.25) andNewMultiHandler(1.26) all post-date it. Its advice tolog.Fatalon startup errors is still valid for tiny programs, with the caveat above that deferred calls are skipped. -
Vivien, Learning Go Programming (2016) uses
logfor diagnostics and, at Go 1.6/1.7, predates contexts in the standard library and any structured logger; third-party libraries (logrus and similar) were the answer then. For new code, uselog/slog; existing third-party loggers usually have ansloghandler adapter so that a program can migrate one package at a time. -
Neither book discusses the
logtoslogbridge (SetDefault,NewLogLogger), which is what makes an incremental migration possible.
Version notes
-
Go 1.0: the
logpackage. -
Go 1.21:
log/slogis added (proposal 56345):Logger,Handler,TextHandler,JSONHandler,Attr,Value,LogValuer,LevelVar,HandlerOptions,SetDefault,NewLogLogger,Group,WithandWithGroup. -
Go 1.22:
slog.SetLogLoggerLevel; thetesting/slogtestpackage gainsRun. -
Go 1.24:
slog.DiscardHandler;LevelandLevelVarimplementencoding.TextAppender(AppendText). -
Go 1.25:
slog.GroupAttrsandRecord.Source. -
Go 1.26:
slog.NewMultiHandlerand theMultiHandlertype. -
Go 1.27: the release notes and the API list for 1.27 contain no changes to
log/slog,logortesting/slogtest. The oldest supported release is 1.26, so every API on this page is available in both supported releases; each is tagged with the release that added it where it is introduced. Examples were checked withgo 1.27ingo.mod.