skip to content

In Go's log/slog, how do slog.Group and Logger.WithGroup differ in what they nest?

level: middleimportance: should knowfreq 56%

answer

  1. one record versus every record
  2. an attribute versus a derived logger
  3. a namespace you open and leave open
  4. JSON nests an object, text joins with dots

basics

~10 s

slog.Group builds one attribute whose value is a nested set of attributes, for a single record. Logger.WithGroup returns a derived logger that qualifies everything added afterwards under that group name, on every record.

solid answer

~40 s

`slog.Group(key, args...)` is an attribute constructor: it takes the same key/value arguments a logging call takes and returns a single `slog.Attr` whose value is that nested set, so the nesting applies to the one record you pass it to. `Logger.WithGroup(name)` is a logger operation: it returns a new logger under which every attribute added later — by a subsequent `With`, or by any individual logging call — is qualified by that name, and calling it again nests a further level. The output shape depends on the handler: the JSON handler writes a nested object per group, and the text handler joins the names with dots, so `gen.file=save.go`. Two rules of the handler contract matter in practice: a group with no attributes is omitted entirely, and `WithGroup("")` returns the receiver unchanged.

code

go · 4 lines
go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
gen := logger.WithGroup("gen").With("dir", "internal/store")

gen.Info("emitting", slog.Group("decl", "kind", "func", "name", "Save"))

go deeper

for a junior

Know that slog records can nest at all, and that a group name shows up as a dotted prefix in text output and as an object in JSON output rather than as part of the key you typed.

for a middle

Explain the difference in scope precisely: slog.Group nests attributes for one record, while Logger.WithGroup opens a namespace on a derived logger that applies to every record after it.

for a senior

Be ready to design the namespace itself — which layer of a service owns which group name — so a library's attributes and an application's stay distinguishable in one stream.

for a principal

Own the log schema across services: whether groups mirror component ownership or call depth, and how renaming a group is rolled out to every consumer that queries the old path.

## Two different scopes for the same idea Both mechanisms produce nesting, and they differ only in how far that nesting reaches. `slog.Group(key string, args ...any) slog.Attr` builds one attribute. Its remaining arguments are interpreted exactly as a logging call's arguments are — a string key followed by its value, or a ready-made `slog.Attr` — and the result is a single attribute whose value is that collection. It nests nothing beyond the record you hand it to. Because it returns an ordinary `slog.Attr`, it composes: a group can be an argument to another group, giving arbitrary depth in one expression. `Logger.WithGroup(name string) *Logger` derives a logger. Every attribute added to that logger afterwards, whether by a later `Logger.With` or by the arguments of an individual `Info`/`Warn`/`Error` call, lands inside the group named `name`. It is a namespace you open and leave open; there is no operation that closes one. Calling `WithGroup` again on the result nests a second level, so `logger.WithGroup("gen").WithGroup("decl")` puts everything two levels deep. ## What the output looks like Groups are structure, not text, so the rendering is the handler's decision. The JSON handler emits a nested object per group: `{"msg":"emitting","gen":{"dir":"internal/store","decl":{"kind":"func","name":"Save"}}}` The text handler has no nesting to work with, so it flattens the path with dots: `gen.dir=internal/store gen.decl.kind=func`. A consumer that indexes JSON sees real nested fields; a human reading text sees a dotted path. Neither is a string you construct yourself — you never write `"gen.dir"` as a key. ## The rules that surprise people The `slog.Handler` contract states several rules that the built-in handlers follow, and two of them are about groups. First, **a group with no attributes is ignored**, even if its key is non-empty. A logger that opened a group and then logged a record with no attributes at all produces no empty object and no dangling prefix — the group simply is not there. This is why `WithGroup` is cheap to call speculatively at the top of a component. Second, **a group whose key is empty has its attributes inlined** into the parent level rather than nested. Relatedly, `Logger.WithGroup("")` is documented to return the receiver unchanged, so an empty name is a no-op rather than an anonymous namespace. A third rule is worth knowing because it explains vanishing attributes generally: an attribute whose key and value are both zero is ignored. ## Choosing between them Use `slog.Group` when the nesting belongs to the event: one record describes a declaration, a file and its position, and those three fields are naturally one sub-object. The grouping is local, visible at the call site, and costs the reader nothing to follow. Use `Logger.WithGroup` when the nesting belongs to the *component*. A code generator that walks a package tree can derive a logger per stage, open a group named for that stage, and let everything the stage logs land underneath it — no call site inside the stage has to remember the prefix, and no call site can forget it. That is the property you actually want when several layers write to one stream: each layer's field names live in its own namespace, so a name chosen in one layer cannot collide with the same name chosen in another. The two compose cleanly. A `slog.Group` attribute passed to a call on a grouped logger nests inside that logger's group, giving you per-component namespacing on the outside and per-event structure on the inside. And because `slog.Group` accepts the same loose argument list as a logging call, the same failure mode applies inside it: a dangling key produces an `!BADKEY` attribute nested within the group rather than at the top level.

  • How do you nest two levels of grouping with log/slog?
    Either call WithGroup twice, or nest one slog.Group inside another. logger.WithGroup("gen").WithGroup("decl") makes a later name attribute render as gen.decl.name in text output and as two nested objects in JSON. The mechanisms also compose: a slog.Group passed to a call on a grouped logger lands inside the logger's group, so a component prefix and a per-event sub-object stack naturally.
  • What do the built-in handlers do with a group that ends up empty?
    They omit it. The handler contract says a group with no attributes is ignored even when its key is non-empty, so a logger that opened a group and then logged nothing under it produces no empty object and no dangling prefix. The same contract says a group whose key is empty has its attributes inlined at the parent level instead of nested.
  • Does slog.Group accept anything other than slog.Attr arguments?
    Yes. Its signature is Group(key string, args ...any), and the remaining arguments are converted the same way a logging call converts its own: a string key followed by its value, or a ready-made attribute. That symmetry has a consequence worth remembering — a dangling key inside a group produces an !BADKEY attribute nested inside that group rather than at the top level.

saying these in an interview costs you the question

  • Thinks WithGroup applies only to the next log call
  • Says slog.Group mutates the logger it is used with
  • Expects an empty group to appear in the output
  • Assumes JSON output flattens groups into dotted keys
  • Writes dotted key names by hand instead of grouping