skip to content

Goroutine Labels and Attribution

pprof.Do attaches key-value labels to a goroutine and every goroutine it starts, so one CPU profile splits by endpoint or tenant instead of blaming shared library code.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

What does runtime/pprof.Do add to a CPU profile, and how do you build its labels?

level: juniorimportance: should knowfreq 30%

answer

  1. who, not just where
  2. labels ride on the goroutine
  3. keys and values, strictly in pairs
  4. one call wraps the work and restores

basics

~20 s

runtime/pprof.Do runs a function with key/value labels attached to the current goroutine, so CPU profile samples taken during it carry those tags. Build the label set with pprof.Labels, which takes alternating key and value strings.

solid answer

~40 s

A profile tells you which functions burned time; labels tell you on whose behalf. `pprof.Do(ctx, labels, f)` attaches a set of string key/value pairs to the calling goroutine, runs `f` with a context carrying those labels, and restores the goroutine's previous labels when `f` returns. Every CPU profile sample taken while `f` is on that goroutine carries the tags, so in `go tool pprof` you can split one hot function's time by tenant, job kind or priority instead of seeing a single undifferentiated total. The label set comes from `pprof.Labels("tenant", "acme", "kind", "reindex")` — alternating keys and values, and an odd number of arguments panics. Keep the values low-cardinality: labels are for grouping, not for tagging every individual unit of work.

code

go · 6 lines
go
func handle(ctx context.Context, j Job) {
	labels := pprof.Labels("tenant", j.Tenant, "kind", j.Kind)
	pprof.Do(ctx, labels, func(ctx context.Context) {
		execute(ctx, j) // CPU samples taken here carry tenant and kind
	})
}

go deeper

for a junior

Be ready to say what a label is (a string key/value pair on a goroutine), name pprof.Do and pprof.Labels, and show the shape of a Do call wrapped around one unit of work.

for a middle

Explain the mechanics: Do derives a context, sets labels on the calling goroutine, and restores the previous set on return, so samples taken inside carry the tags. Know that SetGoroutineLabels is the unscoped lower-level form.

for a senior

Show you would label at the point work executes rather than at startup, and that you keep values low-cardinality so the tags actually aggregate into comparable buckets when you analyse the profile.

for a principal

Own the convention: which label keys a service uses, who is allowed to add one, and the rule that labels group classes of work rather than identify individual requests, so profiles stay comparable across teams and releases.

## The problem labels solve A CPU profile answers *where* a Go program spent time: which functions, reached up which call stacks. In a process that does the same work for many callers — a background job runner executing jobs on behalf of many customers inside one binary — that is only half the question. The profile may say, correctly and uselessly, that 40% of CPU is inside one decode function. Every tenant's jobs go through that function. The profile cannot say which tenant's work put it there, because a stack trace has no notion of *on whose behalf*. `runtime/pprof` labels add that dimension. A label is a string key with a string value. Labels are attached to a **goroutine**, and the runtime copies whatever labels a goroutine currently carries onto each profile sample it takes from that goroutine. Filtering and grouping by those tags then happens in the analysis tool. ## The API Three calls matter. **`pprof.Labels(args ...string) LabelSet`** builds a label set from alternating keys and values: ```go labels := pprof.Labels("tenant", "acme", "kind", "reindex") ``` It panics if you pass an odd number of arguments — there is no compile-time check that pairs are balanced, so a missing value is a runtime panic, not a silent empty label. **`pprof.Do(ctx context.Context, labels LabelSet, f func(context.Context))`** is the call you should normally use. It derives a context from `ctx` with `labels` added, sets those labels on the goroutine that called it, invokes `f` with the derived context, and — this is the part that makes it safe — restores the goroutine's previous label set when `f` returns, including when `f` panics its way out. Because it is scoped like a `defer`, you cannot forget to unset labels. ```go func handle(ctx context.Context, j Job) { pprof.Do(ctx, pprof.Labels("tenant", j.Tenant, "kind", j.Kind), func(ctx context.Context) { execute(ctx, j) }) } ``` **`pprof.SetGoroutineLabels(ctx context.Context)`** is the lower-level form: it sets the current goroutine's labels to whatever the context carries, with no scope and no restore. It exists for the cases where a callback shape does not fit — typically a long-lived loop that re-labels itself each iteration. Combine it with `pprof.WithLabels(ctx, labels)`, which returns a derived context carrying the labels without touching any goroutine. You can read labels back out of a context with `pprof.Label(ctx, "tenant")`, which returns the value and an `ok` boolean, and iterate them with `pprof.ForLabels(ctx, func(key, value string) bool)`. Both read the **context**, not the goroutine, which is a useful reminder of how the two halves relate: the context is how labels travel through your code, and `Do`/`SetGoroutineLabels` are what actually apply them to the runtime. ## What you get downstream Once samples carry tags, `go tool pprof` can work with them: the interactive `tags` command lists the label keys and values present in the profile with their sample weight, and flags such as `-tagfocus` and `-tagshow` narrow the profile to particular tag values or restrict which keys are displayed. That is how the useless "40% in decode" becomes "of that 40%, three quarters is one tenant's reindex jobs". ## Cost and cardinality Labels are not free but they are cheap: a label set is an immutable structure stored on the goroutine, and `Do` costs an allocation plus the save/restore around the call. The real cost is cardinality. A label value per *class* of work — tenant, job kind, priority, endpoint — groups thousands of samples into a handful of buckets and is what the tooling is designed for. A label value per individual unit of work, such as a job UUID or a request id, gives you a profile where almost every sample has a unique tag: it inflates the profile, slows the tool, and aggregates into nothing. Labels are a grouping dimension, not a tracing identifier; if you need per-request identity, that is a different instrument. ## Common first mistakes Applying `Do` at process start rather than around each unit of work labels nothing useful. Assuming a label set will appear in a heap profile does not work — not every profile type carries labels. And treating labels as something the context alone propagates is wrong: putting labels in a context and passing it somewhere does not label the goroutine on the other end until that goroutine applies them.

  • What happens if you call pprof.Labels with an odd number of arguments?
    It panics. `pprof.Labels` takes alternating keys and values as a variadic string list, so there is no compile-time protection against a missing value; the mismatch shows up as a runtime panic at the point you build the label set. In practice that means building label sets from data you control, not from a slice assembled elsewhere.
  • How is pprof.SetGoroutineLabels different from pprof.Do?
    `Do` is scoped: it applies the labels, runs the function you give it, and restores the goroutine's previous label set on return. `SetGoroutineLabels` just sets the current goroutine's labels from a context and never restores them — the goroutine keeps them until something sets them again. Use `Do` unless a callback shape genuinely does not fit, such as a long-lived loop that re-labels itself per iteration.
  • Why should label values be low-cardinality?
    Labels are a grouping dimension. With a value per class of work — tenant, job kind, priority — thousands of samples collapse into a few buckets you can compare. With a value per individual job or request, nearly every sample is unique: the profile grows, the tool slows, and nothing aggregates. Use labels to group, and a different instrument if you need per-request identity.

saying these in an interview costs you the question

  • Thinks labels change what the profiler samples rather than tagging samples
  • Believes labels appear automatically without calling Do or SetGoroutineLabels
  • Tags each sample with a unique job id and expects a useful breakdown
  • Assumes pprof.Labels takes a map instead of alternating strings
  • Calls Do once at startup and expects per-unit-of-work attribution
open as a page

Which goroutines inherit runtime/pprof labels, and when do those labels go away?

level: middleimportance: should knowfreq 42%

basics

~20 s

A goroutine inherits the labels its creator carried when the go statement ran, and keeps them for life unless it sets its own. Already-running goroutines get nothing, and pprof.Do restores labels only on its own goroutine.

open as a page

How do you split a Go CPU profile's hottest function by tenant using pprof labels?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Label each unit of work with the tenant using runtime/pprof.Do where it executes, collect a CPU profile, then in go tool pprof list the tags and use -tagfocus to keep only one tenant's samples, comparing totals across tenants.

open as a page

Which Go runtime profiles carry pprof labels, and which ignore them entirely?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

Only the CPU profile and the goroutine profile carry runtime/pprof labels. The heap and allocation profiles, the block profile, the mutex profile and threadcreate ignore them, so labels cannot split memory or contention by tenant.

open as a page