Skip to content
Codeloom
Go

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.

·8 min read · By Codeloom
Intermediate 12 min read

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.