Go
Go is a compiled language whose runtime runs thousands of cheap goroutines on a handful of operating-system threads, so code that blocks stays fast. That design is why a Go service reads top to bottom with no callbacks — and why its typical failure is not a slow request but a goroutine that waits forever. The page opens with every place a goroutine waits and what releases it, then covers the scheduler inside a container, goroutine lifetimes and leaks, shared memory and the race detector, errors, releases up to Go 1.27, memory and the garbage collector, five documented cases, and where it degrades.
- Current version
- Go 1.27 · 19 Aug 2026
- Model
- Goroutines on a few OS threads
- Support
- The last two releases; a new one every six months
Every place a goroutine waits, and what lets it go
A Node server has to ask which thread pays for each piece of work. A Go server does not: when a goroutine waits — for a channel, a lock, the network — the runtime parks it and runs another goroutine on the same thread, so waiting costs a few kilobytes of memory and nothing else. That moves the risk somewhere else. The useful question about a line of Go is not "is it fast?" but "if this waits, what will wake it — and what happens if nothing ever does?". A goroutine that nothing will wake is not an error: no log line, no crash. It sits in memory with its stack and everything it points to until the process restarts. Here are fourteen places a goroutine waits. Later sections cite the rows by number.
| # | Where it waits | What releases it | If nothing ever does | The control |
|---|---|---|---|---|
| 1 | ch <- v on an unbuffered channel | A receiver takes the value | Leaks — typically a worker whose caller has already returned | A buffer as large as the number of senders, or select on ctx.Done() |
| 2 | v := <-ch | A send, or close(ch) | Leaks | The sending side closes the channel when it is done |
| 3 | for v := range ch | Each send; the loop ends only on close(ch) | Leaks after the last value | Exactly one owner closes the channel |
| 4 | Send or receive on a nil channel | Nothing, ever | Waits forever — by design | Create channels with make; a nil channel is how select switches a case off |
| 5 | Send on a closed channel | Does not wait — it panics | The process stops | Only the sending side closes, and only once |
| 6 | mu.Lock() | Another goroutine’s Unlock() | Every goroutine that needs the lock stops | defer mu.Unlock() on the very next line |
| 7 | wg.Wait() | The counter reaching zero | Leaks; a counter below zero panics | wg.Go(f) (Go 1.25) instead of Add and Done by hand |
| 8 | select with no default | Any one of its cases | Leaks if none of them can ever fire | Always include case <-ctx.Done() |
| 9 | Reading from a network connection | Data, or a deadline | A client that sends nothing holds the goroutine and the socket | conn.SetReadDeadline; http.Server{ReadHeaderTimeout: …} |
| 10 | An outgoing call through http.DefaultClient | A response — the default client has no timeout | Waits as long as the remote server does | An http.Client with Timeout, or a request with a context |
| 11 | A free connection from the database/sql pool | A connection returned to the pool | Every query waits behind result sets nobody closed | defer rows.Close(); a pool sized to the database |
| 12 | <-ctx.Done() | cancel(), or the deadline passing | Never fires on context.Background() | Derive with context.WithTimeout and call cancel — go vet checks |
| 13 | time.After or a time.Ticker in a loop | The timer firing | Before Go 1.23, every unstopped timer stayed in memory until it fired | go 1.23 or later in go.mod; Stop() every ticker |
| 14 | A blocking system call, or a call into C through cgo | The call returning | The runtime starts another thread; past 10,000 threads the program crashes | Bound how many such calls run at once |
The scheduler: why waiting is cheap, and what a container does to it
A goroutine is not a thread. It starts with a stack of a few kilobytes that grows as needed, so a server starts one per request, or one per connection, without pooling them. The runtime runs goroutines on real operating-system threads, and its scheduler has three parts, named by letters in the source code. G is a goroutine. M is a machine — an OS thread. P is a processor: permission to run Go code, with its own queue of runnable goroutines. There are exactly GOMAXPROCS of them, so that number decides how many goroutines run at the same instant; the rest wait in the queues.
When a goroutine waits on a channel, a lock or a timer, the scheduler parks it and the P takes the next one from its queue; the thread never stops. Network waits cost no thread either. The runtime hands every socket to the operating system’s readiness interface — epoll on Linux, kqueue on macOS, the same mechanism Node uses — and wakes the goroutine when its data arrives. A P with an empty queue steals half the queue of a busy one, so work spreads across cores without anyone assigning it. And since Go 1.14 the scheduler can interrupt a goroutine in the middle of a tight loop; before that, a loop with no function calls could hold its thread and stall the garbage collector for the whole process.
Row 14 is the one kind of wait the scheduler cannot hide. A blocking system call, such as reading a file, or a call into C through cgo holds a real thread until it returns. The runtime detaches the P from that thread and gives it to another one — starting a new thread if none is idle — so the other goroutines keep running. The cost is one OS thread per call in progress. A thousand concurrent slow file reads mean a thousand threads, and past 10,000, the default limit in debug.SetMaxThreads, the program crashes. Bound such calls with a semaphore — a buffered channel of size N is enough — rather than letting the number of requests decide.
GOMAXPROCS inside a container
Until Go 1.25, GOMAXPROCS defaulted to the number of CPU cores on the machine — and inside a container that is the wrong number. A Kubernetes CPU limit of 2 does not give the container two cores. It gives it a quota: 200 ms of CPU time in every 100 ms window, enforced by the Linux scheduler. A Go process on a 64-core node still started 64 Ps. When work arrived in a burst — or when the garbage collector, which takes a quarter of the Ps for its own workers, started a cycle — 64 threads spent the whole quota in about 3 ms. The kernel then paused every thread in the container for the remaining 97. The Go team’s own description is that throttling "completely pauses application execution for the remainder of the throttling period".
Go 1.25 reads the container’s CPU limit and sets GOMAXPROCS to it, rounding up, and re-checks it periodically, because an orchestrator can change the limit while the process runs. It considers the limit, not the CPU request. The automatic value switches off as soon as GOMAXPROCS is set by hand, so a deployment that set it years ago keeps whatever it set. Before 1.25 the same effect came from Uber’s automaxprocs package, imported for its side effect; on 1.25 or later it can go. The symptom of the old default is worth recognising, because it still runs in older images: p99latency for the slowest sliceThe response time that 99% of requests come in under — p95 and p50 are the same measure at 95% and at the median. The average hides exactly the requests that make people leave, which is why these are the numbers anyone serious watches.All terms latency in steps of about 100 ms, a CPU graph far below the limit, and the cgroup’s throttled-periods counter climbing.
Every goroutine needs an owner: leaks, context and errgroup
A goroutine has no parent. The function that started it can return, and the goroutine carries on; nothing in the language links the two lifetimes. That is why rows 1–3 and 8 are the most common Go leak. The Go 1.26 release notes show the textbook case. A function starts one goroutine per work item, each sending its result on an unbuffered channel, and collects the results in a loop. When one result is an error, the function returns early — and every goroutine still holding a result blocks on its send forever, because nobody is receiving any more.
The leak grows with traffic, not with time, so it survives every test and appears a week after launch as a memory graph that only climbs. There are three fixes, each matching a shape of code. If the caller may stop reading, give the channel a buffer as large as the number of senders, so every send completes whether anyone listens or not. If the work should stop when the caller gives up, pass a context and make every blocking step a select that also waits on ctx.Done(). And for "start N tasks, stop at the first error", use errgroup.WithContext from golang.org/x/sync: it cancels the shared context on the first failure, and Wait returns that error once every goroutine has finished.
Finding a leak in production
The first signal is a number: runtime.NumGoroutine(), exported as a metric. A healthy service’s count rises and falls with traffic; a leaking one ratchets up and never comes back down. The second is a goroutine profile — /debug/pprof/goroutine from net/http/pprof — which groups thousands of goroutines by the line they are stuck on, so a leak shows up as one line holding forty thousand of them. Go 1.27 adds a third, built for exactly this. The goroutineleak profile, contributed by Vlad Saioc at Uber, uses the garbage collector to find goroutines blocked on a channel or lock that no running goroutine can reach — and that therefore nothing can ever release. It was an experiment in 1.26 and is stable in 1.27, at /debug/pprof/goroutineleak. It cannot see a leak through a global variable, and its documentation says so. In tests, Uber’s goleak package fails any test that leaves goroutines behind.
| What you see | What it usually means | Where to look |
|---|---|---|
| Memory and the goroutine count climb with traffic and never come down | A leak — rows 1–3, 7 or 8 | The goroutine profile, grouped by line; goroutineleak on 1.27 |
| p99 latency in steps of about 100 ms, CPU well under the limit | CPU throttling — GOMAXPROCS above the container’s limit | The cgroup throttling counters; the Go version; GOMAXPROCS |
fatal error: all goroutines are asleep - deadlock! | Every goroutine is waiting on another one | The stack dump printed with it |
fatal error: concurrent map writes | Two goroutines wrote one map without a lock | Both stacks in the message; then run with -race |
| Outgoing calls hang for minutes | No timeout on the client (row 10) | How the http.Client was built |
| The container is OOMKilled though the heap looked fine | GOGC let the heap grow past the limit | GOMEMLIMIT — see the section on memory |
The language: errors as values, a small design, and its releases
Go has no exceptions. A function that can fail returns an error beside its result, and the caller checks it on the next line. People argue about this more than about anything else in Go, and the argument tends to miss what it buys: every place a failure can happen is visible, at the line where it happens, and no hidden path carries control out of a function. The cost is repetition — the three-line if err != nil check that appears everywhere.
Modern Go makes the repetition carry information. Wrapping with fmt.Errorf("load user %d: %w", id, err) adds context and keeps the original error inside, so a failure ten layers down arrives as one readable sentence rather than "connection refused". errors.Is asks whether a particular error is anywhere in that chain; errors.As — and its generic form errors.AsType, added in Go 1.26 — extracts one of a given type; errors.Join (Go 1.20) combines several. The pattern to aim for: low-level code adds context and returns, and exactly one layer near the edge of the program decides — log it, retry it, or turn it into a status code.
The rest of the language is small on purpose, and the omissions are the design rather than an oversight. There is no inheritance: types embed other types, and satisfy an interface simply by having its methods, without declaring it. There are no constructors, no operator overloading, no default arguments, and for the first twelve years there were no generics. The point is that Go written by a stranger looks like Go written by you, which is what keeps a large code base readable by people who did not write it.
| Decision | What you get | What you give up |
|---|---|---|
| Implicit interfaces | The consumer defines the interface it needs; packages stay decoupled | Finding every implementation takes tooling, not grep |
| No inheritance | Composition by embedding; no fragile hierarchy to trace upward | Patterns built on class hierarchies must be redesigned, not translated |
| Errors as values | Every failure path is visible; no invisible unwinding of the stack | Repetition, and a real risk of dropping context at each hop |
| Generics, kept modest (1.18 → 1.27) | Type-safe containers and helpers; since 1.27, generic methods on a type | No type parameters on interface methods; type-level designs still fight the language |
gofmt | One layout for all Go code; formatting is never discussed | No choice — which is the point |
| One static binary | Deploy by copying a file; images of a few megabytes | Cross-compiling needs care once C libraries enter through cgo |
The standard library is unusually complete for a backend: an HTTPHyperText Transfer Protocol — the protocol of the webThe protocol browsers and servers talk in: the client sends a method and an address, the server answers with a status code and a body. It keeps no memory between requests — every request stands alone, which is why anything that must be remembered is carried explicitly.All terms server and client, JSONJavaScript Object Notation — text format for dataA text format for passing data around: key–value pairs, lists, numbers and strings. It came out of JavaScript, but every language reads it now, and that is why most APIs answer in it rather than in anything more capable.All terms, TLS, the SQL interfaces, templates, testing, fuzzing and profiling all ship with the compiler, so a production service can genuinely have a short go.mod. Go 1.27 replaced the engine under encoding/json with the new encoding/json/v2 — stricter by default in the new package, and much faster at decoding in both — while keeping the old package’s behaviour.
Releases, the compatibility promise, and which Go you are looking at
Go ships a major release every six months, in February and August, and supports each one until two newer ones exist — about a year. Patch releases (1.27.1, 1.27.2…) carry security fixes for the two supported lines only. Since Go 1, in 2012, the team has promised that code written for an earlier release keeps compiling and running. When a fix has to change behaviour, it ships behind a GODEBUG setting that keeps the old behaviour for modules declaring an older Go version. The table lists the releases you are most likely to meet in code.
| Version | Released | What you will meet in code | Supported |
|---|---|---|---|
| 1.18 | Mar 2022 | Generics, fuzzing in go test, workspaces | No |
| 1.19 | Aug 2022 | GOMEMLIMIT, a soft memory limit for the garbage collector | No |
| 1.20 | Feb 2023 | errors.Join; coverage for whole programs | No |
| 1.21 | Aug 2023 | min, max, clear; log/slog; the go line becomes a minimum and the toolchain downloads itself | No |
| 1.22 | Feb 2024 | A new variable per loop iteration; for i := range 10; method and wildcard patterns in http.ServeMux | No |
| 1.23 | Aug 2024 | Iterators — range over a function; unstopped timers collected | No |
| 1.24 | Feb 2025 | Generic type aliases; tool lines in go.mod; os.Root; testing.B.Loop; Swiss-table maps | No |
| 1.25 | Aug 2025 | Container-aware GOMAXPROCS; sync.WaitGroup.Go; testing/synctest | Ended Aug 2026 |
| 1.26 | Feb 2026 | new(expr); errors.AsType; the Green Tea garbage collector by default | Until Go 1.28, due Feb 2027 |
| 1.27 | Aug 2026 | Generic methods; the goroutineleak profile; encoding/json/v2 | Until Go 1.29, due Aug 2027 |
Memory: GOGC, GOMEMLIMIT and a container’s limit
Go’s garbage collector is tuned for short pauses rather than maximum throughput. It marks live objects while the program keeps running and stops every goroutine only for brief phases, typically well under a millisecond. It pays for that with CPU. While a cycle runs, it takes about a quarter of the Ps for its own workers, and a goroutine that allocates quickly is made to help with the marking — the "assist" that shows up as latency in whatever request it was serving. Go 1.26 made the new Green Tea collector the default, which the release notes expect to cut garbage-collection overhead by 10–40% in programs that lean on it.
One number decides how often a cycle runs. GOGC, 100 by default, means the heap may grow by 100% of the live data before the next cycle starts: a service holding 1 GB of live objects runs at about 2 GB of heap. Doubling GOGC roughly halves the collector’s CPU and adds the same amount of memory again. That trade is fine on a machine with spare memory and dangerous in a container, because GOGC knows nothing about the container’s limit. A service with 1.5 GB of live data in a 2 GB container lets its heap head for 3 GB before the collector thinks about running — and the kernel kills it on the way.
Go 1.19 added the other half: GOMEMLIMIT, a soft limit on all the memory the runtime manages. As the total approaches it, the collector runs more often, whatever GOGC says. Set it to the container’s limit minus some headroom — the Go garbage-collector guide suggests 5–10% for memory the runtime cannot see, such as C libraries — so GOMEMLIMIT=1800MiB in a 2 GiB container. The limit is soft on purpose. If the live data itself outgrows it, the runtime caps the collector at roughly half the CPU rather than collecting non-stop, so a real leak still ends in an out-of-memory kill — just later, and after a stretch of high CPU that is worth an alert of its own.
Shipping: one binary, and a clean shutdown
The operational story is why Go took over infrastructure. go build produces one executable with no runtime to install, and with CGO_ENABLED=0 it is statically linked, so a container image can be the binary alone — a few megabytes on a scratch or distroless base. It starts in milliseconds, and GOOS and GOARCH cross-compile it for another platform from any machine. Docker, Kubernetes, Terraform and Prometheus are written in Go for exactly these reasons. A scratch image has no shell and no CA certificates, so copy the certificate bundle in, or the first outgoing HTTPS call fails.
A clean stop takes six lines. signal.NotifyContext turns SIGTERM into a cancelled context; when it fires, srv.Shutdown(ctx) stops accepting connections, closes the idle ones and waits for requests in flight, up to the deadline you give it. Make that deadline shorter than the orchestrator’s grace period — 30 seconds by default in Kubernetes — and pass the same context down, so long-running work notices too. And set the server’s timeouts while you are there: an http.Server built as a bare struct has none, which is row 9 waiting for a slow client.
Five cases, and what each one was missing
Each case is documented by the people it happened to — engineering blogs, a certificate authority’s incident report, a research paper. Between them they cover time, the garbage collector twice, the loop variable and data races. Twitch and Uber are told above, where their mechanism lives; the other three follow.
| Case | What happened | What was missing |
|---|---|---|
| Cloudflare, 1 Jan 2017 | A leap second made a measured duration negative; a DNS component panicked on it | A monotonic clock — Go 1.9 added one |
| Twitch, Apr 2019 | 8–10 GC cycles a second on a small heap; a 10 GiB ballast cut them by 99% | A memory limit the collector understood — GOMEMLIMIT, 2022 |
| Discord, Feb 2020 | Latency spikes every two minutes from the GC scanning a huge cache; the service moved to Rust | A heap layout the collector did not have to walk |
| Let’s Encrypt, Feb 2020 | A pointer to a loop variable skipped CAA checks; about 3 million certificates affected | A variable per iteration — the default from Go 1.22 |
| Uber, Jun 2022 | About 2,000 data races found in six months across 2,100 services | The race detector in CI from the start |
The first case is a clock. At the end of 2016 a leap second was added to the last minute of the year, and the wall clock on Cloudflare’s servers stepped back. RRDNS, Cloudflare’s DNS server, written in Go, measured how fast its upstream resolvers answered by subtracting two time.Now() readings — and across the leap second one of those durations came out negative. Later, the code that picked a resolver weighted by those timings was fed the negative number and panicked. A recover caught the panic, so the process survived, but CNAME lookups failed: at the peak about 0.2% of DNS queries to Cloudflare and under 1% of HTTP requests returned errors, and the fix was out worldwide within seven hours. Go’s time.Now() then read only the wall clock, which can jump. Go 1.9, in August 2017, made every time.Time carry a monotonic clock reading as well, so time.Since and Sub no longer go backwards. The lesson outlives the fix: a duration is measured and a timestamp is read, and they come from different clocks.
The fourth case is the loop variable in production. On 29 February 2020 Let’s Encrypt found that Boulder, its certificate authority software, written in Go, had been checking CAA records — the DNS record in which a domain owner says which authorities may issue certificates for it — on only one name of a multi-name certificate. When a request held N names that needed rechecking, Boulder checked one of them N times. The Go blog later quoted the code: the loop copied its key, kCopy := k, but passed &v to a function that kept pointers into it, so every result pointed at the same variable. About 3 million certificates were affected. Let’s Encrypt replaced and revoked about 1.7 million by the 5 March deadline and let the rest expire, since they lived 90 days. Go 1.22 made the loop variable per iteration, which removes this whole class of bug — for modules whose go.mod says so.
Where it degrades
Go is generous in one direction and silent in the other. Concurrency is cheap enough to use without thinking, so a service written plainly scales a long way. But nothing in the language says when a goroutine ends, the standard library sets no timeouts, and the race detector reports only what the tests made happen. Almost every failure is one of these:
- Goroutines started with no owner, which leak whenever their caller stops listening (rows 1–3, 8).
- A
contextaccepted as a parameter and ignored, so cancellation never reaches the work (row 12). - An
http.Serverorhttp.Clientbuilt with no timeouts (rows 9–10). - Result sets left open, so the connection pool drains and every query waits (row 11).
- Shared maps and slices written from several goroutines, with
-racenever run in CI. - A container image built before Go 1.25, running with
GOMAXPROCSset to the host’s core count. - No
GOMEMLIMITin a container whose limit is less than twice the live heap. panicandrecoverused as exceptions for ordinary, expected failures.- Errors returned without context, so a failure arrives as "connection refused" with no clue where.
- A
goline ingo.modolder than the fixes the team believes it has — the loop variable, the timers.
The honest limit is the garbage collector and the deliberately small type system. A service whose heap is tens of millions of long-lived pointers — a huge in-memory cache, an index — pays for every cycle with CPU and latency that tuning can reduce but not remove, which is where Discord left. And a domain that wants to encode its rules in types, with exhaustive matching and no nil, will find Go’s types a cage; that is Rust’s territory, at the price of a much steeper language. For the network services, gateways and tools most teams actually write, the trade runs the other way, which is why so much of the infrastructure underneath them is written in Go.
The practical check takes ten minutes. Run go version against the production binary and find its row in the releases table. Read the go line in go.mod. Look at the goroutine count on a dashboard over a week. Compare GOMAXPROCS and GOMEMLIMIT with the container’s limits. Search the code for http.Client{ and http.Server{ and read their timeouts. And confirm that CI runs the tests with -race.
When to use it
Use it when
- Network services and APIs with high concurrency — the workload the runtime was designed around.
- Infrastructure and CLICommand-Line Interface — control by typed commandsDriving a program by typing commands instead of clicking. The real advantage is not typing speed: any command can be put in a script and repeated exactly, with no human in the loop — which is what makes automation possible at all.All terms tools, where one dependency-free binary is worth more than any language feature.
- Containers and serverless functions that must start in milliseconds and stay small.
- Teams that value one readable style over expressiveness — the language enforces it for you.
Avoid it when
- Huge, long-lived, pointer-heavy heaps where any collector pause is the problem — Rust frees at a known point.
- Domains with deep type-driven modelling, where the deliberately small type system feels like a cage.
- Data science and machine learning, where the libraries are in Python, not here.
- Rapid CRUD work that needs an admin, an ORMObject-Relational Mapping — objects instead of SQLA library that hands you language objects where the database has table rows, so you write little or no SQL. The convenience costs control: the query it generates can be far heavier than the one you would have written yourself, and you only find out under load.Full article →All terms and authentication on day one — Go gives you none of that.
Sources and method
What this page stands on
- 19 sources
- 2 documented failures
- Checked October 13, 2026
Practices and where the terms come from
every place a goroutine waits, the scheduler, the GC and the versions- StandardThe Go Programming Language Specification (opens in a new tab)go.dev
- StandardThe Go Memory Model (opens in a new tab)go.dev
- GuideEffective Go (opens in a new tab)go.dev
- GuideA Guide to the Go Garbage Collector (opens in a new tab)go.dev
- DocumentationData Race Detector (opens in a new tab)go.dev
- DocumentationGo 1.14 Release Notes (opens in a new tab)go.dev
- DocumentationGo 1.19 Release Notes (opens in a new tab)go.dev
- DocumentationGo 1.22 Release Notes (opens in a new tab)go.dev
- DocumentationGo 1.25 Release Notes (opens in a new tab)go.dev
- DocumentationGo 1.26 Release Notes (opens in a new tab)go.dev
- DocumentationGo 1.27 Release Notes (opens in a new tab)go.dev
- DocumentationRelease History (opens in a new tab)go.dev
- ArticleMichael Pratt, Carlos Amedee — Container-aware GOMAXPROCS (opens in a new tab)The Go Blog · Aug 20, 2025
- ArticleFixing For Loops in Go 1.22 (opens in a new tab)The Go Blog · Sep 2023
The failures this page learns from
Cloudflare and Let’s Encrypt, in “Five cases”Research
Twitch, Discord and Uber: the GC and data races at scale- ArticleRoss Engers — Go memory ballast: How I learnt to stop worrying and love the heap (opens in a new tab)Twitch Blog · Apr 10, 2019
- ArticleJesse Howarth — Why Discord is switching from Go to Rust (opens in a new tab)Discord · Feb 4, 2020
- ResearchMilind Chabbi, Murali Krishna Ramanathan — Data Race Patterns in Go (opens in a new tab)Uber Blog · PLDI 2022 · Jun 2, 2022
Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.
Found this useful?
Share it with someone who is working on the same problem.