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
Contextin a struct field; pass it explicitly as a function argument. - Never pass
nil. If you haven't decided which context to use yet, passcontext.TODO(). At the top level (the start ofmainor a test), usecontext.Background().
WithCancel / WithTimeout / WithDeadline
These functions derive a child Context from a parent Context. Each returns a new Context and a cancel function.
| Function | Purpose |
|---|---|
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
stringrisk colliding with keys from other packages. - Treat values as read-only. Instead of overwriting, create a new child
ContextwithWithValue.
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| CancellationToken | context.Context |
| CancellationTokenSource.CancelAfter | context.WithTimeout / WithCancel |
| HttpContext.Items | context.WithValue (limited use) |
Additional mappings that complement the table above:
| .NET (C#) | Go |
|---|---|
CancellationTokenSource.Cancel() | The cancel() returned by WithCancel |
token.IsCancellationRequested | ctx.Err() != nil |
token.ThrowIfCancellationRequested() | if err := ctx.Err(); err != nil { return err } |
OperationCanceledException | context.Canceled / context.DeadlineExceeded (error values) |
token.Register(callback) | context.AfterFunc(ctx, f) (Go 1.21+) |
CancellationTokenSource.CreateLinkedTokenSource | Derive from the parent with WithCancel etc. (linked automatically) |
HttpContext.RequestAborted | r.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.
| State | ctx.Err() |
|---|---|
| Not cancelled | nil |
cancel() was called | context.Canceled |
| Deadline passed | context.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.Contextcarries cancellation, deadlines, and request-scoped values together, and its central role corresponds to C#'sCancellationToken. - Put
ctxas the first parameter of a function, don't store it in a struct, and keep passing it along the call chain. - Derive child
Contexts withWithCancel/WithTimeout/WithDeadline, and always call the returnedcancelwithdefer cancel(). - Cancellation is cooperative. It only works when the worker responds through
ctx.Done()in aselector through...ContextAPIs. - The cancellation reason is an error value, not an exception. Check it with
errors.Is(err, context.Canceled)orerrors.Is(err, context.DeadlineExceeded). - Limit
context.Valueto 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.