PATTERN Cited by 1 source
Context-threaded SQL tag propagation¶
Pattern: thread per-request
SQLCommenter tags through
the application stack via the language's explicit per-request
storage primitive — in Go, context.Context — and render them
to SQL at the database-wrapper layer. The framework-less
counterpart to
ORM-layer tag
propagation for languages without implicit request-local
storage.
Shape (Go)¶
Three components compose:
(1) Two context helpers — typed key + copy-on-read reader + immutable writer:
type contextKey string
const sqlTagsKey contextKey = "sql_tags"
func tagsFromContext(ctx context.Context) map[string]string {
if tags, ok := ctx.Value(sqlTagsKey).(map[string]string); ok {
out := make(map[string]string, len(tags))
for k, v := range tags { out[k] = v }
return out
}
return make(map[string]string)
}
func contextWithTags(ctx context.Context, tags map[string]string) context.Context {
return context.WithValue(ctx, sqlTagsKey, tags)
}
(2) One SQL-rendering helper — deterministic SQLCommenter-format append:
func appendTags(query string, tags map[string]string) string {
if len(tags) == 0 { return query }
parts := make([]string, 0, len(tags))
for k, v := range tags {
parts = append(parts, fmt.Sprintf("%s='%s'", k, url.QueryEscape(v)))
}
sort.Strings(parts)
return query + " /*" + strings.Join(parts, ",") + "*/"
}
(3) Wrapper QueryContext / ExecContext on the database
handle that renders on every call:
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return db.sql.QueryContext(ctx, appendTags(query, tagsFromContext(ctx)), args...)
}
Every enrichment layer (HTTP middleware, auth middleware,
deployment-tag startup, feature-flag evaluation) reads the map
from context, mutates its copy, writes it back via
contextWithTags.
Why this shape¶
The pattern gets five properties right simultaneously:
- Coverage: every function that accepts
ctxparticipates automatically; the wrapper renders regardless of call-site. - Extensibility: new enrichment layers just add keys to the map; no ORM hook registration.
- Thread-safety: copy-on-read makes mutation local; context itself is immutable.
- Deterministic SQL:
sort.Stringsstabilises the emitted comment bytes for plan-cache stability. - No global state: no process-wide registry, no module- level var; everything rides on the request's context.
Composition with middleware layers¶
Canonical full stack (Source: sources/2026-04-21-planetscale-patterns-for-postgres-traffic-control):
var handler http.Handler = mux
handler = SQLTagMiddleware(handler) // route tag
handler = AuthMiddleware(users, handler) // tier tag
// DEPLOYMENT_TAG env var read at startup → feature tag
// dedicated job DB with application_name → workload tag
Each middleware layer is a small function: read tag map, add its key, write back. Middleware-ordering matters (the outer middleware's tags must be in context before the inner middleware reads), but the composition primitive is trivial.
Comparison with ORM-layer pattern¶
| Dimension | ORM-layer | Context-threaded (Go) |
|---|---|---|
| Request-local storage | Thread-local / ORM hook | context.Context |
| Render point | ORM query formatter | Wrapper QueryContext |
| Coverage | All ORM-issued queries | All ctx-accepting sites |
| Library queries | Captured | Captured iff lib takes ctx |
| Canonical example | Rails query_log_tags |
Go + database/sql wrapper |
Same wire format, same observability outcomes; different coverage boundary and different discipline cost.
Generalises beyond Go¶
- Rust: request extensions on
hyper::Request/axumextractors carry the tag map; asqlx/tokio-postgreswrapper renders. - Python async:
contextvars(PEP 567) stores the tag map async-safely; apsycopg3/asyncpgwrapper reads and renders. - Any language with explicit per-request context can instantiate the same three-component shape.
Operational notes¶
- Middleware order is significant. Outer middleware runs first; its keys must be present before inner middleware reads. HTTP-handler stacks typically add request-ID → logger → metrics → auth → tagging, in that order.
- Library functions that drop
ctxbreak the chain. A legacy helperfunc lookup(db *sql.DB, id int) …that doesn't takectxbypasses the wrapper and emits untagged SQL. Audit the codebase forctx-less DB call sites. - Per-query overhead is measurable but small. Allocation
of the map copy +
sort.Strings+ string concat runs on every query. For ultra-high-QPS services, memoising the rendered comment per(tag-set-hash)is a valid optimisation; not worth doing for typical services. - URL-encoding mismatches can bite.
url.QueryEscapeencodes/as%2F— some downstream tools expect it unchanged. Verify the log-analysis pipeline handles the encoded form.
Seen in¶
- sources/2026-04-21-planetscale-patterns-for-postgres-traffic-control — canonical wiki introduction by Josh Brown. Defines the two-helper + wrapper-method shape, five composing middleware layers, and the full-stack wire-up. Complements the Rails ORM-layer pattern with the Go framework-less variant.