#787·zerolog

SlogHandler violates several parts of the slog.Handler contract

Author: anuragkumar19Created Aug 17, 2026Updated Aug 17, 2026

SlogHandler violates several parts of the slog.Handler contract

References

Issues

1. Nested groups are flattened into dotted keys instead of nested objects:

go
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.WithGroup("g").Info("msg", "k", "v")

Output: {"level":"info","g.k":"v","time":"2026-08-12T00:46:25+05:30","message":"msg"}

Expected: {"level":"info","g":{"k":"v"},"time":"2026-08-12T00:59:55+05:30","message":"msg"}

2. Keys collapse onto the most-recently-opened group, regardless of where they were actually added. This can also lead to duplicate keys, as shown in the example below:

go
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.With("l", 1).WithGroup("g").With("l", 2).WithGroup("G").Info("msg", "l", 3)

Output: {"level":"info","g.G.l":1,"g.G.l":2,"g.G.l":3,"time":"2026-08-12T00:55:38+05:30","message":"msg"}

Expected: {"level":"info","l":1,"g":{"l":2,"G":{"l":3}},"time":"2026-08-12T00:55:56+05:30","message":"msg"}

Expected (even with a flattened-key design): {"level":"info","l":1,"g.l":2,"g.G.l":3,"time":"2026-08-12T00:55:38+05:30","message":"msg"}

3. Doesn't respect slog.Record.Time:

In practice, when slog.Record.Time is left at its default (time.Now()), it and the Timestamp() hook's own capture differ by mere nanoseconds — invisible in normal use. But the contract still requires sourcing from record.Time, which becomes obvious once it's set explicitly, as below:

go
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Date(2001, time.April, 1, 0, 0, 0, 0, time.UTC), slog.LevelInfo, "msg", 0))

Output: {"level":"info","time":"2026-08-12T01:07:28+05:30","message":"msg"}

Expected: {"level":"info","time":"2001-04-01T00:00:00Z","message":"msg"}

4. The slog.Handler contract states that a zero record.Time should be dropped; this handler doesn't:

go
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Time{}, slog.LevelInfo, "msg", 0))

Output: {"level":"info","time":"2026-08-12T01:17:48+05:30","message":"msg"}

Expected: {"level":"info","message":"msg"}

5. Logs slog.Record.Time when Timestamp() hook is not present:

This produces inconsistent logs and can also lead to a duplicate time field if a custom timestamp hook is used.

go
hook := zerolog.HookFunc(func(e *zerolog.Event, level zerolog.Level, message string) {
    e.Time(zerolog.TimestampFieldName, time.Now())
})

logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).Hook(hook)))
logger.Info("msg")

Output: {"level":"info","time":"2026-08-12T15:40:07+05:30","time":"2026-08-12T15:40:07+05:30","message":"msg"}

Expected: {"level":"info","time":"2026-08-12T15:40:07+05:30","message":"msg"}

6. Doesn't respect slog.Record.PC:

As a result, the caller field doesn't resolve to the correct call site.

go
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Caller().Logger()))
logger.Info("msg")

Output: {"level":"info","time":"2026-08-12T01:25:31+05:30","caller":"github.com/rs/[email protected]/slog.go:71","message":"msg"}

Expected: {"level":"info","time":"2026-08-12T01:26:03+05:30","caller":"/path/to/project/main.go:12","message":"msg"}

7. The slog.Handler contract states that a zero record.PC should be dropped; this handler doesn't:

go
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Caller().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "msg", 0))

Output: {"level":"info","time":"2026-08-12T01:22:06+05:30","caller":"github.com/rs/[email protected]/slog.go:71","message":"msg"}

Expected: {"level":"info","time":"2026-08-12T01:22:09+05:30","message":"msg"}

8. Values with an empty key are dropped instead of being logged:

Per the slog.Handler contract, only an attr whose key and value are both zero should be dropped — checked via attr.Equal(slog.Attr{}), not a bare key check. zerolog.Logger logs empty keys too.

go
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.Info("msg", "", "v")

Output: {"level":"info","time":"2026-08-12T01:34:17+05:30","message":"msg"}

Expected: {"level":"info","":"v","time":"2026-08-12T01:34:43+05:30","message":"msg"}

9. Doesn't pre-format WithAttrs/WithGroup output (via zerolog.Context), as the handler guide recommends for performance:

WithAttrs accumulates attrs across multiple calls, then re-encodes all of them from scratch — along with the record's own attrs — on every single Handle() call, instead of writing accumulated attrs into the underlying zerolog.Context once, at WithAttrs/WithGroup time.

10. The test suite doesn't run the official testing/slogtest conformance suite. If it did, it would fail because of the issues above.

11. Incorrect tests

  • TestSlogHandler_EmptyMessage is written to test that an empty message is omitted from the log output, but it never actually asserts that.

  • TestSlogHandler_WithAttrsImmutability is meant to test attrs immutability, but it uses two different zerolog.Logger instances with different buffers: child1 is derived from zl1, while the other child is derived from an unrelated zl2. As written, it never proves that calling WithAttrs twice on the same handler leaves the first call's result unmutated.

  • TestSlogHandler_EnabledNilWriter uses zerolog.Nop(), which creates a logger via zerolog.New(nil).Level(Disabled). Since New substitutes io.Discard whenever the writer is nil, the resulting logger's writer is never actually nil — defeating the purpose of the test, which should isolate the w == nil branch specifically, distinct from the Disabled-level branch.

Other issues (non-functional)

1. slog_test.go uses package zerolog_test

  • This is inconsistent with the rest of the project, which uses package zerolog for test files (except files with examples). This makes internal testing helpers like decodeIfBinaryToBytes/decodeIfBinaryToString unavailable.

  • slog_test.go uses its own decodeJSON and decodeOutput helpers instead, which is inconsistent with the rest of the project.

2. slog_test.go uses JSON decoding for assertions

The rest of the project expects log output to be byte-for-byte identical to the expected string. slog_test.go deviates from this pattern by using json.Unmarshal for its assertions instead.