Introduction

Most Go concurrency bugs come from using concurrency at all. The first question should always be: do you actually need goroutines here?

Seriously. A surprising amount of Go code spawns goroutines because it feels like the Go thing to do, not because there's a measurable bottleneck. Sequential code is easier to read, easier to debug, and has zero chance of a race condition. But when you do need concurrency — handling thousands of connections, parallelizing independent I/O, processing a work queue — Go's model is genuinely good.

The primitives come first (goroutines, channels, select, the sync types) as compact reference material. Then the patterns you came here for: fan-out/fan-in, worker pools, pipelines, context cancellation. If you already know the basics, skip to the patterns. If you don't, read from the top.

Goroutines: Lightweight Threads

A goroutine starts with a few kilobytes of stack. Not megabytes like OS threads. The Go runtime multiplexes them onto a smaller number of threads, so running hundreds of thousands is normal. GOMAXPROCS controls how many OS threads execute user code simultaneously. It defaults to the number of logical CPUs. Since Go 1.25, on Linux that default also respects the container's cgroup CPU limit (for example a Kubernetes CPU limit) and the runtime adjusts it if the limit changes. Setting GOMAXPROCS yourself turns that behavior off.

main.go
package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	for i := 0; i < 3; i++ {
		fmt.Printf("Hello from %s (iteration %d)\n", name, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	// Launch two goroutines
	go sayHello("Goroutine A")
	go sayHello("Goroutine B")

	// Give goroutines time to finish
	time.Sleep(500 * time.Millisecond)
	fmt.Println("Main function done")
}
Terminal running go run main.go: goroutines A and B print iterations 0 to 2 interleaved, then Main function done.
Real output. Running main.go. The two goroutines interleave, and the order changes from run to run.

Nondeterministic output order. That's the point.

When main returns, everything dies. Even running goroutines. The time.Sleep above is a hack — real code uses channels or sync.WaitGroup.

Anonymous goroutines show up everywhere:

anonymous_goroutine.go
go func(msg string) {
	fmt.Println(msg)
}("Hello from an anonymous goroutine")

The parentheses at the end pass the argument. Forget those inside a loop and every goroutine captures the same loop variable by reference. Classic bug. Go 1.22 fixed this with per-iteration scoping. But older code is full of it.

Channels: Communication Between Goroutines

Typed conduits. Send a value in one goroutine, receive it in another. Safe by design.

Unbuffered Channels

No internal storage. Send blocks until receive is ready. Receive blocks until send is ready. Natural synchronization points.

unbuffered_channel.go
package main

import "fmt"

func fetchData(ch chan string) {
	// Simulate fetching data from a database
	result := "user_data_from_db"
	ch <- result // Send result into the channel
}

func main() {
	ch := make(chan string) // Create an unbuffered channel

	go fetchData(ch)

	data := <-ch // Receive from the channel (blocks until data arrives)
	fmt.Println("Received:", data)
}

No time.Sleep. The receive blocks until the send happens. That's it.

Buffered Channels

Internal queue. Sends don't block until the buffer fills.

buffered_channel.go
// Buffered channel with capacity of 3
jobs := make(chan int, 3)

// These sends won't block because the buffer isn't full
jobs <- 1
jobs <- 2
jobs <- 3

// This would block because the buffer is full:
// jobs <- 4

fmt.Println("Buffer length:", len(jobs))   // 3
fmt.Println("Buffer capacity:", cap(jobs)) // 3

Good for absorbing bursts when producers and consumers run at different speeds.

Directional Channels

Send-only: chan<- string. Receive-only: <-chan string. Compiler-enforced. The function signature tells you exactly how the channel gets used, which matters more than people think when you're reading unfamiliar code at 2 AM.

Closing a channel signals "no more values." Use range over a channel — the loop exits automatically on close. Or the two-value form: value, ok := <-ch where ok is false when closed.

Select Statement and Multiplexing

A switch for channels. Waits on multiple operations. Whichever is ready first runs. If several are ready simultaneously, one is picked at random.

select_example.go
package main

import (
	"fmt"
	"time"
)

func fetchFromAPI(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "API response"
}

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "Cache hit"
}

func main() {
	apiCh := make(chan string, 1)
	cacheCh := make(chan string, 1)

	go fetchFromAPI(apiCh)
	go fetchFromCache(cacheCh)

	// Use select to take whichever responds first
	select {
	case result := <-apiCh:
		fmt.Println("Got from API:", result)
	case result := <-cacheCh:
		fmt.Println("Got from cache:", result)
	case <-time.After(1 * time.Second):
		fmt.Println("Timeout! Neither responded in time")
	}
}

Exactly what it looks like.

Adding a default case makes the select non-blocking:

select_default.go
select {
case msg := <-ch:
	fmt.Println("Received:", msg)
default:
	fmt.Println("No message available, moving on")
}

Shows up in event loops and polling.

Sync Primitives

Channels are the idiomatic choice. But sometimes a mutex is simpler and a WaitGroup is all you need. The "everything should be channels" advice is wrong. Use the simplest tool that works.

WaitGroup

A counter. Add increments, Done decrements, Wait blocks until zero.

waitgroup.go
package main

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

func processItem(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Decrement counter when function returns

	fmt.Printf("Processing item %d...\n", id)
	time.Sleep(time.Duration(id) * 100 * time.Millisecond)
	fmt.Printf("Item %d done\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Increment counter before launching goroutine
		go processItem(i, &wg)
	}

	wg.Wait() // Block until all goroutines call Done()
	fmt.Println("All items processed!")
}
Terminal running go run waitgroup.go: five items start in random order, all finish, then All items processed.
Real output. wg.Wait() holds main until every item reports done.

Calling wg.Add(1) inside the goroutine instead of before launching it is a race condition. Passes tests most of the time. Fails under load. Always call Add before the go statement.

Go 1.25 added WaitGroup.Go, which does the Add(1) and Done() for you, so this mistake can't happen. On Go 1.25 or newer, prefer it:

waitgroup_go.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		// Go 1.25+: wg.Go calls Add(1) before starting and Done() when the func returns
		wg.Go(func() {
			fmt.Printf("Processing item %d\n", i)
		})
	}

	wg.Wait()
	fmt.Println("All items processed!")
}

go vet also ships a waitgroup check since 1.25 that flags misplaced Add calls.

Mutex and RWMutex

sync.Mutex gives exclusive access. sync.RWMutex allows multiple concurrent readers but only one writer.

Shared counter or map? Mutex. Coordinating a workflow or passing data between stages? Channels.

rwmutex_cache.go
package main

import (
	"fmt"
	"sync"
)

// Cache is safe for concurrent use. Reads share the lock; writes take it exclusively.
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func NewCache() *Cache {
	return &Cache{data: make(map[string]string)}
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func main() {
	cache := NewCache()
	var wg sync.WaitGroup

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			cache.Set(fmt.Sprintf("user:%d", n), fmt.Sprintf("name-%d", n))
		}(i)
	}
	wg.Wait()

	v, ok := cache.Get("user:7")
	fmt.Println(v, ok) // name-7 true
}

Use defer right after locking so an early return or panic can't leave the lock held. Don't copy a struct that contains a mutex; pass a pointer.

sync.Once

Guarantees a function runs exactly once. Lazy initialization. Config loading. Database connection setup. First caller runs it, everyone else blocks until it's done, then returns immediately.

sync_once.go
package main

import (
	"fmt"
	"sync"
)

type Config struct {
	DSN string
}

var (
	cfg     *Config
	cfgOnce sync.Once
)

// loadConfig runs the expensive setup exactly once, no matter how many goroutines call it.
func loadConfig() *Config {
	cfgOnce.Do(func() {
		fmt.Println("loading config...") // Printed once
		cfg = &Config{DSN: "postgres://localhost/app"}
	})
	return cfg
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_ = loadConfig()
		}()
	}
	wg.Wait()
	fmt.Println(loadConfig().DSN)
}

Go 1.21 also added sync.OnceValue and sync.OnceFunc wrappers when you just want a lazily computed value.

Common Concurrency Patterns

These are the patterns you'll actually use. Everything above is reference material for when the specifics get fuzzy.

Fan-Out / Fan-In

Fan-out: distribute work across goroutines. Fan-in: merge results into a single channel. List of URLs to fetch? Fan-out to goroutines, fan-in through one results channel. The main goroutine reads until everything arrives.

Sounds simple. But the hard part is knowing when you're done. Forget to close the results channel and the reader blocks forever.

fan_in.go
package main

import (
	"fmt"
	"sync"
)

// produce sends n values tagged with the producer's name, then closes.
func produce(name string, n int) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ {
			out <- fmt.Sprintf("%s-%d", name, i)
		}
	}()
	return out
}

// merge fans in any number of channels into one.
// The output closes only after every input is drained.
func merge(inputs ...<-chan string) <-chan string {
	out := make(chan string)
	var wg sync.WaitGroup

	for _, in := range inputs {
		wg.Add(1)
		go func(c <-chan string) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(in)
	}

	// Close out once all forwarders finish, so the reader's range ends
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	// Fan-out: three independent producers
	a := produce("a", 2)
	b := produce("b", 2)
	c := produce("c", 2)

	// Fan-in: one channel, read until it closes
	count := 0
	for v := range merge(a, b, c) {
		fmt.Println(v)
		count++
	}
	fmt.Println("received", count, "values") // received 6 values
}

The WaitGroup tracks the forwarding goroutines, and a separate goroutine closes out once they all finish. Output order varies between runs.

Worker Pool

Fan-out but bounded. Fixed number of workers pulling from a shared job channel. You want this instead of unbounded fan-out because launching 10,000 goroutines that all hit the same database will just take it down.

worker_pool.go
package main

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

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		fmt.Printf("Worker %d processing job %d\n", id, job)
		time.Sleep(100 * time.Millisecond) // Simulate work
		results <- job * 2                 // Send result
	}
}

func main() {
	const numWorkers = 3
	const numJobs = 10

	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

	// Start workers
	var wg sync.WaitGroup
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	// Send jobs
	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs) // Signal no more jobs

	// Wait for workers then close results
	go func() {
		wg.Wait()
		close(results)
	}()

	// Collect results
	for result := range results {
		fmt.Println("Result:", result)
	}
}

Pipeline

Stages connected by channels. Each stage reads from inbound, processes, writes to outbound. Generate numbers, square them, filter odds, print. Each stage runs independently.

pipeline.go
package main

import (
	"context"
	"fmt"
)

// gen emits the given numbers, stopping early if ctx is cancelled.
func gen(ctx context.Context, nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// sq squares each value it receives.
func sq(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// odd passes through only odd values.
func odd(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			if n%2 == 0 {
				continue
			}
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // Stops every stage if main returns early

	for v := range odd(ctx, sq(ctx, gen(ctx, 1, 2, 3, 4, 5))) {
		fmt.Println(v) // 1, 9, 25
	}
}

Every send sits in a select with ctx.Done(), so if the consumer stops reading and cancels, upstream stages exit instead of blocking forever.

Pipelines are elegant in theory. In practice, error propagation across stages is the part that gets ugly. When stage three fails, how do stages one and two find out? Context cancellation, usually. Which brings us to the most important pattern.

Context Cancellation

Every goroutine you launch needs a way to be told "stop." Without this, you leak goroutines. A service handles a request, spawns three goroutines to fetch data from different backends, the client disconnects — but the goroutines keep running, doing work nobody will ever read. Multiply this across thousands of requests and your service slowly eats all available memory. The context package solves this. A context carries cancellation signals, deadlines, and request-scoped values. Three constructors: context.WithCancel for manual cancellation, context.WithTimeout for a duration, context.WithDeadline for a wall clock time. Always defer the cancel function.

context_cancel.go
package main

import (
	"context"
	"fmt"
	"time"
)

func longRunningTask(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("%s: cancelled (%v)\n", name, ctx.Err())
			return
		default:
			fmt.Printf("%s: working...\n", name)
			time.Sleep(200 * time.Millisecond)
		}
	}
}

func main() {
	// Create a context that cancels after 500ms
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel() // Always call cancel to release resources

	go longRunningTask(ctx, "Worker-1")
	go longRunningTask(ctx, "Worker-2")

	// Wait for context to expire
	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // Give workers time to print cancellation
	fmt.Println("All workers stopped")
}

Every HTTP handler gets a context. Every database query should accept one. So when a client disconnects, downstream goroutines stop doing pointless work. Skip the ctx.Done() check in a background loop and you leak goroutines. Slowly at first. Then your monitoring dashboard turns red on a Tuesday afternoon.

Race Conditions and How to Avoid Them

Two goroutines. Same data. No synchronization. The result depends on timing, and the bugs only appear under load in production, never in your test suite on a quiet Wednesday.

race_condition.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// BAD: Race condition!
func unsafeCounter() {
	var count int
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count++ // DATA RACE: multiple goroutines write without sync
		}()
	}

	wg.Wait()
	fmt.Println("Unsafe count:", count) // Could be anything < 1000
}

// GOOD: Fixed with a Mutex
func mutexCounter() {
	var count int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			count++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("Mutex count:", count) // Always 1000
}

// BETTER: Fixed with atomic operations
func atomicCounter() {
	var count atomic.Int64
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count.Add(1) // Lock-free atomic increment
		}()
	}

	wg.Wait()
	fmt.Println("Atomic count:", count.Load()) // Always 1000
}

func main() {
	unsafeCounter()
	mutexCounter()
	atomicCounter()
}
Terminal running go run race_condition.go: Unsafe count: 992, Mutex count 1000, Atomic count 1000.
Real output. Without synchronization the counter drops increments. The mutex and atomic versions always reach 1000.

The unsafe version almost never prints 1000. The mutex version fixes it with locking. The atomic version is faster for simple numeric operations — no lock overhead.

Go ships with a race detector. go test -race ./... or go run -race main.go. It instruments code at compile time and catches races found at runtime. Not perfect — only catches races on executed paths — but it finds a surprising amount. Put it in CI and never take it out.

Avoiding races:

Conclusion

If your first instinct is go func(), stop. Write it synchronously first. Add concurrency only when you can measure the bottleneck. Most Go code doesn't need it.

And when you do add it, the choice between channels and mutexes is simpler than people make it. Passing data? Channels. Protecting state in place? Mutex. Don't force one tool into the other's job.

Frequently Asked Questions

A goroutine is a lightweight concurrent function managed by the Go runtime, started with the go keyword. Unlike OS threads, goroutines use only a few kilobytes of stack space and are multiplexed onto a smaller number of threads, making it practical to run thousands or even millions concurrently.

Channels are typed conduits that let goroutines send and receive values safely. You create a channel with make(chan Type), send values using ch <- value, and receive with value := <-ch. Channels synchronize execution by blocking until both the sender and receiver are ready.

Unbuffered channels block the sender until a receiver is ready, providing synchronous communication. Buffered channels have a capacity and only block the sender when the buffer is full, allowing asynchronous sends up to the buffer size.

Use context cancellation, done channels, or timeouts to signal goroutines when they should stop. Always ensure every goroutine you launch has a clear exit path, and use select statements to listen for cancellation signals alongside regular work.

Fan-out starts multiple goroutines to process work from a single channel in parallel. Fan-in merges results from multiple goroutines into a single channel. These patterns, along with worker pools and pipelines, help structure concurrent Go programs for efficient parallel processing.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.