Go Memory Usage Looks High and Why RSS Can Mislead You
Why a Go program shows 500 MB of RSS after freeing it all: HeapIdle held 515 MB, FreeOSMemory returned it on Linux but not on macOS, and how to read both

A Go service is reported as using 600 MB, the heap profile says 80 MB is live, and someone opens an incident for a memory leak. Often there is no leak. Three different numbers all get called "memory" and they measure different things: what your code holds, what the Go runtime holds, and what the operating system has resident for the process. This article allocates 512 MB, drops all of it, and prints each number at every stage, on macOS and on Linux, to show where the difference lives and which number to trust for which question.
Tested with Go 1.25.6 on macOS arm64 (Apple M1 Max) and golang:1.25 on Linux arm64 in Docker, 512 MB allocated then dropped
It sits between GOMEMLIMIT and memory leaks that are not goroutines. To find who holds live memory use heap profiles. The cornerstone is the Go performance guide.
Four numbers called memory
runtime.MemStats exposes the runtime's view. The fields that matter here:
HeapAlloc: bytes of live heap objects, plus garbage not yet collected. This is what your code holds.HeapInuse: bytes in spans that contain at least one object. It is larger thanHeapAllocwhen spans are only partly used, which is fragmentation.HeapIdle: bytes in spans with no objects. The runtime owns them and can reuse them for new allocations without asking the kernel.HeapReleased: the part ofHeapIdlethat the runtime has told the operating system it no longer needs.Sys: the total the runtime has obtained from the operating system, for the heap, stacks and its own structures.
The fifth number is not in MemStats. RSS, the resident set size, is the memory the kernel currently keeps in RAM for the process. It is what ps, top, container metrics and the kernel's out of memory killer look at. The runtime can only influence it by telling the kernel which pages it no longer needs.
The experiment
The program allocates 512 MB in 1 MB slices and touches one byte per page so the kernel really maps them, drops the slices, runs runtime.GC(), then calls debug.FreeOSMemory(), then allocates 64 MB again. At each stage it prints the MemStats fields and the RSS from ps. The script also runs it with GODEBUG=madvdontneed=1.
package main
import (
"fmt"
"os"
"os/exec"
"runtime"
"runtime/debug"
"strconv"
"strings"
)
var keep [][]byte
// rss asks the operating system, because MemStats cannot see what the kernel counts.
func rss() int {
out, err := exec.Command("ps", "-o", "rss=", "-p", strconv.Itoa(os.Getpid())).Output()
if err != nil {
return -1
}
kb, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return kb / 1024
}
func report(stage string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%-34s HeapAlloc %4d HeapInuse %4d HeapIdle %4d HeapReleased %4d Sys %4d RSS %4d (MB)\n",
stage, m.HeapAlloc>>20, m.HeapInuse>>20, m.HeapIdle>>20, m.HeapReleased>>20, m.Sys>>20, rss())
}
func main() {
report("start")
for i := 0; i < 512; i++ {
b := make([]byte, 1<<20)
for j := 0; j < len(b); j += 4096 {
b[j] = 1
}
keep = append(keep, b)
}
report("512 MB allocated and touched")
keep = nil
runtime.GC()
report("dropped, after runtime.GC")
debug.FreeOSMemory()
report("after debug.FreeOSMemory")
b := make([]byte, 64<<20)
for j := 0; j < len(b); j += 4096 {
b[j] = 1
}
runtime.KeepAlive(b)
report("then 64 MB live again")
}set -e
go build -o app .
./app
echo
echo "the same program, with the runtime returning memory immediately (GODEBUG=madvdontneed=1)"
GODEBUG=madvdontneed=1 ./app
rm -f appstart HeapAlloc 0 HeapInuse 0 HeapIdle 3 HeapReleased 3 Sys 7 RSS 4 (MB)
512 MB allocated and touched HeapAlloc 512 HeapInuse 512 HeapIdle 3 HeapReleased 2 Sys 520 RSS 517 (MB)
dropped, after runtime.GC HeapAlloc 0 HeapInuse 0 HeapIdle 515 HeapReleased 2 Sys 520 RSS 517 (MB)
after debug.FreeOSMemory HeapAlloc 0 HeapInuse 0 HeapIdle 515 HeapReleased 514 Sys 520 RSS 518 (MB)
then 64 MB live again HeapAlloc 64 HeapInuse 64 HeapIdle 451 HeapReleased 450 Sys 520 RSS 518 (MB)
the same program, with the runtime returning memory immediately (GODEBUG=madvdontneed=1)
start HeapAlloc 0 HeapInuse 0 HeapIdle 3 HeapReleased 3 Sys 7 RSS 4 (MB)
512 MB allocated and touched HeapAlloc 512 HeapInuse 512 HeapIdle 3 HeapReleased 2 Sys 520 RSS 517 (MB)
dropped, after runtime.GC HeapAlloc 0 HeapInuse 0 HeapIdle 515 HeapReleased 2 Sys 520 RSS 517 (MB)
after debug.FreeOSMemory HeapAlloc 0 HeapInuse 0 HeapIdle 515 HeapReleased 513 Sys 520 RSS 518 (MB)
then 64 MB live again HeapAlloc 64 HeapInuse 64 HeapIdle 451 HeapReleased 450 Sys 520 RSS 518 (MB)Results on macOS
| Stage | HeapAlloc | HeapInuse | HeapIdle | HeapReleased | Sys | RSS |
|---|---|---|---|---|---|---|
| start | 0 | 0 | 3 | 3 | 7 | 4 |
| 512 MB allocated and touched | 512 | 512 | 3 | 2 | 520 | 517 |
dropped, after runtime.GC() | 0 | 0 | 515 | 2 | 520 | 517 |
after debug.FreeOSMemory() | 0 | 0 | 515 | 514 | 520 | 518 |
| 64 MB live again | 64 | 64 | 451 | 450 | 520 | 518 |
(all in MB)
Dropping data does not lower RSS. After the collection HeapAlloc was 0, which is the right answer to "how much does my code hold", and RSS was still 517 MB. The 515 MB moved from HeapInuse to HeapIdle. The runtime keeps the pages so that the next allocation does not need a system call and a page fault. That is the heap doing its job, and it is the reason a service that peaked at 600 MB looks like it uses 600 MB all day.
FreeOSMemory raised HeapReleased and did nothing to RSS on macOS. The runtime did tell the kernel, and the HeapReleased figure jumped to 514 MB. The kernel on macOS marks those pages reclaimable but does not take them away until something else needs the memory, so ps still showed 518 MB. This is a platform difference and an honest limitation of the measurement: on a laptop RSS is a poor indicator of what the program holds.
Results on Linux
The same source built with the golang:1.25 image and run in a container (arm64 Linux) printed:
| Stage | HeapAlloc | HeapIdle | HeapReleased | RSS |
|---|---|---|---|---|
| 512 MB allocated and touched | 512 | 3 | 2 | 516 |
dropped, after runtime.GC() | 0 | 515 | 2 | 516 |
after debug.FreeOSMemory() | 0 | 515 | 515 | 3 |
| 64 MB live again | 64 | 451 | 450 | 67 |
docker run --rm -v "$PWD":/src:ro -w /tmp golang:1.25 bash -c \
'mkdir w && cp /src/main.go w/ && cd w && go mod init lab && go build -o /tmp/app . && /tmp/app'On Linux the story is the textbook one. RSS stayed at 516 MB after the collection and fell to 3 MB after FreeOSMemory, because the runtime returns pages with MADV_DONTNEED and the kernel drops them at once. Allocating 64 MB afterwards took RSS to 67 MB: released memory is faulted back in on demand, which is why returning memory too eagerly costs page faults later. Setting GODEBUG=madvdontneed=1 gave the same result as the default here, since Go 1.16 uses MADV_DONTNEED on Linux already.
Why the runtime does not return memory at once
The runtime keeps freed spans for reuse and returns them to the kernel in the background. A scavenger goroutine releases idle memory slowly, aiming to keep total retained memory a little above what the heap goal needs, and it returns memory faster when a memory limit is close (GOMEMLIMIT). That trade is intentional. A server whose traffic goes up and down every minute would pay for a system call and a page fault on every allocation if it returned memory eagerly.
You can see the scavenger's effect over time. Run the lab, drop the data and watch RSS on Linux without calling FreeOSMemory: it falls over minutes, not at once. Calling debug.FreeOSMemory() forces a collection and a full release. It is meant for tools that know their peak has passed (after loading a big file, after a batch), not for request paths, because it stops the world for a collection and makes the next allocations fault.
A decision table
| You see | It usually means | Check |
|---|---|---|
High RSS, low HeapAlloc | idle memory not yet returned | HeapIdle and HeapReleased |
High RSS, high HeapAlloc | the program holds it, or the collector has not run | heap profile; live heap after GC |
HeapInuse far above HeapAlloc | fragmentation across partly used spans | allocation size mix |
RSS above Sys | memory outside the runtime: cgo, mapped files | pmap, cgo libraries |
RSS rising with HeapAlloc after GC | a real leak | heap profile diff |
The last row is the only one that needs a fix in the code. The rest need an explanation, and sometimes a runtime setting.
What to do about it
- Alert on the live heap after collection,
/gc/heap/live:bytes, and on RSS separately. Alerting on RSS alone fires after every traffic peak. - Set
GOMEMLIMITbelow the container limit so that the scavenger works harder when memory is tight (GOMEMLIMIT). - Compare heap profiles taken at two times with
go tool pprof -base; a leak shows as a type whose in-use bytes keep growing (heap profiles). - Do not call
FreeOSMemoryin a loop to make a graph look better. It trades RSS for page faults and latency. - Remember the platform. A development laptop running macOS will show RSS that never shrinks. Take the measurement on the OS you deploy to.
Checklist
HeapAllocis held by the program;HeapIdleis held by the runtime; RSS is held by the kernel's accounting.- Idle memory is retained on purpose and returned gradually by the scavenger.
FreeOSMemoryreleased 515 MB on Linux and showed no RSS change on macOS.- Judge leaks by the live heap after collection, not by RSS.
- Measure on the operating system the service runs on.
The fields are documented in the runtime.MemStats reference and the scavenger in the Go GC guide.


