Go Concurrency for Python Developers Who Know asyncio
Map asyncio concepts to Go with runnable pairs: tasks, gather, timeouts, queues, locks and cancellation, and what changes without an event loop.

If you write Python with asyncio, you already understand most of what concurrency means: many tasks in flight, waiting on I/O, a scheduler that resumes whichever is ready. You also carry habits that are correct for asyncio and wrong, or merely unnecessary, in Go. There is no event loop to start, no await to remember, and no rule that blocking calls must be avoided. The part that surprises Python developers most is the opposite: Go runs your goroutines in parallel on every core, so the safe-by-construction shared state that asyncio gave you for free is gone.
Tested with Go 1.25.6, Python 3.14, macOS on Apple Silicon (arm64)
This guide maps the asyncio vocabulary to Go one concept at a time, with pairs of runnable programs, and ends with the traps that catch people who arrive from Python. It connects to goroutines, channels and context, and sits in the concurrency patterns cluster. A broader comparison of the models is in goroutines, threads and async await. Everything ran on Go 1.25.6 and Python 3.14.
The mental shift
In asyncio, a function that waits is a coroutine, declared async def, and it yields control to the event loop at every await. Only one coroutine runs at a time, on one thread. The program is cooperative: nothing else runs until the current coroutine awaits.
In Go, a function that waits is a plain function. A goroutine is a function call that runs concurrently with its caller, started with the go keyword. When it blocks (on a channel, a lock, a network read, a sleep), the Go runtime parks it and runs another on whatever core is free. There is no await, because blocking is not a special event the programmer marks; it is just what the runtime handles. The runtime is preemptive, too: a goroutine in a tight loop is interrupted (async preemption), where a coroutine in a tight loop would freeze the whole asyncio program.
Two consequences follow. Any function can be run concurrently without being rewritten, so there are no sync and async variants of a library. And because goroutines really run at the same time on multiple cores, the hazards of threads apply, which asyncio programs avoid.
One more difference worth stating before the examples: the unit of failure. A Python script with a bug in one coroutine usually logs the exception and keeps the loop alive for the others. A Go program with an unrecovered panic in one goroutine exits for all of them, with a stack trace of the guilty goroutine. That makes Go programs fail fast and loud, and it means the places where goroutines are started are the places to decide what a crash should mean.
Running two things at once, and waiting for both
The core asyncio idiom is to create tasks and gather them. Here are three simulated "fetches" with different delays, started together:
import asyncio, time
async def fetch(name, delay):
await asyncio.sleep(delay)
print(f"{name} done after {delay:.1f}s")
return name.upper()
async def main():
start = time.time()
results = await asyncio.gather(fetch("users", 0.3), fetch("orders", 0.1), fetch("stats", 0.2))
print(results, f"total {time.time() - start:.1f}s")
asyncio.run(main())orders done after 0.1s
stats done after 0.2s
users done after 0.3s
['USERS', 'ORDERS', 'STATS'] total 0.3sThe Go version has no async, no await and no asyncio.run. fetch is a normal function that sleeps; go starts it; a WaitGroup plays the role of gather. Results go into a preallocated slice, one slot per task, so the order matches the input as gather does:
package main
import (
"fmt"
"strings"
"sync"
"time"
)
func fetch(name string, delay time.Duration) string {
time.Sleep(delay)
fmt.Printf("%s done after %.1fs\n", name, delay.Seconds())
return strings.ToUpper(name)
}
func main() {
start := time.Now()
inputs := []struct {
name string
delay time.Duration
}{{"users", 300 * time.Millisecond}, {"orders", 100 * time.Millisecond}, {"stats", 200 * time.Millisecond}}
results := make([]string, len(inputs))
var wg sync.WaitGroup
for i, in := range inputs {
wg.Go(func() { results[i] = fetch(in.name, in.delay) })
}
wg.Wait()
fmt.Println(results, fmt.Sprintf("total %.1fs", time.Since(start).Seconds()))
}orders done after 0.1s
stats done after 0.2s
users done after 0.3s
[USERS ORDERS STATS] total 0.3sBoth finish in about 0.3 seconds, printing the tasks in the order they complete and the results in the order they were requested. In Go, the function you wrote already worked; only the call site changed. If a task can fail, you reach for errgroup, which is the counterpart of asyncio.TaskGroup (Python 3.11): the first error cancels the rest and Wait returns it (errgroup).
Timeouts and cancellation
asyncio gives you asyncio.wait_for(coro, timeout) and task.cancel(), which raises CancelledError inside the coroutine at its next await. Go has no way to inject an exception into a running goroutine, and that is deliberate: cancellation is cooperative and explicit. You pass a context.Context down, and each blocking function watches it:
import asyncio
async def slow():
try:
await asyncio.sleep(2)
return "finished"
except asyncio.CancelledError:
print("slow(): cancelled, cleaning up")
raise
async def main():
try:
await asyncio.wait_for(slow(), timeout=0.2)
except asyncio.TimeoutError:
print("main(): timed out")
asyncio.run(main())slow(): cancelled, cleaning up
main(): timed outpackage main
import (
"context"
"errors"
"fmt"
"time"
)
func slow(ctx context.Context) (string, error) {
select {
case <-time.After(2 * time.Second):
return "finished", nil
case <-ctx.Done():
fmt.Println("slow(): cancelled, cleaning up")
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if _, err := slow(ctx); errors.Is(err, context.DeadlineExceeded) {
fmt.Println("main(): timed out")
}
}slow(): cancelled, cleaning up
main(): timed outThe output is the same two lines. The difference is in who does the work. In Python the framework raises the exception at the right moment. In Go the function chooses to check ctx.Done(), usually by passing the context to whatever it calls (http.NewRequestWithContext, db.QueryContext), so cancellation is part of every blocking function's signature. It feels like more typing, and it removes a whole class of surprises where a coroutine is cancelled in the middle of something you did not expect (context cancellation, the timeout pattern).
Queues, locks and semaphores
The asyncio synchronization primitives each have a Go counterpart, and the translation is mostly mechanical:
| asyncio | Go |
|---|---|
asyncio.create_task(f()) | go f() |
await asyncio.gather(...) | sync.WaitGroup, or errgroup for errors |
asyncio.Queue(maxsize=n) | make(chan T, n) |
asyncio.Semaphore(n) | make(chan struct{}, n) or semaphore.Weighted |
asyncio.Lock() | sync.Mutex |
asyncio.Event() | close(ch) on a chan struct{} |
asyncio.wait_for(f, t) | context.WithTimeout |
loop.run_in_executor(...) | nothing; call the blocking function in a goroutine |
async for x in agen() | for x := range ch, or for x := range seq |
A producer and a consumer joined by a bounded queue is the standard structure, and the code is nearly line for line the same:
package main
import (
"fmt"
"sync"
)
func main() {
queue := make(chan int, 2) // like asyncio.Queue(maxsize=2): put blocks when full
var wg sync.WaitGroup
wg.Go(func() { // producer
defer close(queue) // like putting a sentinel: tells the consumer no more are coming
for i := 1; i <= 5; i++ {
queue <- i
fmt.Println("produced", i)
}
})
wg.Go(func() { // consumer
for item := range queue { // ends when the queue is closed and drained
fmt.Println(" consumed", item)
}
})
wg.Wait()
}produced 1
produced 2
produced 3
consumed 1
consumed 2
consumed 3
consumed 4
produced 4
produced 5
consumed 5A bounded channel gives the same backpressure as asyncio.Queue(maxsize=...): the producer blocks when the buffer is full. Closing a channel replaces the sentinel value or queue.join() dance, and a for range over a channel ends when it is closed and empty. The lines of output interleave in an order that depends on scheduling, which is the first hint of the next section.
TaskGroup and errgroup
Python 3.11 added asyncio.TaskGroup, structured concurrency for tasks: the async with block does not exit until every task in it has finished, and if one raises, the others are cancelled. Go's counterpart is errgroup.WithContext. Here a task fails after 100 ms while two others are still running, and both languages cancel the survivors:
import asyncio
async def worker(name, delay, fail=False):
try:
await asyncio.sleep(delay)
if fail:
raise ValueError(f"{name} failed")
print(f"{name} finished")
except asyncio.CancelledError:
print(f"{name} was cancelled")
raise
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(worker("a", 0.3))
tg.create_task(worker("b", 0.1, fail=True))
tg.create_task(worker("c", 0.5))
except* ValueError as group:
print("group failed:", group.exceptions[0])
asyncio.run(main())c was cancelled
a was cancelled
group failed: b failedpackage main
import (
"context"
"errors"
"fmt"
"slices"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
var mu sync.Mutex
var events []string
note := func(s string) { mu.Lock(); events = append(events, s); mu.Unlock() }
worker := func(name string, delay time.Duration, fail bool) func() error {
return func() error {
select {
case <-time.After(delay):
if fail {
return errors.New(name + " failed")
}
note(name + " finished")
return nil
case <-ctx.Done():
note(name + " was cancelled")
return ctx.Err()
}
}
}
g.Go(worker("a", 300*time.Millisecond, false))
g.Go(worker("b", 100*time.Millisecond, true))
g.Go(worker("c", 500*time.Millisecond, false))
err := g.Wait()
slices.Sort(events)
for _, e := range events {
fmt.Println(e)
}
fmt.Println("group failed:", err)
}a was cancelled
c was cancelled
group failed: b failedThe outcomes match: b fails, a and c are cancelled before they finish, and the group reports the first error. The difference is in the plumbing. Python's cancellation arrives as an exception wherever a coroutine happens to be awaiting. In Go each worker selects on ctx.Done() itself, because errgroup cancels a context and nothing else; a worker that ignores the context keeps running until it returns on its own, and Wait waits for it. Neither runtime promises the order in which the cancelled workers report: Python printed c before a, and the Go program sorts its events before showing them so the output is stable.
What asyncio spared you
In asyncio, counter += 1 inside a coroutine is safe, because no other coroutine can run between the read and the write: switches happen only at await. A great many asyncio programs rely on this without knowing it. In Go, the equivalent code is a data race:
var counter int
for range 100 {
go func() { counter++ }() // data race: two goroutines may run this at once
}The goroutines run in parallel, and counter++ is a read, an add and a write that can interleave. The result is wrong, not slow (data races). The Go tools for this are a mutex (what you would reach for with threads in Python), an atomic for a single value, a channel to hand ownership of the data to one goroutine, or a design that avoids sharing. And the tool that finds the mistakes is the race detector: go test -race should be part of your muscle memory, like running a linter (the race detector).
Python's GIL changes the picture a little for threaded Python (the interpreter serializes bytecode), and free-threaded builds of Python 3.13 and later remove it, but asyncio code never needed to think about it. Go code always does.
Here is the failure on the Go side, using the race detector. The program prints counter: 3000 where 4000 is correct, and the detector reports the conflict whether or not the number happens to look right:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for range 1000 {
counter++ // safe in asyncio, a data race in Go
}
})
}
wg.Wait()
fmt.Println("counter:", counter)
}counter: 3000
==================
WARNING: DATA RACE
Read at 0x00c000124038 by goroutine 8:
main.main.func1()
example.test/lab/main.go:14 +0x38
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x54
Previous write at 0x00c000124038 by goroutine 9:
main.main.func1()
example.test/lab/main.go:14 +0x48
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x54
Goroutine 8 (running) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:237 +0x78
main.main()
example.test/lab/main.go:12 +0x70
Goroutine 9 (finished) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:237 +0x78
main.main()
example.test/lab/main.go:12 +0x70
==================
==================
WARNING: DATA RACE
Write at 0x00c000124038 by goroutine 8:
main.main.func1()
example.test/lab/main.go:14 +0x48
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x54
Previous write at 0x00c000124038 by goroutine 10:
main.main.func1()
example.test/lab/main.go:14 +0x48
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x54
Goroutine 8 (running) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:237 +0x78
main.main()
example.test/lab/main.go:12 +0x70
Goroutine 10 (finished) created at:
sync.(*WaitGroup).Go()
sync/waitgroup.go:237 +0x78
main.main()
example.test/lab/main.go:12 +0x70
==================
Found 2 data race(s)
exit status 66Two repairs are usual. If the shared value is a single number, make it atomic: var counter atomic.Int64 and counter.Add(1). If several fields must change together, put a sync.Mutex next to them and hold it around the update. A coroutine-style habit of "I'll keep it all on one goroutine" translates directly too: one goroutine owns the state and the others send it messages over a channel, which is the closest thing in Go to the asyncio model of a single thread of control (actor style).
Blocking calls and CPU-bound work
Two situations that need special handling in asyncio need none in Go. A blocking library call, such as a database driver or an requests.get, forces Python code into run_in_executor so the event loop is not stalled. In Go you call the blocking function from a goroutine, and the runtime moves other goroutines to other threads while it waits (blocking syscalls explains how). And CPU-bound work, which in Python goes to a process pool because of the GIL, goes to goroutines too. Eight goroutines each hashing a file use eight cores, with no pickling of arguments between processes and no separate worker lifecycle. The cost is that you are now responsible for bounding them, since nothing like ProcessPoolExecutor(max_workers=8) is implied.
Debugging looks different
asyncio has debug mode (PYTHONASYNCIODEBUG=1) to warn about slow callbacks and never-awaited coroutines. Go's equivalents are the race detector for correctness, go test -race, and goroutine dumps for hangs: a SIGQUIT or the pprof goroutine profile lists every goroutine with its wait reason, such as chan receive or select, so a stuck program is diagnosed from the dump (reading goroutine dumps). The tracer shows the timeline (the execution tracer). None of it needs a special mode; the tools ship with the toolchain.
Errors are values, and panics are not exceptions
In asyncio an exception in a task propagates to whoever awaits it, and an exception in a task nobody awaits is logged as "Task exception was never retrieved" and otherwise lost. In Go, errors are returned values, so a goroutine's failure goes where you send it: a channel, a slot in a result slice, or an errgroup. A goroutine cannot throw to the function that started it. The related rule: a panic in any goroutine, unrecovered, terminates the whole program. There is no equivalent of an unhandled exception in one task being logged while the others carry on. For servers that means the recovery has to be explicit at the top of each goroutine that runs untrusted work (recovering in goroutines, error handling).
Habits to unlearn
Looking for the event loop. There is none to start, stop or block. main returning ends the program, including every goroutine still running, which is the Go version of "the loop closed with tasks pending": use a WaitGroup or a channel to wait.
Avoiding blocking calls. In asyncio, a time.sleep or a blocking HTTP library freezes everything. In Go, blocking is normal and cheap; use http.Get and time.Sleep directly in a goroutine.
Creating tasks without keeping a reference. Python can garbage-collect a task you did not hold on to. Goroutines are not collected while running, which is the other danger: a goroutine you forgot about runs forever, so every goroutine needs a way to stop (goroutine leaks).
Unbounded fan-out. gather(*[fetch(u) for u in urls]) over a hundred thousand URLs works until it does not, in both languages. Bound it with a worker pool or errgroup.SetLimit (bounded parallelism).
Using a thread pool for CPU work. In Python you move CPU-bound work to a process pool because of the GIL. In Go the same goroutines do both kinds of work, spread over the cores, and the number to pay attention to is GOMAXPROCS (concurrency vs parallelism).
A plan for the first week
Rewrite one small asyncio script in Go: take its three or four concurrent calls, replace the tasks with goroutines and a WaitGroup, thread a context through, and run it with -race. Then read how the standard library builds on these ideas: the concurrency chapter of Effective Go is short, and the Go blog's context article explains the cancellation model you replaced CancelledError with. The Python asyncio documentation is useful to keep open on the other side while you translate.


