---
title: Profiling
source: 'https://academia.sh/en/courses/go-production/profiling'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:33+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Measures and compares the allocation counts of the same function's two call forms with AllocsPerOp"
    - 'Demonstrates by running that the values a branch that never executes would allocate never appear in the profile'
    - 'Distinguishes that a profile answers "where allocation happens" but not "why something is slow"'
---

# Profiling

A profile is a run fact and in this lesson it is read as a number: how many values the same function's two call forms allocate. Limiting measurement: a profile sees only the path that runs — the values a branch that never executes would have allocated never appear in the profile, even if they sit written in the source.

The previous lesson showed that coverage ratio counts which lines ran, not whether they
were tested correctly. This lesson asks the same question of memory: where a running
program allocates cannot be seen by reading the source, but once a run finishes, a
profile states it **as a number**. A profile is a run fact just like coverage — someone
reading the source cannot see how many values a given function allocated; they can only
run it and ask.

This shared shape is not a coincidence: every lesson since the course opened has used
the same skeleton — first classify whether a fact stands in the source, in a tool, or
in a run, then measure the limit specific to that class. The profile's place here is
clear: like coverage, it is learned only by running. But unlike coverage, what the
profile reads is not a true/false decision but a **quantity** — how that difference
carries into the measurement is this lesson's subject.

The Concurrency course (M09/K03) already established profiling and a measurement
discipline: a measurement cannot be compared without stating what it counts. This
lesson carries that discipline forward with Go's own tools; that course's axis (the
overlapping step) is not used here. The question here is narrow: what does a profile
tell you — **where** — and what does it not tell you?

## Three Profile Types, One Number

The catalog defines this topic with three profile types: a CPU profile, a memory
profile, and a block profile. All three can be produced with the language's own
measurement package, but the three do not carry the same kind of information. A CPU
profile samples where the program spends its time by function — by its nature this
measurement is **duration**, and duration is not written, printed, or measured in any
lesson of this course. A block profile samples similarly: it measures how long a
goroutine waits on a lock or a channel, again duration-based.

A memory profile answers a different question: **how many** values were allocated, not
**how much time** passed. This is the only profile type this course can count — its
number stays constant from one run to the next, unlike duration, which depends on the
machine and the load at that moment. This lesson therefore focuses only on the memory
profile, and on a single summary of it — the allocation count per operation. CPU and
block profiles continue to exist; they are not measured here, because what they measure
is duration.

## Profile: How Many Values, Which Call

In this lesson the word "profile" is reduced to a single number: how many values a call
allocates per operation. This number is read **from inside the program** with the
language's own measurement package — memory bytes, duration, and stack traces are never
printed.

```go
// profile.go — the two call forms of the same function, showing how many values the profile allocates
package main

import "fmt"

type Record struct {
	Name   string
	Amount int
}

// format turns a record into a single line. When verbose is requested it
// builds an extra field slice; when it is not, that body never runs.
func format(r Record, verbose bool) string {
	line := fmt.Sprintf("%s:%d", r.Name, r.Amount)
	if verbose {
		fields := make([]string, 0, 3)
		fields = append(fields, fmt.Sprintf("name=%s", r.Name))
		fields = append(fields, fmt.Sprintf("amount=%d", r.Amount))
		fields = append(fields, fmt.Sprintf("length=%d", len(r.Name)))
		for _, f := range fields {
			line += " " + f
		}
	}
	return line
}
```

**PM1.** `format` is a single function but carries two separate paths: when `verbose`
is false, only the first line runs; when it is true, three separate formatting calls
and a concatenation loop are added. Someone reading the source sees that both paths
exist but cannot see how much each allocates — that is information no reading can
supply, only a run can.

## Measuring the Two Call Forms

`format` itself measures nothing — it only produces a result. Measurement stands in a
separate layer: the language's own benchmark function (`func(*testing.B)`)
repeats the call `b.N` times; the measurement package counts how many values moved to
the heap during these repetitions and yields an average number per operation. The file
below defines two separate benchmark functions — one calls
`verbose=false`, the other `verbose=true` — and runs each of them five times.

```go
// measurement.go — two calls of the same function, allocation count across five runs
package main

import (
	"fmt"
	"testing"
)

var sampleRecord = Record{Name: "bolt", Amount: 12}

func profilePlain(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = format(sampleRecord, false)
	}
}

func profileVerbose(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = format(sampleRecord, true)
	}
}

func fiveRuns(f func(*testing.B)) (int64, int) {
	values := map[int64]bool{}
	var first int64
	for i := 0; i < 5; i++ {
		r := testing.Benchmark(f)
		values[r.AllocsPerOp()] = true
		if i == 0 {
			first = r.AllocsPerOp()
		}
	}
	return first, len(values)
}

func main() {
	fmt.Println("-- profile: how many values were allocated --")
	plain, count1 := fiveRuns(profilePlain)
	verbose, count2 := fiveRuns(profileVerbose)
	fmt.Printf("%-24s allocs=%-3d distinct values across five runs=%d\n", "plain call", plain, count1)
	fmt.Printf("%-24s allocs=%-3d distinct values across five runs=%d\n", "verbose call", verbose, count2)
	fmt.Println("difference:", verbose-plain, "allocs, belonging only to the verbose path")
}
```

```
-- profile: how many values were allocated --
plain call               allocs=2   distinct values across five runs=1
verbose call             allocs=9   distinct values across five runs=1
difference: 7 allocs, belonging only to the verbose path
```

**PM2.** `fiveRuns` applies the same pattern that holds in every performance lesson of
this course: the same measurement is repeated five times and the number of distinct
values is counted. Duration is never read here; only `AllocsPerOp` is taken from the
result `testing.Benchmark` returns. A single value appearing in all five runs shows
that this number is constant from run to run — unlike duration, it does not depend on
the environment.

`values` is kept as a `map[int64]bool`, not a single counter; the reason is to
**prove**, not assume, whether the five runs give the same value. If the five runs
give the same number, `values` carries a single key and `len(values)` comes out as
one; if a different number showed up, a second key would be added and this count
would rise to two — exactly like the duration measurement in the course's shared core
coming out different in two of eight runs and being excluded from the frame. For the
allocation count this never happens; both lines in the output below giving
`distinct values across five runs=1` confirms this claim.

## Profiling Inside a Function, Profiling a Service

The measurement above is a scaled-down form of a profiling tool. Profiling a service
running in production measures **by sampling** how much memory which function
allocates under real traffic — the service runs for hours, the profiling tool collects
allocations throughout that time, and in the end it yields a distribution. This
distribution reflects real user input but does not come out the same from run to run:
if a day brings more verbose records, the profile changes too.

The method used in this lesson does something different: it runs a single function
with a single input five times and looks for a constant number. This does not stand in
for profiling a service — it is a small, isolated, and reproducible measurement. The
difference matters: a production profile answers the question "what is actually
happening," and its answer changes with traffic; the measurement here answers the
question "what does this function do with this particular input," and its answer is
constant. Both carry the word profile, both are run facts, but one is a repeatable
experiment and the other is a continuous observation.

This narrowing has a cost: the measurement of an isolated function does not show that
function's share inside a real service. `format` might take one percent of a request's
time, or ninety percent — this lesson never says, because the measurement severs the
function from its context. A production profile's real strength is exactly here: it
shows which function makes up how much of the **total** allocation while the whole
program runs. The method built in this lesson is a model of examining a single tile
inside that larger picture with a magnifying glass — not the larger picture itself.

## The Difference Between Profiling and Benchmarking

A later lesson in this course also uses the same measurement tool
(`testing.Benchmark`, `AllocsPerOp`) but asks a different question: which of two
**implementations** of the same work allocates less. This lesson does not ask that
question — here there is a single function, `format`, and its two call forms separate
its **two branches**. Benchmarking compares two separate implementations;
profiling shows where a single implementation allocates. The tool is the same, the
question is different: one asks "which is better," the other asks "where." This
distinction matters because the same number (`AllocsPerOp`) shows up in both contexts,
and which question it answers can only be told from how the measurement is set up.

## Reading the Numbers

The `plain` call allocates two values: the `fmt.Sprintf("%s:%d", ...)` call itself
produces a string, and in doing so moves at least one intermediate value to the heap.
The `verbose` call allocates nine values. The seven allocations in between belong
entirely to the `if verbose` block: three separate `fmt.Sprintf` calls, one slice
allocation, and part of the concatenations inside the loop. In the source, both paths
sit side by side in the same function body; without a profile, no one can tell which
one allocates how much.

This makes clear the question a profile answers: **where** allocation happens. Which
call form allocates more values is read directly from the measurement. But a profile
does not say **why** something is slow — a question never measured in this lesson,
because answering it requires looking at duration, and duration is not written in any
lesson of this course. A high allocation count does not mean the program runs slowly;
it only means that path requests more memory. The two usually move together, but the
profile itself does not separate them — the person reading the allocation count does.

The plain call's two allocations are themselves a lesson in reading. The source shows
a single `fmt.Sprintf` call, but this one line hides more steps than it appears to:
`Sprintf` produces a new string, and while doing so, it also wraps an integer like
`r.Amount` into an interface value as it passes it to a variadic parameter (`...any`).
Even though the source shows no pointer, no `new`, no `append`, exactly these two
steps — string production and wrapping into an interface — lead to two separate
allocations. Which value gets wrapped this way and when is a consequence of the
language's own calling convention and a decision made by escape analysis. That
decision itself is the subject of the next lesson; this lesson only counts how many
values were allocated, not which decision led to it.

The course's opening split eleven facts into source, tool, and run; this lesson uses
only the run out of those three classes, because a profile's number can only be
learned by running.

## Limiting Measurement: The Path That Does Not Run Is Absent From The Profile

In the table above, the `plain` row shows two values — **none** of the three
`Sprintf` calls, the slice allocation, or the concatenation loop enter this row. The
reason is not that these values are small or that the compiler optimizes them away:
when `verbose` is passed false, the `if` block's **body never runs at all**. The
source holds code corresponding to seven allocations, but `profilePlain` never
triggers it; a profile cannot see anything that never triggered.

**PM3.** This is the limit profiling shares with coverage ratio (the previous
lesson): both measure only the path that **runs**. Coverage ratio did not count an
untested line; a profile does not count an untriggered branch's allocation. Before
concluding that a branch "allocates little," one must be certain that branch was
actually triggered — otherwise what is measured is not that branch's behavior, but
the fact that it never ran. The parallel in form is also worth noting: coverage ratio
ignored an untested line by counting it as zero, and a profile ignores an untriggered
branch's allocation by counting it as zero too — both write "absent" and
"unmeasured" with the same symbol (`0`), and it is up to the reader to tell the two
apart.

This limit works in two directions in practice. On one hand, a rarely triggered
branch (say, a code path entered only on an error) may never show up in daily
profiles and go unnoticed. On the other hand, when comparing two call forms, one must
be sure the input used actually triggers both branches — otherwise the comparison is
looking at the wrong path. In the measurement above, `profilePlain` calls only with
`verbose=false`, and `profileVerbose` calls only with `verbose=true` — this choice is
deliberate, because the comparison's purpose is exactly to see these two branches
separately.

The same limit holds inside a single call form too. `format` was called with only two
cases: `verbose` either entirely true or entirely false. A real set of callers might
call this function with a mixed distribution — a tenth of the records verbose, the
rest not — and the average allocation count that distribution produces would equal
neither of the two extremes above. A profile describes the input it was measured
with; a different input distribution's profile produces a different table. This is
why a profile number cannot be carried without stating which input produced it — just
as the previous lesson's coverage ratio cannot be carried without stating which test
set produced it.

## What The Number Must Not Decide

A two-line table alone does not propose an action. The `verbose` call allocating
seven more values does not mean the `verbose` branch should be removed — that branch
probably serves a real need, and formatting three separate fields individually may
genuinely be used somewhere else. The profile number only shows **where** allocation
happens; deciding whether that allocation is necessary requires knowing which caller
uses which branch at what frequency, something the number does not address. This is a
warning the next lesson will repeat too: a number getting smaller is not
automatically an improvement, because the number alone does not say what it got
smaller in exchange for.

This repeats a pattern that has held since the course's first lesson: the
information a tool states is limited to **what the tool measures**. The coverage
tool only stated which line ran, not whether it was correct; the profiling tool only
states which call allocated how many values, not whether that allocation is needed.
In both cases, a reading that goes past the limit does not come from the tool
itself, but from the reader forgetting the tool's limit.

## Summary

- A profile is a run fact: how many values a given call allocates is learned only by
  running it, not by reading the source.
- The same function's two call forms (`plain`, `verbose`) were measured with
  `AllocsPerOp`; the verbose call allocated seven more values and this difference did
  not change across five runs.
- A profile answers **where** allocation happens, not **why** something is slow —
  duration is not measured in any lesson of this course.
- The values a branch that never runs would have allocated never appear in a profile;
  code sitting in the source does not change this, only that path actually running
  does.
- This limit matches the previous lesson's coverage ratio limit in shape: both
  measure only the path that runs.

## Next Step

A profile states where a value was allocated after the run. But the decision of
whether a value will sit on the stack or the heap is made long before the run, while
the source is merely being compiled — this lesson saw the **result** of that decision
as a number, never the decision itself. The next lesson looks at that decision's
mechanism: how the compiler reports this decision, which patterns move a value to the
heap, and how a small change to the same function changes the decision.
