Most explanations of Go concurrency stop at the vocabulary. Goroutines are cheap, channels let goroutines talk to each other, don't communicate by sharing memory. All true, but none of it particularly useful once you're actually trying to reason about a program that runs things at the same time. This isn't another rundown of the same three sentences everyone quotes. It's the stuff that only becomes obvious once you sit down and deliberately try to break your own assumptions about how these primitives behave.
Cheap Is Not the Same as Free
Spawning a goroutine per unit of work is the standard first move, and it's easy to treat that as basically free because the syntax is one keyword. It isn't free in the way people mean when they say it. A goroutine that's blocked forever on a channel receive isn't doing any work, but it also isn't garbage. The garbage collector only reclaims what's unreachable, and a blocked goroutine is still reachable by definition, still holding onto everything it closed over. Spin up enough of these and memory climbs steadily with nothing to show for it, because nothing failed loudly enough to notice.
The actual discipline isn't "use fewer goroutines." It's making sure every goroutine you spawn has exactly one way to exit under every outcome it could hit: success, failure, or a parent context getting canceled. A select between the real work and ctx.Done() covers this. What it protects against isn't a crash, it's a slow leak that only shows up as memory that never comes back down after load drops.
Cancellation Doesn't Rewind Time
context.Context cancellation is purely a signal. Calling cancel() or hitting a timeout closes the Done() channel, and that's the entire contract. It doesn't reach backward and undo anything a goroutine already did before it checked that channel.
This matters more than it sounds like it should. If a goroutine had already made an outbound call, written a file, or mutated shared state before the cancellation arrived, that action already happened. Canceling the context stops anything downstream from waiting on a result nobody wants anymore. It does not stop the action itself. Code that treats a timeout as proof that nothing happened is making an assumption Go's cancellation model never promised.
Race Conditions Are Run-dependent, Not Code-dependent
Two goroutines appending to the same slice without a lock is a textbook data race, and it's genuinely hard to catch by hand, because it often just works. Manual testing rarely lines up two goroutines finishing within nanoseconds of each other, so the corruption doesn't show up until the exact interleaving happens.
The race detector (go test -race) doesn't analyze your code for the theoretical possibility of a race. It watches the memory accesses that actually occur during a specific execution and flags the conflicts it observes in that run. A race that depends on a certain number of concurrent goroutines finishing close together can pass cleanly one run and fail the next, purely based on scheduling. A clean -race run on a lightly concurrent test proves a lot less than it feels like it proves. If you want to actually catch a race, you generally have to force the interleaving that triggers it, not just run the code once and hope.
Channels as Ownership Transfer, Not a Queue
The instinct coming from a shared-memory mindset is to reach for a map plus a mutex any time multiple goroutines need to report results to one place. It works, but it puts the burden of correctness on every single goroutine remembering to lock before it touches that map.
A channel that only one goroutine ever reads from does something structurally different. Each producer sends its own result and moves on. The consumer owns everything the moment it comes off the channel. Nothing needs a lock, because nothing is actually shared at any point in time, only handed off. This is the part of "share memory by communicating" that doesn't land until you've felt the alternative fail. It isn't a style preference. It removes an entire category of bug instead of relying on someone remembering to guard against it.
WaitGroup Gets You Partway; Errgroup Gets You the Rest
sync.WaitGroup will wait for a fixed set of goroutines to finish, but it has nothing to say about errors. Fan out N pieces of work with a plain WaitGroup and you end up hand-rolling an error slice, a mutex to protect it, and your own logic for deciding what one failure should do to the others still running.
golang.org/x/sync/errgroup wraps exactly that pattern. Wait() returns the first error the group produced, and when the group is built with errgroup.WithContext, it cancels that shared context the moment any goroutine returns a non-nil error. Every other goroutine in the group is already checking that same context, so cancellation propagates without any extra plumbing. It doesn't do anything a WaitGroup plus some manual bookkeeping couldn't do. It just means you stop rewriting that bookkeeping every time.
The Deadlock a Debugger Won't Explain for You
An unbuffered channel send blocks until something receives it. That's the whole mechanism, and it's also the whole failure mode: if the goroutine that was supposed to receive exits early, on an error path, say, without draining the channel, the sender blocks on that send forever.
Go's runtime deadlock detector only fires when every single goroutine in the process is asleep with no possibility of waking up. One stuck goroutine sitting next to others that are still busy doing unrelated things won't trigger it at all. So this kind of bug doesn't crash anything. It just quietly hangs one specific path while the rest of the program looks completely fine, and you find out only when a request that should've returned in milliseconds never returns at all. An unbuffered channel is a contract that someone is always listening, and any code path that lets the listener leave early breaks that contract without producing an error anywhere.
Where This Actually Leaves You
None of this requires learning new syntax. select, channels, and WaitGroup are things you can write correctly on day one in terms of getting them to compile. What separates code that merely uses these primitives from code that's actually safe under load is asking one question for every goroutine you spawn: what happens to this if the thing it's waiting for never shows up. Go's tools don't ask that question for you, and most of the bugs above trace back to exactly that gap.
