Releases: semihalev/zlog
v2.0.8 — Restore Info(...any) backward compatibility, add InfoF for zero-alloc typed
Walks back the v2.0.7 API break on the global helpers. v2.0.6 untyped call sites compile and run again with no source changes. v2.0.7 typed call sites continue to work too. The zero-alloc typed path moves to new *F entrypoints that opt in by name.
What changed
zlog.Debug / Info / Warn / Error / Fatal once again accept ...any and route correctly:
zlog.Info("user logged in", "username", "alice", "user_id", 12345) // 0 allocs (KV path)
zlog.Info("user logged in", zlog.String("username", "alice")) // 3 allocs (Field via ...any)zlog.DebugF / InfoF / WarnF / ErrorF / FatalF are new typed-only entrypoints that take ...Field directly:
zlog.InfoF("user logged in", zlog.String("username", "alice"), zlog.Int("user_id", 12345)) // 0 allocszlog.InfoKV / DebugKV / WarnKV / ErrorKV / FatalKV are kept as explicit aliases for the untyped path. Anyone who already migrated to them in v2.0.7 keeps working.
Why the rollback
v2.0.7's switch to Info(msg, fields ...Field) was strictly a perf decision — it shaved 3 allocs from the typed-via-global path by avoiding Field-into-...any boxing. But it was the wrong call for a non-major release: every existing v2.0.6 caller of the untyped style (zlog.Info("msg", "k", v)) became a compile error.
The structurally-correct design is two names — one for each variadic shape — added additively. v2.0.8 does that:
| Style | API | Allocations |
|---|---|---|
| Untyped key/value pairs | zlog.Info("msg", "k", v) |
0 |
| Typed Fields (compat) | zlog.Info("msg", String(...)) |
3 (Field→any boxing at callsite) |
| Typed Fields (zero-alloc) | zlog.InfoF("msg", String(...)) |
0 |
The 3-alloc cost on Info(...any) with typed Fields is structural: passing a 56-byte Field through a ...any parameter heap-boxes each argument at the Go callsite, before the function runs. No compiler trick on the callee side eliminates that. The InfoF entrypoint sidesteps it by taking ...Field directly.
Compatibility matrix
| Source style | v2.0.6 | v2.0.7 | v2.0.8 |
|---|---|---|---|
zlog.Info("msg", "k", v) (untyped) |
0 allocs | compile error | 0 allocs |
zlog.Info("msg", String(...)) (typed) |
silent drop bug | 0 allocs | 3 allocs |
zlog.InfoF("msg", String(...)) |
n/a | n/a | 0 allocs |
zlog.InfoKV("msg", "k", v) |
n/a | 0 allocs | 0 allocs |
Default().Info("msg", String(...)) |
0 allocs | 0 allocs | 0 allocs |
Migration
- Coming from v2.0.6: bump the dependency, no code changes. (You also pick up every v2.0.7 fix: the silent-drop bug, the
Bytes(empty)panic, theLogger.log65535 truncation, the proper MPMC ring buffer.) - Coming from v2.0.7: bump the dependency, no code changes. Typed
zlog.Info(...)calls now cost 3 allocs each — rename tozlog.InfoF(...)to keep zero-alloc behavior. The compiler doesn't catch this; it's a perf migration, not a correctness one.
Bench (Apple M5)
zlog.Info("msg", "k", v) 32 ns/op 0 allocs (KV)
zlog.InfoF("msg", String(...)) 34 ns/op 0 allocs (typed, no boxing)
zlog.Info("msg", String(...)) 87 ns/op 3 allocs (typed via ...any)
Default().Info("msg", String(...)) 34 ns/op 0 allocs (typed, instance)
Other changes
Carries forward all v2.0.7 fixes:
Bytes(nil)andBytes([]byte{})no longer panic.Logger.logclamps long messages to 65535 to match the uint16 header.Logger.FatalandStructuredLogger.Fatalalways exit, even when filtered.LogfmtWriter.Writeis zero-alloc on long records (one-pass size estimate).LogfmtWritercaches the formatted RFC3339 timestamp by Unix second.- AsyncWriter uses an LMAX Disruptor MPMC ring buffer (per-slot sequence numbers) — 212 ns/op end-to-end, race-clean.
Tests
Coverage for all three global-helper paths (KV via Info, typed via Info, typed via InfoF) plus the Fatal-when-filtered subprocess test. Full race-detector run is green; long-record alloc test gated //go:build !race because the race detector triggers GC frequently enough to clear sync.Pool's localcache for the 1 MiB-class buffers (a -race property, not a production bug).
v2.0.7 — Typed global helpers, KV direct path, lock-free MPMC ring buffer
Polish + correctness release on top of v2.0.6. Highlights: typed-Field global helpers are now genuinely zero-alloc, the untyped KV path got a direct-text shortcut, several real correctness bugs fixed, and the AsyncWriter ring buffer was rewritten as a proper LMAX Disruptor MPMC.
Breaking change
Global helpers zlog.Debug / Info / Warn / Error / Fatal now take typed Field varargs:
func Info(msg string, fields ...Field)The previous signature accepted ...any and tried to handle both typed and untyped calls. With Field at 56 bytes (won't fit inline in interface storage), every call boxed each Field on the heap — three allocs/op even when the caller meant the typed path. Splitting the API removes that cost.
Migration: untyped key/value style moves to the explicit *KV helpers (zlog.InfoKV, DebugKV, etc.) — same signature as before, just renamed:
// Before:
zlog.Info("user logged in", "user_id", 12345)
// After:
zlog.InfoKV("user logged in", "user_id", 12345)The Go compiler catches every old call site as a type mismatch on the variadic, so migration is mechanical. Anyone already using typed Fields through the global helper gets a 3× speedup for free — same call site, zero allocations.
Performance
| Bench (Apple M5) | v2.0.6 | v2.0.7 |
|---|---|---|
zlog.Info("msg", String(..), Int(..)) global |
88 ns / 3 allocs | 34 ns / 0 / 0 |
zlog.InfoKV("msg", "k", v) global |
53 ns | 32 ns / 0 / 0 |
| StructuredLogger → TerminalWriter | 27 ns | 27 ns / 0 / 0 |
| LogfmtWriter binary decode | 76 ns | 65 ns / 0 / 0 |
| LogfmtWriter direct structured | 89 ns | 72 ns / 0 / 0 |
| AsyncWriter | 234 ns | 212 ns / 0 / 0 |
All paths zero-alloc. Direct-text KV path on *TerminalWriter and *LogfmtWriter skips the binary encode + decode round-trip — that's where the 40 % KV speedup comes from.
Correctness fixes
Bytes(nil)andBytes([]byte{})no longer panic.&val[0]was unconditional; now an empty slice produces an empty Bytes field.Logger.logclamps long messages to 65535 bytes (the uint16 header limit). Previously a 70 KB message produced a record where the headermsgLenwrapped viauint16(70000) = 4464but all 70 KB were copied — decoders would read the wrong length and treat the rest as garbage.Logger.FatalandStructuredLogger.Fatalalways exit, even when the level filters the message out. PreviouslySetLevel(>Fatal)could log nothing AND skip the exit.LogfmtWriter.Write(binary decode path) is now zero-alloc on long records. It pre-sizes the buffer with a one-pass scan over the field section, mirroring whatwriteStructuredalready did.LogfmtWritercaches the formatted timestamp by Unix second soAppendFormatruns once per second, not once per log.
AsyncWriter ring buffer rewritten
The previous lock-free ring buffer had a real correctness bug under -race: once a consumer's CAS-tail succeeded, the producer's full check immediately allowed a Put at head = oldTail + size, which targets the same slot the consumer is about to read. The consumer's post-read Store(nil) would clobber a freshly-stored item from the next generation, causing a nil-deref in workers. Wrapping the head/tail counters via mask additionally exposed the design to ABA.
v2.0.7 replaces the design with the LMAX Disruptor pattern (per-slot sequence numbers): each slot carries an atomic seq counter that doubles as a generation marker. Producers claim only when seq == head; consumers consume only when seq == tail+1. After a Get, the slot's seq is set to tail + size, explicitly marking it ready for the producer's next generation. No silent overwrite, no ABA, no spin-wait — fully non-blocking. Holds size items rather than size-1 since the seq counter distinguishes empty from full.
The corrected lock-free design ends up faster than the original broken version (212 ns/op vs 234 ns/op).
Other changes
- Long-record alloc test (
long_alloc_test.go) is now//go:build !race. Race detector triggers GC frequently enough to clearsync.Pool's localcache for 1 MiB-class buffers, leaking ~1 alloc/op into the measurement window. That's a property of-race, not the production hot path. Production code without-racekeeps the pool warm and runs at 0 allocs/op as advertised. - Inlined the field encoder into
formatStructuredMessage(saves a function call per field). StructuredLogger.logKVnow detects*TerminalWriterand*LogfmtWriterand dispatches to the direct-text path, skipping the binary encode + decode round-trip.- Comprehensive regression test coverage:
Bytes(empty),LogfmtWriter.Writelong-record alloc, plain Logger long-message header/payload match, KV direct-path output on terminal + logfmt, Fatal-with-filtered-level subprocess test. - Doc fixes:
root.go init()comment matches the code,Any()documents thatfmt.Sprintallocates,ultimate_zerons claim corrected to match measured M5 number.
Compatibility
- Go 1.23+ (unchanged).
- Public surface of
Logger,StructuredLogger,UltimateLogger, allFieldconstructors and writers behaves identically apart from theInfo/Debug/Warn/Error/Fatalglobal helpers, which now take...Field(KV style →*KVhelpers). - Binary log format unchanged from v2.0.6.
v2.0.6 — True zero-allocation, 2-3× faster, format unified
Major correctness + performance pass. Every logging path is now genuinely zero-alloc, parallel scales near-linearly, and the binary log format is unified across logger types.
Highlights
| Bench (Apple M5) | v2.0.5 | v2.0.6 |
|---|---|---|
UltimateLogger |
45 ns / 128B / 1 alloc | 16 ns / 0 / 0 |
UltimateLogger parallel |
56 ns / 128B / 1 alloc | 5 ns / 0 / 0 |
StructuredLogger |
100 ns / 512B / 1 alloc | 36 ns / 0 / 0 |
StructuredLogger parallel |
54 ns / 512B / 1 alloc | 10 ns / 0 / 0 |
Structured + 5 fields |
113 ns / 512B / 1 alloc | 45 ns / 0 / 0 |
Structured + 10 fields |
145 ns / 512B / 1 alloc | 77 ns / 0 / 0 |
Structured → TerminalWriter (real-world) |
~150 ns | 27 ns / 0 / 0 |
Parallel is now faster than serial on several benches because the cross-core contention point was removed.
Bug fixes
-
Zero-alloc claim was not actually true on any path. Two unrelated bugs combined:
- The "small message stack buffer" optimization in
Logger.log,StructuredLogger.logFields, andUltimateLogger.logdeclaredvar stackBuf [N]byteand passed it towriter.Write. BecauseWriteis an interface method, escape analysis forced the array to the heap on every call (confirmed via-gcflags=-m=2). The "fast path" was strictly slower than the pool path it tried to skip. - The custom
leadingZeros64inbuffer_pool_generic.gowas broken — it summed eight 6-bit lookup values where seven of eight always returned 64, so it returned 512 for any input. The pool'sPutrejected every buffer because of the bogus index calculation, so everyGetallocated fresh.
Both fixed; all logger paths now hit the pool's reuse path.
- The "small message stack buffer" optimization in
-
Wall-clock timestamps were wrong.
runtime.nanotime()(monotonic since process start) was being treated as Unix nanoseconds in the binary header, so rendered timestamps came out as e.g.1970-01-01T21:35:59. Switched to a once-captured wall+mono offset (~5 ns/log, portable across darwin/linux/windows). -
AsyncWriterV2was racy under concurrent writers.RingBuffer.Putwas single-producer-only, butAsyncWriterV2.Writeis the package'sio.Writerand gets called from many goroutines. Two producers could overwrite the same slot. Fixed with CAS on head. -
MMapWriterhad a wrap-around race.offset.Addfollowed byoffset.Storecould interleave across writers and corrupt entries near the end of the file. Fixed with a CAS-loop on offset. -
Goroutine-per-flush in mmap writers removed.
MS_ASYNC(Linux/macOS) andFlushViewOfFile(Windows) are non-blocking; the previousgo w.syncRange(...)was both racy and a goroutine fan-out hazard. -
LogfmtWritercorrupted UTF-8.appendQuotedranged over runes then wrotebyte(c), truncating any multi-byte sequence to its low byte. Fixed; full UTF-8 round-trips. -
Float formatting was lossy. Hand-rolled formatter only emitted three decimals, mishandled NaN/±Inf, overflowed at 2⁶⁴. Replaced with
strconv.AppendFloat.
Performance
- Atomic sequence counter dropped. It was never read by any writer in the package, but every
Add(1)was the dominant cross-core cache-line contention point — the entire reason parallel was 2.5× slower than serial. Removed. - Direct-text fast path in
StructuredLogger. When the writer is*TerminalWriter, format text directly into the pooled buffer and skip the binary encode → re-decode round trip. Type assertion is ~1 ns; saves ~30-40 ns. The most common case (humans reading colored logs) is now also the fastest. - Native byte order in field encoding. Each int/float was eight individual byte stores. Now a single
*(*uint64)(unsafe.Pointer(&buf[pos])) = f.numper numeric field. The binary form is internal — only this package's writers consume it — so big-endian bought nothing. TerminalWritertimestamp cache.time.AppendFormatruns once per second, not once per log. Manual digit formatting via shift+mask.- Pre-built level prefix LUTs.
[6][]byte{"\x1b[32mINFO \x1b[0m", ...}so the line prefix is one append, not three. - 256-byte classifier table for escape detection. Branchless OR-loop; Go compiles byte-LUT loads to NEON
tbl/ SSSE3pshufbon supported architectures. defer mu.UnlockinTerminalWriter.Writereplaced with explicit unlock (~6 ns/log).
Breaking changes
The on-disk / on-the-wire binary log format changed:
old structured: magic(4) ver(1) lvl(1) seq(8) ts(8) msgLen(1) msg ...
new (unified): magic(4) ver(1) lvl(1) ts(8) msgLen(2) msg [fieldCount(1) fields...]
- 16-byte header instead of 22 (no sequence counter).
msgLenwidened fromuint8touint16.- Field values are stored in native byte order. Anything reading binary logs across machines with different endianness needs to handle this; in practice only
TerminalWriterandLogfmtWriterdecode the binary form, both in-package.
If you've persisted binary logs from a previous version and want to render them, do it before upgrading.
Other changes
- README rewritten — fresh benchmark numbers, accurate description of how zero-alloc actually works (the previous "stack-allocated buffers" claim was the bug we just fixed).
runtime.exitreplaced byos.Exitfor cross-compilation support (carried over from earlier release).
Compatibility
- Go 1.23+ (unchanged from v2.0.5).
- Public API surface is unchanged —
Logger,StructuredLogger,UltimateLogger, allFieldconstructors, all writers behave identically. Only the on-the-wire binary format changed.
v2.0.5 — Default log level: Info
Small release that flips the default log level from Debug to Info.
Breaking
- The default level for
New()/NewStructured()/NewUltimateLogger()is nowLevelInfo. Apps relying onDebugmessages appearing without an explicitSetLevel(LevelDebug)will now see them filtered out.
Why
Production deployments almost always want Info-and-above. Defaulting to Debug meant every new caller had to remember to bump the level or pay for verbose output and the small cost of formatting it.
Migration
If you want the old behavior:
log := zlog.NewStructured()
log.SetLevel(zlog.LevelDebug)v2.0.4
Release v2.0.4
🐛 Cross-Compilation Fix
Fixed Windows Cross-Compilation
Replaced runtime.exit with standard os.Exit to enable cross-compilation support.
🔧 What Was Fixed
The library was using an internal Go runtime function runtime.exit which caused the following error when cross-compiling:
link: github.com/semihalev/zlog/v2: invalid reference to runtime.exit
This has been fixed by using the standard os.Exit(1) function instead.
✅ Now Works
# Cross-compile for Windows from Linux/macOS
GOOS=windows GOARCH=amd64 go build
# Cross-compile for Linux from Windows/macOS
GOOS=linux GOARCH=amd64 go build
# All cross-compilation targets now work📦 Installation
go get github.com/semihalev/zlog/v2@v2.0.4This fix ensures zlog can be cross-compiled to any platform from any platform.
v2.0.3
Release v2.0.3
🐛 Critical Bug Fix
Fixed Terminal Writer Format Detection
The terminal writer now correctly handles both logger formats:
- Basic Logger: 16-byte header with 2-byte message length
- Structured Logger: 22-byte header with 1-byte message length
🔧 What Was Fixed
The terminal writer was only parsing the basic logger format, causing garbled output when used with structured loggers (including the default global logger).
This fix ensures all log messages display correctly regardless of which logger type you use.
v2.0.2 - Better Default Logger
Release v2.0.2
🎯 Better Out-of-Box Experience
Default Logger Now Uses Terminal Output
The global default logger now automatically uses colored terminal output instead of binary format. This provides a much better user experience, especially for new users and during development.
🐛 Bug Fixes
- Fixed early logging showing binary format - Logs that happen before logger configuration (like "Register middleware") now display properly
- Default logger initialization - Changed from binary stderr to terminal stdout for human-readable output
💡 What Changed
Before v2.0.2:
// Early logs would show as: GOLZ...binary data...
zlog.Info("Starting application") // Binary format by defaultAfter v2.0.2:
// Now shows as: INFO [01-25|20:45:00] Starting application
zlog.Info("Starting application") // Beautiful colored output by default🔧 Usage
No code changes required! The default logger now "just works" with terminal output.
You can still customize as before:
// Use binary format if needed
zlog.SetWriter(os.Stderr)
// Or use your own configuration
logger := zlog.NewStructured()
logger.SetWriter(zlog.StderrTerminal())
zlog.SetDefault(logger)📦 Installation
go get github.com/semihalev/zlog/v2@v2.0.2This release improves the developer experience by making zlog work beautifully out of the box without any configuration.
v2.0.1 - Module Path Fix
Release v2.0.1
🐛 Bug Fix
Fixed Module Path for Go Modules v2
- Updated
go.modto usegithub.com/semihalev/zlog/v2module path - Updated all import statements in examples and documentation
- This fix ensures proper Go modules v2 compatibility
📦 Installation
go get github.com/semihalev/zlog/v2🔄 Migration from v2.0.0
If you already installed v2.0.0, update your imports:
// Old (incorrect)
import "github.com/semihalev/zlog"
// New (correct)
import "github.com/semihalev/zlog/v2"For full v2 features and changes, see the v2.0.0 release notes.
v2.0.0 - Major Performance Overhaul
Release v2.0.0
🚀 Major Performance Overhaul
This release represents a complete performance rewrite leveraging Go 1.23+ features to achieve unprecedented speed and true zero allocations.
⚡ Performance Highlights
- 2x faster than v1.x - Reduced from ~40 ns/op to 21.88 ns/op
- True zero allocations - Direct writer operations now have 0 B/op
- 45.7 million logs/second - Industry-leading throughput
- Removed 64MB upfront allocation - More memory efficient
🔄 Breaking Changes
- Removed redundant logger implementations:
NanoLogger- UseUltimateLoggerinsteadSimpleLogger- UseLoggerinsteadZeroAllocLogger- UseUltimateLoggerinstead
- Changed internal buffer pool implementation
- Minimum Go version is now 1.23
✨ New Features
Generic Buffer Pool
- Type-safe buffer management with zero interface{} boxing
- Tiered allocation strategy (64B to 1MB+)
- Automatic size class detection
Zero-Copy String Operations
StringToBytes()- Convert strings without allocationBytesToString()- Convert bytes without allocation- Uses
unsafe.StringDatafrom Go 1.20+
Lock-Free Async Writer
- Generic ring buffer with
atomic.Pointer[T] - Multiple worker goroutines for parallel writes
- Reduced allocations from 542 B/op to 262 B/op
Windows Color Support
- Automatic ANSI color detection for Windows 10+
- Virtual terminal processing enablement
NO_COLORenvironment variable support- Manual color control via
SetColorEnabled()
🐛 Bug Fixes
- Fixed terminal writer magic header mismatch
- Fixed buffer allocation issues for large messages
- Fixed string escaping performance with loop unrolling
- Fixed Windows terminal color escape sequences
📊 Benchmark Improvements
BenchmarkUltimateLogger-10 56M 21.88 ns/op 0 B/op 0 allocs/op
BenchmarkAsyncWriterDirect-10 8M 158.0 ns/op 0 B/op 0 allocs/op
BenchmarkZeroCopyString-10 2000M 0.497 ns/op 0 B/op 0 allocs/op
🔧 Migration Guide
For NanoLogger users:
// Old
logger := zlog.NewNanoLogger()
// New
logger := zlog.NewUltimateLogger()For Windows users seeing escape codes:
// Option 1: Disable colors
tw := zlog.NewTerminalWriter(os.Stdout).(*zlog.TerminalWriter)
tw.SetColorEnabled(false)
logger.SetWriter(tw)
// Option 2: Use plain writer
logger.SetWriter(zlog.StdoutWriter())
// Option 3: Set environment variable
// SET NO_COLOR=1Full changelog: v1.2.5...v2.0.0
v1.2.5
What's New in v1.2.5
🚀 Major Performance Improvements
Zero-Allocation Terminal Writer
- Completely redesigned TerminalWriter with zero allocations during operation
- Performance improvements:
- Simple messages: 2x faster (203ns → 101ns)
- With fields: 4.1x faster (703ns → 171ns)
- Processes 7-8 million logs/second
✨ Key Changes
-
Pre-allocated Buffers
- Increased buffer size from 1KB to 2KB to handle complex logs without reallocation
- Reuses buffers across writes with mutex protection
-
Optimized String Operations
- Direct byte slice operations instead of string conversions
- Stack-based number formatting
- Inline field decoding
-
Pre-computed Lookup Tables
- Level strings and colors are pre-allocated
- Padding spaces pre-allocated
📊 Benchmark Results
BenchmarkTerminalWriterWithFields-10
Before: 203.7 ns/op 56 B/op 4 allocs/op
After: 101.1 ns/op 0 B/op 0 allocs/op
BenchmarkTerminalWriterManyFields-10
Before: 703.2 ns/op 120 B/op 14 allocs/op
After: 171.2 ns/op 0 B/op 0 allocs/op
🔧 Other Improvements
- Fixed NetBSD platform support
- Updated demo applications
- Maintained full API compatibility