When memory disappears without a trace
Your Go service starts clean. It handles requests, writes to the database, and stays responsive. Three days later, the container hits its memory limit and the orchestrator restarts it. You check the logs and find nothing. No panics. No stack traces. Just a slow, silent climb in RAM usage until the system gives up. You need to see what the runtime is actually doing with memory. You need a window into the allocator.
The dashboard under the hood
runtime.ReadMemStats is that window. It populates a runtime.MemStats struct with a snapshot of your program's memory allocation state. Think of it like a car's dashboard computer. It does not tell you exactly which tire is losing air, but it shows you fuel consumption, engine temperature, and trip distance. In Go's case, it tracks how much memory is currently live, how much has been handed out over the program's lifetime, how many times the garbage collector has run, and how much memory the runtime itself has asked the operating system for.
The Go runtime manages memory in two main places. The stack grows and shrinks with function calls. It is fast, automatic, and invisible to most developers. The heap is for values that outlive their function scope. Slices that grow, maps, channels, and objects returned from functions all live on the heap. runtime.ReadMemStats focuses on the heap and the garbage collector. It reads internal counters that the allocator updates as it hands out and reclaims memory.
The minimal snapshot
Here is the simplest way to grab a memory snapshot. You declare a struct, pass its address to the function, and read the fields.
package main
import (
"fmt"
"runtime"
)
func main() {
// Declare the struct to hold the snapshot.
var stats runtime.MemStats
// Pass the address so the runtime can fill it in.
runtime.ReadMemStats(&stats)
// Alloc shows live heap memory in bytes.
fmt.Printf("Live heap: %d MB\n", stats.Alloc/1024/1024)
// TotalAlloc only grows. It counts every allocation ever made.
fmt.Printf("Total allocated: %d MB\n", stats.TotalAlloc/1024/1024)
// NumGC tracks how many full GC cycles have completed.
fmt.Printf("GC runs: %d\n", stats.NumGC)
}
Reading the numbers correctly
When you call runtime.ReadMemStats, the runtime walks through its internal bookkeeping structures and copies the values into your struct. It does not stop the world. Modern Go versions read these counters atomically or with minimal synchronization, so the call returns quickly. The values are a point-in-time snapshot. If your program is allocating heavily, the numbers will change the moment you print them.
The most important fields to understand are Alloc, TotalAlloc, Sys, and NumGC. Alloc is the current amount of heap memory in use. It goes up when you allocate and down when the garbage collector reclaims memory. TotalAlloc is a cumulative counter. It starts at zero and only increases. It tells you the total volume of memory your program has requested from the allocator since startup. A high TotalAlloc with a low Alloc means your program is allocating and freeing memory rapidly, which puts pressure on the garbage collector.
Sys is the total amount of memory obtained from the operating system. This includes heap memory, stack memory, and memory used by the runtime itself. Sys is almost always larger than Alloc because the runtime asks the OS for memory in large chunks to avoid frequent system calls. The garbage collector uses Sys to decide when to trigger a collection cycle. NumGC counts completed garbage collection cycles. If NumGC is climbing fast while your CPU usage spikes, you are likely creating too many short-lived objects.
HeapObjects tracks the number of currently allocated objects on the heap. If HeapObjects grows steadily while Alloc stays flat, you are leaking small objects. If HeapObjects stays flat while Alloc climbs, you are allocating fewer but larger objects. Watching these two fields together tells you whether your leak is a volume problem or a size problem.
The runtime package follows a strict convention: functions that mutate a struct passed by pointer take the address as their only argument. You will see this pattern across the standard library. The community accepts this because it avoids heap allocations for the receiver and keeps the API surface minimal. Trust the pointer. Pass the address. Let the runtime fill it in.
Monitoring in production
In production, you rarely call this function from your main request loop. You put it in a debug endpoint or a background monitoring goroutine. Here is a pattern that exposes memory stats over HTTP without blocking your application logic.
package main
import (
"fmt"
"net/http"
"runtime"
)
// handleMemStats writes a formatted memory report to the HTTP response.
func handleMemStats(w http.ResponseWriter, r *http.Request) {
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
// Format the output as plain text for easy curl debugging.
fmt.Fprintf(w, "Alloc: %d MB\n", stats.Alloc/1024/1024)
fmt.Fprintf(w, "TotalAlloc: %d MB\n", stats.TotalAlloc/1024/1024)
fmt.Fprintf(w, "Sys: %d MB\n", stats.Sys/1024/1024)
fmt.Fprintf(w, "NumGC: %d\n", stats.NumGC)
fmt.Fprintf(w, "HeapObjects: %d\n", stats.HeapObjects)
}
func main() {
// Register the debug handler on a local-only port.
http.HandleFunc("/debug/mem", handleMemStats)
fmt.Println("Listening on :8080")
http.ListenAndServe(":8080", nil)
}
Where things go wrong
The biggest mistake developers make is calling runtime.ReadMemStats in a tight loop or on every request. The function reads multiple runtime counters and performs synchronization. Doing it thousands of times per second adds measurable latency and can actually trigger more garbage collection by creating temporary pressure. Keep it to debug endpoints, periodic logging, or metrics exporters that run on a separate schedule.
Another common trap is confusing Sys with actual memory usage. The runtime asks the OS for memory in megabyte-sized spans. If your program allocates 10 megabytes, the runtime might ask for 32 megabytes upfront. Sys will show 32 megabytes even if Alloc is only 10. This is normal. The runtime holds onto freed memory to reuse it later instead of returning it to the OS immediately. If you need to force the runtime to release memory back to the OS, you can call runtime.GC() followed by runtime.GC(), but you should only do this in long-running batch jobs, not in request-driven services.
If you pass a nil pointer to runtime.ReadMemStats, the runtime panics with a runtime error: invalid memory address or nil pointer dereference. The function requires a valid address to write into. If you accidentally declare the struct inside a loop without taking its address, the compiler rejects the program with cannot use stats (variable of struct type runtime.MemStats) as *runtime.MemStats value in argument. Always pass &stats.
The runtime package is an exception to the usual Go rule about interfaces. Most Go code accepts interfaces and returns structs. The runtime returns concrete structs because reflection and interface dispatch add overhead that defeats the purpose of low-level monitoring. You will also notice that runtime.MemStats fields are exported. The package designers made them public so you can read them directly without calling accessor methods. Public names start with a capital letter. Private start lowercase. No keywords like public or private. The convention is baked into the language syntax.
Do not poll the allocator in your hot path. Sample it on a schedule. Let the runtime do its job.
Choosing the right tool
Use runtime.ReadMemStats when you need a lightweight, programmatic snapshot of heap allocations and GC cycles for logging or custom metrics. Use net/http/pprof when you need to trace exactly which functions are allocating memory and where leaks originate. Use expvar when you want to expose runtime metrics over HTTP without writing custom handlers. Use external APM agents when you need distributed tracing alongside memory profiling across multiple services. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.