Performance and Profiling
For developers with .NET (C#) experience, this guide explains pprof, trace, benchmarks, and reducing allocations. In C# you pick among external tools such as BenchmarkDotNet, PerfView, dotnet-trace, dotnet-counters, and dotMemory. In Go, the profiler (pprof), the execution tracer (go tool trace), benchmarks (testing.B), and runtime metrics (runtime/metrics) are included in the toolchain and the standard library from the start. This guide shows how to practice "measure before you optimize" with Go's tools.
1. What This Document Covers
- Enabling and reading net/http/pprof
- Heap and allocation analysis
- sync.Pool and preallocation
- GOMAXPROCS in containers
Enabling and reading net/http/pprof
Blank-importing net/http/pprof registers endpoints under /debug/pprof/ on http.DefaultServeMux. The main profiles you can obtain are:
| Endpoint | Content |
|---|---|
/debug/pprof/profile?seconds=30 | CPU profile (sampled for the given number of seconds) |
/debug/pprof/heap | Heap (memory usage and allocations) |
/debug/pprof/allocs | All past allocations |
/debug/pprof/goroutine | Stacks of all goroutines (?debug=2 for details) |
/debug/pprof/block | Time blocked on synchronization (must be enabled) |
/debug/pprof/mutex | Mutex contention (must be enabled) |
/debug/pprof/trace?seconds=5 | Execution trace (open with go tool trace) |
You read the collected profiles with go tool pprof.
# CPU: sample for 30 seconds and open in the browser
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
# Heap: the default is memory in use (inuse_space)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
In the web UI you can switch among Top (flat and cum per function), Graph, Flame Graph, Source, and other views.
flat: the amount consumed by the function itself.cum: the cumulative amount including the functions it calls.
First look for "wide" (high-consumption) spots in the Flame Graph or Top, then inspect them in Source.
Heap and allocation analysis
Go's memory performance depends heavily on the number of allocations and on the size of the heap the GC has to scan. Look at it from these angles:
- Heap profile sample types:
inuse_space/inuse_objects(what is in use now) are for investigating leaks and resident memory, andalloc_space/alloc_objects(cumulative so far) are for investigating GC load and the volume of temporary objects. Switch withgo tool pprof -sample_index=alloc_space .... - Diffing two profiles:
-base(or-diff_base) shows the difference between heap profiles taken at two points in time. - Allocations in benchmarks:
go test -bench . -benchmemreports bytes per operation (B/op) and allocations per operation (allocs/op). - Escape analysis:
go build -gcflags=-mmakes the compiler report which variables escape to the heap. Whether a value fits on the stack or is allocated on the heap is decided by compiler analysis, not by the type being aclassor astructas in C#. - Tuning the GC:
GOGC(default 100) is the percentage of heap growth that triggers a GC.GOMEMLIMIT(Go 1.19 and later) gives a soft memory limit. When a container memory limit is fixed, settingGOMEMLIMITslightly below it makes OOM kills easier to avoid.
sync.Pool and preallocation
The three main ways to reduce allocations are:
- Preallocation: when you know the capacity, allocate up front with
make([]T, 0, n)for slices,make(map[K]V, n)for maps, andGrow(n)forstrings.Builder. This avoids the reallocation and copying that happen as elements are added. sync.Pool: pools short-lived, reusable objects (such as large buffers). It is close to .NET'sArrayPool<T>andObjectPool<T>. However, objects in the pool can be released when the GC runs, so reuse is never guaranteed.- Avoiding needless copies and conversions: converting between
stringand[]bytecopies data.fmt.Sprintfand reflection are convenient, butstrconvandstrings.Builderallocate less in hot paths.
GOMAXPROCS in containers
GOMAXPROCS is the upper bound on the number of OS threads (Ps) that can execute Go code at once, and defaults to the number of logical CPUs. Where the .NET thread pool automatically takes Environment.ProcessorCount and container CPU limits into account, Go differs as follows:
- Go 1.24 and earlier: the default ignores the container CPU limit (cgroup CPU quota) and uses the host's logical CPU count. For example, even with a CPU limit of 2, on a 64-core host
GOMAXPROCSbecomes 64, which can cause worse latency through CPU throttling. As countermeasures, use Uber'sgo.uber.org/automaxprocsor set theGOMAXPROCSenvironment variable explicitly. - Go 1.25 and later: on Linux, the default
GOMAXPROCStakes the cgroup CPU bandwidth limit into account and follows changes to the limit. If you setGOMAXPROCSexplicitly through the environment variable orruntime.GOMAXPROCS, that value takes precedence.
Check the release notes for the behavior of the version you use.
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| dotnet-trace / dotnet-counters | go tool trace / runtime/metrics |
| PerfView / dotMemory | pprof (CPU / heap / goroutine) |
| BenchmarkDotNet | testing.B / benchstat |
Additional correspondences that complement the table above:
| .NET (C#) | Go |
|---|---|
[Benchmark] / [MemoryDiagnoser] | func BenchmarkXxx(b *testing.B) / go test -bench . -benchmem |
| BenchmarkDotNet statistics (mean, error, etc.) | Run several times with -count=10 and compare with benchstat |
ArrayPool<T> / ObjectPool<T> | sync.Pool |
Avoiding allocations with Span<T> / stackalloc | Slices (subslicing does not allocate), escape analysis |
GC.GetTotalAllocatedBytes() / GC.CollectionCount() | runtime/metrics (/gc/heap/allocs:bytes, etc.), runtime.ReadMemStats |
DOTNET_gcServer / GCHeapHardLimit | GOGC / GOMEMLIMIT |
Environment.ProcessorCount / thread pool | GOMAXPROCS / the goroutine scheduler |
dotnet-dump dumpheap / thread listing | /debug/pprof/heap / /debug/pprof/goroutine?debug=2 |
| PGO (Dynamic PGO) | PGO (put default.pgo in the main package, Go 1.21 and later) |
3. Common Pitfalls for C# Developers
Profile; do not guess
Guesses about where the time goes are often wrong, in C# as well. First check the top CPU and allocation consumers with pprof, then change the code. Compare before and after with benchmarks taken under the same conditions.
A single benchmark run is not reliable
go test -bench results fluctuate with machine load and CPU frequency. It does not process statistics automatically like BenchmarkDotNet, so run it several times with something like -count=10 and compare before and after with benchstat. benchstat also tells you whether a difference is statistically significant.
go test -bench . -benchmem -count=10 > old.txt
# change the code
go test -bench . -benchmem -count=10 > new.txt
benchstat old.txt new.txt
benchstat is not included by default; install it with go install golang.org/x/perf/cmd/benchstat@latest.
The benchmark target can be optimized away
A loop whose result is unused may be removed entirely by compiler optimization, or folded into a constant. From Go 1.24, for b.Loop() { ... } keeps calls in the loop alive, which mitigates this problem and the hand-written b.N loop. On older versions, use for i := 0; i < b.N; i++ and assign the result to a package-level variable so it is not eliminated.
Do not expose the pprof endpoints
net/http/pprof exposes information close to memory contents and internal structure, so never publish it on the internet. Serve it on a separate port bound only to localhost, apart from the application's main port, and reach it with something like kubectl port-forward when needed. The blank import registers on http.DefaultServeMux, so if the application itself also uses DefaultServeMux, the endpoints are exposed unintentionally.
CPU profiles are samples and do not show waiting
A CPU profile samples time spent using the CPU, so time stopped on lock waits or I/O waits does not show up. When it is slow but the CPU is idle, use block / mutex profiles or go tool trace. Block and mutex profiles are disabled by default; enable them with runtime.SetBlockProfileRate and runtime.SetMutexProfileFraction.
Do not decide between values and pointers by size alone
In C#, the type decides: struct is passed by value and class by reference. In Go you choose either one yourself, so making everything a pointer on the assumption that "pointers must be faster" can increase heap allocations as a result of escape analysis and raise GC load. Small structs are often faster kept as values, so decide by measuring.
A subslice keeps a huge array alive
Taking a subslice such as small := big[:10] keeps the entire underlying array from being garbage collected. If you pull out a small part of a large buffer and hold it for a long time, copy it into a new slice with slices.Clone or copy.
sync.Pool is an optimization tool, not a prerequisite for correctness
An object taken from the pool still holds the contents of its previous use, so always initialize it, for example with Reset, before using it. After Put, do not keep using references to that object. The pool can also become empty at any time. Always confirm the effect with a benchmark.
The GC is not generational
The .NET GC is generational and compacts. Go's GC is a concurrent mark-and-sweep collector that is neither generational nor compacting. As a result, the assumption that "short-lived objects are cheap" holds less well, and the number of allocations and the size of the live heap translate directly into GC load.
Do not worry too much about small-function overhead
The cost of things like defer and calls through interfaces is small with the current compiler and is often not a problem. Touch only the spots you have confirmed to be hot paths.
4. Code Examples
Example 1: Enabling pprof
In C#, you attach dotnet-trace collect or dotnet-counters monitor from the outside. In Go, you add a few lines to the application and fetch profiles over HTTP.
package main
import (
"log"
"net/http"
_ "net/http/pprof" // registers /debug/pprof/ on DefaultServeMux
"time"
)
func main() {
// start a separate server for pprof only, bound to localhost
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // nil = DefaultServeMux
}()
// the application itself (uses its own mux, not DefaultServeMux)
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
After starting it, collect and analyze from another terminal as follows:
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
# execution trace
curl -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"
go tool trace trace.out
go tool trace shows goroutine scheduling, GC, blocking, network waits, and more on a timeline, which suits investigating why something is slow even though the CPU is idle. It serves a purpose similar to the combination of dotnet-trace and PerfView in .NET.
To use block and mutex profiles, enable them at startup.
runtime.SetBlockProfileRate(1) // record every blocking event (mind the overhead)
runtime.SetMutexProfileFraction(5) // record 1 in 5 contention events
Example 2: Benchmarks and reducing allocations
C# (BenchmarkDotNet):
[MemoryDiagnoser]
public class BuildBenchmarks
{
[Benchmark(Baseline = true)]
public List<string> Naive()
{
var list = new List<string>();
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}
[Benchmark]
public List<string> Prealloc()
{
var list = new List<string>(1000);
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}
}
Go (build.go):
package perf
import "strconv"
func buildNaive(n int) []string {
var out []string
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}
func buildPrealloc(n int) []string {
out := make([]string, 0, n) // allocate capacity up front to avoid reallocation and copying
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}
Go (build_test.go, using b.Loop from Go 1.24):
package perf
import "testing"
func BenchmarkNaive(b *testing.B) {
for b.Loop() {
buildNaive(1000)
}
}
func BenchmarkPrealloc(b *testing.B) {
for b.Loop() {
buildPrealloc(1000)
}
}
Run and compare as follows:
go test -bench . -benchmem -count=10 | tee bench.txt
benchstat bench.txt
The output includes the time per operation (ns/op), bytes (B/op), and allocations (allocs/op). On Go 1.23 and earlier, replace for b.Loop() with for i := 0; i < b.N; i++. To collect CPU and memory profiles, add -cpuprofile cpu.out -memprofile mem.out and open them with go tool pprof cpu.out.
Example 3: Reusing temporary buffers with sync.Pool
The usage is close to ArrayPool<byte>.Shared.Rent / Return in .NET.
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(name string) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // the previous contents remain, so always initialize
defer bufPool.Put(buf)
buf.WriteString("hello, ")
buf.WriteString(name)
return buf.String() // String() returns a copy, so it is safe after Put
}
func main() {
fmt.Println(render("gopher"))
}
If you use a reference into a buffer you returned to the pool (such as the result of buf.Bytes()) after Put, it collides with reuse by another goroutine. Either return a copy as buf.String() does, or call Put after the caller has finished using it.
Example 4: Reading runtime state with runtime/metrics
You can read, from the program, values corresponding to the GC and thread information you watched with dotnet-counters.
package main
import (
"fmt"
"runtime/metrics"
)
func main() {
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:bytes"},
{Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)
for _, s := range samples {
switch s.Value.Kind() {
case metrics.KindUint64:
fmt.Printf("%s = %d\n", s.Name, s.Value.Uint64())
case metrics.KindFloat64:
fmt.Printf("%s = %f\n", s.Name, s.Value.Float64())
default:
fmt.Printf("%s: (unsupported kind)\n", s.Name)
}
}
// the list of available metrics is available from metrics.All()
fmt.Println("available metrics:", len(metrics.All()))
}
When you use Prometheus or OpenTelemetry, collectors and packages that expose runtime metrics are available. First check the available names and descriptions with metrics.All().
Example 5: GOMAXPROCS and the memory limit
C# (runtimeconfig.json example):
{
"configProperties": {
"System.GC.HeapHardLimit": 1073741824
}
}
In Go, you tune with environment variables.
# a guideline for the memory limit (soft limit); set slightly below the container limit
export GOMEMLIMIT=900MiB
# in containers on Go 1.24 and earlier, set it explicitly to match the CPU limit
export GOMAXPROCS=2
To check from the program:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 only returns the current value
}
On Go 1.24 and earlier, if you want the CPU limit reflected automatically without setting the environment variable, you can load go.uber.org/automaxprocs with import _ "go.uber.org/automaxprocs". From Go 1.25, the runtime itself takes the cgroup CPU bandwidth limit into account.
Example 6: Checking escape analysis
package main
import "fmt"
type point struct{ x, y int }
func newPoint() *point {
p := point{1, 2}
return &p // returns a pointer to the caller, so it escapes to the heap
}
func sum() int {
p := point{1, 2} // stays within the function, so it can live on the stack
return p.x + p.y
}
func main() {
fmt.Println(newPoint().x, sum())
}
go build -gcflags=-m
Messages such as moved to heap: p show which variables are allocated on the heap. The details of the output vary by Go version.
5. Summary
- Go provides a profiler (
pprof), an execution tracer (go tool trace), benchmarks (testing.B), and runtime metrics (runtime/metrics) in the toolchain and the standard library. - Enable
net/http/pprofon a separate,localhost-only port, and read CPU, heap, and goroutine profiles withgo tool pprof. Never expose it to the internet. - CPU profiles do not show waiting time, so combine them with block / mutex profiles and
go tool trace. - For benchmarks, look at allocations with
-benchmem, run several times with-count, and compare before and after withbenchstat. - Reducing allocations centers on preallocation,
sync.Pool, and avoiding needless copies and conversions. Check escape analysis with-gcflags=-m. - Go's GC is not generational, so the number of allocations and the size of the live heap become the load. Tune with
GOGCandGOMEMLIMIT. - In containers, on Go 1.24 and earlier you must set
GOMAXPROCSto match the CPU limit. From Go 1.25 the runtime takes the cgroup limit into account. - Start from measurement, not guesses, and optimize only the spots confirmed to be hot paths.