Skip to main content

Command Palette

Search for a command to run...

GoLang Context: Is It Really Essential for Your Library Functions?

Updated
6 min read

This is the classic “design smell vs. design wisdom” question of golang. It’s a boundary where good design and bad habits can look deceptively similar. Let’s unpack it carefully with reasoning.


The short answer:

Yes, it’s good practice to pass context.Context into library functions — as long as the library function performs work that can be cancelled, times out, or carries request-scoped data.
But don’t inject or store context inside a struct or global - and don’t add it to every single helper function just for the sake of “consistency.”


Let me simplify the jargons

Let’s slow down and break that short answer apart carefully.

When I say “as long as the library function performs work that can be cancelled, times out, or carries request-scoped data”, I’m referring to what kinds of work context.Context is designed to control.

1. “Work that times out”

The most common and basic use - sometimes you want to stop an operation after a deadline.
You can set that timeout once at a higher level, and all context-aware functions automatically respect it ( expect them to respect it 🥲 based on who wrote the code).

Example:

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

data, err := repo.FetchData(ctx)

If FetchData and everything it calls uses the same ctx variable, they’ll all stop when 3 seconds pass (atleast .
That’s why you inject the context into those library functions - it lets the caller control timing from the outside.

2. “Work that can be cancelled”

Imagine your function starts a long-running operation - say, a database query or an HTTP call.
In Go, context.Context gives callers a way to stop that work early if it’s no longer needed.

Example:

func FetchUser(ctx context.Context, id string) (*User, error) {
    // The query will stop if ctx is canceled
    return db.QueryContext(ctx, "SELECT ... WHERE id=?", id)
}

Now if the user closes their browser tab or the parent goroutine cancels the context:

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

user, err := FetchUser(ctx, "123")

the query will abort gracefully rather than running forever.

So: if your function calls something that might take time, and can be aborted, pass a context.

3. “Carries request-scoped data”

This one’s subtler. A context.Context can carry small bits of “data” (metadata) that are specific to a single request or operation - like a user ID, trace ID or correlation ID (a unique id for a request as it moves through a complex system of microservices or distributed systems) for logging.

Example:

ctx = context.WithValue(ctx, "traceID", "abc123")

logRequest(ctx)

Then inside your library:

func logRequest(ctx context.Context) {
    traceID := ctx.Value("traceID")
    fmt.Println("Processing request:", traceID)
}

So, if your library function is part of a request pipeline or traceable workflow, it should accept ctx to preserve that metadata chain.

In simpler words, when each function in your code accepts the ctx and passes it forward, that information travels with it. We can then use it to trace the flow of that request in your code.

In short:

You inject context.Context into a library function only if that function:

  1. Does something that might need to be stopped or canceled,

  2. Might exceed a time limit, or

  3. Needs to propagate small per-request values (like trace info).


The reasoning:

context.Context in Go is designed for propagation - not dependency injection. It exists to:

  • Cancel ongoing operations (like database calls, network requests, or background tasks).

  • Propagate deadlines and timeouts.

  • Carry request-scoped metadata (like user IDs, trace IDs, etc.).

This is why you’ll see Go’s standard library functions like:

http.NewRequestWithContext(ctx, ...)
db.QueryContext(ctx, ...)

They all take ctx as the first parameter. The rule of thumb:

If your function might block, calls another context-aware API, or should respect cancellation/timeouts, pass a context.Context.


When it’s good practice:

Let’s say you’re writing a MongoDB or HTTP wrapper:

func (r *Repo) FetchUser(ctx context.Context, id string) (*User, error) {
    return r.db.FindUser(ctx, id)
}

Perfect. Your library is context-aware, and you’re not holding onto it longer than needed.


When it’s bad practice:

If you start doing this for every function:

type MyService struct {
    ctx context.Context
}

func (s *MyService) DoSomething() error {
    <-s.ctx.Done() // or using s.ctx in random spots
    ...
}

That’s an anti-pattern. Contexts should be per-operation, not stored long-term in structs or passed implicitly around like a dependency.

Similarly, utility functions that do pure computation (like string parsing, JSON marshaling, etc.) shouldn’t take a ctx just because “everything else does.”


Context don’t cancel themselves

Some people are so accustomed to using context that they have forgot that the functions that actually use context, cancel it when needed. (mostly HTTP, DB, etc in daily life). That is where the actual use case lies. Not in basic and regular function that just do computational tasks.

Go’s context will not automatically stop execution or time out your function “by magic.”
Timeouts and cancellations only take effect if your code (or library code you call) explicitly checks ctx.Done() (or ctx.Err()) at appropriate points.

What context really does

context.Context is a signal channel, not an enforcer.
When you create a timeout or deadline context, for example:

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

Go internally schedules a timer. After 2 seconds:

  • The context’s internal done channel is closed.

  • ctx.Err() becomes context.DeadlineExceeded.

But nothing else happens automatically.

If your function never checks that channel, it will happily continue running forever:

func doSomething(ctx context.Context) {
    // this loop ignores context, so it won't stop
    for {
        time.Sleep(1 * time.Second)
        fmt.Println("still running...")
    }
}

Even if ctx times out, the above code keeps running, because you never checked <-ctx.Done().

How context cancellation actually takes effect

The timeout affects your program only if your function or a library you call respects it, e.g.:

func doSomething(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("context canceled:", ctx.Err())
            return
        default:
            fmt.Println("working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

Now when the timeout triggers, the goroutine will exit.

Libraries and context

Many standard-library calls do honor context automatically, such as:

  • http.Request with req.WithContext(ctx)

  • database/sql queries (db.QueryContext)

  • os/exec.CommandContext

In those cases, the library implements the <-ctx.Done() logic internally.

So your code doesn’t need to explicitly check it if the library does and just pass the context form the request down to these library functions.


Practical guideline:

Ask yourself:

  1. Does this function initiate work that can be canceled or timed out?

  2. Does it call another function that requires a context.Context?

  3. Does it handle request-scoped or trace-scoped information?

If any of these are true → pass ctx.
Otherwise → skip it.


In summary:
Injecting context in Go library functions is good practice when the function’s work is cancelable, time-bound, or request-scoped. But it’s a code smell if you use it as a general-purpose dependency or global variable substitute.

Open to any suggestions and new perspectives.