GOMAXPROCS is the knob that controls how many OS threads can run Go code at the same time. In scheduler terms from the previous chapter, it sets the number of P's. This lesson covers how to read it, how to set it, what the default is, and why you almost never want to touch it.
GOMAXPROCS Actually ControlsGOMAXPROCS is the upper bound on the number of OS threads (M's) that can execute Go code simultaneously. The Go scheduler creates one P (processor) per GOMAXPROCS unit, and only an M attached to a P can run a goroutine. If GOMAXPROCS is 4, at most four goroutines can be running Go code at the same instant, even if a thousand goroutines are ready to go.
The word "simultaneously" matters. Goroutines that are blocked on system calls or network I/O don't count, the runtime parks them and frees up the P for another goroutine. GOMAXPROCS is about CPU parallelism, not concurrency. A program with GOMAXPROCS=1 can still have ten thousand goroutines making progress, they just can't run Go code in parallel on multiple cores.
The diagram shows GOMAXPROCS=4 on a 4-core machine. Four P's exist, one for each core. At any instant, four goroutines are running. The rest sit in run queues waiting for a P to pick them up. If you raise GOMAXPROCS to 8 on the same 4-core machine, you'd have 8 P's competing for 4 cores, the OS would time-slice them, and you'd probably get worse performance, not better.
The actual mapping of P's to physical cores is handled by the operating system. Go doesn't pin threads to specific cores. The OS scheduler decides which core runs which thread.
The runtime package has two functions that look similar but report different things.
runtime.NumCPU() returns the number of logical CPUs the OS sees. On a 4-core CPU with hyperthreading, that's 8. This is a hardware fact; it doesn't change based on Go's configuration.
runtime.GOMAXPROCS(0) reads the current setting. The 0 argument is a convention: passing 0 (or any non-positive number) means "don't change it, just tell me what it is." Passing any positive n sets it to n and returns the previous value.
On a typical machine with no container limits, the two values are equal because Go's default is runtime.NumCPU(). They diverge when you're running in a container with CPU limits or when you've explicitly set GOMAXPROCS.
GOMAXPROCSThere are three ways to change it, and they have a clear order of precedence.
| Method | When It Applies | Typical Use |
|---|---|---|
runtime.GOMAXPROCS(n) | At runtime, programmatically | Tests, benchmarks, special workloads |
GOMAXPROCS environment variable | When the program starts | Deployment configuration |
| Default (since Go 1.5) | If nothing else is set | Almost always the right answer |
Setting it from code:
Setting from the environment is the same value, just configured outside the program:
The environment variable is read once when the runtime initializes, before main runs. Programmatic calls to runtime.GOMAXPROCS(n) happen after that, so they override the environment.
Cost: Calling runtime.GOMAXPROCS(n) doesn't just change a number. It triggers a stop-the-world pause while the runtime reshapes its P pool, creating or retiring P's as needed. Don't call it inside hot loops or per-request handlers.
Since Go 1.5, the default GOMAXPROCS has been runtime.NumCPU(). Before 1.5, it was 1, which meant Go programs were single-threaded out of the box and you had to opt into parallelism. That changed because the runtime became stable enough to handle multi-core scheduling correctly, and the old default was leaving performance on the table.
For most programs running directly on a machine, the default is right. You have N cores, Go uses N P's, the scheduler maps goroutines to them, and your program uses every core when it has enough work.
Containers complicate this. A container can be limited to a fraction of a core (--cpus=0.5) or a small number of cores (--cpus=2) on a host that has 64 cores. Before Go 1.25, runtime.NumCPU() returned the host's core count regardless of the container limit. So a Go program running in a container limited to 2 cores on a 64-core host would set GOMAXPROCS=64, create 64 P's, and try to run 64 goroutines in parallel on what the OS only allowed to be 2 cores' worth of CPU time. The result was painful: tons of context switching, high tail latency, and CPU throttling by the kernel.
Since Go 1.25, the default GOMAXPROCS respects Linux cgroup CPU quotas. The runtime reads the cgroup CFS (Completely Fair Scheduler) limits and computes a sensible default based on the container's actual CPU allocation, not the host's core count. If you set --cpus=2, Go now sees that and defaults GOMAXPROCS to 2.
This is a significant practical change. Go programs running in containers with CPU limits no longer over-subscribe by default. Before Go 1.25, every deployment had to either set GOMAXPROCS manually, use a library like automaxprocs, or accept the performance hit. Upgrading to Go 1.25 eliminates that requirement for container deployments.
Details:
runtime.NumCPU() (the host's count).--cpus=1.5 limit gets a default GOMAXPROCS of 2, not 1.5, because P's are whole units.1. Even --cpus=0.1 gives you GOMAXPROCS=1.runtime.GOMAXPROCS(n) still override the new default. The change is just about what "no setting" means.NumCPU still reports the host's count because that's what the OS tells it. GOMAXPROCS is the value Go actually uses for scheduling. On Go 1.24 and earlier, both would have shown 64.
The automaxprocs library from Uber is the historical workaround for this. It reads cgroup info at startup and calls runtime.GOMAXPROCS(n) with the right value. On pre-1.25 Go, you imported it as a side-effect import and forgot about it. On Go 1.25+, you don't need it for cgroup CFS quotas anymore, the runtime does the same thing natively. The library still has uses (cpuset cgroups, custom rounding policies), but for the common case it's no longer required.
The clearest way to see what GOMAXPROCS does is to run a CPU-bound workload with different values and time it. A small program that generates a product report by computing a heavy aggregate per product is a good test.
Run it with different settings and you see clear scaling on a multi-core machine:
Doubling GOMAXPROCS roughly halves the wall time as long as you have enough physical cores and enough independent work for them. Past the core count, the curve flattens, more P's than cores means threads start contending for the same hardware.
This measurement pattern is useful when a workload appears not to be using the cores available to it. The fix is rarely "raise GOMAXPROCS" though, it's usually "find out why goroutines aren't running in parallel" (mutex contention, channel serialization, single-goroutine bottlenecks).
Cost: Setting GOMAXPROCS higher than the number of physical cores wastes CPU. The OS time-slices threads onto cores, and the extra context switches eat into throughput. Setting it lower than the core count leaves cores idle. Match the workload to the hardware.
The honest answer is: rarely. The default has been the right call for most Go programs since Go 1.5, and Go 1.25's cgroup awareness fixes the last common case where the default was wrong. A few situations where tuning still makes sense:
Leaving headroom for other processes. If a Go service shares a machine with other CPU-hungry workloads (a sidecar, another database, a logging agent), you might lower GOMAXPROCS to keep the Go program from monopolizing the CPU. This shows up most on bare-metal deployments without strict isolation. A 32-core host running a Go service and a Postgres process might set the Go service to GOMAXPROCS=24 to leave 8 cores' worth of headroom.
Benchmarking and reproducibility. When you want stable benchmark numbers, pinning to a specific number of cores removes variability. GOMAXPROCS=1 for single-threaded baselines, GOMAXPROCS=4 for a fixed parallel comparison.
Legacy container environments on pre-1.25 Go. If you're on Go 1.24 or earlier and running in a container with CPU limits, either set GOMAXPROCS from the environment or use automaxprocs. Without one of those, you're running over-subscribed on every container.
Investigating a specific scheduler problem. If you suspect a bug or pathological behavior in the scheduler (extremely rare in modern Go), forcing GOMAXPROCS=1 can help isolate whether the issue is parallelism-related.
Outside these cases, the default is best. If a Go program isn't using your cores, the answer is almost always in the program's structure, not the scheduler's configuration.
GOMAXPROCS=1 Anti-PatternSetting GOMAXPROCS=1 on the theory that it will "make the program single-threaded" and avoid race conditions is a mistake. It doesn't. It only reduces the number of goroutines that can run Go code at the same instant. Goroutines still interleave, the runtime can still preempt one mid-statement, and shared state can still get corrupted.
A classic broken counter:
The expected value is 1000. With GOMAXPROCS=1, you might get 1000 most of the time because the runtime tends to schedule goroutines cleanly when there's only one P, but you can still lose updates whenever a preemption happens between the load and the store of orderCount++. Worse, the program is now hiding a real bug that would explode the moment someone bumps GOMAXPROCS back up.
The fix is the same regardless of GOMAXPROCS: protect shared state with a mutex or use sync/atomic. GOMAXPROCS is not a substitute for synchronization, and treating it as one will produce data races when parallelism increases.
Cost: Forcing GOMAXPROCS=1 to dodge race conditions also throws away every other core on the machine. The trade is unused hardware for data races that appear the moment load increases.
The program below generates per-product analytics reports, spawning one goroutine per product and timing the whole batch.
The work is fully CPU-bound (no I/O, no syscalls), so it scales almost linearly with GOMAXPROCS until it hits the physical core count. The error handling pattern still applies; even in a parallel workload, every fallible call returns an error and the caller checks it. The goroutines per batch (32) exceed GOMAXPROCS in every run, which is fine, the scheduler queues the rest until a P is free.
In production code, you typically wouldn't change GOMAXPROCS mid-program like this. The loop is just here to make the scaling visible in one run. The actual lesson is that the default value plus a decent goroutine structure usually gets you most of the available throughput without any tuning.
10 quizzes