Concurrency is the reason a lot of teams switch to Go. Goroutines are cheap, channels are first-class, and the runtime makes “spawn 10,000 of these and wait” a one-liner. It’s also where Go bites people who treat goroutines like threads.

This post is a practical guide to Go’s concurrency model. We’ll cover the primitives, the patterns that actually work in production, and the foot-guns to avoid. By the end you’ll be able to write concurrent Go code that’s correct, fast, and shutdown-safe.

If Go is still new, start with Getting Started with Go for Backend Developers .

What’s a goroutine?

A goroutine is a function running concurrently with other goroutines, scheduled by the Go runtime onto a small pool of OS threads. They start small (~2 KB stack) and grow as needed. You can run hundreds of thousands of them on a single process.

go doWork()  // doWork runs in a new goroutine

That’s the entire syntax. The go keyword spawns a goroutine and returns immediately.

package main

import (
    "fmt"
    "time"
)

func main() {
    for i := 1; i <= 3; i++ {
        go func(n int) {
            time.Sleep(100 * time.Millisecond)
            fmt.Println("worker", n)
        }(i)
    }
    time.Sleep(1 * time.Second)  // wait so we see output
}

Output (order may vary):

worker 2
worker 1
worker 3

Three goroutines, scheduled in parallel, no thread pool to manage. This is the magic.

sync.WaitGroup — wait for goroutines to finish

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
    wg.Add(1)  // one more goroutine to wait for
    go func(n int) {
        defer wg.Done()  // signal completion
        time.Sleep(100 * time.Millisecond)
        fmt.Println("worker", n)
    }(i)
}

wg.Wait()  // block until all goroutines call Done

Three rules:

  1. wg.Add(N) before spawning the goroutine.
  2. defer wg.Done() as the first line of the goroutine.
  3. wg.Wait() blocks until the counter hits zero.

This is the simplest pattern and probably the most useful one in everyday Go code.

Channels — typed pipes between goroutines

A channel is a typed FIFO queue you can send to and receive from. Other goroutines can do the same. Communication is synchronous by default — sender and receiver meet at the channel.

ch := make(chan int)

go func() {
    ch <- 42      // send (blocks until someone receives)
}()

value := <-ch     // receive (blocks until someone sends)
fmt.Println(value) // 42

Channels are the idiomatic way goroutines communicate in Go. The slogan: “Don’t communicate by sharing memory; share memory by communicating.”

Buffered channels

By default channels are unbuffered (synchronous). Buffered channels let the sender continue without a receiver, up to the buffer size:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// would block here without 4th receiver: ch <- 4

Useful when you want a small queue between producer and consumer.

Closing channels

close(ch)
v, ok := <-ch  // ok == false if channel is closed and drained

Close from the sender side, never the receiver side. Receiving from a closed channel returns the zero value immediately. Sending to a closed channel panics.

Range over a channel

for v := range ch {
    fmt.Println(v)
}

Loops until ch is closed and drained. The cleanest way to consume all values from a channel.

A real pattern: worker pool

A bounded set of goroutines processing jobs from a channel:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Job struct{ ID int }
type Result struct{ JobID int; Output string }

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        time.Sleep(100 * time.Millisecond) // simulate work
        results <- Result{JobID: job.ID, Output: fmt.Sprintf("worker %d done with %d", id, job.ID)}
    }
}

func main() {
    jobs := make(chan Job, 100)
    results := make(chan Result, 100)
    var wg sync.WaitGroup

    // 5 workers
    for w := 1; w <= 5; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Send 20 jobs
    for j := 1; j <= 20; j++ {
        jobs <- Job{ID: j}
    }
    close(jobs)

    // Close results when all workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    for r := range results {
        fmt.Println(r.Output)
    }
}

20 jobs, 5 workers, all parallelism handled by the runtime. The same pattern in Python with threads is much more code (and the GIL means it’s slower for CPU-bound work).

A note on direction: <-chan Job (receive-only) and chan<- Result (send-only) on the function signature. Compiler enforces that worker can only receive jobs and send results. Use direction qualifiers liberally — they catch bugs early.

select — multiplexing channels

select is switch for channels. It blocks until one of its cases is ready.

select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case ch3 <- "hello":
    fmt.Println("sent to ch3")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

time.After(d) returns a channel that delivers a value after d — perfect for timeouts. select with a default case is non-blocking — the default runs if no channel is ready.

Cancellation with context

Real-world goroutines need to stop when work is cancelled (request closed, deadline exceeded, server shutting down). context.Context is the standard way:

import "context"

func doWork(ctx context.Context) error {
    for i := 0; i < 100; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()       // context.Canceled or context.DeadlineExceeded
        default:
        }
        // ... do a chunk of work
        time.Sleep(50 * time.Millisecond)
    }
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    if err := doWork(ctx); err != nil {
        fmt.Println("aborted:", err)
    }
}

Pass context.Context as the first argument of any function that does I/O, and check ctx.Done() periodically inside loops. This is one of the most important Go conventions — and it’s how net/http, database/sql, and basically all good Go libraries propagate cancellation.

sync.Mutex and friends

Channels are great for communication. For protecting shared state, use a mutex:

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

sync.RWMutex is the read/write variant — many readers OR one writer.

sync.Once runs an initializer exactly once, even called from many goroutines:

var (
    db   *sql.DB
    once sync.Once
)

func DB() *sql.DB {
    once.Do(func() {
        db = openDB()
    })
    return db
}

sync.Map is a concurrent map but don’t reach for it by default — a regular map plus a mutex is usually faster and clearer. Only use sync.Map for the specific patterns it’s designed for (write-once-read-many; disjoint key sets per goroutine).

The classic foot-guns

1. Loop variable captured in goroutine (pre-Go 1.22)

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // probably prints 5, 5, 5, 5, 5
    }()
}

In Go 1.22+, this is fixed (the loop variable is per-iteration). In older Go, you’d pass i as an argument:

go func(i int) { fmt.Println(i) }(i)

2. Goroutine leak: forgetting to drain or close

ch := make(chan int)
go func() {
    ch <- 1   // blocks forever if nobody receives
}()
// ... and the goroutine is leaked

Every goroutine you spawn should have a clear path to termination. If it sends to a channel, somebody must receive. If it loops forever, it must respect context cancellation.

3. Race conditions

If two goroutines access the same memory without synchronization and at least one writes, that’s a data race. Run with the race detector during development:

go run -race main.go
go test -race ./...

It will yell loudly when something is unsafe. Use it. Run your CI tests with -race always.

4. Channels as locks

sem := make(chan struct{}, 1)
sem <- struct{}{}  // "lock"
// critical section
<-sem              // "unlock"

This works but a sync.Mutex is simpler and faster. Use channels for flow; use mutexes for guarding state.

A complete real-world example: rate-limited fetcher

Putting it together:

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "sync"
    "time"
)

func fetch(ctx context.Context, url string) (string, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return "", err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    return string(body), err
}

func main() {
    urls := []string{
        "https://httpbin.org/anything/1",
        "https://httpbin.org/anything/2",
        "https://httpbin.org/anything/3",
        "https://httpbin.org/anything/4",
        "https://httpbin.org/anything/5",
    }

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    sem := make(chan struct{}, 2) // max 2 concurrent
    var wg sync.WaitGroup
    var mu sync.Mutex
    results := make(map[string]string)

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            sem <- struct{}{}        // acquire
            defer func() { <-sem }() // release

            body, err := fetch(ctx, u)
            if err != nil {
                log.Printf("error %s: %v", u, err)
                return
            }
            mu.Lock()
            results[u] = body[:50]
            mu.Unlock()
        }(url)
    }

    wg.Wait()
    fmt.Printf("got %d results\n", len(results))
}

5 URLs, max 2 concurrent, all cancellable via context, results collected safely. This is the bread-and-butter shape of “fan out work” in Go.

When to use what

  • go func() + sync.WaitGroup → simple “do these N things, wait for all of them”
  • Channel + range → producer/consumer, streaming
  • select → multiplexing channels, timeouts, cancellation
  • context → cancellation propagation across function calls
  • sync.Mutex → guarding shared state inside a single struct
  • Worker pool → bounded concurrency with a job queue
  • errgroup (golang.org/x/sync/errgroup) → run several goroutines, return on first error, propagate context cancellation

errgroup is worth knowing — it’s the modern, idiomatic way to do “fan out, wait, collect first error”:

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    return err
}

If you’re not using errgroup yet, start. It replaces a lot of WaitGroup + error-channel boilerplate.

Conclusion

Go’s concurrency primitives aren’t magic — they’re a small, well-designed toolkit (go, channels, select, sync, context) that composes well. Master those five, run with -race during development, and use errgroup for fan-out work, and you’ll write concurrent code that holds up in production.

If you want to put this into practice, see how a real HTTP server uses it: Building a REST API in Go with net/http .

Happy concurring!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .