
Go Logging
- 677 installs
- 137 repo stars
- Updated June 20, 2026
- cxuu/golang-skills
go-logging is a Claude Code skill that adds structured, leveled logging to Go services and CLIs with consistent fields, context propagation, and production-ready observability hooks for developers who need uniform log ou
About
go-logging is a Go instrumentation skill from cxuu/golang-skills that guides adding structured, leveled logging to services and command-line tools. The skill emphasizes consistent field keys, context propagation through request handlers, and hooks compatible with production observability pipelines so logs remain parseable in aggregation systems. Developers reach for it when standing up a new Go microservice, CLI, or refactoring printf-style debugging into leveled structured output. Outputs include logger initialization patterns, context-aware child loggers, and field conventions that survive multi-package codebases. Use it during backend build when traceability and operability must be designed in, not bolted on after deploy.
- Structured slog patterns
- Log levels and fields
- Context-aware loggers
- Production defaults
- CLI and HTTP instrumentation
Go Logging by the numbers
- 677 all-time installs (skills.sh)
- Ranked #22 of 98 Go skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cxuu/golang-skills --skill go-loggingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 677 |
|---|---|
| repo stars | ★ 137 |
| Last updated | June 20, 2026 |
| Repository | cxuu/golang-skills ↗ |
How do you add structured logging to Go services?
Add structured, leveled logging to Go services and CLIs with consistent fields, context propagation, and production-ready observability hooks.
Who is it for?
Go developers building APIs or CLIs who need production-grade structured logging with context fields from the start.
Skip if: Teams already standardized on a complete observability stack with tracing and metrics and only need duplicate logger setup docs.
When should I use this skill?
A developer asks to add structured logging, log levels, context fields, or observability hooks to Go code.
What you get
Leveled structured logger setup, consistent field schema, and context-propagated log calls across Go packages.
- Logger initialization
- Context-aware log helpers
- Field naming conventions
Files
Go Logging
Compatibility:log/slogrequires Go 1.21+;testing/slogtestrequires Go 1.22+.
Resource Routing
references/LEVELS-AND-CONTEXT.md- Read when choosing log levels, deciding logger-in-context versus explicit parameters, or excluding sensitive fields.references/LOGGING-PATTERNS.md- Read when configuring slog handlers, logging HTTP requests, testing handlers, or migrating fromlog.Printf.
Core Principle
Logs are for operators, not developers. Every log line should help someone diagnose a production issue. If it doesn't serve that purpose, it's noise.
---
Choosing a Logger
Normative: Use log/slog for new Go code.slog is structured, leveled, and in the standard library (Go 1.21+). It covers the vast majority of production logging needs.
Which logger?
├─ New production code → log/slog
├─ Trivial CLI / one-off → log (standard)
└─ Measured perf bottleneck → zerolog or zap (benchmark first)Do not introduce a third-party logging library unless profiling shows slog is a bottleneck in your hot path. When you do, keep the same structured key-value style.
---
Structured Logging
Normative: Always use key-value pairs. Never interpolate values into the message string.
The message is a static description of what happened. Dynamic data goes in key-value attributes:
// Good: static message, structured fields
slog.Info("order placed", "order_id", orderID, "total", total)
// Bad: dynamic data baked into the message string
slog.Info(fmt.Sprintf("order %d placed for $%.2f", orderID, total))Key Naming
Advisory: Use snake_case for log attribute keys.Keys should be lowercase, underscore-separated, and consistent across the codebase: user_id, request_id, elapsed_ms.
Typed Attributes
For performance-critical paths, use typed constructors to avoid allocations:
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
slog.String("method", r.Method),
slog.Int("status", code),
slog.Duration("elapsed", elapsed),
)---
Log Levels
Advisory: Follow these level semantics consistently.
| Level | When to use | Production default |
|---|---|---|
| Debug | Developer-only diagnostics, tracing internal state | Disabled |
| Info | Notable lifecycle events: startup, shutdown, config loaded | Enabled |
| Warn | Unexpected but recoverable: deprecated feature used, retry succeeded | Enabled |
| Error | Operation failed, requires operator attention | Enabled |
Rules of thumb:
- If nobody should act on it, it's not Error — use Warn or Info
- If it's only useful with a debugger attached, it's Debug
slog.Errorshould always include an"err"attribute
slog.Error("payment failed", "err", err, "order_id", id)
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
slog.Info("server started", "addr", addr)
slog.Debug("cache lookup", "key", key, "hit", hit)---
Request-Scoped Logging
Advisory: Derive loggers from context to carry request-scoped fields.
Use middleware to enrich a logger with request ID, user ID, or trace ID, then pass the enriched logger downstream via context or as an explicit parameter. Keep the full context-key and middleware implementation in the logging patterns reference so request-scoped logging has one owner.
---
Log or Return, Not Both
The handle-once rule belongs to go-error-handling. In logging work, apply it by choosing either a local log-and-recover path or a return path with context, not both for the same error.
Exception: HTTP handlers and other top-of-stack boundaries may log detailed errors server-side while returning a sanitized message to the client:
if err != nil {
slog.Error("checkout failed", "err", err, "user_id", uid)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}See go-error-handling for the full handle-once pattern and error wrapping guidance.
---
What NOT to Log
Normative: Never log secrets, credentials, PII, or high-cardinality unbounded data.
- Passwords, API keys, tokens, session IDs
- Full credit card numbers, SSNs
- Request/response bodies that may contain user data
- Entire slices or maps of unbounded size
---
Quick Reference
| Do | Don't |
|---|---|
slog.Info("msg", "key", val) | log.Printf("msg %v", val) |
| Static message + structured fields | fmt.Sprintf in message |
snake_case keys | camelCase or inconsistent keys |
| Log OR return errors | Log AND return the same error |
| Derive logger from context | Create a new logger per call |
Use slog.Error with "err" attr | slog.Info for errors |
Pre-check Enabled() on hot paths | Always allocate log args |
---
Related Skills
- Error handling: See go-error-handling when deciding whether to log or return an error, or for the handle-once pattern
- Context propagation: See go-context when passing request-scoped values (including loggers) through context
- Performance: See go-performance when optimizing hot-path logging or reducing allocations in log calls
- Code review: See go-code-review when reviewing logging practices in Go PRs
Levels and Context
Detailed guidance on log level semantics, context-based logging patterns, performance considerations, and what to keep out of logs.
Contents
- Level Semantics
- Custom Verbosity Levels
- Context-Based Logging
- Performance Considerations
- What NOT to Log
Level Semantics
Debug
Developer-only diagnostics. Disabled in production by default. Use for tracing internal state that helps during development or troubleshooting:
slog.Debug("cache lookup", "key", key, "hit", hit)
slog.Debug("parsed config", "fields", len(cfg.Fields))
slog.Debug("SQL query", "query", q, "args", args)When to use: Internal state transitions, cache behavior, detailed request/response data during development.
Info
Notable events that confirm the system is working as expected. These should be useful in production for understanding system behavior:
slog.Info("server started", "addr", addr, "version", version)
slog.Info("config loaded", "path", cfgPath, "env", env)
slog.Info("migration completed", "version", v, "elapsed_ms", elapsed)
slog.Info("user registered", "user_id", uid)When to use: Startup/shutdown, configuration changes, significant business events, periodic health summaries.
Warn
Something unexpected happened, but the system recovered or degraded gracefully. An operator may want to investigate but no immediate action is required:
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
slog.Warn("deprecated endpoint called", "path", r.URL.Path, "user_id", uid)
slog.Warn("rate limit approaching", "current", rate, "limit", max)
slog.Warn("fallback to default config", "err", err)When to use: Retries that eventually succeeded, deprecated code paths, approaching resource limits, fallback behavior.
Error
An operation failed and requires operator attention. The system could not fulfill the request or complete the task:
slog.Error("payment failed", "err", err, "order_id", id, "amount", amt)
slog.Error("database connection lost", "err", err, "host", dbHost)
slog.Error("message processing failed", "err", err, "msg_id", msgID)When to use: Failed operations that affect users, lost connections, data integrity issues, external service failures that weren't recovered.
Always include the error: slog.Error calls should always have an "err" attribute with the actual error value.
Choosing Between Warn and Error
Did the operation ultimately succeed?
├─ Yes (after retry/fallback) → Warn
└─ No (caller gets an error) → Error
├─ Requires immediate attention → Error
└─ Can wait for next review → Warn---
Custom Verbosity Levels
slog levels are integers. Define custom sub-levels between the standard ones for fine-grained control:
const (
LevelTrace = slog.Level(-8) // below Debug
LevelNotice = slog.Level(2) // between Info and Warn
)
slog.Log(ctx, LevelTrace, "detailed trace", "span_id", spanID)Use HandlerOptions.Level with a slog.LevelVar to control the minimum level at runtime.
---
Context-Based Logging
Pattern 1: Logger in Context
Use this when HTTP middleware needs to add request-scoped fields as the request moves through a handler chain. The canonical context-key and middleware implementation lives in LOGGING-PATTERNS.md.
Pattern 2: Explicit Logger Parameter
Pass *slog.Logger as a function parameter alongside context:
func processOrder(ctx context.Context, logger *slog.Logger, order *Order) error {
logger.Info("processing order", "order_id", order.ID)
// ...
}Pros: Explicit dependency, easier to test, no context key. Cons: Extra parameter in every function signature.
When to Use Each
| Situation | Recommendation |
|---|---|
| HTTP handlers / middleware chains | Logger in context |
| Library code with no HTTP dependency | Explicit parameter |
| Background workers / batch jobs | Explicit parameter |
| Deep call chains (5+ levels) | Logger in context |
---
Performance Considerations
Pre-Check with Enabled()
Avoid allocating log arguments when the level is disabled:
// Expensive: args are always evaluated, even if Debug is disabled
slog.Debug("request details",
"headers", fmt.Sprintf("%v", r.Header),
"body", string(bodyBytes),
)
// Better: skip entirely when disabled
if slog.Default().Enabled(ctx, slog.LevelDebug) {
slog.Debug("request details",
"headers", fmt.Sprintf("%v", r.Header),
"body", string(bodyBytes),
)
}This matters when argument construction is expensive (formatting, marshaling, or reading data). For simple attributes (slog.String, slog.Int), the overhead is negligible.
Use LogAttrs on Hot Paths
slog.LogAttrs avoids the []any allocation that the convenience methods (slog.Info, etc.) incur:
// Standard — allocates a []any for the key-value pairs
slog.Info("request handled", "method", r.Method, "status", code)
// Faster — typed attributes, no []any allocation
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
slog.String("method", r.Method),
slog.Int("status", code),
)Avoid Logging in Tight Loops
If a loop processes thousands of items, log a summary rather than each iteration:
// Bad: one log per item in a 10k-item batch
for _, item := range items {
slog.Debug("processing item", "id", item.ID)
process(item)
}
// Good: log summary
slog.Info("batch started", "count", len(items))
processed, failed := processBatch(items)
slog.Info("batch completed", "processed", processed, "failed", failed)---
What NOT to Log
Secrets and Credentials
Never log:
- Passwords, API keys, tokens (OAuth, JWT, session)
- Private keys, certificates
- Database connection strings with credentials
// Bad
slog.Info("connecting", "dsn", dsn) // may contain password
// Good
slog.Info("connecting", "host", dbHost, "database", dbName)Personally Identifiable Information (PII)
Avoid logging unless required for debugging and your retention policy allows it:
- Email addresses, phone numbers
- Full names, physical addresses
- IP addresses (in some jurisdictions)
- Credit card numbers, SSNs
If you must log a user identifier, use an opaque ID rather than PII.
High-Cardinality Unbounded Data
Don't log entire request bodies, full stack traces at Info level, or unbounded collections:
// Bad: unbounded data
slog.Info("received", "body", string(requestBody))
slog.Info("users loaded", "users", users) // could be 100k entries
// Good: bounded summary
slog.Info("received", "content_length", len(requestBody), "content_type", ct)
slog.Info("users loaded", "count", len(users))Decision Table
| Data type | Log it? | Alternative |
|---|---|---|
| Request ID / trace ID | Yes | — |
| User ID (opaque) | Yes | — |
| HTTP method, path, status | Yes | — |
| Error messages | Yes | — |
| Passwords / tokens | Never | Log token prefix or "redacted" |
| Full request body | No | Log content length and type |
| PII (email, name) | Avoid | Log opaque user ID |
| Large collections | No | Log count or summary |
| Stack traces | Debug only | Use slog.Debug |
Logging Patterns
Detailed patterns for slog setup, handler configuration, testing, HTTP middleware, and migration from the legacy log package.
Contents
- Setting Up slog
- Custom Handler Patterns
- Testing with slogtest
- HTTP Request Logging Middleware
- Migration from log.Printf to slog
Setting Up slog
Basic Configuration
package main
import (
"log/slog"
"os"
)
func main() {
// JSON handler for production (machine-parseable)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("server started", "addr", ":8080")
// Output: {"time":"...","level":"INFO","msg":"server started","addr":":8080"}
}Text Handler for Development
// Human-readable output for local development
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
// Output: time=... level=DEBUG msg="cache lookup" key=user:42 hit=trueDynamic Level Control
Use slog.LevelVar to change the minimum level at runtime (e.g., via an admin endpoint or signal handler):
var programLevel = new(slog.LevelVar) // default Info
func init() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: programLevel,
}))
slog.SetDefault(logger)
}
// Call from an admin endpoint or signal handler
func enableDebug() {
programLevel.Set(slog.LevelDebug)
}---
Custom Handler Patterns
Adding Source Location
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
}))
// Output includes: "source":{"function":"main.handleRequest","file":"server.go","line":42}Wrapping Handlers with Default Attributes
Use slog.Handler middleware to inject fields into every log record:
type contextHandler struct {
inner slog.Handler
attrs []slog.Attr
}
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error {
r.AddAttrs(h.attrs...)
return h.inner.Handle(ctx, r)
}
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{inner: h.inner.WithAttrs(attrs), attrs: h.attrs}
}
func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{inner: h.inner.WithGroup(name), attrs: h.attrs}
}Multi-Handler (Fan-Out)
Write to multiple destinations (for example stdout plus a file) by composing handlers behind a small slog.Handler wrapper. Forward Enabled, Handle, WithAttrs, and WithGroup to each destination, and test the wrapper with slogtest.
---
Testing with slogtest
Go 1.22+ provides testing/slogtest to verify handler implementations:
package myhandler_test
import (
"testing"
"testing/slogtest"
)
func TestHandler(t *testing.T) {
// newHandler returns your custom slog.Handler and a func that
// parses the output into []map[string]any for verification.
results := func(t *testing.T) map[string]any {
// parse your handler's output here
}
h := NewMyHandler(buf, nil)
slogtest.Run(t, func(t *testing.T) slog.Handler { return h }, results)
}Capturing Logs in Tests
For unit tests that assert on log output, write to a buffer:
func TestOrderProcessing(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
processOrder(logger, order)
if !strings.Contains(buf.String(), `"order_id"`) {
t.Error("expected order_id in log output")
}
}---
HTTP Request Logging Middleware
A complete middleware that logs each request with timing, status, and request-scoped fields:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.NewString()
}
logger := slog.With(
"request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
)
// Wrap the response writer to capture the status code
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
// Store logger in context for downstream handlers
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(rw, r.WithContext(ctx))
logger.Info("request completed",
"status", rw.status,
"elapsed_ms", time.Since(start).Milliseconds(),
)
})
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}Retrieving the Logger from Context
type ctxKey struct{}
var loggerKey = ctxKey{}
func loggerFromCtx(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}---
Migration from log.Printf to slog
Step 1: Replace Direct Calls
// Before
log.Printf("user %s logged in from %s", userID, ip)
// After
slog.Info("user logged in", "user_id", userID, "ip", ip)Step 2: Replace log.Fatalf in main()
// Before
log.Fatalf("failed to connect: %v", err)
// After — slog has no Fatal; use slog + os.Exit in main
slog.Error("failed to connect", "err", err)
os.Exit(1)Step 3: Bridge Legacy Code
If migrating incrementally, redirect the standard log package output through slog:
// In main(), after setting up slog:
slog.SetDefault(logger)
// The standard log package now writes through slog's default handler.
// This works because slog.SetDefault also updates log.Default().Step 4: Replace Logger Parameters
// Before: passing *log.Logger around
func NewServer(addr string, logger *log.Logger) *Server
// After: pass *slog.Logger explicitly
func NewServer(addr string, logger *slog.Logger) *Server
// Or derive from context in handlers
func (s *Server) handleRequest(ctx context.Context) {
logger := loggerFromCtx(ctx)
logger.Info("handling request")
}Migration Checklist
| Step | What to change | Verify |
|---|---|---|
| 1 | log.Printf → slog.Info/Warn/Error | rg 'log\.Printf' returns 0 hits |
| 2 | log.Fatalf → slog.Error + os.Exit(1) in main | Only in main() |
| 3 | Set slog.SetDefault early in main | Legacy log calls route through slog |
| 4 | *log.Logger params → *slog.Logger | All constructors updated |
| 5 | Remove "log" imports where replaced | goimports handles this |
Related skills
FAQ
What does go-logging standardize in Go projects?
go-logging standardizes leveled structured log output, consistent field names across packages, and context propagation so request-scoped metadata flows to child loggers in Go services and CLIs.
When should developers invoke go-logging?
Developers should invoke go-logging when adding or refactoring logging in Go services and CLIs, especially when replacing ad-hoc fmt.Printf calls with production-parseable structured logs and observability hooks.