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

log.Ldate, log.Ltime, log.Lmicroseconds

Date 2009/01/23, time 01:23:23, and microsecond resolution for the time. log.LstdFlags is Ldate | Ltime.

log.LUTC

Report the time in UTC instead of the local zone.

log.Lshortfile, log.Llongfile

Add file.go:23 or the full path and line of the call. The cost is a stack walk per line.

log.Lmsgprefix

Put the prefix before the message instead of at the start of the line.

log.New(w, prefix, flags)

A new logger. SetPrefix, SetFlags and SetOutput change one later; log.Default() returns the standard logger.

Fatal, Fatalf, Fatalln

Print, then call os.Exit(1). Deferred functions do not run.

Panic, Panicf, Panicln

Print, then panic with the message.

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

This is enough for a small tool. It is not enough for a service, for four reasons that motivated slog:

  • Levels do not exist. Print has no severity; programs invented [DEBUG] prefixes and if 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:

flowchart LR A["Application code: logger.Info(msg, args)"] --> B["Logger: With attrs"] B -->|Enabled?| C{"Handler.Enabled"} C -->|no| X["record discarded; arguments were already evaluated"] C -->|yes| D["Record: time, level, message, PC, attrs"] D --> E["Handler.Handle"] E --> F["TextHandler: key=value"] E --> G["JSONHandler: one object per line"] E --> H["your Handler: wrapper or sink"]
  • slog.Logger is the front end. It has the convenience methods Debug, Info, Warn and Error, their …Context forms, the general Log and LogAttrs, and the derivation methods With and WithGroup. A Logger is immutable and safe for concurrent use; With returns a new one.

  • slog.Handler is the back end, an interface with four methods (Enabled, Handle, WithAttrs, WithGroup). The standard library supplies TextHandler, JSONHandler, MultiHandler (Go 1.26) and DiscardHandler (Go 1.24).

  • slog.Record is the data passed between them: the time, the level, the message, the program counter of the call and a list of attributes.

  • slog.Attr is a key and a slog.Value. A Value is 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"}

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, grep

production services whose logs are collected by a machine

Values

fmt-style rendering; encoding.TextMarshaler and error honoured

encoding/json rendering; json.Marshaler and error honoured

Groups

dotted keys, http.method=GET

nested objects, "http":{"method":"GET"}

Durations

1.5s

integer nanoseconds (1500000000)

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

slog.LevelDebug

-4

Developer detail that is off in production: cache hits, SQL text, decisions.

slog.LevelInfo

0

Normal events worth keeping: a request served, a book lent, the service started.

slog.LevelWarn

4

Something unexpected that the program handled: a retry, a rejected input, a slow dependency.

slog.LevelError

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.

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"

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 an any;

  • typed slog.Attr values built by slog.String, slog.Int, slog.Int64, slog.Uint64, slog.Float64, slog.Bool, slog.Duration, slog.Time, slog.Any, slog.Group and slog.GroupAttrs: more to type, checked by the compiler, and allocation-free for the basic kinds when passed to LogAttrs.

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"}

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 as lent 9780134685991 to ada would prevent.

  • Pick one spelling for each key across the code base (isbn, not isbn here and ISBN there; err, not error sometimes). A const block or a small set of attribute constructors helps.

  • Log the error as a value, "err", err, not err.Error(): handlers (and ReplaceAttr) then see the error itself. A nil error is logged as null or <nil>, so test for nil first.

  • 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}}
  • The ISBN value implements fmt.Stringer. TextHandler prints the result of String(), JSONHandler marshals the underlying string, so the two outputs differ. (json.Marshaler and encoding.TextMarshaler are the interfaces to implement if the two must agree.)

  • A struct renders as %+v in text and as a JSON object in JSON; a pointer to a struct is dereferenced by JSON and printed as &{…} in text.

  • A nil error appears as <nil> in text and null in JSON; a non-nil error appears as its Error() string.

  • A duration is 1.5s in text but 1500000000 (nanoseconds) in JSON; convert in ReplaceAttr or log d.Milliseconds() if the consumer wants a unit.

  • []byte is 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

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 (or Attr values) for a single record.

  • slog.GroupAttrs(key, attrs ...Attr) (Go 1.25) does the same from Attr values 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 already Attr values.

  • logger.WithGroup(name) returns a logger whose later attributes, both those given to With afterwards and those in each call, are all qualified by name. 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}}

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"

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

Level

The minimum level, a slog.Leveler (default LevelInfo). Use *slog.LevelVar to change it at run time.

AddSource

Add a source attribute holding the function, file and line of the call (a *slog.Source). It costs a stack lookup on every enabled record. Text shows file.go:23 (with the full path), JSON shows an object.

ReplaceAttr

A function called for each non-group attribute, including the built-in time, level, msg and source, before it is written.

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"}
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"}}

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 Token type whose LogValue returns 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. LogValue is 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

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 …Context methods.

  • 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", err are equivalent. JSONHandler writes err.Error(); a custom type that wants structured output implements LogValuer (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 at Debug or Info, not Error.

  • An err that is nil is logged as null or <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

Enabled

True if any inner handler is enabled for the level. The Logger therefore builds the record if one sink wants it.

Handle

Calls Handle on each inner handler that is enabled for the record’s level, passing it a clone of the record, so a handler that modifies the record cannot affect the others. The errors are combined with errors.Join; all handlers are tried even if one fails.

WithAttrs, WithGroup

Apply to every inner handler and return a new MultiHandler.

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

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) bool is called first, before arguments are processed, and decides whether the record is made.

  • Handle(ctx, record) error is called only if Enabled returned true. If record.Time is the zero time, omit the time; if record.PC is zero, omit the source; resolve each Attr's value; skip an Attr equal to the zero slog.Attr{}; inline a group whose key is empty; omit a group with no attributes. Logger ignores the error returned.

  • WithAttrs(attrs) Handler returns a new handler that has the receiver’s attributes plus attrs. The receiver must not be modified.

  • WithGroup(name) Handler returns 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.Writer must share the same mutex, which is why LineHandler below holds a *sync.Mutex.

  • To modify a Record before passing it on, call Record.Clone() first; copies of a Record share 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)
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"}

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

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"

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.Buffer with a JSONHandler, 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() {}
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)

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()) calls expensive() every time. Guard with log.Enabled(ctx, slog.LevelDebug), or pass a LogValuer (the work then happens only for enabled records), or pass the value itself and let the handler format it (for example, a *url.URL instead of u.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 any when a value does not fit the small-value cases.

  • With pre-formats. A logger derived with With or WithGroup has 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; a TextHandler on io.Discard does 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 keep ReplaceAttr redaction as a second line of defence.

  • Pick levels by who must act: Error means someone may need to look; Warn means the program coped; Info describes normal operation at a volume you can afford; Debug is off by default and switchable with a LevelVar.

  • Log at the edge, not in every layer: request middleware, job runners and main log; 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: slog is synchronous and unbuffered, so records are on the writer when the call returns. If you wrap a buffered writer, flush it before exit; os.Exit skips 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

Logger (front end) and Handler (back end), both in the standard library

SLF4J plus Logback in Java, log plus env_logger or tracing-subscriber in Rust, the logging module’s loggers and handlers in Python

Request-scoped fields

With, …Context methods and a context-reading handler

MDC in SLF4J/Logback, tracing spans in Rust, LoggerAdapter or context variables in Python

Levels

four named levels, any int allowed, LevelVar for run-time changes

TRACE to FATAL in most Java and Python frameworks; Go has no FATAL or TRACE

Structured output

built in (JSONHandler)

a JSON layout or encoder library in Java and Python; tracing-subscriber's JSON formatter in Rust

Lazy arguments

not automatic; use Enabled or LogValuer

placeholders ({}) in SLF4J avoid formatting when disabled

Exceptions in logs

an err attribute; there is no stack trace unless you add one

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 (!BADKEY)

Rely on go vet, or use LogAttrs with Attr constructors.

Formatting the message (Sprintf) and putting the facts in it

A constant message with attributes; format only for human text that has no fields.

An expensive argument for a disabled level

Enabled guard, a LogValuer, or pass the value and let the handler format it.

A wrapping handler that forgets WithAttrs and WithGroup

Override both to return the wrapper again, as in the context handler above.

Modifying a Record in a handler and passing it on

r.Clone() first; copies share state.

Two handlers writing to one file without a shared lock

Share one handler, or share the mutex, as in LineHandler; the built-in handlers lock per handler, not per file.

Using SetDefault in library code or in init

Call it only in main; libraries accept a *slog.Logger.

log.Fatal or os.Exit after logging, with deferred cleanup skipped

Return an error to main and exit there (see the run pattern in Error Handling).

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 slog.Default().

Logging secrets or whole request bodies

LogValuer on secret types; log identifiers and sizes.

Different value rendering in TextHandler and JSONHandler (Stringer, durations)

Implement json.Marshaler/encoding.TextMarshaler or convert in ReplaceAttr; test both handlers if both are used.

Relying on the order of attributes or on key uniqueness

Neither is guaranteed; handlers write duplicates and consumers disagree about which wins.

Assuming a Fatal level exits the program

slog only writes the record; exit explicitly.

Where the books are outdated

  • Bodner, Learning Go (1st edition) covers the log package 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) and NewMultiHandler (1.26) all post-date it. Its advice to log.Fatal on startup errors is still valid for tiny programs, with the caveat above that deferred calls are skipped.

  • Vivien, Learning Go Programming (2016) uses log for 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, use log/slog; existing third-party loggers usually have an slog handler adapter so that a program can migrate one package at a time.

  • Neither book discusses the log to slog bridge (SetDefault, NewLogLogger), which is what makes an incremental migration possible.

Version notes

  • Go 1.0: the log package.

  • Go 1.21: log/slog is added (proposal 56345): Logger, Handler, TextHandler, JSONHandler, Attr, Value, LogValuer, LevelVar, HandlerOptions, SetDefault, NewLogLogger, Group, With and WithGroup.

  • Go 1.22: slog.SetLogLoggerLevel; the testing/slogtest package gains Run.

  • Go 1.24: slog.DiscardHandler; Level and LevelVar implement encoding.TextAppender (AppendText).

  • Go 1.25: slog.GroupAttrs and Record.Source.

  • Go 1.26: slog.NewMultiHandler and the MultiHandler type.

  • Go 1.27: the release notes and the API list for 1.27 contain no changes to log/slog, log or testing/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 with go 1.27 in go.mod.