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?
- Context should carry only request-specific information. Logger is not information, it is an object. Potentially a very heavy object.
- If a function is using something (logger in this example), it should receive it as a parameter, not hide it in the context.
- No logging library provides this functionality. If it was in the standard library, I would not have to discuss it here.
Why storing logger object in the context may be a good idea?
- The intention of the logger is to store request-specific information in logs. It is a complex object, so it technically breaks the rules for what can be stored in the context. Its purpose, however, is not to smuggle complex logic to functions, but provide a basic functionality.
- Context is usually already passed between functions. Storing a logger in it would be an extension and would not require adding a parameter for already existing functions.
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.
