Go: logs with context

What I want to achieve

As a developer I want to have a logging system that can be easily used for nested functions so that log entries have all the attributes in a given function call chain.

What is my idea

Logger

There are many logging libraries in Go. I decided to try with the log/slog package, because it is from the standard library. It has a Logger object that can hold attributes. So basically I can do this:

logger := slog.With("path", r.URL.Path).With("method", r.Method)
logger.Info("Request received")

and it will produce output like this:

2025/05/13 18:40:28 INFO Request received path=/hello method=GET

Context

Context in Go is idiomatically something that gets passed between functions and carries request-scoped values. So it looks like an ideal solution for passing the logger object.

Why not use the WithValue and Value functions of Context to store the logger object?

Controversy

Apparently, this topic brings a lot of controversy.

Let’s start by looking at what the Internet has to say.

There is an instruction describing exactly what I have in mind, but using a different logging library: Using Go’s ‘context’ library for logging.

Then, there are these discussions on Stack Overflow and Reddit. In both of them, there are arguments saying that it is a bad thing to do, and others that say it is ok.

I also asked AI, and it entered an endless loop. It first said that it is bad, but when I stated the question differently, it said that it is actually a good approach.

Why I should not store logger object in the context?

Why storing logger object in the context may be a good idea?

Implementation

I decided to try both approaches to clearly show what design choices mean to the code.

In all examples I will be using the same program: a simple server that says hello to whoever talks to it:

package main

import (
	"context"
	"fmt"
	"net/http"
)

func getMessage(_ context.Context, name string) string {
	return fmt.Sprintf("Hello, %s!\n", name)
}

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		name := r.URL.Query().Get("name")
		w.Write([]byte(getMessage(r.Context(), name)))
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

When I do curl http://localhost:8080/hello?name=world, it says Hello, world!.

It is obvious that this kind of software needs to have logs so that developers know what is going on in the running program. Let’s add logging with two approaches discussed here.

Logger object passed between functions

Here is the main function:

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
)

func getMessage(_ context.Context, logger *slog.Logger, name string) string {
	logger = logger.With("name", name)
	logger.Info("Generating message")
	return fmt.Sprintf("Hello, %s!\n", name)
}

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		logger := slog.With("path", r.URL.Path).With("method", r.Method)
		logger.Info("Request received")
		defer logger.Info("Request handled")
		name := r.URL.Query().Get("name")
		w.Write([]byte(getMessage(r.Context(), logger, name)))
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

The important thing is that we use the slog.With function to create a logger object with some fields. A nice thing is that we can call it many times in one line.

Then, we pass the resulting *slog.Logger to the worker function, so that it can log its own entries.

The server generates the following logs, with fields correctly set in the inner function:

2025/05/13 18:40:28 INFO Request received path=/hello method=GET
2025/05/13 18:40:28 INFO Generating message path=/hello method=GET name=world
2025/05/13 18:40:28 INFO Request handled path=/hello method=GET

Logger object in context

This approach requires writing some code that can put and retrieve the logger object from the context. It also introduces a new package named logger, which has to be used from now on in the codebase:

package logger

import (
	"context"
	"log/slog"
)

type contextKey struct{}

func CreateContext(ctx context.Context, key string, value any) context.Context {
	lgr := Ctx(ctx)
	lgr = lgr.With(key, value)
	return context.WithValue(ctx, contextKey{}, lgr)
}

func Ctx(ctx context.Context) *slog.Logger {
	if lgr, ok := ctx.Value(contextKey{}).(*slog.Logger); ok {
		return lgr
	}
	return slog.Default()
}

func NoCtx() *slog.Logger {
	return slog.Default()
}

The main function looks like this:

package main

import (
	"context"
	"fmt"
	"maciejgaleja/golang-logs-with-context/slog/logger"
	"net/http"
)

func getMessage(ctx context.Context, name string) string {
	ctx = logger.CreateContext(ctx, "name", name)
	logger.Ctx(ctx).Info("Generating message")
	return fmt.Sprintf("Hello, %s!\n", name)
}

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		ctx := logger.CreateContext(r.Context(), "path", r.URL.Path)
		ctx = logger.CreateContext(ctx, "method", r.Method)
		logger.Ctx(ctx).Info("Request received")
		defer logger.Ctx(ctx).Info("Request handled")
		name := r.URL.Query().Get("name")
		w.Write([]byte(getMessage(ctx, name)))
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

Since we are using the “net/http” library, we have a request Context that we can use. Then we add a logger to it and use it in all functions that are called while handling the request.

As opposed to the previous example, the getMessage function does not need to take logger as an argument. It can extract it from the context and use it.

Which way to go?

In the end, the main value is the maintainability and overall quality of the software systems that I work with. What I discuss here feels like an implementation detail.

I can see good points in both approaches and I understand where all arguments come from.

I believe that adding a function argument that does not reflect any business logic is not optimal. And to me it is more important to avoid it, than to break the rule of what context is for.