Go Profiling with pprof: CPU, Memory, and Goroutines
Learn how to profile Go applications using pprof to find CPU bottlenecks, memory leaks, and goroutine issues with practical examples.
What you'll learn
- ✓Profile CPU usage to find hot paths
- ✓Detect memory allocations and leaks
- ✓Debug goroutine leaks and deadlocks
- ✓Use pprof with HTTP servers and benchmarks
Prerequisites
- •Go basics and standard library
- •Running Go tests and benchmarks
- •Basic command line usage
Performance problems in Go applications usually come down to three things: excessive CPU usage, too many allocations, or goroutine leaks. Go ships with pprof, a powerful profiling tool built into the standard library. Unlike external profilers, pprof understands Go’s runtime deeply, including goroutines, garbage collection, and scheduler behavior.
Setting Up pprof for HTTP Servers
The quickest way to enable profiling for a running server is to import the net/http/pprof package. It registers HTTP handlers that expose profiling data.
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // Register pprof handlers
)
func main() {
// Your application routes
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
// pprof is available at /debug/pprof/
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
If you use a custom mux or framework like chi, you can register pprof handlers explicitly.
package main
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
// Register pprof handlers under /debug/pprof/
r.HandleFunc("/debug/pprof/", pprof.Index)
r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
r.HandleFunc("/debug/pprof/profile", pprof.Profile)
r.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
r.HandleFunc("/debug/pprof/trace", pprof.Trace)
r.Handle("/debug/pprof/heap", pprof.Handler("heap"))
r.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
r.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
r.Handle("/debug/pprof/block", pprof.Handler("block"))
r.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
http.ListenAndServe(":8080", r)
}
In production, protect these endpoints behind authentication or serve them on a separate port that is not exposed publicly.
CPU Profiling
CPU profiling tells you where your program spends its time. Start a 30-second CPU profile from a running server.
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
While this runs, generate load against your server. When the profile completes, you drop into the pprof interactive shell.
(pprof) top 10
Showing nodes accounting for 4.5s, 90% of 5s total
flat flat% sum% cum cum%
1.5s 30.00% 30.00% 1.5s 30.00% runtime.memmove
0.8s 16.00% 46.00% 0.8s 16.00% encoding/json.(*decodeState).scanWhile
0.6s 12.00% 58.00% 1.2s 24.00% yourapp/handler.ProcessRequest
...
The flat column shows time spent directly in that function. The cum column includes time spent in functions it calls. Focus on functions with high flat time first.
You can also profile from benchmark tests.
func BenchmarkProcessData(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ProcessData(data)
}
}
go test -bench=BenchmarkProcessData -cpuprofile=cpu.prof
go tool pprof cpu.prof
Reading the Flame Graph
The web visualization is often more useful than the text interface.
go tool pprof -http=:9090 http://localhost:8080/debug/pprof/profile?seconds=30
This opens a browser with several views. The flame graph shows the call stack with width proportional to CPU time. Wide bars at the bottom are functions that consume the most time. Look for unexpectedly wide bars in your own code.
The graph view shows function calls with edge weights representing time. It helps you understand the relationship between callers and callees.
Memory Profiling
Memory profiling shows where your program allocates memory. There are two types of memory profiles.
The heap profile shows currently allocated memory. It tells you what is using memory right now. This is what you want for finding memory leaks.
go tool pprof http://localhost:8080/debug/pprof/heap
The allocs profile shows all allocations since the program started, including those that have been freed. This is what you want for reducing allocation pressure and GC overhead.
go tool pprof http://localhost:8080/debug/pprof/allocs
In the interactive shell, you can switch between viewing bytes allocated and number of objects.
(pprof) top 10
(pprof) top 10 -cum
A common optimization is reducing allocations in hot paths. Here is an example.
// Before: allocates a new slice every call
func processItems(items []string) []string {
var result []string
for _, item := range items {
if isValid(item) {
result = append(result, transform(item))
}
}
return result
}
// After: pre-allocate based on expected size
func processItems(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if isValid(item) {
result = append(result, transform(item))
}
}
return result
}
You can also use sync.Pool to reuse allocations.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// Use buf instead of allocating a new buffer
buf.Write(data)
buf.WriteString("-processed")
return buf.String()
}
Memory Profiling in Benchmarks
Benchmarks can report allocations directly.
func BenchmarkJSON(b *testing.B) {
data := []byte(`{"name": "test", "value": 42}`)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var m map[string]any
json.Unmarshal(data, &m)
}
}
go test -bench=BenchmarkJSON -benchmem -memprofile=mem.prof
go tool pprof mem.prof
The benchmark output shows allocations per operation.
BenchmarkJSON-8 500000 3200 ns/op 1024 B/op 12 allocs/op
Goroutine Profiling
Goroutine leaks are a common problem in Go. A goroutine that is blocked forever on a channel or waiting for a context that never cancels will consume memory and scheduler resources.
go tool pprof http://localhost:8080/debug/pprof/goroutine
This shows how many goroutines are running and where they are blocked.
(pprof) top
300 60.00% 60.00% 300 60.00% runtime.gopark
50 10.00% 70.00% 50 10.00% runtime.chanrecv
30 6.00% 76.00% 30 6.00% net/http.(*connReader).backgroundRead
If you see the goroutine count growing over time, you have a leak. To debug, look at the full stack trace.
(pprof) traces
Here is a common goroutine leak pattern and how to fix it.
// Leak: goroutine blocks forever if ctx is never cancelled
// and nobody reads from results
func fetchAll(urls []string) <-chan string {
results := make(chan string)
for _, url := range urls {
go func(u string) {
resp, err := http.Get(u)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
results <- string(body) // blocks if nobody reads
}(url)
}
return results
}
// Fixed: use context and buffered channel
func fetchAll(ctx context.Context, urls []string) <-chan string {
results := make(chan string, len(urls))
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
select {
case results <- string(body):
case <-ctx.Done():
}
}(url)
}
go func() {
wg.Wait()
close(results)
}()
return results
}
Block and Mutex Profiling
Block profiling shows where goroutines block waiting for synchronization primitives like channels and mutexes. Mutex profiling shows contention on mutexes.
You need to enable these explicitly because they have runtime overhead.
import "runtime"
func main() {
// Enable block profiling (rate: 1 means capture every event)
runtime.SetBlockProfileRate(1)
// Enable mutex profiling
runtime.SetMutexProfileFraction(1)
// ... rest of your application
}
Then access them through pprof.
go tool pprof http://localhost:8080/debug/pprof/block
go tool pprof http://localhost:8080/debug/pprof/mutex
High block times on a mutex indicate contention. Consider sharding your data, using sync.RWMutex for read-heavy workloads, or restructuring to avoid shared state.
Programmatic Profiling
For command-line tools or specific code sections, use pprof programmatically.
package main
import (
"os"
"runtime"
"runtime/pprof"
)
func main() {
// CPU profile
cpuFile, _ := os.Create("cpu.prof")
defer cpuFile.Close()
pprof.StartCPUProfile(cpuFile)
defer pprof.StopCPUProfile()
// Do work...
doExpensiveWork()
// Memory profile (take snapshot after work)
memFile, _ := os.Create("mem.prof")
defer memFile.Close()
runtime.GC() // Force GC to get accurate heap data
pprof.WriteHeapProfile(memFile)
}
Comparing Profiles
When optimizing, compare profiles before and after your changes.
# Capture baseline
go test -bench=. -cpuprofile=before.prof
# Make changes, then capture again
go test -bench=. -cpuprofile=after.prof
# Compare (shows delta)
go tool pprof -base=before.prof after.prof
The comparison shows only the differences, making it easy to verify that your optimization had the intended effect.
Continuous Profiling in Production
For production systems, consider continuous profiling. Capture short profiles periodically and store them for later analysis.
func startContinuousProfiling(ctx context.Context, dir string) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
timestamp := time.Now().Format("20060102-150405")
filename := filepath.Join(dir, fmt.Sprintf("heap-%s.prof", timestamp))
f, err := os.Create(filename)
if err != nil {
slog.Error("failed to create profile", "error", err)
continue
}
runtime.GC()
pprof.WriteHeapProfile(f)
f.Close()
slog.Info("heap profile written", "file", filename)
case <-ctx.Done():
return
}
}
}
Services like Pyroscope and Parca provide continuous profiling infrastructure that can aggregate and visualize profiles over time.
Wrapping Up
Go’s built-in profiling tools are powerful and practical. For HTTP servers, drop in net/http/pprof and you get instant access to CPU, memory, goroutine, block, and mutex profiles. For benchmarks, add -cpuprofile and -memprofile flags. Focus on high flat time in CPU profiles, high allocation counts in memory profiles, and growing goroutine counts for leak detection. Profile first, then optimize. The data will tell you exactly where to look.
Related articles
- Go Go pprof Profiling Tutorial
Profile Go programs with pprof: enable the HTTP endpoint, capture CPU and heap profiles, read flame graphs, and find the hot spot that is actually costing you latency.
- Python Python Profiling: Find and Fix Performance Bottlenecks
Learn how to profile Python code with cProfile, line_profiler, memory_profiler, and timeit to identify slow functions, memory leaks, and optimize runtime performance.
- JavaScript Measuring Performance with the JavaScript Performance API
Use the Performance API to measure load times, mark custom timings, observe long tasks, and profile real-user performance in JavaScript applications.
- React React Performance: Memo, useMemo, and useCallback Guide
Optimize React performance with React.memo, useMemo, and useCallback. Learn when to use each, how to profile, and avoid common pitfalls.