Right, so you’ve got a service running. It’s chugging along, but something’s off. Maybe it’s a bit sluggish under load, or perhaps memory usage is doing a concerning impression of a ski jump. You need to see what’s happening right now, on its terms, in production. You don’t get to stop the world and attach a debugger. This is where net/http/pprof becomes your best friend—a Swiss Army knife that’s mostly sharp blades for introspection.

Think of it as a live diagnostic port. By importing net/http/pprof, you automatically register a bunch of HTTP handlers onto your existing server mux (usually the default one, which is both a blessing and a curse we’ll get to). These handlers let you pull a stunning variety of live profiles straight from the running process, on demand.

First, the one-line incantation to make it available. Just import it. That’s it. The init() functions in the package do the dirty work.

import (
    _ "net/http/pprof" // Side-effect registration is the point here
    "net/http"
)

func main() {
    go http.ListenAndServe("localhost:6060", nil) // Use the default mux
    // ... the rest of your application code ...
}

Boom. Your server is now listening on localhost:6060 and serving up a buffet of profiling endpoints alongside your actual application. Hit http://localhost:6060/debug/pprof/ in your browser. You’ll get a friendly list, and this is your mission control.

The Big Three: CPU, Heap, and Goroutines

You’ll primarily live in three profiles: CPU, heap, and goroutines.

To grab a CPU profile, which tells you what functions are burning cycles, you run a command. This is where most people start. The profile runs for 30 seconds (by default) and records the stack traces during that period.

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

Why 30 seconds? It’s a sane default. You need a long enough sample to see meaningful patterns, especially for those intermittent issues. You can change it with the seconds parameter. Pro tip: do this while generating load against your service. Profiling an idle service is about as useful as a chocolate teapot.

The heap profile (/debug/pprof/heap) is a snapshot of where memory is being allocated, not a real-time measure of usage. This is a crucial distinction. The Go runtime samples memory allocations; it doesn’t track every single one because the overhead would be insane. When you pull this profile, you’re seeing the allocation hotspots.

go tool pprof http://localhost:6060/debug/pprof/heap

Then there’s the goroutine profile (/debug/pprof/goroutine). This is your first resort when you suspect a concurrency bug, a leak, or just general weirdness. It gives you a breakdown of all current goroutines and their stack traces. It’s incredibly powerful for answering the question “what is my application actually doing right now?”

go tool pprof http://localhost:6060/debug/pprof/goroutine

The Default Mux and The Security Pitfall

See that http.ListenAndServe("localhost:6060", nil)? I used nil as the handler. That means it’s using the default http.ServeMux. The net/http/pprof import secretly registers its handlers with this global default mux. It’s convenient, but it’s also a footgun the size of a cannon.

Never, ever expose the pprof endpoints on a public interface. I’m serious. If you bind to 0.0.0.0:6060 and your firewall is open, anyone on the internet can download your heap profiles, see your stack traces, and even trigger 30-second CPU profiles that could slow your service down. This is a massive security and denial-of-service risk.

The right way is to be explicit. Use a dedicated router and mux for your profiling endpoints, and bind it to localhost. This is non-negotiable.

// Good. Safe. Do this.
pprofMux := http.NewServeMux()
pprofMux.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index))
pprofMux.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
pprofMux.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile))
pprofMux.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol))
pprofMux.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace))

pprofServer := &http.Server{
    Addr:    "localhost:6060",
    Handler: pprofMux,
}
go pprofServer.ListenAndServe()

Beyond the Basics: Trace and Mutex

The trace endpoint (/debug/pprof/trace) is a different beast. It doesn’t give you a pprof profile. It captures an execution trace for a specified number of seconds. While a CPU profile tells you where time is spent, a trace tells you why it spent time there. It’s essential for diagnosing latency issues: it shows you goroutine scheduling, network stalls, blocking syscalls, and garbage collection pauses. You view it with go tool trace.

The mutex profile (/debug/pprof/mutex) is for finding contention. If your beautifully concurrent code is actually spending all its time waiting on locks, this will show you which mutexes are the culprits. You have to explicitly enable this one by setting runtime.SetMutexProfileFraction(1) in your code, because, rightly so, the runtime designers assumed you wouldn’t want the overhead unless you asked for it.

So there you have it. net/http/pprof is your window into the soul of your running Go process. Use it liberally in development and staging, and deploy it safely in production. The next time something feels “slow,” you’re no longer guessing. You’re asking. And the application will tell you.