Profiling shows me exactly where my Go programs spend their time and memory. It turns performance improvement from a guessing game into a methodical process. Let’s look at practical ways to collect and use this data to make applications faster and more efficient.
I start with CPU profiling because it often gives the biggest initial insight. The built-in pprof package makes this straightforward. I run my program with profiling enabled, perform the operations I want to measure, and then examine the results. The profile tells me which functions are consuming the most CPU cycles.
Here is a basic example of how I collect CPU profile data.
package main
import (
"log"
"os"
"runtime/pprof"
)
func findPrimeNumbers(limit int) []int {
var primes []int
for num := 2; num <= limit; num++ {
isPrime := true
for i := 2; i*i <= num; i++ {
if num%i == 0 {
isPrime = false
break
}
}
if isPrime {
primes = append(primes, num)
}
}
return primes
}
func main() {
// Create a file to hold the CPU profile.
cpuProfile, err := os.Create("cpu_profile.prof")
if err != nil {
log.Fatal("could not create CPU profile: ", err)
}
defer cpuProfile.Close()
// Start recording the CPU activity.
if err := pprof.StartCPUProfile(cpuProfile); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
// Ensure profiling stops before the program ends.
defer pprof.StopCPUProfile()
// This is the work I want to measure.
_ = findPrimeNumbers(50000)
}
After running this, I have a file called cpu_profile.prof. I use the go tool pprof command to analyze it. The tool shows me a list of functions and how much CPU time each one used. I look for the functions at the top of the list—those are my performance bottlenecks.
Memory profiling is my next step. A program can be fast but use too much memory, which can lead to slowdowns from garbage collection. A heap profile shows me what parts of my code are allocating memory. I can see both how many allocations happen and how large they are.
I often add a function to write heap profiles at key moments, like after processing a large batch of data.
func writeMemorySnapshot(filename string) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
// Write a snapshot of the current heap memory usage.
return pprof.WriteHeapProfile(f)
}
func processUserBatch(users []UserData) error {
var processedData []ProcessedUser
for _, user := range users {
// Simulate an operation that might allocate memory.
profile := generateUserProfile(user)
processedData = append(processedData, profile)
}
// Take a memory snapshot after the batch.
if err := writeMemorySnapshot("heap_after_batch.prof"); err != nil {
return err
}
return nil
}
When I analyze the heap profile, I look for two things. First, which functions are responsible for the most individual memory allocations. Second, which functions allocate the largest total amount of memory. Sometimes, reducing a million tiny allocations has a bigger impact than fixing one large one.
Understanding concurrency issues is crucial. Block profiling helps me with that. It shows me where my goroutines are getting stuck waiting for things like mutexes or channel operations. I enable it at the start of my program.
import "runtime"
func main() {
// Record detailed information about every blocking event.
runtime.SetBlockProfileRate(1)
// Start my application server or main logic here.
startApplication()
}
After the program runs for a while, I can collect the block profile just like a CPU or memory profile. The report lists the places in my code where goroutines spent the most time blocked. If I see a mutex lock at the top of the list, I know I have high contention there and might need a different synchronization strategy.
Sometimes my program seems slow, but CPU and memory profiles look fine. This is where goroutine profiling helps. It gives me a snapshot of every goroutine currently running in my program. I can see if I have goroutine leaks, where thousands of finished goroutines are still in memory, or if I have goroutines stuck in a certain state.
I can trigger a goroutine profile on demand, often via a signal or an HTTP endpoint.
import (
"net/http"
_ "net/http/pprof" // This registers the pprof HTTP handlers automatically.
)
func main() {
// Run the HTTP server for profiling on a separate port.
go func() {
// I can now visit http://localhost:6060/debug/pprof/goroutine?debug=1
http.ListenAndServe("localhost:6060", nil)
}()
// My main application logic starts here.
runMyApp()
}
Visiting that URL gives me a list of all current goroutines and their stack traces. If the number keeps growing over time without going down, I have a leak. If I see many goroutines stuck in a select statement or a channel read, I know where to investigate.
The standard library’s testing framework has built-in profiling support. This is one of my favorite ways to profile specific functions. I write a benchmark test for the function I’m concerned about, and then I run it with profiling flags.
Here is a benchmark for a hypothetical data compression function.
// In a file called compress_test.go
package main
import "testing"
func BenchmarkCompressData(b *testing.B) {
data := generateLargeTestData() // Assume this function exists.
b.ResetTimer() // Start timing after setup.
for i := 0; i < b.N; i++ {
compressData(data)
}
}
I run this benchmark from the command line and generate profiles at the same time.
go test -bench=BenchmarkCompressData -cpuprofile=cpu.out -memprofile=mem.out -blockprofile=block.out
This gives me three profile files focused solely on the execution of that benchmark. It’s a clean, isolated environment for performance testing. I can iterate on the compressData function, re-run the benchmark profiles, and see the direct impact of my changes.
For long-running applications like servers, the built-in HTTP handlers from net/http/pprof are invaluable. Once imported, they provide a live dashboard for the application’s internal state. I use this constantly during development and sometimes even in controlled staging environments.
Beyond the standard profiles, execution tracing gives me a different view. A profile aggregates data—it tells me “function X used 30% of CPU.” A trace shows me the timeline—it tells me “goroutine A started, then waited 5ms for goroutine B to send on a channel, then ran for 2ms.” It’s essential for debugging latency and concurrency bugs.
Creating a trace is similar to creating a CPU profile.
import (
"os"
"runtime/trace"
"sync"
)
func concurrentTask(id int, wg *sync.WaitGroup) {
defer wg.Done()
// Simulate some work with channels and sleeps.
}
func main() {
traceFile, err := os.Create("my_trace.out")
if err != nil {
panic(err)
}
defer traceFile.Close()
// Start capturing the trace of all events.
if err := trace.Start(traceFile); err != nil {
panic(err)
}
defer trace.Stop()
// Run some concurrent work to be traced.
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go concurrentTask(i, &wg)
}
wg.Wait()
}
I then use go tool trace my_trace.out to open a visual interface in my browser. I can see a timeline of goroutine execution, when network calls happened, and how the garbage collector affected my program. It’s a powerful tool for seeing the “why” behind timing issues.
Looking at raw profile tables can be overwhelming. This is where visualization tools like flame graphs come in. A flame graph turns the profile data into a visual format where the width of each box represents the time or resources used. The top of the graph shows the function currently running, and below it is its call stack.
I typically generate a profile, then use a tool like Brendan Gregg’s FlameGraph scripts or the pprof web interface’s built-in flame graph view to see it. A wide box at the top of the graph immediately draws my eye to the hottest code path.
Profiling isn’t a one-time activity. To manage performance over the life of a project, I compare profiles. If a new version of my code is slower, I can compare its profile directly against the profile from the old, faster version to see what changed.
The pprof tool has a -diff_base flag for this.
# First, I get a profile from the old, known-good version (v1.0).
# Then, I get a profile from the new version (v1.1).
# I compare them like this:
go tool pprof -http=:8080 -diff_base v1.0_cpu.prof v1.1_cpu.prof
This opens a web view highlighting the differences. Functions that use more CPU in the new version are shown in red, and functions that use less are shown in green. It makes finding performance regressions very direct.
For critical production services, I use continuous profiling. This means profiles are collected automatically, all the time, and stored alongside metrics and logs. Tools like Pyroscope integrate directly with Go applications. When there’s a performance incident, I can look at the profiles from exactly that time period to see if a specific function suddenly started using more CPU or memory.
Finally, the Go compiler itself can use profiles to make better optimization decisions. This is called Profile-Guided Optimization (PGO). The process is simple. First, I build my binary with profiling enabled and deploy it. As it runs in production, it generates a representative CPU profile. Then, I feed that profile back to the compiler when building the next release.
The compiler uses this real-world data to make smarter choices, like inlining functions that are called frequently together. To use it, I place a file named default.pgo in my project’s main directory. When I run go build, the compiler will automatically use it. Early results show this can improve performance by 5-15% for many workloads with no code changes at all.
Each of these methods gives me a different lens to view my program’s behavior. I use CPU and memory profiling for broad optimization, block and goroutine profiling for concurrency issues, and tracing for complex timing bugs. Integrating profiling into my tests and build process helps me catch problems early. By making profiling a regular part of my workflow, I can build Go applications that are not just functional, but consistently fast and efficient.