Introduction

Uber's Zap logger buffers log entries for performance, writing them asynchronously to improve throughput. When a Go process exits without calling logger.Sync(), buffered log entries are lost. This is particularly problematic during shutdown when the most important logs (shutdown status, cleanup results, final errors) are the ones most likely to be buffered and lost. The Sync() method flushes all buffered entries, but it returns errors for stdout/stderr which must be handled correctly.

Symptoms

bash
# Last log in stdout before exit:
2026-04-09T10:00:00.000Z    INFO    Starting shutdown...
# Missing: "Database connections closed", "All workers stopped", etc.
# These logs were buffered and never flushed

Or sync errors:

bash
2026/04/09 10:00:01 failed to encode log: write /dev/stdout: file already closed

Common Causes

  • Sync() not called before exit: Process exits with buffered logs still in memory
  • Sync error ignored: Calling Sync on stdout/stderr returns non-actionable errors
  • Logger created but not deferred: Sync deferred but function exits early
  • Fatal logger used without sync: logger.Fatal() calls os.Exit() before sync
  • Buffer size too small: Buffer fills up and entries dropped
  • Logger replaced globally: logger.ReplaceGlobals() not synced

Step-by-Step Fix

Step 1: Always defer Sync with error handling

```go import ( "log" "os"

"go.uber.org/zap" )

func main() { logger, err := zap.NewProduction() if err != nil { log.Fatalf("Failed to create logger: %v", err) } defer func() { // Sync flushes buffered logs if err := logger.Sync(); err != nil { // stdout/stderr Sync returns non-nil error that can be ignored // Check if it's a "bad file descriptor" error (normal for stdout) if !isStdoutStderrSyncError(err) { log.Printf("Logger sync error: %v", err) } } }()

// Use logger logger.Info("Application started") // ... rest of application }

func isStdoutStderrSyncError(err error) bool { // Zap returns this for stdout/stderr sync - safe to ignore return err != nil && (err.Error() == "sync /dev/stdout: invalid argument" || err.Error() == "sync /dev/stderr: invalid argument") } ```

Step 2: Use logger replacement with sync

```go func setupLogger() *zap.Logger { logger, _ := zap.NewProduction()

// Replace global logger old := zap.L() zap.ReplaceGlobals(logger)

// Sync the old global logger before replacing _ = old.Sync()

// Set up sugar logger too zap.ReplaceGlobals(logger) _ = zap.S().Sync()

return logger } ```

Step 3: Configure production logger with safe options

```go func newProductionLogger() (*zap.Logger, error) { config := zap.NewProductionConfig()

// Write to file for reliable output config.OutputPaths = []string{ "stdout", "/var/log/app/application.log", } config.ErrorOutputPaths = []string{"stderr"}

// Encoder settings config.EncoderConfig.TimeKey = "timestamp" config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

logger, err := config.Build() if err != nil { return nil, err }

return logger, nil }

// Graceful shutdown with logger sync func gracefulShutdown(server *http.Server, logger *zap.Logger) { logger.Info("Shutting down server")

ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel()

server.Shutdown(ctx) logger.Info("Server stopped")

// Sync logger as LAST action before exit if err := logger.Sync(); err != nil { // Cannot log this error -- we're shutting down fmt.Fprintf(os.Stderr, "Logger sync error: %v\n", err) } } ```

Prevention

  • Always defer logger.Sync() immediately after creating the logger
  • Handle Sync errors from stdout/stderr -- they are normal and can be ignored
  • Call Sync() as part of graceful shutdown, after all other cleanup
  • Use file output for critical logs that must not be lost
  • Never use logger.Fatal() without ensuring Sync is called first
  • Add logger sync to your shutdown sequence documentation
  • Test log flushing by generating logs and immediately exiting in test cases