Go only supports cooperative goroutine shutdown: your worker returns when it sees a closed channel, a canceled context.Context, drained work, or another signal you design. The sections below walk through the patterns people mean when they search for stopping or “killing” goroutines, without implying a hard-kill API exists.
Tested on: Go 1.22 on 64-bit Linux; full programs in this guide were run with
go runwhile the page was revised.
Can You Kill a Goroutine in Go?
No. Go does not provide anything like killGoroutine(id) as part of the language or standard library. A goroutine ends only when the function you launched with go returns (or the goroutine panics and is not recovered). Another goroutine cannot forcibly unwind its stack or inject a return; it can only signal shutdown through a channel, context cancellation, a timeout, closing a work queue, or another cooperative mechanism the worker checks in a loop or select.
That matches what people mean when they search for “golang kill goroutine” or “how to stop a goroutine”: there is still no hard kill—only patterns that make the goroutine choose to exit. Maintainers have explained on golang-nuts that tight loops without preemption points can starve other work; fixing that is not the same as an API to stop an arbitrary goroutine from outside. Since Go 1.14 the scheduler can preempt long-running loops for scheduling fairness, but that is an implementation detail: you still design shutdown with explicit cancel checks so defer runs, locks release, and I/O stops.
Why goroutines must stop by returning
Until the goroutine function returns, its stack and any pointers it still holds remain reachable. A goroutine that never observes shutdown therefore leaks work and can keep larger objects alive. Cooperative shutdown preserves invariants: defer can unlock mutexes, flush buffers, call cancel() on child contexts, and close handles in a defined order.
While drafting this guide I ran
go test -raceon Go 1.22+ (Linux) against a small module that mirrors the snippets. The fullmainlistings use{run=false}so you can paste them locally without a site runner stepping throughtime.Sleep.
For background on goroutines and channels, see goroutines in Go, concurrency basics, and using context.
Quick Ways to Stop a Goroutine
These are the patterns you will combine in real programs:
| Pattern | Best for |
|---|---|
done / quit channel (often chan struct{}, sometimes closed to broadcast) |
A worker owned by nearby code where you do not need to thread Context through helpers |
context.WithCancel |
Request-scoped work, parent/child cancellation, and APIs that already accept context.Context |
context.WithTimeout / WithDeadline |
Hard time limits on work (HTTP clients, DB calls, background retries) |
sync.WaitGroup |
Waiting until goroutines have actually finished after you signaled shutdown—it does not cancel by itself |
The context package documentation describes Context as carrying deadlines, cancellation signals, and request-scoped values across API boundaries, and recommends propagating it through the call chain for outgoing requests and library boundaries. Use a raw done channel when the scope is small and nothing needs a Context; use Context when cancellation should follow nested calls and third-party libraries.
Stop a goroutine using a done channel
Use a readable signal channel (often chan struct{}). close on that channel unblocks every receiver waiting in a select, which is handy for one-to-many shutdown.
package main
import (
"fmt"
"time"
)
func main() {
quit := make(chan struct{})
go func() {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-quit:
fmt.Println("worker returning")
return
case <-t.C:
fmt.Println("tick")
}
}
}()
time.Sleep(1200 * time.Millisecond)
close(quit)
time.Sleep(100 * time.Millisecond)
fmt.Println("main done")
}Run locally: you should see a few tick lines, then worker returning, then main done.
Stop a goroutine using context cancellation
context.WithCancel returns a Context and a cancel function. Call cancel() from the controller; the worker selects on <-ctx.Done() and returns (often after logging ctx.Err()).
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
t := time.NewTicker(300 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("exit:", ctx.Err())
return
case <-t.C:
fmt.Println("tick")
}
}
}()
time.Sleep(1 * time.Second)
cancel()
time.Sleep(100 * time.Millisecond)
}Stop multiple goroutines with one cancel signal
Call cancel() once; every goroutine that reads ctx.Done() unblocks. This is the usual way to fan out shutdown across workers.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-ctx.Done()
fmt.Println("worker", id, "stopped")
}(i)
}
time.Sleep(50 * time.Millisecond)
cancel()
wg.Wait()
fmt.Println("all workers returned")
}Stop Goroutines in Common Scenarios
The sections below map to how programs are usually structured: long-running loops, worker pools that drain a queue, first failure that should cancel sibling workers, and time-bounded work. Together they cover the intent behind searches like stopping a worker pool, canceling background work, or ending a goroutine after a deadline—not a runtime “kill” switch.
Stop an infinite loop goroutine
An “infinite” worker should still select on shutdown: combine work channels, tickers, and ctx.Done() or a quit channel so return is reachable.
for {
select {
case <-ctx.Done():
return
default:
// small unit of work; avoid blocking here without select
}
}If the body must call a blocking API, that API needs its own deadline or cancellation (for example Request with Context, Conn.SetDeadline, or a goroutine dedicated to bridging cancel into the API).
Stop a worker goroutine after all jobs are done
Close the job channel when producers finish. The consumer exits the for range loop and returns.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan string, 2)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for msg := range jobs {
fmt.Println("handled:", msg)
}
fmt.Println("worker drained queue")
}()
jobs <- "a"
jobs <- "b"
close(jobs)
wg.Wait()
}This pairs naturally with WaitGroup when main must wait for cleanup before exiting.
Stop goroutines when one worker returns an error
Propagate failure by canceling a shared Context once any goroutine detects the error; siblings select on ctx.Done() and exit. (Libraries such as golang.org/x/sync/errgroup wrap the same idea with Wait() returning the first error.)
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
errs := make(chan error, 1)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(80 * time.Millisecond)
errs <- errors.New("db unreachable")
cancel()
}()
for i := 0; i < 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-ctx.Done()
fmt.Println("helper", id, "stops")
}(i)
}
wg.Wait()
fmt.Println("first error:", <-errs)
}Stop a goroutine after timeout
Use context.WithTimeout (or WithDeadline) so ctx.Done() fires automatically—this is the usual answer when the real problem is “this might run too long” (remote calls, scraping, child processes), not only “I want to flip a stop flag.” Always defer cancel() to release timer resources promptly, even when the function returns early.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
<-ctx.Done()
fmt.Println("stopped:", ctx.Err())
}()
<-done
}Wait for Goroutines to Exit Cleanly
Use sync.WaitGroup with cancellation
WaitGroup answers “have these goroutines finished?” It does not stop them: cancel or close channels first, then Wait() so teardown runs after workers return.
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
const n = 4
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
}()
}
cancel()
wg.Wait()Run cleanup before returning from a goroutine
Use defer for locks, file handles, timers, and child cancel functions so they run on every return path—including when ctx.Done() fires.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
defer fmt.Println("cleanup: ticker stopped")
for {
select {
case <-ctx.Done():
return
case <-t.C:
// periodic work
}
}
}()
time.Sleep(200 * time.Millisecond)
cancel()
time.Sleep(100 * time.Millisecond)
}Common Mistakes That Cause Goroutine Leaks
Sending on a channel after the receiver exits
If the consumer returns or stops receiving while the producer still sends on an unbuffered channel, the producer can block forever—a classic goroutine leak. The Go blog article on pipelines explains how stages interact when receivers stop early and senders keep trying to send, and why closing channels must follow clear ownership rules. Fixes include: close the channel from the producer when no more values will be sent, use a Context plus select so sends can abort, use a buffered channel with a documented capacity, or signal shutdown explicitly before the receiver exits.
Forgetting to close a done channel
If workers wait on <-quit but nothing ever closes quit (or sends the agreed signal), they never return. The opposite mistake—closing twice—panics. Pick one owner for close and document it.
Blocking forever on sleep, receive, or network calls
time.Sleep without a larger select cannot be interrupted. Blocking Recv on a channel with no sender, or a network read without a deadline, stalls shutdown. Prefer select with ctx.Done(), use time.NewTimer in a select, or set deadlines on I/O.
Goroutine Cancellation Cheat Sheet
Context vs done channel vs WaitGroup
| Goal | Use | Notes |
|---|---|---|
| Stop one simple goroutine | done or quit channel |
Worker must select on the channel (or receive from it) and return; closing can fan out to many waiters |
| Stop many related goroutines | context.WithCancel |
Call cancel() once; everyone waiting on ctx.Done() can exit |
| Stop after a time limit | context.WithTimeout / WithDeadline |
Always defer cancel() to free the timer; ctx.Err() is DeadlineExceeded when time runs out |
| Wait until goroutines have exited | sync.WaitGroup |
Signals completion, not cancellation—cancel first, then Wait() |
| Stop workers after all jobs are produced | Close the jobs channel | Consumers for range the channel and return when drained |
| Avoid blocked sends when shutdown is racy | select with default or ctx.Done() |
Prevents leaks when a receiver exits early; pairs with pipeline patterns |
| Run cleanup on every exit path | defer inside the goroutine |
Stop tickers, unlock mutexes, close files, call child cancel functions |
| Forcefully kill a goroutine | Not supported | Design workers to observe cancel signals and return |
References
- Package context (overview describes deadlines and cancellation across API boundaries)
- Package sync
- Go Blog: Pipelines and cancellation
- Go Wiki: Common mistakes
- golang-nuts: scheduler, tight loops (Ian Lance Taylor)

