Skip to Content
Course content

63: Profiling Go Applications with pprof

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably hit a point in your projects where the code works, but it feels... sluggish. Maybe your memory usage is climbing steadily, or a specific request is taking 200ms when it should take 20ms. You could pepper your code with time.Since() calls, but that's a blunt instrument. That's why we use pprof.

It's the gold standard for profiling in Go. Instead of guessing, pprof lets you see exactly which line of code is eating your CPU cycles or hogging the heap. I'm going to show you how to set it up using a scenario I've run into a dozen times: a "data processor" that's accidentally doing way too much work.

Building a slow log processor

Let's start with a piece of code that's intentionally inefficient. We're simulating a function that processes a massive slice of log entries and builds a summary string. I'm using fmt.Sprintf inside a tight loop, which is a classic way to kill performance in Go due to constant allocations.

package main

import (
	"fmt"
	"net/http"
	"runtime"
	"time"
)

func processLogs(logs []string) string {
	var result string
	for _, log := range logs {
		// This is the "hidden" performance killer
		result += fmt.Sprintf("Processed: %s\n", log)
	}
	return result
}

func main() {
	// Start a background server for pprof
	go func() {
		http.ListenAndServe("localhost:6060", nil)
	}()

	fmt.Println("Server started on :6060. Processing logs...")

	// Create a large dummy dataset
	logs := make([]string, 100000)
	for i := 0; i < 100000; i++ {
		logs[i] = fmt.Sprintf("log entry number %d", i)
	}

	for {
		processLogs(logs)
		time.Sleep(time.Millisecond * 100)
	}
}

The "Where is the endpoint?" mistake

I'll be honest—the first time I used pprof, I wrote the code above, ran it, and then tried to visit http://localhost:6060/debug/pprof in my browser. I got a 404. I spent ten minutes questioning if my firewall was blocking the port or if ListenAndServe had failed silently.

The problem? I forgot the "blank import." In Go, the net/http/pprof package registers its handlers with the http.DefaultServeMux, but it only does that if the package is imported. Because I wasn't calling any functions from that package directly, I needed to import it for its side effects.

To fix it, I added this to my imports:

import _ "net/http/pprof"

Now, when the program starts, that blank import triggers the init() function inside the pprof package, which attaches the /debug/pprof handlers to the default server. Now the endpoint actually exists.

Capturing the CPU profile

With the server running, we don't just look at the browser. The real power is in the go tool pprof command-line utility. I want to see where the CPU is spending its time, so I'll run this in my terminal while the app is humming along:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

This tells Go to sample the application for 30 seconds. Once it finishes, you'll be dropped into an interactive prompt. You can type top to see the heaviest functions, but I prefer the visual representation. If you have Graphviz installed, you can run web, or just start the tool with the -http flag to get a beautiful browser-based UI:

go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30

Identifying the culprit in the graph

When you open the web UI, look at the "Graph" view. You'll see boxes connected by arrows. The larger the box and the thicker the arrow, the more resources are being consumed. In our log processor, you'll see a massive node for runtime.mallocgc and fmt.Sprintf.

The graph makes it obvious: we aren't spending time "processing logs"; we're spending almost all our time allocating memory for new strings. Every time I used result += ..., Go had to create a brand new string in memory and copy the old content into it. That's an O(n²) operation that kills performance as the input grows.

To fix this, I'd replace the string concatenation with a strings.Builder. If you ran the profiler again after that change, you'd see the mallocgc node shrink significantly, and the CPU usage would plummet.




📋 Practical Task

Exercise: Hunting the Heap in a Mock Cache

You are given a program that implements a simple in-memory cache. However, the developer forgot to implement a cleanup mechanism, and the memory usage grows indefinitely (a classic memory leak). Your goal is to use pprof to prove where the memory is being held.

Requirements:

  1. Create a program that starts an HTTP server on port 6060 with net/http/pprof enabled.
  2. Implement a global map[string][]byte that acts as a cache.
  3. Create a goroutine that continuously adds large random byte slices (e.g., 1MB each) to this map using unique keys.
  4. Run the application and use go tool pprof to capture a heap profile (not a CPU profile).
  5. Use the top command or the web UI to identify the specific line of code responsible for the most memory allocations.
  6. Fix the leak: Implement a simple "TTL" (Time To Live) or a maximum size limit for the map to stabilize memory usage, then verify the fix by taking a second heap profile.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.