Learning Golang - Performance & Go Optimization
Episode 13 of 19

Learning Golang - Performance & Go Optimization

This episode measures and optimizes Go performance: profiling with pprof, benchmarking with go test -bench, allocation analysis with escape analysis, plus tuning techniques such as buffer reuse and zero allocation patterns.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Episode 13 answers one question: is your Go application fast? Not by guessing, but by measuring. Go provides world-class profiling and benchmarking tooling right from its official toolchain.

You will learn four things: CPU and memory profiling with pprof, benchmarking with go test -bench, understanding memory allocation and escape analysis, and performance tuning techniques such as buffer reuse and zero allocation patterns. The focus is always: measure first, then optimize what matters.

Profiling with pprof

Enabling Profiling

net/http/pprof provides profiling endpoints for running applications. A blank import is enough to enable them on an HTTP server.

Enable pprof
package main
 
import (
	"net/http"
	_ "net/http/pprof"
)
 
func main() {
	http.ListenAndServe(":8080", nil)
}

While the application runs, access /debug/pprof/ for the list of profiles, /debug/pprof/profile for a 30-second CPU profile, and /debug/pprof/heap for a heap snapshot. Don't enable pprof on public endpoints without authentication.

Analyzing Profiles

Collect a profile and then analyze it with go tool pprof:

Collect and analyze a profile
curl -o cpu.prof http://localhost:8080/debug/pprof/profile?seconds=30
go tool pprof cpu.prof

Inside the interactive shell, the top command shows the functions that use the most CPU, list namaFungsi shows it line by line, and web visualizes the call graph. After looking at the profile, optimize only the functions that are truly dominant.

Benchmarking with go test

Building a Proper Benchmark

Benchmarks (from episode 6) are the basis for comparison. When optimizing, measure before and after, then compare:

Benchmark two implementations
func BenchmarkGabungBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var sb strings.Builder
		for j := 0; j < 100; j++ {
			sb.WriteString("data")
		}
		_ = sb.String()
	}
}

Run it with go test -bench=. -benchmem -count=5 ./... for more stable results. -count repeats the benchmark so network and OS noise can be averaged out.

Preventing Compiler Optimizations

The Go compiler can eliminate code whose results are never used. Every benchmark result should be stored in a package-level variable so it isn't optimized away.

Store results so they aren't optimized away
var hasilBenchmark string
 
func BenchmarkKode(b *testing.B) {
	var s string
	for i := 0; i < b.N; i++ {
		s = gabungkan()
	}
	hasilBenchmark = s
}

Escape Analysis and Allocation

When Objects Are Allocated to the Heap

Escape analysis determines whether a variable lives on the stack (cheap) or the heap (expensive, needs GC). Variables that "escape" out of a function — returned, stored in a pointer, or captured by a closure — are allocated on the heap.

Looking at escape analysis
func buatPointer() *int {
	n := 42
	return &n
}

Run go build -gcflags="-m" main.go to see the compiler's decisions. If the output mentions moved to heap, the object is allocated on the heap. Reducing heap allocations directly lowers the garbage collector's workload. Alternatively, go build -gcflags="-m" main.go still produces a binary even while showing the allocation analysis.

Allocation and Latency

Every heap allocation adds GC work: the more allocations, the more often the GC runs. That's why zero allocation patterns on hot paths can significantly improve throughput.

Performance Tuning Techniques

Buffer Reuse

For repeated data reads, allocate a buffer once and reuse it many times. sync.Pool even lets buffers be shared across goroutines:

Buffer pool
var bufPool = sync.Pool{
	New: func() any {
		return make([]byte, 1024)
	},
}
 
func proses(data []byte) {
	buf := bufPool.Get().([]byte)
	defer bufPool.Put(buf)
	copy(buf, data)
	// proses data
}

sync.Pool returns objects that were created before and discards them when the GC runs. It suits temporary buffers, not state that must survive.

Slices with the Right Capacity

Repeated append triggers reallocation when the capacity is full. Set the initial capacity when creating a slice so the allocation happens only once:

Allocating initial capacity
hasil := make([]int, 0, 1000)
for _, v := range sumber {
	hasil = append(hasil, v)
}

With make([]int, 0, 1000), the capacity is reserved upfront. For thousands of elements, this avoids dozens of unnecessary reallocations.

Reducing Garbage

A few more practices: reuse structs, avoid pointers to small data, and use range over values for immutable data. Never optimize before a profile shows a problem — premature optimization only adds complexity.

Closing

Episode 13 equipped you with the measurement and optimization cycle: profiling with pprof and go tool pprof, benchmarking with go test -bench and -benchmem, understanding escape analysis and heap allocation, plus the techniques of buffer reuse, sync.Pool, and proper slice capacity.

Key takeaways:

  • Measure first with pprof before optimizing anything.
  • Benchmarks use -benchmem and -count for stable results.
  • Escape analysis decides whether variables go to the stack or the heap.
  • Reduce heap allocations to keep the garbage collector workload down.
  • sync.Pool reuses buffers across goroutines.
  • Reserve slice capacity so append doesn't reallocate often.

In the next episode we will discuss observability, logging, and tracing — structured logging with zap, logrus, or zerolog, distributed tracing with OpenTelemetry, and structured metrics with the Prometheus client. Your applications will be introspectable while running in production.

Learning Golang - Performance & Go Optimization | Learning Golang