A small, very fast, truly zero-allocation structured logger for Go.
Apple M5, Go 1.23+:
BenchmarkUltimateLogger 15.9 ns/op 0 B/op 0 allocs/op
BenchmarkUltimateLoggerParallel 5.2 ns/op 0 B/op 0 allocs/op
BenchmarkStructuredLogger 35.6 ns/op 0 B/op 0 allocs/op
BenchmarkStructuredLoggerParallel 10.5 ns/op 0 B/op 0 allocs/op
BenchmarkStructured + 5 fields 45.2 ns/op 0 B/op 0 allocs/op
BenchmarkStructured + 10 fields 76.7 ns/op 0 B/op 0 allocs/op
BenchmarkRealWorld → TerminalWriter 26.5 ns/op 0 B/op 0 allocs/op
BenchmarkDisabledDebug 0.23 ns/op 0 B/op 0 allocs/op
The interesting number is the last column on every row: every logging path is genuinely 0 allocs/op once the buffer pool is warm, including the colored terminal output. Parallel benchmarks are faster than serial because each GOMAXPROCS slot keeps its own pool localcache and there's no contention on a sequence counter.
go get github.com/semihalev/zlog/v2Requires Go 1.23+.
package main
import "github.com/semihalev/zlog/v2"
func main() {
log := zlog.NewStructured()
log.SetWriter(zlog.StdoutTerminal())
log.Info("server started",
zlog.String("addr", ":8080"),
zlog.Int("workers", 4),
)
log.Warn("slow query",
zlog.String("table", "users"),
zlog.Float64("duration_ms", 327.4),
)
log.Error("db connection lost",
zlog.String("err", "i/o timeout"),
zlog.Int("retry", 3),
)
}INFO [04-25|15:51:30] server started addr=:8080 workers=4
WARN [04-25|15:51:30] slow query table=users duration_ms=327.4
ERROR [04-25|15:51:30] db connection lost err="i/o timeout" retry=3
| Type | Purpose | Cost |
|---|---|---|
zlog.NewUltimateLogger() |
Bare-message hot path. No fields. | ~16 ns/op serial, ~5 ns/op parallel |
zlog.NewStructured() |
Typed fields, the recommended API. | ~36 ns/op + ~10 ns per extra field |
zlog.New() |
Plain Logger. Same shape as Ultimate. |
~21 ns/op |
For most apps, NewStructured() is the right choice: zero alloc, typed fields, ~26ns end-to-end when the writer is a TerminalWriter (that path is direct-text, no binary intermediate).
log.Info("event",
zlog.String("name", "Alice"),
zlog.Int("age", 30),
zlog.Int64("id", 123456789),
zlog.Uint("count", 42),
zlog.Uint64("total", 9999999),
zlog.Float32("score", 98.5),
zlog.Float64("pi", 3.14159),
zlog.Bool("active", true),
zlog.Bytes("data", []byte{0x01, 0x02, 0x03}),
)All field constructors are inlinable and allocation-free.
zlog.Debug / Info / Warn / Error / Fatal accept either typed Field
values or alternating key/value pairs. The two styles are interchangeable:
// Untyped key/value pairs — zero allocations on the hot path.
zlog.Info("user logged in", "username", "alice", "user_id", 12345)
// Typed Fields — also accepted, but pays 3 allocs per call from the
// Field-into-...any boxing at the callsite (Field is 56 bytes, doesn't
// fit inline in interface storage). Functionally identical output.
zlog.Info("user logged in", zlog.String("username", "alice"), zlog.Int("user_id", 12345))For the zero-allocation typed-Field path through the global helper, use the
F variants — DebugF / InfoF / WarnF / ErrorF / FatalF. They take
...Field directly so no boxing happens:
zlog.InfoF("user logged in", zlog.String("username", "alice"), zlog.Int("user_id", 12345))The compatibility-oriented *KV helpers (InfoKV, WarnKV, ...) are kept as
explicit aliases for the untyped path, equivalent to passing ...any to the
bare names.
zlog.StdoutTerminal()/zlog.StderrTerminal()— colored, padded, human-readable terminal output. The structured logger detects this writer and formats text directly into a pooled buffer (no binary intermediate).zlog.StdoutWriter()/zlog.StderrWriter()— raw binary output to stdout/stderr.zlog.NewLogfmtWriter(io.Writer)—key=valuetext format on top of the binary log.zlog.NewMMapWriter(path, size)— memory-mapped file with no per-write syscall (Linux/macOS/Windows).zlog.NewAsyncWriter(io.Writer, bufSize)— lock-free ring buffer with worker drains; for fan-out at high throughput.- Any
io.Writerworks. The package writes a compact binary record; the terminal/logfmt writers are the supported decoders.
mmap, _ := zlog.NewMMapWriter("/var/log/app.log", 100*1024*1024) // 100 MB
defer mmap.Close()
log := zlog.NewStructured()
log.SetWriter(mmap)log := zlog.NewStructured()
log.SetLevel(zlog.LevelWarn) // Only Warn / Error / Fatal are emitted.
if log.GetLevel() <= zlog.LevelDebug {
// expensive debug computation only when needed
}A disabled level call is ~0.23 ns/op (a single atomic load + compare).
DEBUG [01-02|15:04:05] starting up
INFO [01-02|15:04:05] server initialized
WARN [01-02|15:04:05] config not found, using defaults
ERROR [01-02|15:04:05] db connection failed error=timeout retry=3
Colors:
- DEBUG → cyan
- INFO → green
- WARN → yellow
- ERROR → red
- FATAL → magenta
Color is auto-detected from the underlying *os.File (TTY check). It also respects NO_COLOR and TERM=dumb. To force-disable in code:
tw := zlog.NewTerminalWriter(os.Stdout).(*zlog.TerminalWriter)
tw.SetColorEnabled(false)
log.SetWriter(tw)The terminal writer caches the formatted timestamp by Unix second, so time.Time decomposition runs once per second, not per log line.
ANSI colors work on Windows 10 (build 14393+) automatically. On older Windows or when output isn't a TTY, plain text is used. Same env vars (NO_COLOR, TERM=dumb) apply.
A few of the things that matter:
- Every record is built into a
sync.Pool-backed buffer indexed by a power-of-two size class. Allocation only happens when the pool is cold. - The "small message → stack buffer" fast path that traditional loggers use was deliberately removed: passing a stack array to an
io.Writer.Writeinterface call forces it onto the heap. The pool path is the actual zero-alloc path. - Field encoding writes int/float values as a single
*(*uint64)(unsafe.Pointer(&buf[pos])) = ...store in native byte order — one instruction per numeric field, not eight byte stores. - The structured logger detects when its writer is a
*TerminalWriterand formats text directly into the pooled buffer, skipping the binary encode → re-decode round-trip. Type assertion is ~1ns; the saving is ~30-40ns. - Wall-clock timestamps are computed as
baseWallNs + (nanotime() - baseMonoNs), wherebaseWallNsandbaseMonoNsare sampled once atinit(). One VDSO call per log, ~5ns. Drifts from the kernel's wall clock if NTP adjusts the system time after init — fine for log timestamps. - Escape-detection scans use a 256-byte classifier table (Go compiles byte-LUT loads to NEON
tbl/ SSSE3pshufbon supported architectures). - No
runtime.exit, no panic-recovery in hot paths, nodeferin the terminal writer'sWrite.
If you change a logger or writer and start seeing 1 alloc/op, run go build -gcflags='-m=2' and look for escapes to heap on the buffer — that's almost always an interface boundary somewhere.
The binary record on disk / on the wire is:
0..3 magic "ZLOG" (uint32, little-endian)
4 version (1)
5 level (1)
6..13 unix nanoseconds (uint64, native order)
14..15 msgLen (uint16, native order)
16+ message bytes
optional: 1-byte fieldCount, then fields
Each field is keyLen(1) + key + type(1) + value. Numeric values are 4 or 8 bytes native order. String / bytes values are len(uint16, native) + payload.
Native byte order is intentional: only this package's writers consume the binary form, so a bswap per field would buy nothing. The binary form is not stable across machines with different endianness — if you ship binary logs over the wire, decode on the producer's architecture.
go test -race ./...
go test -bench=. -benchmem ./...CI runs build + race + zero-alloc verification on Linux / macOS / Windows.
MIT — see LICENSE.