← Back to blog

Go Logging Libraries Explained: Zap vs Slog vs Zerolog (And How I Chose for My Project)

Every developer has that moment. You have an idea. You stare at the blank screen. Before you can code, you need to pick a framework, a database, a library. The choices overwhelm you. You spend hours researching instead of building.

I used to always pick zerolog for most of my projects. Or used slog for simplicity sometimes. But recently I started thinking: what if I actually want to do this right?

I Was Thinking About Building A URL Shortener

I was thinking about building a URL shortener the other day.

Not because the world needs one, but because I like building things by hand. There's an exciting feeling to it that no AI generated code gives you. So I start developing.

But before I could start coding, there's that moment. You're not actually coding yet. You're just thinking.

And then the framework questions start. The database questions. The library questions.

I usually skip over logging. It seems boring. "Just use zerolog or slog" I think.

This time, I decided: what if I actually understood what I was picking?

I googled "best Go logging library" and hit the same wall: three names, everywhere. Zap. Slog. Zerolog.

Each one different. Each one with benchmarks I didn't understand.

But here's the thing about a URL shortener: if it ever got real traffic, logging suddenly matters. Thousands of redirects per second. Do I need to sample logs? Route errors somewhere special? Track requests across the system?

That's when I realized: I couldn't just pick one randomly this time.

I had to actually understand the tradeoffs.

Here's What I Didn't Understand At First

When I googled "Zap vs Slog vs Zerolog", I immediately felt lost.

The blog posts compared them on things like:

  • "Zero allocations on the hot path"
  • "log/slog is built into stdlib"
  • "Zerolog uses method chaining"

And I thought: "Okay, but what does that actually MEAN for me?"

I've used Zerolog and Slog before. I know structured logging works. But when I tried to pick between all three for this project, I realized there were things I'd never actually investigated:

1. Why allocations matter on the hot path

I see benchmarks showing "0 allocs/op" vs "1 allocs/op". But what does that translate to in real terms? Does it matter? When does it start to matter?

2. Why Zap is still everywhere if Zerolog has a cleaner API

This confused me. Zerolog's API is objectively nicer. So why does everyone still recommend Zap?

3. Log sampling - and when you actually need it

I'd seen the term. But I didn't understand: when do I actually need to sample logs? When does it become critical?

4. Context propagation differences

I knew Zerolog had it built-in. But I didn't understand: why is this such a big deal? When would I actually use it?

5. The gotchas nobody mentions

Each library has traps. Slog allocates with bare key-value pairs. Zerolog's .Interface() breaks zero-alloc. But I'd never run into them.

So I Started Understanding Each Thing

At this point I had five specific questions. I decided to actually investigate instead of just guessing. Here's what I learned.


1. Why allocations matter on the hot path

Let me start with this one because it sounds technical but it's actually simple.

When a Go program creates a value that needs heap storage, Go's runtime may need to allocate memory for it. That allocation has a CPU cost. Individual allocations are usually very fast, but when a program performs thousands or millions of them, the cumulative allocation and garbage-collection overhead can become significant.

The following program demonstrates how allocations work in different scenarios like creating a new string everytime and using a buffer.

go
package main import ( "fmt" "runtime" ) const iterations = 100000 func main() { fmt.Println("=== Allocation Demonstration ===") // -------------------------------------------------- // METHOD 1: Create a new string every time // -------------------------------------------------- runtime.GC() var before1 runtime.MemStats runtime.ReadMemStats(&before1) for i := 0; i < iterations; i++ { // Creates a new string. s := fmt.Sprintf("log message number %d", i) // Keep the compiler from eliminating the variable. _ = s } var after1 runtime.MemStats runtime.ReadMemStats(&after1) allocs1 := after1.Mallocs - before1.Mallocs bytes1 := after1.TotalAlloc - before1.TotalAlloc fmt.Println("\nMETHOD 1: New string every iteration") fmt.Printf(" Allocations: %d\n", allocs1) fmt.Printf(" Bytes allocated: %d KB\n", bytes1/1024) fmt.Printf(" Bytes/op: %.2f\n", float64(bytes1)/iterations) // -------------------------------------------------- // METHOD 2: Reuse the same byte buffer // -------------------------------------------------- runtime.GC() var before2 runtime.MemStats runtime.ReadMemStats(&before2) buffer := make([]byte, 0, 100) for i := 0; i < iterations; i++ { buffer = buffer[:0] buffer = append(buffer, "log message number "...) buffer = appendInt(buffer, i) _ = buffer } var after2 runtime.MemStats runtime.ReadMemStats(&after2) allocs2 := after2.Mallocs - before2.Mallocs bytes2 := after2.TotalAlloc - before2.TotalAlloc fmt.Println("\nMETHOD 2: Reuse the same buffer") fmt.Printf(" Allocations: %d\n", allocs2) fmt.Printf(" Bytes allocated: %d KB\n", bytes2/1024) fmt.Printf(" Bytes/op: %.2f\n", float64(bytes2)/iterations) // -------------------------------------------------- // COMPARISON // -------------------------------------------------- fmt.Println("\n=== COMPARISON ===") fmt.Printf("Extra allocations: %d\n", allocs1-allocs2) fmt.Printf("Extra memory: %d KB\n", (bytes1-bytes2)/1024) } // Append an integer without creating an intermediate string. func appendInt(buf []byte, n int) []byte { if n == 0 { return append(buf, '0') } var digits [20]byte pos := len(digits) for n > 0 { pos-- digits[pos] = byte('0' + n%10) n /= 10 } return append(buf, digits[pos:]...) }

If you run the program, you will see output like the following.

=== Allocation Demonstration === METHOD 1: New string every iteration Allocations: 199748 Bytes allocated: 3124 KB Bytes/op: 31.99 METHOD 2: Reuse the same buffer Allocations: 0 Bytes allocated: 0 KB Bytes/op: 0.00 === COMPARISON === Extra allocations: 199748 Extra memory: 3124 KB

For a URL shortener, the hot path is the redirect. Someone clicks a short link. Your code:

  1. Looks up the URL in the database
  2. Logs the redirect
  3. Sends back an HTTP redirect If you're getting thousands of redirects per second, and each redirect logs something, you're potentially logging thousands of times per second.

If each log allocates memory, that's thousands of allocations per second.

From our demonstration above, we can do simple math to understand the impact per operation.

METHOD 1: New string each time

  • Total allocations: 199,748
  • Total iterations: 100,000
  • Allocations per operation: 199,748 ÷ 100,000 = ~2 allocations/op

METHOD 2: Reuse the buffer

  • Total allocations: 0
  • Total iterations: 100,000
  • Allocations per operation: 0 ÷ 100,000 = 0 allocations/op

Now let's scale this to your URL shortener.

If your service processes 1,000 redirects per second:

  • Method 1: 1,000 × 2 = 2,000 allocations/second
  • Method 2: 1,000 × 0 = 0 allocations/second

If traffic spikes to 10,000 redirects per second:

  • Method 1: 10,000 × 2 = 20,000 allocations/second
  • Method 2: 10,000 × 0 = 0 allocations/second

At 10,000 redirects/second, Method 1 creates 20,000 new objects per second that the garbage collector has to clean up. That's where the CPU overhead comes from. That's why the difference matters at scale.

For my side project getting zero traffic right now, this makes no difference.

But if it ever got real traffic, this is the difference between a responsive service and one that grinds to a halt during GC pauses.


How Slog, Zerolog, and Zap Handle Allocations

Now that we understand allocations matter, the question becomes: which logging library does this best? The answer depends on how each one is designed.

Slog and heap allocation

Slog is allocation-conscious, not universally zero-allocation.

Think of its behavior like this:

SituationAllocation behavior
Logging common values like int, bool, stringslog.Value can represent them without creating a separate heap object
logger.Info(...)Designed to minimize allocations, but the complete logging path may still allocate
logger.LogAttrs(...)Most efficient API; gives slog more opportunity to avoid allocations
fmt.Sprintf(...) before loggingCan introduce an unnecessary allocation before slog even sees the message
Handler formatting/outputMay allocate depending on the handler and output path
Reusing attributes with WithAttrsCan reduce repeated work and allocations
Overall slog callNot guaranteed to be zero allocations

The key principle is that with structured key-value pairs, slog can pass values like 123 directly to the handler without first converting them to strings. This avoids unnecessary intermediate allocations.

Here's a practical example:

go
package main import ( "fmt" "io" "log/slog" "testing" ) const iterations = 10_000 func main() { logger := slog.New( slog.NewJSONHandler(io.Discard, nil), ) fmt.Println("=== slog Allocation Demonstration ===") fmt.Printf("Iterations: %d\n\n", iterations) // 1. Pre-formatting with fmt.Sprintf allocs1 := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info(fmt.Sprintf("user %d logged in", i)) } }) // 2. Structured logging allocs2 := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info( "user logged in", "user_id", i, "action", "login", ) } }) // 3. LogAttrs + typed Attrs allocs3 := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.LogAttrs( nil, slog.LevelInfo, "user logged in", slog.Int("user_id", i), slog.String("action", "login"), ) } }) printResult("fmt.Sprintf", allocs1) printResult("Key/value pairs", allocs2) printResult("LogAttrs + typed Attrs", allocs3) fmt.Println("\nNote: allocation counts depend on the Go version and runtime.") } func printResult(name string, allocations float64) { fmt.Printf( "%-25s %.0f allocations/run\n", name, allocations, ) }

The results show that pre-formatting with fmt.Sprintf introduces extra allocations, while slog's structured and typed LogAttrs APIs can progressively reduce them and reaches zero allocations in this particular test.

=== slog Allocation Demonstration === Iterations: 10000 fmt.Sprintf 19744 allocations/run Key/value pairs 9744 allocations/run LogAttrs + typed Attrs 0 allocations/run Note: allocation counts depend on the Go version and runtime.
Zap and allocations

As we used testing.AllocsPerRun previously, for our next set of experiment, we can use the same.

Experiment 1: No Structured Fields
package main import ( "fmt" "io" "testing" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) const iterations = 10_000 func main() { // Real JSON encoder, but discard the resulting output. encoderConfig := zap.NewProductionEncoderConfig() core := zapcore.NewCore( zapcore.NewJSONEncoder(encoderConfig), zapcore.AddSync(io.Discard), zap.InfoLevel, ) logger := zap.New(core) allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info("hello") } }) fmt.Println("=== Zap Allocation Demonstration ===") fmt.Printf("Iterations: %d\n", iterations) fmt.Printf("Allocations: %.0f\n", allocs) fmt.Printf("Allocations per log: %.6f\n", allocs/iterations) }
=== Zap Allocation Demonstration === Iterations: 10000 Allocations: 0 Allocations per log: 0.000000

This demonstrates an allocation-free path for this particular logging operation.

Adding Structured Fields
go
allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info("failed to fetch URL", zap.Int("attempt", i), ) } })
=== Zap Allocation Demonstration === Iterations: 10000 Allocations: 10000 Allocations per log: 1.000000

If we add another string as a structured field

allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info("failed to fetch URL", // Structured context as strongly typed Field values. zap.Int("attempt", i), zap.String("action", "fetch"), ) } })
=== Zap Allocation Demonstration === Iterations: 10000 Allocations: 10000 Allocations per log: 1.000000

At first glance, this might seem contradictory to the statement that Zap provides zero-allocation logging.

It isn't.

The important change is the API call itself. Zap's Logger.Info API accepts structured fields through a variadic parameter:

go
func (log *Logger) Info(msg string, fields ...Field)

Conceptually, the second call involves a collection of zap.Field values.

The introduction of this structured-field path changes the allocation behavior in our experiment.

However, there is an important caveat.

Does the zap.Field slice definitely cause the allocation?

We cannot prove that from testing.AllocsPerRun alone.

AllocsPerRun tells us:

The complete operation resulted in one heap allocation.

It does not tell us exactly which internal operation caused that allocation.

The allocation could be associated with the variadic field handling, the encoder path, or another internal operation.

Zerolog and allocations

Zerolog's method-chaining API is designed to be zero-alloc by default. Each typed method (.Str(), .Int(), .Dur()) ensures values are handled efficiently.

However, it has the same trap: .Interface() breaks the zero-alloc promise.

Experiment 1: message only
package main import ( "fmt" "io" "testing" "github.com/rs/zerolog" ) const iterations = 10_000 func main() { logger := zerolog.New(io.Discard) allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info().Msg("failed to fetch URL") } }) fmt.Println("=== Zerolog Zero-Allocation Demonstration ===") fmt.Printf("Iterations: %d\n", iterations) fmt.Printf("Allocations: %.0f\n", allocs) fmt.Printf("Allocations per log: %.6f\n", allocs/iterations) }
=== Zerolog Zero-Allocation Demonstration === Iterations: 10000 Allocations: 0 Allocations per log: 0.000000
Experiment 2: Add structured fields
allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info(). Int("attempt", i). Str("action", "fetch") } })
=== Zerolog Zero-Allocation Demonstration === Iterations: 10000 Allocations: 0 Allocations per log: 0.000000
Experiment 3:
package main import ( "fmt" "io" "testing" "github.com/rs/zerolog" ) const iterations = 10_000 type User struct { ID int Name string } func main() { logger := zerolog.New(io.Discard) user := User{ ID: 123, Name: "Alice", } allocs := testing.AllocsPerRun(1, func() { for i := 0; i < iterations; i++ { logger.Info(). Interface("user", user). Msg("user logged in") } }) fmt.Println("=== Zerolog Interface Allocation Demonstration ===") fmt.Printf("Iterations: %d\n", iterations) fmt.Printf("Allocations: %.0f\n", allocs) fmt.Printf("Allocations per log: %.6f\n", allocs/iterations) }
=== Zerolog Interface Allocation Demonstration === Iterations: 10000 Allocations: 50009 Allocations per log: 5.000900

In this benchmark, using Zerolog's .Interface() method resulted in approximately five heap allocations per log call. This is significantly different from Zerolog's optimized typed-field paths. The exact number is implementation and value dependent, so .Interface() should not be assumed to have a fixed allocation cost.

2. Why Zap is still everywhere if Zerolog has a cleaner API

This one really confused me because I genuinely think Zerolog's API is nicer.

Compare them:

go
// Zerolog - method chaining, beautiful logger.Info(). Str("user_id", userID). Str("action", "login"). Dur("latency", 45*time.Millisecond). Msg("user logged in") // Zap - explicit types, more verbose logger.Info("user logged in", zap.String("user_id", userID), zap.String("action", "login"), zap.Duration("latency", 45*time.Millisecond), ) // Slog - simple key-value pairs logger.Info("user logged in", "user_id", userID, "action", "login", "latency", 45*time.Millisecond, )

Zerolog is the most readable. Slog is the simplest. Zap is the most explicit.

So why is Zap everywhere?

I found a few reasons:

First, timing. Zap came out in 2016 and immediately proved itself at Uber's massive scale. By the time Zerolog came out in 2017, Zap already had market dominance. That's hard to overcome.

Second, production concerns. Zap has battle-tested log sampling built-in. When you're running a service that logs millions of times per second and you need to reduce that load during spikes, Zap's sampling is proven at scale. Zerolog doesn't have this built-in.

Third, explicit over implicit. Zap forces you to say zap.String(), zap.Int(), etc. This means you can't accidentally do something expensive. Zerolog's cleaner API has a trap: if you use .Interface() on a custom object, it allocates. Easy mistake to make.

So Zap wins on: maturity, proven sampling, and preventing mistakes.

Zerolog wins on: API ergonomics and built-in context propagation.

Neither is objectively better. They made different bets about what matters.


3. Log sampling - and when you actually need it

I'd heard "log sampling" mentioned but never understood when it actually matters.

Here's the scenario: your service is running fine at 1,000 requests per second. Then a surge hits. Suddenly it's 10,000 requests per second.

If you log every request, your logging becomes the bottleneck. CPU spikes to 100%. The service gets slower. Everything degrades.

Sampling solves this: instead of logging every request, you log 1% of them. Or you log all errors, but only 10% of info-level logs.

Zap does this with something called zapcore.NewSamplerWithOptions:

go
sampler := zapcore.NewSamplerWithOptions( core, time.Second, 100, // Always log first 100 per second 100, // Then sample 1 in every 100 ) logger := zap.New(sampler)

This is production-tested at Uber. It works.

For my URL shortener? Do I need this right now?

No. I'm not at that scale.

But if my project ever got real traffic, this becomes critical. And by then, I'd want to pick a library that has this built-in and tested, not something I have to implement myself.

That's an argument for Zap.


4. Context propagation differences

This one is about passing data through your application without manually threading it everywhere.

Let's say I have a URL shortener that grows. Now I have multiple services:

  • Service A: The shortening API
  • Service B: An analytics service (tracking stats)
  • Service C: A cleanup service (removing old links) A user shortens a URL. It flows through all three services. I want logs from all three services to have the same request_id so I can trace the request.

Zerolog makes this easy:

go
// Service A: Store logger with request ID in context ctx = zerolog.Ctx(r.Context()). With(). Str("request_id", requestID). Logger(). WithContext(r.Context()) // Call Service B, pass the context callServiceB(ctx) // Service B: Pull logger back from context automatically func serviceB(ctx context.Context) { log := zerolog.Ctx(ctx) // Automatically has request_id log.Info().Msg("processing") }

Zap or Slog require manual setup:

go
// Store in context manually ctx = context.WithValue(r.Context(), loggerKey, logger) // Pull back manually func serviceB(ctx context.Context) { log := ctx.Value(loggerKey).(*zap.Logger) log.Info("processing") }

For distributed systems, Zerolog's approach is cleaner.

But here's the thing: for my URL shortener starting out, I don't have multiple services. So this advantage doesn't matter yet.

If I grow and add services, I'd want this. But that's a future problem.


5. The gotchas nobody mentions

As I dug deeper, I found each library has traps that aren't obvious:

Zap's gotcha: You have to remember defer logger.Sync()

go
func main() { logger, _ := zap.NewProduction() // Missing: defer logger.Sync() logger.Info("all is well") // Process exits. Last few logs disappear. They're still in buffer. }

Zap buffers logs for performance. If you don't Sync before exit, you lose the last 2-3 seconds of logs. That's exactly when you need them most.

Slog's gotcha: Bare key-value pairs allocate

go
// This allocates (Go has to figure out what type each value is) logger.Info("event", "name", "Alice", "status", 200) // This doesn't allocate (you're explicit about types) logger.Info("event", slog.String("name", "Alice"), slog.Int("status", 200), )

If you use Slog for convenience with bare pairs, you lose the zero-alloc promise.

Zerolog's gotcha: .Interface() breaks zero-alloc

go
type User struct { Name string ID int } user := User{"Alice", 42} // This doesn't allocate logger.Info().Str("name", user.Name).Int("id", user.ID).Msg("event") // This DOES allocate (Zerolog has to inspect the object) logger.Info().Interface("user", user).Msg("event")

Easy trap. Looks convenient. Breaks the zero-alloc guarantee.


What This All Meant For Me

After going through these five things, I understood the landscape better.

But I still had to make a choice.

Zap was mature and production-tested. Zerolog had a nicer API and better context support. Slog was simple and built-in.

Each one solved different problems. None of them was objectively "best."

I had to think about my actual situation, not hypotheticals.

The Production Scenarios That Changed My Thinking

Understanding allocations, sampling, and context propagation is one thing. But I had to ask myself: when would I actually need these things?

Let me walk through what happens as my URL shortener grows.

Scenario 1: Small Side Project (Now)

Traffic: 0 requests/second Reality: It's not deployed. I'm building locally.

At this scale:

  • Allocations don't matter (I'm not at 10k redirects/sec)
  • Log sampling isn't needed (no traffic spikes)
  • Context propagation isn't critical yet (single service)

All three libraries work fine. The differences are invisible.

What matters: Simplicity and learning. I want to build something, but also learn the right patterns now instead of having to rewrite later.

Scenario 2: Deployed but Modest Traffic

Traffic: 100-500 requests/second Reality: A few friends are using it.

At this scale:

  • Allocations still don't matter (20k allocations/sec is negligible)
  • Log sampling isn't needed (traffic is steady)
  • Context propagation still isn't needed (still one service)

All three libraries work fine. The allocation differences don't show up in CPU usage.

What matters: Clean observability. I want logs that are easy to read and query. I want to structure logs in a way that feels natural.

Scenario 3: Real Traffic & Growing

Traffic: 5,000-10,000 requests/second Reality: The service is actually popular. I'm thinking about adding analytics or a worker service.

At this scale:

  • Allocations become noticeable. But more importantly, I'm thinking about multiple services.
  • Log sampling might matter, but traffic is somewhat predictable.
  • Context propagation becomes critical. If I have a Service A that calls Service B, I need request IDs flowing through easily.

What matters: Production stability and distributed tracing. I need context to flow naturally through services. I need proven patterns for multi-service logging.

This is where Zerolog's built-in context propagation becomes extremely valuable. If I had picked Slog, I'd have to implement manual context handling. If I had picked Zap, I'd need to add OpenTelemetry or custom wiring.

With Zerolog, zerolog.Ctx() just works across services.

The Honest Truth

My URL shortener right now is in Scenario 1. It's getting zero traffic. So technically, none of this matters yet.

But I'm building it as if it might grow into Scenario 3. And for Scenario 3, context propagation matters a lot more to me than log sampling.

If I'm building a service that might eventually have multiple components, Zerolog's context propagation is worth learning now. I don't want to migrate later just to get that feature working cleanly.


Here's What I Actually Picked

After understanding all five things, I had to make a choice.

I chose Zerolog.

Here's why.

My Actual Situation

  • This is a side project, but one that might grow.
  • I value clean, readable code. The method-chaining API appeals to me.
  • If this project grows, I'll likely add services. Context propagation is important for that.
  • I'm not worried about log sampling yet. Steady traffic means no need for it.
  • I want to learn the right patterns now, not rewrite later.

Zerolog fits all of these.

Why Not Slog?

Slog is great, but for a project that might grow, I'd eventually need to implement context propagation manually. That's extra work later. I'd rather learn how to do it right now with Zerolog's built-in support.

Why Not Zap?

Zap is battle-tested at massive scale. If I was building a service that would definitely have 100k req/sec from day one, I'd pick Zap.

But I'm not. I'm building a side project that might grow. Zap's verbosity (zap.String(), zap.Int()) feels like premature optimization for my situation. For high-traffic services, that explicitness prevents mistakes. For a side project, Zerolog's method chaining is just more pleasant to use.

Zap's sampling is incredibly valuable, but I don't need it yet. If traffic grows and becomes truly spiky, I could always migrate.

The Trade-offs I'm Making

What I'm gaining:

  • Beautiful, readable API (method chaining feels natural)
  • Built-in context propagation (future-proofs for multiple services)
  • Zero-alloc by design with typed methods (safe by default)
  • Proven to work in production (it's mature, used widely)

What I'm potentially losing:

  • No built-in log sampling (but I don't need it yet)
  • The .Interface() trap exists (but now I know to avoid it)
  • Less explicit than Zap about types (but less verbose too)

The Important Part

I'm not picking Zerolog because it's objectively best. I'm picking it because it fits my thinking about how this project might grow.

If the URL shortener stays a tiny side project forever, Slog would have been fine. If it becomes Uber's next logging challenge, Zap would have been better.

But I'm betting on the middle ground: steady growth, eventually multiple services, and wanting to understand context propagation well.

That's my actual bet, so Zerolog is my actual choice.

How I'm Using It

go
package main import ( "io" "os" "github.com/rs/zerolog" ) func main() { // Simple setup logger := zerolog.New(os.Stdout).With().Timestamp().Logger() // Using typed methods (safe, zero-alloc) logger.Info(). Str("slug", "abc123"). Str("original_url", "https://example.com/very/long/url"). Int("status", 200). Msg("url shortened") // For my actual service, I'll inject this logger // And use context propagation when adding new services }

Clean. Readable. Ready to grow.


What I'd Tell Someone Else

If you're facing this same choice, here's what I learned:

The Decision Framework

Ask yourself these questions in order:

1. Are you in the standard library camp?

  • If only stdlib → Slog (it's built-in, that's enough reason)
  • If open to dependencies → Continue...

2. Will you eventually need distributed tracing (multiple services)?

  • If definitely yes → Zerolog (context propagation is seamless)
  • If definitely no → Continue...

3. Do you need log sampling?

  • If yes → Zap (proven, battle-tested at scale)
  • If maybe or no → Continue...

4. Do you value API elegance or explicit safety more?

  • If elegant → Zerolog (method chaining is beautiful)
  • If explicit → Zap (forces you to be clear about types)
  • If simple → Slog

Honest Comparison

SituationPickWhy
Side project, might growZerologContext propagation is future-proofing
High-traffic serviceZapSampling is worth the verbosity
Small tool, go home afterSlogNo dependency, works great
Building a library others useSlogDon't force a logging dependency
Microservices from startZerologContext propagation is essential
Learning Go loggingZerologBeautiful API teaches good patterns

The Philosophy

Here's what matters more than the library itself:

1. Output JSON, not text All three do this. Structured logging is non-negotiable.

2. Attach context to every log Request ID, user ID, trace ID. Whatever identifies the request. All three support this.

3. Use the right API for your situation

  • Slog: Great if you're in stdlib-only world
  • Zerolog: Great if you'll need context propagation or just want a clean API
  • Zap: Great if you're at scale or need sampling

4. Pick one and commit Don't spend three weeks deciding. Pick the one that makes sense for your situation. Use it for a real project. Then you'll have actual experience, not theoretical knowledge.

5. You can always change later If you pick Zerolog and later need Zap's sampling, you can migrate. It's not a permanent prison. The principles of structured logging transfer across libraries.

My Actual Advice

If you're building a side project that might grow: Pick Zerolog. The context propagation will save you later.

If you're building for a job: Ask your team what they use. Consistency matters more than optimization.

If you're building at massive scale: Pick Zap. Its sampling and ecosystem support are worth learning.

If you just want something simple: Pick Slog. It's stdlib, it works, no drama.

What I Might Reconsider

Honestly, if my URL shortener somehow became Uber-scale overnight, I'd migrate to Zap for the sampling. That's a real operational need at that scale.

But if it grows into multiple services? Zerolog's context propagation will have been the right call from day one.

For my actual bet on how this grows? Zerolog is perfect.


The End (Kind Of)

I spent time investigating these five things because I wanted to understand what I was picking instead of just guessing.

Here's what I learned: none of them is objectively "best." They make different trade-offs about what matters. And the "best" one for you depends entirely on your situation.

For my URL shortener side project that might grow into multiple services: Zerolog.

For your production service at massive scale: maybe Zap.

For your simple tool: maybe Slog.

And that's okay. That's how real engineering works.

Pick one. Commit to it. Learn what works and what doesn't. Then make your next choice from experience instead of theory.

That's actually understanding the tradeoffs.