Skip to main content

The context Package

For developers with .NET (C#) experience, this guide explains cancellation, timeouts, and propagating request-scoped values. If you are comfortable with C#'s CancellationToken, most of the concepts carry over. The big difference is that in Go, cancellation, deadlines, and request-scoped values are all bundled into a single type, context.Context.

1. What This Document Covers​

  • The convention of passing ctx as the first argument
  • WithCancel / WithTimeout / WithDeadline
  • When to use context.Value and its caveats

The convention of passing ctx as the first argument​

In Go, any function that may need to handle cancellation or a deadline conventionally takes ctx context.Context as its first parameter. In C#, CancellationToken is usually the last optional parameter, so the position is the opposite.

func GetUser(ctx context.Context, id int) (*User, error)
  • Name the parameter ctx.
  • Do not store a Context in a struct field; pass it explicitly as a function argument.
  • Never pass nil. If you haven't decided which context to use yet, pass context.TODO(). At the top level (the start of main or a test), use context.Background().

WithCancel / WithTimeout / WithDeadline​

These functions derive a child Context from a parent Context. Each returns a new Context and a cancel function.

FunctionPurpose
context.WithCancel(parent)Cancel explicitly by calling cancel()
context.WithTimeout(parent, d)Cancelled automatically after the given duration elapses
context.WithDeadline(parent, t)Cancelled automatically when the given time is reached

WithTimeout(parent, d) is equivalent to WithDeadline(parent, time.Now().Add(d)).

Cancellation propagates from parent to child. When a parent is cancelled, every child derived from it is cancelled too. Cancelling a child does not affect its parent.

When to use context.Value and its caveats​

context.WithValue lets you carry request-scoped values such as a request ID, the authenticated user, or trace information. However, its use is limited.

  • Do not use it to carry a function's ordinary input parameters. Pass those explicitly as arguments.
  • Use a private, custom-defined type for keys. Built-in types such as string risk colliding with keys from other packages.
  • Treat values as read-only. Instead of overwriting, create a new child Context with WithValue.

2. .NET Mapping​

.NET (C#)Go
CancellationTokencontext.Context
CancellationTokenSource.CancelAftercontext.WithTimeout / WithCancel
HttpContext.Itemscontext.WithValue (limited use)

Additional mappings that complement the table above:

.NET (C#)Go
CancellationTokenSource.Cancel()The cancel() returned by WithCancel
token.IsCancellationRequestedctx.Err() != nil
token.ThrowIfCancellationRequested()if err := ctx.Err(); err != nil { return err }
OperationCanceledExceptioncontext.Canceled / context.DeadlineExceeded (error values)
token.Register(callback)context.AfterFunc(ctx, f) (Go 1.21+)
CancellationTokenSource.CreateLinkedTokenSourceDerive from the parent with WithCancel etc. (linked automatically)
HttpContext.RequestAbortedr.Context() (*http.Request)
Task.Delay(ms, token)Use select to wait on ctx.Done() and time.After / time.Timer

3. Common Pitfalls for C# Developers​

Cancellation is cooperative​

As in C#, cancellation in Go is not forced. Calling cancel() does not stop a goroutine by itself. It only takes effect when the worker monitors ctx.Done(), or when you pass ctx to an API that accepts it (such as http.NewRequestWithContext or QueryContext in database/sql). If a function accepts a ctx but never uses it, cancellation is ignored.

Write waits with select and ctx.Done()​

In C#, an API takes a token and throws an exception, as in await Task.Delay(ms, token). Go is channel-based, so the standard approach is to use select to wait on ctx.Done() and other events at the same time.

Always call cancel​

Always call the cancel function returned by WithCancel, WithTimeout, and WithDeadline once the work is done. If you don't, resources such as timers and the registration with the parent are not released until the deadline. You normally write defer cancel() immediately after obtaining it. The lostcancel check in go vet detects a forgotten call.

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

It feels similar to C#'s using var cts = new CancellationTokenSource(...), which guarantees Dispose.

Determine the cancellation reason with error values​

Cancellation is expressed not as an exception but as an error value returned by ctx.Err(). Use errors.Is to check it.

Statectx.Err()
Not cancellednil
cancel() was calledcontext.Canceled
Deadline passedcontext.DeadlineExceeded

From Go 1.20 onward, you can also get a more detailed cause with context.WithCancelCause and context.Cause(ctx).

Token and Source are separate as in C#, but Context is immutable​

Similar to C#'s separation of CancellationToken (the reading side) and CancellationTokenSource (the controlling side), in Go ctx (reading side) and cancel (controlling side) are separate values. A function that receives a ctx cannot trigger its cancellation on its own. On the other hand, a Context is immutable: WithTimeout and similar functions do not modify the existing ctx but return a new child. You cannot set a deadline on an existing source after the fact, as you can with C#'s CancelAfter.

Don't use context.Value as a replacement for a DI container or HttpContext.Items​

If you stuff everything into Value the way you might with HttpContext.Items, a function's dependencies can no longer be read from its types. Pass dependencies such as database connections, loggers, and configuration through constructors or arguments. Limit Value to values that flow across process boundaries as cross-cutting concerns, such as request IDs and authentication information.

Type assertions and key types when retrieving values​

ctx.Value(key) returns any, so a type assertion is required. The standard practice is to make the key a private custom type and expose a helper function for retrieval (see the example in the next section).

Detaching from the parent's cancellation​

For follow-up work that should continue even if the request is cancelled (such as writing an audit log), use context.WithoutCancel(ctx) from Go 1.21 onward. It keeps the values but detaches cancellation and deadlines. Use it only when you do so intentionally.

4. Code Examples​

Example 1: An HTTP request with a timeout​

C#:

public static async Task<string> FetchAsync(HttpClient client, string url, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2));

using var response = await client.GetAsync(url, cts.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cts.Token);
}

Go:

package main

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
)

func fetch(ctx context.Context, url string) (string, error) {
// Derive a child ctx with a 2-second timeout from the parent ctx
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // Always release resources

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}

func main() {
_, err := fetch(context.Background(), "https://example.com")
switch {
case errors.Is(err, context.DeadlineExceeded):
fmt.Println("timed out")
case errors.Is(err, context.Canceled):
fmt.Println("cancelled")
case err != nil:
fmt.Println("error:", err)
}
}

The linking that CreateLinkedTokenSource provides happens automatically when you simply derive from the parent ctx.

Example 2: Cancelling a background worker​

C#:

static async Task WorkerAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(500, ct); // Throws OperationCanceledException when cancelled
Console.WriteLine("tick");
}
}

using var cts = new CancellationTokenSource();
var task = WorkerAsync(cts.Token);
await Task.Delay(1600);
cts.Cancel();

Go:

package main

import (
"context"
"fmt"
"time"
)

func worker(ctx context.Context) error {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
// Return the cancellation reason (Canceled / DeadlineExceeded)
return ctx.Err()
case <-ticker.C:
fmt.Println("tick")
}
}
}

func main() {
ctx, cancel := context.WithCancel(context.Background())

done := make(chan error, 1)
go func() { done <- worker(ctx) }()

time.Sleep(1600 * time.Millisecond)
cancel() // Notify the worker to stop

fmt.Println("worker stopped:", <-done)
}

The worker waits on ctx.Done() in a select, and the caller just calls cancel(). To wait for the goroutine to finish, combine this with a completion channel (or a sync.WaitGroup).

Example 3: Graceful shutdown on OS signals​

With signal.NotifyContext, signals such as Ctrl+C are treated as cancellation of a Context. It is used much like Console.CancelKeyPress or IHostApplicationLifetime.

package main

import (
"context"
"fmt"
"os/signal"
"syscall"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

fmt.Println("running... (press Ctrl+C to exit)")
<-ctx.Done()
fmt.Println("shutting down:", ctx.Err())
}

Example 4: Request-scoped values (context.WithValue)​

In C#, this is the scenario where you store a request ID in HttpContext.Items.

app.Use(async (context, next) =>
{
context.Items["RequestId"] = Guid.NewGuid().ToString();
await next();
});

// Retrieval
var id = (string?)context.Items["RequestId"];

In Go, you provide a private key type together with helper functions for setting and getting the value.

package main

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

// Keep the key type private to prevent collisions with other packages
type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}

// Middleware: attach a request ID to the ctx
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-12345" // In practice, generate something like a UUID
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func handler(w http.ResponseWriter, r *http.Request) {
id, _ := RequestID(r.Context())
fmt.Fprintf(w, "request id: %s\n", id)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
http.ListenAndServe(":8080", requestIDMiddleware(mux))
}

r.Context() is cancelled when the client disconnects or the handler returns. If you keep passing this ctx to the lower-level work you call from the handler (database or other service calls), that work stops when the request is aborted.

Example 5: Keep passing ctx down to lower-level calls​

func (s *Service) GetOrder(ctx context.Context, id int) (*Order, error) {
row := s.db.QueryRowContext(ctx, "SELECT id, total FROM orders WHERE id = $1", id)

var o Order
if err := row.Scan(&o.ID, &o.Total); err != nil {
return nil, fmt.Errorf("get order %d: %w", id, err)
}
return &o, nil
}

A function that receives a ctx should pass it straight through to any ...Context APIs it calls. If you swap it for context.Background() midway, cancellation no longer propagates from that point on.

5. Summary​

  • Go's context.Context carries cancellation, deadlines, and request-scoped values together, and its central role corresponds to C#'s CancellationToken.
  • Put ctx as the first parameter of a function, don't store it in a struct, and keep passing it along the call chain.
  • Derive child Contexts with WithCancel / WithTimeout / WithDeadline, and always call the returned cancel with defer cancel().
  • Cancellation is cooperative. It only works when the worker responds through ctx.Done() in a select or through ...Context APIs.
  • The cancellation reason is an error value, not an exception. Check it with errors.Is(err, context.Canceled) or errors.Is(err, context.DeadlineExceeded).
  • Limit context.Value to cross-cutting values such as request IDs and authentication information, and handle it with a private key type and helper functions. Don't use it in place of dependencies or ordinary arguments.

References​