Why do log/slog JSON records sometimes contain the same key twice, and how do you prevent it?
answer
- two layers, one name
- nothing merges the attributes
- the handler appends and does not check
- the consumer, not slog, picks a winner
- give each layer its own namespace
basics
~20 slog/slog does not deduplicate attributes. If a derived logger already carries file and a call site passes file again, the JSON handler writes both keys. Give each layer its own group so its names cannot collide.
solid answer
~40 sAttributes are appended in the order they were attached, and neither `log/slog` nor its built-in handlers check whether a key is already present, so a record can carry `"file"` twice — once from a logger derived with `Logger.With` up the stack, once from the logging call's own arguments. JSON permits repeated object names, but consumers disagree about them: some keep the first, some the last, some reject the document, so the same record reads two ways in two systems and a field seems to change value. `go vet` cannot help, because the derived logger was built in other code. The durable fix is namespacing: have each component open its own group with `Logger.WithGroup`, so a name chosen inside a library cannot land beside the same name chosen by the application.
code
go · 4 linesgen := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("file", "save.go")
// several layers down, another author picks the same obvious name
gen.Info("emitted", "file", "save_test.go")go deeper
Know that slog appends attributes in order and checks nothing, so the same key really can appear twice in one record rather than the second replacing the first.
Explain where the two attributes came from — one from a logger derived with With, one from the call site — and that the built-in handlers write both without merging them.
Diagnose it from a raw captured record rather than from a dashboard that has already picked a winner, and fix it by namespacing a layer with a group instead of renaming keys one at a time.
Own the record schema: which keys are reserved at the top level, which layers must open their own group, and how that contract is checked before records reach an ingestion pipeline.
## The symptom A field in your log search shows the wrong value, or disappears, for a subset of records — and the code that sets it looks obviously correct. Pulling one raw record out of the pipeline, rather than looking at the rendered view, shows the real story: the key is there twice. `{"time":"...","level":"INFO","msg":"emitted","file":"save.go","file":"save_test.go"}` The text handler makes the same thing look like a typo: `msg=emitted file=save.go file=save_test.go`. ## Why slog allows it `log/slog` treats attributes as an ordered sequence, not a map. A logging call appends its arguments after whatever the logger already carries, and the built-in text and JSON handlers write what they are given. Nothing in the handler contract requires deduplication, and the built-in handlers do not do it — checking every key against every other key on every record would cost work on the hot path for a condition that is a bug in the caller. The collision almost always spans two pieces of code that never see each other: - A component derives a logger once — `logger = logger.With("file", path)` — and passes it down. - A function several layers below logs `logger.Info("emitted", "file", generatedPath)`, using the same obvious name for a different thing. Both are locally reasonable. Neither author can see the other's key. A shared library that attaches its own context to a logger handed in by the application is the most common source, because the library cannot know what names the application already used. ## Why it is worse than it looks The JSON specification allows an object to repeat a name, and leaves the behaviour to the parser. In practice, consumers differ: some keep the first occurrence, some the last, some surface both, and some refuse the document. That means the record is not simply wrong — it is *ambiguous*, and two systems reading the same line can disagree about what happened. Worse, the failure is silent at every stage: the logging call succeeds, the record is written, the pipeline accepts it, and only a query result looks strange. `go vet` will not find it. Its slog analyzer checks that a call's key/value arguments pair up and that keys are strings; it has no way to know what attributes a logger acquired somewhere else in the program. ## The fix: namespaces, not renaming The reflex fix is to rename one of the keys — `file` becomes `source_file` — and it works exactly until the next collision. The durable fix is to stop sharing a flat namespace at all. Have each component open its own group once, at the point it derives its logger: `logger = logger.WithGroup("gen")`. Every attribute that component attaches afterwards, including the arguments of its individual logging calls, is nested under `gen`, so `gen.file` and a top-level `file` coexist without touching each other. The application keeps the top level for the identifiers that queries filter on; each library or subsystem gets one name it owns, and that name is the only thing the two sides have to agree about. Three supporting habits make it stick. Write down which top-level keys are reserved by the service and treat additions as a schema change. Have libraries open a group rather than assume the top level is free. And test the shape where it matters: log into an `io.Writer` you control, decode the record, and assert the field is where the consumer expects it — a test that would have caught this collision at the moment it appeared. ## Reading the evidence When you suspect this, do not debug through the dashboard, because the dashboard has already picked a winner and hidden the duplicate. Capture the raw line at the source — the same handler writing to a file or to standard output in a local run — and look at the bytes. Seeing the key twice takes the diagnosis from an argument about which code path is wrong to a five-minute fix.
- Which of the two attributes wins when a duplicated key reaches a log consumer?That is the consumer's decision, which is exactly why the bug is slippery. JSON permits repeated object names and parsers differ: some keep the first, some the last, some expose both, and some reject the document. Nothing in log/slog picks a winner, so two systems reading the identical record can report different values and neither is misbehaving.
- Would go vet catch a duplicated attribute key?No. The slog analyzer checks that a call's key/value arguments pair up and that keys are strings; it has no view of what attributes a logger picked up elsewhere in the program, and that logger was usually built in another package entirely. Catching it needs a convention about who owns which key, or a test that decodes a captured record and asserts its shape.
- How would you structure groups so a shared library cannot collide with the application?Give the library one group name of its own and have it open that group once, when it derives its logger. Everything it attaches afterwards nests under that name regardless of what the application logs at the top level. The application then owns the top-level namespace, and the only thing the two sides must agree on is the single group name, which is easy to review and easy to change.
It is two people writing on the same form in different rooms. Neither line is wrong, the form now has two boxes labelled the same, and whoever reads it later decides which one counts.
saying these in an interview costs you the question
- Says the later attribute overwrites the earlier one
- Assumes slog rejects or merges a duplicate key
- Thinks duplicate JSON names are invalid everywhere
- Expects go vet to catch a cross-logger collision
- Fixes it by renaming one key and moving on