Sunday, October 25, 2026
Why Is There No Compile-Time Deadlock Detection In Go?
Posted by

Introduction
There is no compile-time deadlock detection in Go because deadlock is a property of a running program, not a property of source text alone. Whether a wait ends depends on runtime values, on how many goroutines start, on which channels they use, and on the order in which partners arrive, and none of that is fixed when the compiler reads the code.
Before You Start
- Compile time: the stage when the compiler reads source code, checks types and syntax, and produces a binary. At compile time, the program has not run, no input has arrived, and no goroutine has started.
- Runtime: the stage when the program runs. Values arrive from input, files, networks, and configuration, goroutines start and stop, and channel operations either meet a partner or wait.
- Static analysis: analysis of source code without running it. The compiler and
go vetare static tools. They can see structure, types, and names, but they cannot see runtime values or schedules. - Dynamic analysis: analysis of a running program. The race detector and the runtime deadlock check are dynamic tools. They observe what actually happens in one run, and they cannot report on runs that did not happen.
- Goroutine: a function running concurrently with other functions in the same program, started with the
gokeyword. A goroutine runs until its function returns. - Channel: a typed conduit that carries values between goroutines, created with
make. A send blocks until a receiver is ready, for an unbuffered channel, or until the buffer has space, for a buffered channel. A receive blocks until a value is ready or the channel is closed and drained. - Blocked: a goroutine stopped at an operation, waiting for an event that would let it continue, such as a receiver, a sender, or a closed channel.
- Deadlock: a state in which no goroutine can make progress, because every goroutine is blocked on a wait that no other goroutine can end. This article is about deadlock in general, not about one deadlock shape. The shapes themselves are covered in the related post on select and unbuffered channels, linked at the end.
- Partial deadlock: a state in which some goroutines are blocked forever, while other goroutines keep running. The program as a whole does not stop, so the blocked part looks like a hang, not a crash.
- Data race: a state in which two goroutines access the same variable concurrently, and at least one of the accesses is a write. A data race is a memory access problem, not a waiting problem, and the tool that finds it is a different tool from any deadlock check.
- Scheduler: the runtime component that decides which runnable goroutine runs on which thread. The order in which goroutines run is a runtime outcome, not a source order promise.
- Timeout: a bound on how long an operation may wait, usually built from a context with a deadline, or from a channel returned by
time.After. A timeout turns an unbounded wait into a bounded one. - Context: a value from the
contextpackage that carries cancellation and deadlines. When a context is cancelled, or its deadline passes, itsDonechannel closes, and a select case onDonebecomes ready.
Prerequisites
- Go 1.20 or later, because the article uses
context,time, and channel patterns that are stable in that version. - A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later.
Project Layout
deadlock-detection-demo/
├── go.mod # module declaration, Go version
├── main.go # input dependent exchange, timeout version, and main function
└── main_test.go # partner ready, no partner, and timeout tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the exchange functions, so the input dependence can be read in one place, plus the
mainfunction that runs the safe version. - main_test.go: holds tests that prove the safe version returns with a partner, returns on timeout without a partner, and never waits without a bound.
What The Compiler Does Check
The compiler checks properties that are visible in source text, independent of execution. It checks that types match, that a channel direction is respected, that a function is called with the right number and types of arguments, that imported packages are used, and that declared local variables are used. Those checks are valuable, and they catch a large class of mistakes before a program runs.
None of those checks answers the question deadlock asks. Deadlock asks whether a particular wait, in a particular run, with particular input, will ever end. That question is about partners, values, and schedules, and the compiler has none of those at compile time. A program can pass every compile check and still deadlock, because the source text is correct as text, while the runtime behaviour is wrong as execution.
That distinction is the core of this article. Compile checks are about what the code says. Deadlock is about what the code does, when it runs, with real input, alongside other goroutines.
Why A Wait Cannot Be Judged From Source Alone
A channel operation in source text has no fixed outcome. The same receive line can complete at once, complete after a delay, or wait forever, depending on what else is running. The compiler sees the line, not the partner.
Consider a receive from a channel. Whether it completes depends on whether some goroutine sends on that channel, and whether that goroutine reaches its send. A sender that is itself blocked, that returns early on an error, or that never starts because a condition was false, leaves the receiver waiting. The source text shows the receive, and it may show a sender elsewhere, but it does not show whether that sender will run in this execution.
That is the first reason compile-time deadlock detection is absent: blocking depends on partner arrival, and partner arrival is a runtime event.
Why Runtime Values Decide The Outcome
The second reason is input. A program that deadlocks for one input can run cleanly for another, with no change to the source text.
A count read from input can decide how many goroutines start. A flag can decide whether a partner goroutine starts at all. A value from a file, a network response, or configuration can decide which branch runs, which channel is used, and whether a loop sends once, many times, or not at all. A channel capacity taken from a variable can decide whether a send completes into a buffer or blocks for a receiver.
In each case, the compiler would need to know the input to judge the outcome, and the input does not exist at compile time. A check that answers correctly for one input and wrongly for another is not a deadlock detector. It is a guess about one run.
The complete example later in this article is built on exactly this point. One boolean input decides whether a partner starts, and the same exchange function either completes or waits, depending on that input.
Why Dynamic Goroutine Creation Breaks A Static Count
The third reason is that goroutines are created at runtime, in loops, in callbacks, and inside libraries. The compiler cannot count how many goroutines will exist in a run, because that number can depend on input, on data size, and on events that happen while the program runs.
A loop that starts one goroutine per item starts a different number of goroutines for a list of three items and a list of three thousand items. A server that starts a goroutine per request starts a different number for each load pattern. A library that starts workers internally adds goroutines the calling code never sees in its own source.
Deadlock depends on the whole set of goroutines and their waits. A static count that is wrong about that set cannot prove that no wait in the set will last forever. The compiler would need to track every goroutine a program could ever start, in every order, for every input, and that set is not fixed in the source.
Why The Communication Graph Is Not Fixed In Source
The fourth reason is that channels are values. A channel can be passed as a function argument, stored in a struct, held in a map or a slice, and sent over another channel. The set of goroutines that share a channel, and the order in which they use it, is therefore decided at runtime, not written in one place in the source.
Two goroutines that look unrelated in source can end up sharing a channel through a value passed at runtime. Two goroutines that look like partners in source can end up using different channels, because a channel variable was reassigned, or because a function received a different channel in this run. A static tool that reads the source sees names and types, not the channel identities that actually meet at runtime.
That is why a deadlock check cannot be a simple graph drawn from source text. The graph itself is a runtime object, built as channels are created, passed, and shared.
Why Predicting Every Run Is A Different Kind Of Problem
Taken together, those reasons point to a practical boundary, stated carefully. To prove that a program never deadlocks, a tool would need to answer, for every input and every schedule, whether every wait in the program ends. That is a question about execution, not about structure. It asks the tool to predict what a program will do, for all inputs, before the program runs.
This article presents that as the practical reason for the absence of a compile-time deadlock check, not as a formal theorem. It does not claim a proof of undecidability for Go deadlock detection, and it does not need one. The practical point stands on its own: the compiler checks structure and types, while deadlock depends on values, partners, and schedules that only exist at runtime.
That boundary also explains why the tools that do catch deadlocks are runtime tools. They do not predict every run. They observe one run, and they report what happened in that run.
What The Runtime Detector Does, And Where It Stops
The Go runtime has a deadlock check, and it works at runtime, not at compile time. When no goroutine in a program can make progress, the runtime stops the program, instead of leaving it waiting forever with no possible change. That check is valuable for a small program, where a blocked program is visible at once, and the stop points at the blocked goroutines.
The check has a limit that follows directly from its rule. It fires only when no goroutine can make progress. In a server, a single request handler blocked on a channel does not stop the whole program, because other goroutines are still running. That handler hangs that request, and the hang looks like a slow request, not a crash. The runtime check cannot catch that partial case, by its own rule, because the program as a whole is still making progress.
A related distinction matters here. A goroutine that stays blocked while the rest of the program runs is a leak, covered in the related post on ranging over a channel, linked at the end. A deadlock in the runtime sense is the total case, where nothing can move. Both involve a blocked goroutine, but only the total case triggers the runtime stop.
What Go Vet Checks, And What It Does Not Check
go vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string. Its documentation states the limit directly: vet uses heuristics that do not guarantee all reports are genuine problems, and it does not check every possible problem, so it should be used as guidance only, not as a firm indicator of program correctness.
The check list published with go vet includes checks for copying locks, for lost cancellation, for WaitGroup misuse, and for loop variable capture, among others. No check in that list is a deadlock check, and no check in that list proves that a channel wait will end. Vet can catch mistakes near a deadlock, such as a copied lock, but a clean vet run is not evidence that a program is deadlock free.
That is a common misreading worth correcting. Vet is a static tool, with the same boundary as the compiler: it sees source text, not runtime partners, values, or schedules.
Why The Race Detector Does Not Detect Deadlock
The race detector is also a runtime tool, but it detects a different bug. A data race occurs when two goroutines access the same variable concurrently, and at least one of the accesses is a write. The race detector reports conflicting memory accesses that actually happen in a run, and its own documentation states that it only finds races that happen at runtime, so it cannot find races in code paths that are not executed.
Deadlock is not a conflicting memory access. It is a waiting problem: goroutines blocked on channels, locks, or waits, with no partner to end the wait. A program can deadlock with no shared variable access at all, and a program can have a data race with no deadlock. Running a test with -race therefore does not test for deadlock, and a clean -race run is not evidence that a program is deadlock free.
The two tools also differ in what a clean run proves. A clean race run proves that no data race was observed in that run, on that machine, with that schedule. It does not prove that no race exists in any run, and it certainly does not prove that no wait in any run will last forever.
What Actually Catches Deadlocks In Practice
In practice, deadlocks are caught by a combination of design and runtime evidence, not by a single compile check:
- Tests with timeouts: a test that waits for a result with a bounded wait turns a hang into a failure. The test in the complete example does exactly that, with a select on a done channel and a time bound.
- Context deadlines and cancellation: a context with a deadline, passed to every stage that can wait, turns an unbounded wait into a bounded one, and a cancelled context lets a stage stop when its caller no longer needs the result.
- The runtime total deadlock check: for a small program, the runtime stop on total deadlock points at the blocked goroutines, and it is the fastest signal for that case.
- Goroutine count as a rough signal:
runtime.NumGoroutinereturns the number of goroutines currently existing in a program. A count that grows with requests and never falls, in a test, is a signal that goroutines are being left behind. It is a rough signal only: a growing count does not prove a deadlock, and a stable count does not prove the absence of one, because legitimate goroutines also raise the count, and a single blocked goroutine in a large program barely moves it.
No item in that list is a compile-time proof. Each one observes a run, or bounds a wait, and each one must be applied to the code paths that can actually block.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Compiler type and structure checks | Every build, as a baseline | Catches type and structure mistakes, but cannot judge runtime waits |
| Go vet | Every build, as guidance | Catches suspicious constructs near a deadlock, but its checks are heuristics, and no check in its list is a deadlock check |
| Race detector with -race | Tests and continuous integration, for data races | Finds data races observed in a run, but does not detect deadlock, and a clean run proves nothing about waits |
| Runtime total deadlock check | Small programs and reproduction cases | Stops a totally blocked program at once, but cannot catch a partial deadlock in a running server |
| Tests with timeouts | Every concurrent code path that can wait | Turns a hang into a failure, but only for the inputs and schedules the test actually exercises |
| Context deadlines and cancellation | Requests, jobs, and pipelines with a lifetime | Bounds every wait, but every stage must respect the context, or the bound is partial |
| Goroutine count in tests | Long running tests and load tests, as a trend signal | Shows goroutines being left behind, but cannot identify which goroutine is stuck, or prove a deadlock |
| Buffered channel as a fix | Known bursts that need decoupling | Delays a block until the buffer fills, but does not remove the need for a partner or a bound |
A design in this article has a deliberate compromise, stated openly. The complete example uses a single exchange, not a full pipeline or worker pool, so the input dependence can be read without extra machinery. In a real pipeline, the same rule applies at every stage: each wait needs a partner, a close, or a bound, and no single stage can be judged in isolation. The single exchange here is a teaching compromise, not a production layout. The core claim is not compromised: whether the wait ends depends on the runtime input, not on the source text.
Complete Example
The complete example is a small program where the same exchange function either completes or waits, depending on a runtime input. A partner goroutine starts only when the input is true. The safe version adds a timeout, so the no partner case returns instead of waiting forever. The unsafe shape is shown for comparison, clearly labelled, and never called by main or by any test, so the test binary cannot hang on it.
// go.mod
module deadlockdetection
go 1.20
// main.go
package main
import (
"fmt"
"time"
)
func unsafeExchange() string {
ch := make(chan string)
return <-ch
}
func exchangeWithTimeout(partnerReady bool) (string, bool) {
ch := make(chan string)
if partnerReady {
go func() {
ch <- "hello"
}()
}
select {
case value := <-ch:
return value, true
case <-time.After(100 * time.Millisecond):
return "", false
}
}
func main() {
value, ok := exchangeWithTimeout(true)
fmt.Printf("partner ready: %q, ok=%v\n", value, ok)
value, ok = exchangeWithTimeout(false)
fmt.Printf("no partner: %q, ok=%v\n", value, ok)
}
Commands to run it, from the deadlock-detection-demo folder:
go run .
Expected output:
partner ready: "hello", ok=true
no partner: "", ok=false
Why this program shows the compile-time problem, in order:
exchangeWithTimeouthas one receive line, and that line has two outcomes. With a partner, the receive completes. Without a partner, the timeout case wins, and the function returns.- The compiler sees the same receive line in both runs. It cannot tell, from the line alone, which outcome will happen, because the outcome depends on the
partnerReadyvalue, which is a runtime input. unsafeExchangeshows the shape without a bound. It has no partner at all, so a call to it would wait forever, and in a program with no other goroutine, the runtime total deadlock check would stop the program. It is never called here, by design, so the example program always finishes.- The timeout does not make the compiler smarter. It makes the wait bounded, so the program does not need the compiler to predict the outcome. That is the practical replacement for a compile-time proof: a bound, not a prediction.
Testing Channels And Waits
Test a concurrent function by completion with a bound, not by waiting without one. A test that waits without a bound can hang exactly like the program it is meant to check, and a hanging test is itself a symptom, not a result.
// main_test.go
package main
import (
"testing"
"time"
)
func TestExchangeWithTimeoutPartnerReady(t *testing.T) {
value, ok := exchangeWithTimeout(true)
if !ok {
t.Fatal("exchange with partner returned ok=false, want true")
}
if value != "hello" {
t.Errorf("exchange with partner = %q, want %q", value, "hello")
}
}
func TestExchangeWithTimeoutNoPartner(t *testing.T) {
value, ok := exchangeWithTimeout(false)
if ok {
t.Fatal("exchange without partner returned ok=true, want false")
}
if value != "" {
t.Errorf("exchange without partner = %q, want empty", value)
}
}
func TestExchangeReturnsWithinBound(t *testing.T) {
done := make(chan struct{})
go func() {
exchangeWithTimeout(false)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("exchange did not return within 2 seconds")
}
}
The partner test checks the completion path. The no partner test checks the timeout path, and it returns quickly because the function itself has a 100 millisecond bound. The bound test checks the contract from outside: the call must return within two seconds, or the test fails instead of hanging forever. No test calls unsafeExchange, and no test waits without a bound.
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test, including the bound test, because the timeout version always returns. go vet reports suspicious constructs. It does not detect deadlock, so a clean vet run is not proof that a wait will end. go test -race can be run alongside these tests for data races, but it does not change the deadlock conclusion: a clean race run is not a deadlock check.
Common Mistakes
- Expecting the compiler to catch a deadlock: a program that compiles cleanly can still deadlock, because the compiler checks types and structure, not runtime partners, values, or schedules.
- Expecting go vet to catch a deadlock: vet reports suspicious constructs with heuristics, and no check in its published list is a deadlock check. A clean vet run is guidance, not a proof.
- Expecting the race detector to catch a deadlock: the race detector reports data races observed in a run. Deadlock is a waiting problem, not a conflicting memory access, so
-racecannot report it. - Treating the runtime stop as a compile error: the runtime total deadlock check fires when a program runs and no goroutine can make progress. It is a runtime signal, not a build failure, and it does not appear at compile time.
- Treating a partial hang as a runtime detection case: a single handler blocked while a server keeps running will not trigger the total deadlock check, because other goroutines are still making progress. That hang needs a request timeout, not a runtime report.
- Adding a buffer and calling the wait fixed: a buffered channel lets a sender complete up to the buffer size without a receiver, then it blocks on the next send, exactly like an unbuffered channel. A buffer delays a block, it does not prove a partner exists.
- Counting goroutines and calling the count a proof: a rising
runtime.NumGoroutinecount is a signal that goroutines are being left behind, but legitimate goroutines also raise the count, and a single blocked goroutine barely moves it. A count alone cannot identify a stuck goroutine. - Testing only the happy path: a test that only exercises the partner ready case cannot catch the no partner case, because the deadlock depends on the input the test never used. Tests need the no partner case, with a bound.
- Testing one schedule and calling it a proof: a test that passes once, with one schedule, proves that one run completed. A different schedule, with a different partner arrival order, can still block.
- Assuming a small program scales: a program that completes in a test, with one goroutine and one channel, can deadlock in production, with many goroutines, many channels, and input that changes the communication graph.
Production Notes
- Timeouts on every wait that can block: every channel operation that can wait for a partner, a file, or a network response needs a bound, from a context deadline or a timeout case. An unbounded wait is a hang waiting for an input that never arrives.
- Cancellation through the whole call chain: a context passed to every stage that can wait lets a caller stop work it no longer needs. A stage that ignores the context keeps waiting after its caller has moved on, and that wait is a partial deadlock in practice.
- Design for one owner per channel: each channel needs a clear sender, a clear close rule, and a clear bound. When ownership is unclear, the partner question has no answer in the design, and no tool can supply one later.
- Observability: a hang in production shows up as a request that never returns, or a goroutine count that grows with load and never falls. Request timeouts turn the first symptom into a bounded failure, and a goroutine count trend turns the second into a signal worth investigating.
- Deployment: the example needs Go 1.20 or later. Pin that version in
go.mod, rungo vet ./...andgo test ./...in continuous integration, and do not deploy a change that fails the completion or bound tests. A vet pass alone is not enough for concurrent code, because vet does not prove that a wait will end.
Decision Guide
- When a wait has a guaranteed partner in the same design: use a plain channel operation, and document why the partner is guaranteed, in the design, not in a tool output.
- When a wait depends on input, a network, or another service: use a select with a timeout case or a context deadline, so the wait is bounded even when the partner never arrives.
- When a test must prove a wait ends: use a bound in the test itself, with a done channel and a time bound, so a hang becomes a failure instead of a hanging test.
- When checking for data races: use
go test -race, and read a clean run as evidence about observed memory accesses only, never as evidence about waits. - When checking for suspicious constructs: use
go vet, and read a clean run as guidance about source patterns only, never as evidence about deadlock. - When a program stops with a total deadlock: use the runtime report as a starting point for the blocked goroutines, then fix the partner, the close, or the bound, and add a timeout test for that case.
- When a server hangs on one request: use request timeouts and goroutine count trends, because the runtime total deadlock check cannot fire while other goroutines keep running.
Conclusion
There is no compile-time deadlock detection in Go because deadlock is not a property the compiler can see. It depends on runtime values, on how many goroutines start, on which channels they share, and on the order in which partners arrive, and all of that exists only when the program runs.
Summary Of Key Points
- Compiler checks structure, not waits: types, directions, and unused declarations are visible in source text, while deadlock depends on partners, values, and schedules.
- Input decides the outcome: the same exchange can complete or wait, depending on a runtime value, so no source-only check can answer for every input.
- Goroutines and channels are dynamic: goroutine counts and channel identities are decided at runtime, so a static graph cannot capture the real communication pattern.
- Prediction is not the tool: proving no deadlock for every input and schedule would mean predicting execution, and this article presents that as the practical reason, not as a formal theorem.
- Runtime tools observe one run: the total deadlock check fires only when nothing can make progress, and it cannot catch a partial hang in a running server.
- Vet and race are different checks: vet reports suspicious constructs with heuristics, and the race detector reports observed data races, and neither one detects deadlock.
- Bounds replace prediction: timeouts, context deadlines, and timeout tests turn a hang into a bounded failure, and that bound is the practical answer to a question the compiler cannot answer in advance.
Design every wait with a partner, a close, or a bound, test the no partner case with a timeout, and read clean compiler, vet, and race runs for what they actually prove, not for what they cannot.
Related Posts
- Why Does My Go Program Deadlock On Select And Unbuffered Channels?
- Why Do My Goroutines Leak When I Range Over A Channel?
Learn more:
Additional Resources
- Go Documentation: The Go Programming Language Specification - channel types, send and receive operations, and select execution
- Go Documentation: Effective Go - share by communicating, goroutines, and channel blocking rules
- Go Documentation: Data Race Detector - what a data race is, and that the detector only finds races that happen at runtime
- Go Packages: vet command - vet examines source code with heuristics, and does not check every possible problem
- Go Packages: runtime package - goroutine count and other runtime operations
- Go Packages: context package - cancellation, deadlines, and the Done channel
- Go Blog: Go Concurrency Patterns: Pipelines and cancellation - stages, cancellation, and done channels in pipelines
References
- The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec. Accessed 2026-10-25.
- Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go#concurrency. Accessed 2026-10-25.
- Data Race Detector. Go team, Go Documentation. https://go.dev/doc/articles/race_detector. Accessed 2026-10-25.
- vet command. Go team, Go Packages. https://pkg.go.dev/cmd/vet. Accessed 2026-10-25.
- runtime package. Go team, Go Packages. https://pkg.go.dev/runtime. Accessed 2026-10-25.
- context package. Go team, Go Packages. https://pkg.go.dev/context. Accessed 2026-10-25.
- Go Concurrency Patterns: Pipelines and cancellation. Go Blog. https://go.dev/blog/pipelines. Accessed 2026-10-25.