Sitemap

Hands-on Memory Profiling and Optimization in Go

A practical workflow for measuring allocations, reading pprof data, and optimizing hot paths without guessing

--

Press enter or click to view image in full size

“Measurement is a crucial component of performance improvement since reasoning and intuition are fallible guides and must be supplemented with tools like timing commands and profilers.”

Brian W. Kernighan and Rob Pike, The Practice of Programming, Addison-Wesley, 1999, Chapter 7, “Performance”, Section 7.2, “Timing and Profiling”.

This is the fifth and final article in the series. The previous articles built the mental model: stack and heap, escape analysis, garbage collection, everyday allocations, data structures, goroutines, and retained memory. Now we turn that model into a workflow.

This article is hands-on. The companion project for this article is intended to be published as a GitHub repository named go-memory-profiling-lab. The repository contains benchmark cases, allocation-heavy examples, optimized alternatives, a tiny HTTP server with net/http/pprof, and commands you can run locally. Replace the placeholder repository URL below with your own GitHub link when publishing.

Full code repository: https://github.com/cassiuspaim/cassius-labs/tree/main/go-memory-profiling-lab.

Before optimizing anything, we need one rule: measure before and after. The official Go diagnostics documentation presents profiling, tracing, runtime statistics, and debugging tools as the way to diagnose performance problems rather than guessing from source code alone: https://go.dev/doc/diagnostics. The runtime/pprof documentation also shows that go test can produce profiles directly from benchmarks: https://pkg.go.dev/runtime/pprof.

Requires Go 1.26 or newer for the examples in this article. The Go 1.26 release notes identify Go 1.26 as the current release family: https://go.dev/doc/go1.26. The examples use the standard testing package, the go test benchmark flags, runtime/pprof, and net/http/pprof; the net/http/pprof documentation also notes that, as of Go 1.22, the documented endpoints must be requested with GET: https://pkg.go.dev/net/http/pprof.

The project structure

The companion project is intentionally small. It exists to make memory behavior visible, not to model a real business domain. The repository is structured like this:

go-memory-profiling-lab/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ └── processor/
│ ├── pool.go
│ ├── pool_test.go
│ ├── processor.go
│ ├── processor_test.go
│ ├── stream.go
│ └── stream_test.go
├── go.mod
├── Makefile
└── README.md

The internal/processor package contains the benchmarkable examples used throughout the article. processor.go and processor_test.go cover text formatting, strings.Builder, preallocation, and slice retention. pool.go and pool_test.go cover temporary buffer reuse with sync.Pool. stream.go and stream_test.go compare full JSON decoding with stream-oriented processing using encoding/json.Decoder and encoding/json.Encoder.

The cmd/server package exposes a small HTTP endpoint and imports net/http/pprof so you can collect heap and goroutine profiles from a running process. That structure gives us two measurement modes: local benchmarks and runtime profiles. Local benchmarks create a controlled feedback loop for comparing implementations with -benchmem and -memprofile. Runtime profiles show how memory behaves when the code runs inside a long-lived process. We will start with local benchmarks because they make allocation changes easier to isolate before moving into profile-driven investigation.

Start with benchmark allocation output

The first command to run for local allocation investigation is:

go test -bench=. -benchmem ./...

This command has three important pieces. go test compiles and runs package tests. -bench=. tells the test binary to run benchmarks whose names match the regular expression .; because . matches every benchmark name, it runs all benchmarks. -benchmem prints memory allocation statistics for each benchmark. The final ./... asks the Go command to apply the command recursively to the current module's packages. The go test command documents -bench and explains that no benchmarks are run by default unless a benchmark pattern is provided; it also documents -benchmem as the flag that prints benchmark allocation statistics: https://pkg.go.dev/cmd/go#hdr-Testing_flags.

A benchmark in the project looks like this:

func BenchmarkFormatUsersSlow(b *testing.B) {
users := sampleUsers(100)
b.ReportAllocs()
for b.Loop() {
_ = FormatUsersSlow(users)
}
}

The testing package defines B as the type passed to benchmark functions to manage benchmark timing and control the number of iterations: https://pkg.go.dev/testing#B. In this example, b *testing.B is not application data; it is the benchmark controller provided by the testing framework. It decides how many times the measured code should run, exposes methods such as Loop, ReportAllocs, ResetTimer, StopTimer, and StartTimer, and records benchmark results.

The call to sampleUsers(100) happens before the measured loop, because the benchmark is trying to measure FormatUsersSlow, not test-data construction. b.ReportAllocs() asks this benchmark to report allocation statistics; the documentation states that ReportAllocs is equivalent to setting -test.benchmem for that benchmark function: https://pkg.go.dev/testing#B.ReportAllocs. The for b.Loop() form is the current benchmark style documented by the testing package; it keeps running the loop body as long as the benchmark should continue and manages the benchmark timer for the measured loop: https://pkg.go.dev/testing#B.Loop.

A typical output line looks like this:

BenchmarkFormatUsersSlow-12      237861      4892 ns/op      7424 B/op      204 allocs/op

Those columns come from the benchmark output printed by go test. The first column is the benchmark name and execution suffix. The second column is the number of benchmark iterations completed. ns/op is nanoseconds per operation, so it describes time per measured call. B/op is bytes allocated per operation, and allocs/op is the number of allocations per operation. The allocation columns appear because -benchmem or b.ReportAllocs() enabled allocation reporting.

None of these columns is sufficient alone. A change that reduces allocs/op but increases ns/op significantly may not be a win. A change that improves a microbenchmark but makes production code harder to maintain may not be worth it. The purpose of the benchmark is to create evidence, not to turn every allocation into a defect.

Once you have a baseline, optimize one thing at a time. Otherwise, you cannot attribute the result to a specific change.

Compare a slow string-building path with strings.Builder

The intentionally slow version uses fmt.Sprintf and repeated concatenation:

func FormatUsersSlow(users []User) string {
out := ""
for _, user := range users {
out += fmt.Sprintf("%s:%d\n", user.Name, user.Score)
}
return out
}

This code is clear, but it creates work in a loop. fmt.Sprintf is general-purpose formatting. Repeated out += ... can create intermediate strings because strings in Go are immutable, and each new result may require a new string value. That does not make fmt.Sprintf wrong; it means general formatting and repeated string construction should be measured when they appear in hot paths.

The optimized version uses strings.Builder, documented as a tool to efficiently build a string using write methods: https://pkg.go.dev/strings#Builder.

func FormatUsersBuilder(users []User) string {
var b strings.Builder
b.Grow(len(users) * 16)
    for _, user := range users {
b.WriteString(user.Name)
b.WriteByte(':')
b.WriteString(strconv.Itoa(user.Score))
b.WriteByte('\n')
}
return b.String()
}

var b strings.Builder creates a builder value that accumulates text before producing the final string. Grow is a capacity hint for the builder's internal byte storage. It is about memory capacity, not CPU scheduling or processing priority. Calling b.Grow(n) tells the builder to make room for at least n additional bytes, which can reduce internal reallocations while the string is being built. In this example, len(users) * 16 is deliberately rough because user names vary; it is only an estimate of the output size, not a correctness requirement.

WriteString appends text without the general formatting machinery of fmt.Sprintf. WriteByte writes fixed delimiters such as : and \n. strconv.Itoa converts an integer to decimal text; the strconv package documents numeric conversions at https://pkg.go.dev/strconv.

The recommendation is not “never use fmt.” The recommendation is narrower: in measured hot paths, prefer a purpose-built construction path when general formatting is responsible for significant allocation or CPU cost. If a path is not hot, fmt.Sprintf may be the clearer choice.

Preallocate slices when size is known

Another common example is building slices. The slow path starts with a nil slice and lets append grow capacity repeatedly:

func NamesSlow(users []User) []string {
var names []string
for _, user := range users {
names = append(names, user.Name)
}
return names
}

The optimized version preallocates the output capacity:

func NamesPreallocated(users []User) []string {
names := make([]string, 0, len(users))
for _, user := range users {
names = append(names, user.Name)
}
return names
}

The language specification documents make for slices and explains that the optional capacity controls the initial capacity: https://go.dev/ref/spec#Making_slices_maps_and_channels. Here, len(users) is already known, so the capacity hint is not guesswork. It expresses expected output size and can reduce reallocations as append fills the result.

This is a good optimization candidate because it improves clarity and performance together. The code says what it means: the result will contain at most one name per user.

Use memory profiles when benchmark output is not enough

Benchmark allocation columns tell you that allocation exists. Profiles help you investigate where allocation cost accumulates. Generate a memory profile from benchmarks with:

go test -bench=. -benchmem -memprofile=mem.out ./internal/processor

This command runs benchmarks for one package, prints allocation statistics, and writes an allocation profile to mem.out. The package path matters here: -memprofile writes a single profile file, so it must be used with a single package such as ./internal/processor, not with the recursive ./... pattern. The -memprofile flag is documented by the go test command as writing an allocation profile after tests have passed: https://pkg.go.dev/cmd/go#hdr-Testing_flags.

The runtime/pprof documentation explains how Go profiles can be collected and inspected with go tool pprof: https://pkg.go.dev/runtime/pprof. For this article, the focus is memory profiling, so the benchmark workflow uses -memprofile to write allocation data for a single package:

go test -bench=. -benchmem -memprofile=mem.out ./internal/processor

The workflow is intentionally narrow: run the benchmark, ask go test to write a memory profile, and inspect that profile with go tool pprof. CPU profiling follows a similar inspection model and deserves its own article; here, keeping the scope limited to memory makes the allocation workflow easier to reason about.

After the profile is generated, the fastest first read is usually the textual top view:

go tool pprof -top mem.out

That command prints the largest contributors in the profile and then returns to the normal shell. A simplified output may look like this:

Showing nodes accounting for 5.50MB, 100% of 5.50MB total
flat flat% sum% cum cum%
3.00MB 54.55% 54.55% 3.00MB 54.55% processor.FormatUsersSlow
1.50MB 27.27% 81.82% 1.50MB 27.27% fmt.Sprintf
1.00MB 18.18% 100% 1.00MB 18.18% strings.(*Builder).grow

The exact numbers will vary by machine, Go version, benchmark duration, and input size, so the example should be read structurally rather than as a fixed expected output. flat means memory attributed directly to that function. flat% is that direct contribution as a percentage of the selected profile. sum% is the running total of flat% up to the current row, which helps show how quickly the top entries explain the profile. cum means cumulative memory attributed to that function plus the functions it calls. cum% is the cumulative contribution as a percentage of the selected profile. A function with high flat allocation is allocating directly. A function with low flat but high cum may be an entry point that calls allocation-heavy helpers.

When -top is not enough, open the interactive shell:

go tool pprof mem.out

The (pprof) prompt means the profile is loaded and ready for exploration. It is not a hang. Inside the prompt, top shows the same kind of ranking interactively, help lists available commands, and quit exits back to the shell:

(pprof) top
(pprof) help
(pprof) quit

After identifying a function, use list to connect the profile back to source code:

(pprof) list FormatUsersSlow

list annotates source lines with profile data, which is useful when a function contains multiple allocation sites. This matters because a benchmark can tell you that a function allocates, while list can often show which line is responsible. If the slow path combines fmt.Sprintf, repeated string concatenation, and helper calls, line-level attribution helps avoid optimizing the wrong part of the function.

For visual exploration, pprof also supports graph views from inside the interactive shell:

(pprof) web

The web command renders a call graph. It is useful for seeing whether allocations come directly from the function under investigation or from deeper calls. In graph views, larger or hotter nodes usually represent functions with greater contribution to the selected profile, and edges show call relationships. This can be easier to understand than a flat list when allocation cost is distributed across multiple helper functions.

Get Cássius Paim’s stories in your inbox

Join Medium for free to get updates from this writer.

The graph view has one external dependency: Graphviz. pprof uses the dot executable from Graphviz to render graph-based views. If Graphviz is not installed or dot is not available on PATH, web may fail with an error like this:

failed to execute dot. Is Graphviz installed? Error: exec: "dot": executable file not found in $PATH

On macOS, install Graphviz with:

brew install graphviz

On Debian or Ubuntu, install it with:

sudo apt-get update
sudo apt-get install graphviz

You can verify the installation with:

dot -V

A browser-based alternative is:

go tool pprof -http=:8080 mem.out

This starts a local web UI for exploring the same profile. The UI is often easier to navigate than the terminal because it provides views such as top entries, graph-oriented exploration, source views, and flame graphs depending on the profile and local tooling. Some graph-based views may still require Graphviz, but the textual and source-oriented views remain useful even without it.

A practical first sequence is therefore:

go test -bench=. -benchmem -memprofile=mem.out ./internal/processor
go tool pprof -top mem.out
go tool pprof mem.out

Then, inside the interactive shell:

(pprof) top
(pprof) list FormatUsersSlow
(pprof) quit

Use graph views after the textual view has already shown where to look. That order keeps the workflow simple: benchmark output tells you whether a path allocates, pprof -top shows the main contributors, list connects the profile to source lines, and graph or HTTP views help when call relationships are harder to follow.

For production-like services, a benchmark profile may not be enough. You may need runtime profiles from a running server, especially when memory behavior depends on real request shape, concurrency, cache state, or long-lived goroutines.

Add net/http/pprof carefully

The project includes a tiny server that imports net/http/pprof for side effects:

import _ "net/http/pprof"

The official net/http/pprof documentation says the package serves runtime profiling data under /debug/pprof/ and is typically imported for the side effect of registering HTTP handlers: https://pkg.go.dev/net/http/pprof. As of Go 1.22, the documented endpoints must be requested with GET, so avoid examples that rely on other HTTP methods.

Run the server:

go run ./cmd/server

Then request profiles from another terminal:

curl "http://localhost:8080/work?n=1000"
go tool pprof "http://localhost:8080/debug/pprof/heap"
go tool pprof "http://localhost:8080/debug/pprof/goroutine"

The curl command generates work so the server has something to profile. The heap profile helps inspect live heap behavior. The goroutine profile helps inspect goroutines, which is useful when blocked goroutines are retaining references or accumulating unexpectedly.

In a real service, do not expose pprof endpoints publicly. The standard package registers handlers, but deployment safety is your responsibility. Bind profiling endpoints to an internal interface, protect them behind authentication, or enable them only in controlled environments. This is operational security guidance; the net/http/pprof package documents handler registration, not a complete deployment security policy.

Once pprof is available safely, heap and goroutine profiles can connect local benchmark intuition with production-like runtime behavior.

Optimize retained memory, not only allocation count

The project includes a slice-retention example because it demonstrates a non-obvious optimization: sometimes you allocate more to retain less.

func PrefixView(data []byte, n int) []byte {
return data[:n]
}

This returns a small slice, but the returned slice may retain a large backing array. The safer alternative copies the prefix:

func PrefixCopy(data []byte, n int) []byte {
out := make([]byte, n)
copy(out, data[:n])
return out
}

This version allocates a new small buffer. That allocation is intentional because the new buffer has the desired lifetime. The old large buffer can become unreachable if no other references remain. This is why memory optimization cannot be reduced to allocs/op alone.

A good pprof workflow distinguishes allocation pressure from retained heap. Allocation profiles tell you where allocation happens. Heap profiles help show what remains live. Goroutine profiles help find blocked goroutines that retain references.

Use sync.Pool only for the right shape of reuse

Some hot paths allocate short-lived buffers repeatedly. If those buffers are created, used, and discarded on every operation, the allocation rate can become visible in benchmarks and memory profiles. In that specific shape of problem, sync.Pool can be useful: it lets the program reuse temporary objects across operations without making correctness depend on reuse.

The official documentation defines a Pool as a set of temporary objects that may be individually saved and retrieved, and it explicitly says that items stored in the pool may be removed automatically at any time: https://pkg.go.dev/sync#Pool. That detail matters because it means a pool is not a cache, not an ownership mechanism, and not a lifecycle guarantee. It is only a best-effort reuse mechanism for temporary objects.

A small example makes the intended shape clearer. Suppose a hot path builds many temporary byte buffers:

var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}

func encodeRecord(record Record) []byte {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer func() {
if buf.Cap() <= 64*1024 {
bufferPool.Put(buf)
}
}()
buf.WriteString(record.ID)
buf.WriteByte(':')
buf.WriteString(strconv.Itoa(record.Count))
out := make([]byte, buf.Len())
copy(out, buf.Bytes())
return out
}

The New function creates a buffer only when the pool cannot provide one. Get retrieves a temporary buffer from the pool, and the type assertion converts the returned any back to *bytes.Buffer. Reset is required because the buffer may contain data from a previous use. The deferred function returns the buffer to the pool only if its capacity is below a chosen threshold. That guard avoids keeping unusually large buffers alive after rare large inputs.

The copy before returning is also intentional. Returning buf.Bytes() directly would expose memory owned by the pooled buffer. Once the buffer is put back into the pool, another operation may reuse and mutate it. Creating out gives the caller an independent result with a clear lifetime.

This example shows both the value and the cost of pooling. sync.Pool may reduce repeated allocation of temporary buffers under load, but it also adds lifecycle rules: get, reset, use, copy if ownership must leave the function, and put back only when safe. That is why pooling should usually come after simpler options have been measured. Preallocation and streaming often improve clarity first. Pooling is useful when the allocation shape fits, the benchmark shows pressure, and the added lifecycle complexity remains contained.

Stream instead of loading everything

One of the most effective memory optimizations is architectural: avoid loading all data into memory when streaming is enough. For JSON, for example, the standard encoding/json package provides Decoder and Encoder for stream-oriented processing: https://pkg.go.dev/encoding/json#Decoder and https://pkg.go.dev/encoding/json#Encoder.

The difference is easier to see with code. A simple implementation may decode the full request into memory, build a full response slice, and only then encode the response:

type InputUser struct {
ID string `json:"id"`
Active bool `json:"active"`
}

type OutputUser struct {
ID string `json:"id"`
}

func FilterActiveUsersInMemory(r io.Reader, w io.Writer) error {
var users []InputUser
if err := json.NewDecoder(r).Decode(&users); err != nil {
return err
}
active := make([]OutputUser, 0, len(users))
for _, user := range users {
if user.Active {
active = append(active, OutputUser{ID: user.ID})
}
}
return json.NewEncoder(w).Encode(active)
}

This version is straightforward, but it has a clear memory shape: the input slice keeps all decoded users in memory, and the output slice keeps all active users in memory before writing the response. For small payloads, this may be perfectly acceptable. For large payloads or high concurrency, the peak memory cost can become more important than small local allocation optimizations.

A streaming version changes the shape of the problem. Instead of holding the entire input and output at once, it reads one JSON element at a time and writes matching results progressively:

func FilterActiveUsersStreaming(r io.Reader, w io.Writer) error {
dec := json.NewDecoder(r)

token, err := dec.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return fmt.Errorf("expected JSON array")
}
if _, err := io.WriteString(w, "["); err != nil {
return err
}
first := true
enc := json.NewEncoder(w)
for dec.More() {
var user InputUser
if err := dec.Decode(&user); err != nil {
return err
}
if !user.Active {
continue
}
if !first {
if _, err := io.WriteString(w, ","); err != nil {
return err
}
}
first = false
if err := enc.Encode(OutputUser{ID: user.ID}); err != nil {
return err
}
}
token, err = dec.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' {
return fmt.Errorf("expected end of JSON array")
}
_, err = io.WriteString(w, "]")
return err
}

Here, json.Decoder reads from the io.Reader incrementally. The first Token call validates that the input starts with a JSON array. dec.More() then reports whether there are more elements inside that array. Each call to Decode materializes only one InputUser at a time. On the output side, json.Encoder writes each selected OutputUser to the io.Writer as soon as it is available. The first flag exists only to keep the JSON array valid by inserting commas between elements.

This version is more complex, but it changes peak memory behavior. It does not need a []InputUser containing the full request and it does not need a []OutputUser containing the full response. That can matter more than replacing one string-building pattern with another, because the optimization changes the architecture of data flow instead of only changing one local operation.

The recommendation is conditional: choose streaming when it preserves correctness and reduces peak memory without making the protocol, validation, or error handling unreasonably complex. For small payloads, full decoding may be simpler and good enough. For large payloads, high concurrency, or pipelines that naturally process one item at a time, streaming can be the more memory-aware design.

A practical memory optimization workflow

A practical workflow is deliberately boring.

First, identify a symptom: high RSS, GC CPU, tail latency, out-of-memory kills, high allocation rate, or goroutine growth. Second, collect evidence with benchmarks, heap profiles, allocation profiles, goroutine profiles, runtime metrics, or traces. Third, isolate a code path. Fourth, make one change. Fifth, measure again.

The first optimizations to consider are usually the least clever ones: preallocate when size is known, avoid avoidable formatting in hot loops, copy small slices that retain large arrays, bound caches, stream large data, cancel goroutines, and remove accidental retention. Only after that should pooling or deeper rewrites enter the discussion.

That order keeps Go code readable. It also matches the central message of the whole series: good memory management in Go is not manual control. It is understanding lifetime, allocation behavior, garbage collection, measurement, and trade-offs.

Conclusion

Memory optimization in Go starts with measurement. -benchmem shows allocation cost per operation. -memprofile and go tool pprof show where allocation and retained heap accumulate. net/http/pprof helps connect runtime behavior to production-like workloads. Escape analysis explains compiler decisions, but benchmarks and profiles decide whether those decisions matter.

The best optimizations usually make lifetime clearer: preallocate known sizes, avoid accidental retention, bound caches, cancel goroutines, stream large data, and use specialized builders only when the path is hot. The goal is not to eliminate every allocation. The goal is to make memory behavior predictable enough for production.

💬 Join the Discussion

What memory optimization has paid off most in your Go services: preallocation, reducing formatting, avoiding slice retention, bounding caches, streaming, pprof-driven fixes, or careful sync.Pool usage? Share the benchmark or production signal that proved it worked.

Originally published on Medium by Cassius Paim, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin

Tags

#Go, #pprof, #Benchmarks, #Memory Optimization, #Performance Engineering

--

--

Cássius Paim
Cássius Paim

Written by Cássius Paim

Software Engineer | Software Architect | Golang | Kotling | Java