In Go's log/slog, why does the order of Logger.With and Logger.WithGroup change the output?
answer
- each call hands back a new logger
- the chain is replayed in order
- a group can only catch what comes after
- nothing ever closes a group
- swap two lines, get a different shape
basics
~10 sLogger.With and Logger.WithGroup each return a new logger and are applied in call order: attributes added before a WithGroup stay at the top level, while those added after it are nested inside that group.
solid answer
~40 sNeither method mutates the logger it is called on; each returns a derived `*Logger` that remembers the chain of operations in the order you made them. `WithGroup("gen")` opens a namespace at the point it appears, so `base.With("dir", d).WithGroup("gen")` leaves `dir` at the top level, while `base.WithGroup("gen").With("dir", d)` puts `dir` inside `gen` — same two calls, different record shape. There is no operation that closes a group, so once one is open every later attribute, including the arguments of an individual logging call, is nested under it; to get back to the top level you use a logger from before the `WithGroup`. Because the receiver is untouched, you must use the returned logger — a `With` whose result is discarded attaches nothing, and the parent keeps logging exactly what it did before.
code
go · 9 linesbase := slog.New(slog.NewJSONHandler(os.Stdout, nil))
a := base.With("dir", "internal/store").WithGroup("gen")
a.Info("emitting", "file", "save.go")
// dir stays at the top level; file lands inside "gen"
b := base.WithGroup("gen").With("dir", "internal/store")
b.Info("emitting", "file", "save.go")
// both dir and file land inside "gen"go deeper
Remember to assign the result: logger = logger.With("job", id). Calling With and dropping the return value attaches nothing and reports no error anywhere.
Explain that the chain is replayed in call order, so a With before a WithGroup lands at the top level while the identical With after it lands inside the group.
Show that you use the ordering on purpose: identifiers a query filters on attached before any group, component detail attached after, so dashboards keep finding their fields.
Decide how loggers are handed down through a codebase — a base logger with agreed top-level fields, and a rule for which layers may open a group — so record shape stays stable as teams add components.
## A logger is a value, not an object you mutate `Logger.With(args ...any) *Logger` and `Logger.WithGroup(name string) *Logger` both build a **new** logger. The receiver is left alone. This is the first thing to internalise if you are arriving from a logging library where you configure a logger in place: `logger.With("dir", d)` on its own line does nothing at all, because the derived logger that carries `dir` was thrown away. The practical shape is therefore assignment or chaining: `logger := logger.With("dir", dir)` inside a function, or a package-level constructor that returns a component's own logger. Since the parent is unaffected, handing a derived logger to another package is safe — nothing that package does to it can change what the rest of the program logs. ## Order is preserved, and it is the whole story A derived logger records the operations in the order they were applied. When a record is written, the attributes and the group openings are replayed in that same order. A `WithGroup` opens a namespace **at its position in the chain**, so it can only affect what comes after it. Attributes attached before it are already placed at the level they were placed at, and no later call can retroactively move them. That gives the two arrangements distinct meanings: - `base.With("dir", d).WithGroup("gen")` — `dir` at the top level; everything logged later inside `gen`. - `base.WithGroup("gen").With("dir", d)` — both `dir` and everything logged later inside `gen`. And there is no closing operation. Once a chain has opened a group, every attribute added afterwards is under it, including the key/value arguments of individual `Info` and `Error` calls. Nesting a second `WithGroup` goes one level deeper. If you need something back at the top level, you keep a reference to the logger from before the group was opened and log through that one. ## Using the ordering deliberately The rule turns into a simple design guideline: **attributes a consumer queries on go before the group; attributes that describe one component's internals go after it.** Consider a code generator that walks a package tree and emits files. A run identifier and the target module are things every dashboard and every search filters on, so they belong at the top level, attached before any group is opened. The per-stage detail — which directory is being walked, which file is being written, which declaration is being emitted — is only meaningful in the context of that stage, so the stage opens its own group and attaches its detail after. The result is records whose top level is stable and small, with a predictable sub-object per stage. The ordering rule is also the fix for a subtle mistake: a component that opens its group first and *then* attaches what it thought were global fields will bury those fields inside the group, where the query that looks for them at the top level finds nothing. The record is present, the field is present, and the search still misses — which is a much more annoying failure than a missing log line. ## What to watch for in review Three things are worth flagging when reading a diff. A `With` or `WithGroup` whose result is discarded, which attaches nothing. A group opened early in a constructor followed by attributes that were meant to be top-level, which buries them. And a chain long enough that a reader cannot tell what level a given attribute ends up at — at that point the honest fix is to build the logger in one place, with a comment naming the intended record shape, rather than deriving it incrementally across several files.
- What happens if you call Logger.With and ignore the returned logger?Nothing is attached. With does not modify the receiver; it returns a derived logger holding the extra attributes, and the original keeps logging exactly as before. Discarding the result is a common mistake for anyone used to a mutable logger object, and it fails quietly: the records still appear, just without the context you thought you added.
- Is it safe to hand a logger derived with With to another package or goroutine?Yes. A *Logger built by With or WithGroup is a new value and the parent is untouched, so sharing a derived logger cannot change what anything else logs. Giving each component its own derived logger, carrying its own attributes and its own group, is the intended way to attach context without threading extra parameters through every function.
- Where does a group opened with WithGroup stop applying?It does not stop on that logger. Every attribute added afterwards is nested under it, including the key/value arguments of individual logging calls, and a second WithGroup nests one level deeper. There is no call that closes a group, so returning to the top level means logging through a logger captured before the WithGroup was applied.
saying these in an interview costs you the question
- Thinks Logger.With mutates the logger in place
- Expects WithGroup to nest attributes added earlier
- Assumes some call closes an open group
- Believes the parent logger inherits derived attributes
- Discards the logger returned by With