How to Use logrus for Structured Logging in Go

Initialize a logrus logger with a JSON formatter and use WithFields to add structured key-value data to your log entries.

The problem with plain text logs

A production web server handles thousands of requests per minute. Each request triggers database queries, external API calls, and business logic. When something fails, the logs fill up with lines like 2024/01/15 14:22:01 failed to process request for user 482. Searching for every failure related to that specific user across gigabytes of text output is tedious. You end up writing regular expressions, grepping through rotated files, and hoping the timestamp format never changes.

Plain text logs treat every message as a sentence. Sentences are great for humans reading a single line. They are terrible for machines trying to filter, aggregate, or correlate events across services. Structured logging solves this by treating every log entry as a data record instead of a paragraph. You stop writing prose and start shipping key-value pairs.

What structured logging actually does

Structured logging means every line that hits your output stream follows a predictable schema. Instead of concatenating strings, you pass discrete values to a logger. The logger pairs each value with a label, serializes the whole batch, and writes it out. The output is usually JSON or a machine-parseable text format.

Think of it like filling out a customs form instead of writing a travel diary. A diary entry says Visited Paris, stayed three nights, bought a baguette. A customs form has labeled boxes: Destination: Paris, Duration: 3 nights, Purchase: Baguette. A machine can read the form in milliseconds. A human can scan it without parsing grammar.

In Go, the standard library provides log, which writes timestamped text lines. It works for simple scripts. It falls apart when you need to filter by request ID, trace a transaction across microservices, or feed logs into a dashboard. Third-party packages like logrus fill that gap by adding field accumulation, level filtering, and pluggable formatters.

Your first logrus logger

Here is the smallest working setup. You create a logger instance, tell it how to format output, set the minimum severity level, and write a message with attached fields.

package main

import (
	"github.com/sirupsen/logrus"
)

func main() {
	// Instantiate a fresh logger with default stdout output
	log := logrus.New()
	// Switch from the default text format to structured JSON
	log.SetFormatter(&logrus.JSONFormatter{})
	// Drop Trace and Debug lines; keep Info and above
	log.SetLevel(logrus.InfoLevel)

	// Attach context-specific metadata before writing the message
	log.WithFields(logrus.Fields{
		"user_id": 123,
		"action":  "login",
	}).Info("User logged in")
}

The WithFields call returns a new logger instance that already carries the user_id and action keys. When you call .Info(), those keys merge with the standard msg and level fields, then the formatter serializes everything to JSON. The output looks like {"action":"login","level":"info","msg":"User logged in","time":"2024-01-15T14:22:01Z","user_id":123}.

Logrus follows a convention where the logger itself is a value you pass around. You do not call package-level functions like logrus.Info(). You create an instance, configure it once, and share it. This matches the Go pattern of accepting interfaces and returning structs. Your code depends on a configured logger object, not a global singleton.

How the fields flow through the pipeline

Field accumulation is the core mechanic. Every call to WithFields or WithField returns a new logger. The new logger holds a copy of the previous fields plus the new ones. This design avoids mutating shared state and keeps the API thread-safe.

When you chain calls, the fields stack up. log.WithField("req_id", "abc").WithField("user", "42") produces a logger carrying both keys. The final logging method (Info, Error, etc.) triggers the actual write. At that moment, logrus merges the accumulated fields with the message, stamps the timestamp, applies the level, and hands the batch to the formatter.

Formatters are just structs that implement a single method. The JSON formatter marshals the field map to []byte. The text formatter prints key-value pairs in a readable layout. You can swap formatters at runtime, which is useful for development versus production environments. Developers usually prefer text for terminal readability. Production systems prefer JSON for pipeline compatibility.

Level filtering happens before serialization. Logrus maintains a threshold. If you set the level to Info, any call to Debug or Trace short-circuits immediately. The function returns without allocating a map or running the formatter. This keeps the overhead near zero for disabled log lines.

Convention aside: Go functions that accept a logger should take it as a parameter, not rely on a package-level variable. The same rule applies to context.Context. Both go first in the parameter list. func HandleRequest(ctx context.Context, logger *logrus.Logger, r *http.Request) makes dependencies explicit and testable.

Logging in a real HTTP handler

Real applications need to attach request-scoped metadata to every log line. You extract a request ID from headers, pull the user ID from authentication, and attach them to the logger. Every downstream function that receives the logger inherits those fields automatically.

Here is how that pattern looks in an HTTP handler.

package main

import (
	"net/http"
	"github.com/sirupsen/logrus"
)

// HandleLogin processes authentication requests and logs outcomes
func HandleLogin(logger *logrus.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Extract correlation ID from incoming headers
		reqID := r.Header.Get("X-Request-ID")
		// Create a child logger carrying request-scoped fields
		reqLog := logger.WithFields(logrus.Fields{
			"req_id": reqID,
			"method": r.Method,
			"path":   r.URL.Path,
		})

		// Simulate authentication step
		userID, err := authenticate(r)
		if err != nil {
			// Log the failure with the error attached as a field
			reqLog.WithError(err).Warn("Authentication failed")
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		// Attach user context for downstream logging
		reqLog = reqLog.WithField("user_id", userID)
		reqLog.Info("Login successful")
		w.WriteHeader(http.StatusOK)
	}
}

The WithError helper is a convenience wrapper around WithField("error", err). It formats the error using its Error() method and stores it as a string field. If you need the full error chain for debugging, you can attach it manually or use a custom formatter that calls fmt.Errorf or errors.Unwrap.

Notice the receiver and parameter naming. The logger is named logger, not log or l. Clear names prevent confusion when multiple loggers exist in scope. The handler returns a function that captures the logger closure. This is standard Go middleware style.

Convention aside: Go error handling is verbose by design. You will see if err != nil { return err } repeatedly. The community accepts the boilerplate because it forces you to acknowledge the unhappy path. Logging follows the same philosophy. You explicitly attach errors to log lines instead of swallowing them or relying on stack traces.

Where things go wrong

Structured logging introduces new failure modes. The most common mistake is high cardinality. High cardinality means a field can take millions of unique values. Putting a full request body, a stack trace, or a UUID in every log line explodes storage costs and breaks aggregation tools. Logging systems group by low-cardinality keys like status_code or endpoint. They filter by medium-cardinality keys like user_id. They struggle with high-cardinality noise.

Another trap is forgetting to set the formatter. Logrus defaults to text output. If your pipeline expects JSON, you will get parse errors downstream. The compiler will not catch this. You will only notice when your log aggregator drops half your entries.

Type mismatches also cause friction. Logrus accepts interface{} for field values. If you pass a struct that does not implement json.Marshaler, the JSON formatter will serialize it as a nested object. If you pass a pointer to a nil struct, you get a null value. If you pass a channel or a function, the formatter panics. The compiler rejects obvious type errors with messages like cannot use ch (variable of type chan int) as logrus.Fielder value in argument. Runtime panics happen when you accidentally log a circular reference or a mutex-locked struct.

Goroutine safety is handled by logrus internally. The logger uses mutexes to protect its configuration and output writer. You can safely share a single logger across hundreds of goroutines. You do not need to wrap calls in sync.Mutex. You do need to ensure your custom formatters are thread-safe if you write one.

Convention aside: Public names start with a capital letter. Private start lowercase. Logrus exposes Info, Error, WithFields as public methods. You call them directly. You do not reach for unexported helpers. The naming boundary tells you exactly what is part of the stable API.

When to reach for logrus

Use logrus when you need a mature, well-tested structured logger with minimal configuration. Use the standard log package when you are writing a small CLI tool and only need timestamped text lines. Use a zero-allocation logger like zap when you are building a high-throughput service and every nanosecond matters. Use a context-aware logger like slog (Go 1.21+) when you want structured logging baked into the standard library without external dependencies.

The decision comes down to trade-offs. Logrus prioritizes developer experience and flexibility. It allocates maps for fields, which adds CPU overhead. It supports hooks, custom formatters, and entry-level chaining. The standard library prioritizes simplicity and zero dependencies. Zap prioritizes raw performance and strict field typing. slog prioritizes ecosystem unification and built-in context propagation.

Pick the tool that matches your throughput requirements and team familiarity. Do not overengineer logging for a prototype. Do not underengineer it for a production API.

Where to go next

Loggers are just pipes. Structure the data at the source. Keep cardinality low. Trust the formatter.