Right, so we’ve got channels. We know how to send, receive, and close them. But if you just start flinging <- operators around willy-nilly, you’ll end up with a mess of deadlocks that would make a plate of spaghetti look organized. Let’s talk about the actual patterns you use to structure this chaos. These are the blueprints that turn a novelty act into a professional concurrency powerhouse.

The Pipeline

Think of this less like a Rustic aqueduct and more like an assembly line. One goroutine takes in some raw materials, does a specific task, and passes the semi-finished product to the next goroutine in line. Each stage only cares about receiving from the stage before it and sending to the stage after it.

Why is this brilliant? Separation of concerns. You can scale, debug, or replace each stage independently. Let’s say we want to generate some numbers, square them, and then sum them. Textbook pipeline.

// generate sends integers from 1 to n on a channel.
func generate(n int) <-chan int {
    out := make(chan int)
    go func() {
        for i := 1; i <= n; i++ {
            out <- i
        }
        close(out) // Crucial: we close to signal no more values
    }()
    return out
}

// square receives integers from an input channel and sends their squares.
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in { // Loop automatically breaks when 'in' is closed.
            out <- n * n
        }
        close(out) // And we close our output too. The chain continues.
    }()
    return out
}

// sum receives integers and adds them all up.
func sum(in <-chan int) int {
    total := 0
    for n := range in {
        total += n
    }
    return total
}

func main() {
    // Set up the pipeline: generate -> square -> sum
    gen := generate(3)
    sq := square(gen)
    result := sum(sq) // sum just receives, so it blocks until the channel is closed.
    fmt.Println(result) // 1^2 + 2^2 + 3^2 = 14
}

The beauty here is in the range loops and the close() calls. Closing the channel at the end of the generating stage is what allows the range in the next stage to break, which in turn allows it to close its channel, and so on. It’s a graceful, coordinated shutdown.

Fan-Out (Parallelism)

Your first stage is a firehose of data, and the next stage is a computationally expensive operation. Having one goroutine do that expensive operation is a bottleneck. The solution? Fan-out. Start multiple identical goroutines all reading from the same input channel. You’ve just created a worker pool.

This is how you horizontally scale a stage. The number of workers is your concurrency limit. It’s often tied to something sensible, like runtime.NumCPU().

func main() {
    in := generate(1000000) // Wow, a million numbers! That's a big firehose.

    // Let's create FIVE worker goroutines to square all those numbers in parallel.
    const numWorkers = 5
    var workers []<-chan int
    for i := 0; i < numWorkers; i++ {
        workers = append(workers, square(in)) // All five workers read from 'in'
    }

    // But now we have five output channels. How do we handle that?
    // We need to fan them back in...
}

Pitfall alert: All those workers are competing for reads on the same channel. The Go scheduler handles this beautifully—sends and receives on a channel are inherently synchronized. Whichever worker is ready first gets the next value. It’s a cheap, easy load balancer.

Fan-In (Multiplexing)

You’ve fanned out, and now you have a handful of channels. But the next stage in your pipeline, like our sum function, expects just one channel. We need to combine, or multiplex, these channels back into one. This is fan-in.

The trick is to start a goroutine for each input channel that forwards everything to a single output channel. You then need to use a sync.WaitGroup to wait for all the forwarding goroutines to finish before closing the output channel.

// merge combines multiple integer channels into one.
func merge(channels []<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Define an output function for each input channel.
    output := func(c <-chan int) {
        for n := range c {
            out <- n
        }
        wg.Done() // Signal that this goroutine is done when its channel closes.
    }

    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Start a goroutine to close out after all outputs are done.
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func main() {
    in := generate(1000000)
    const numWorkers = 5

    var workers []<-chan int
    for i := 0; i < numWorkers; i++ {
        workers = append(workers, square(in))
    }

    // Fan-out is done. Now fan-in the results from all workers.
    mergedChannel := merge(workers)

    result := sum(mergedChannel)
    fmt.Println(result)
}

The WaitGroup is the key here. It’s the only sane way to know when all the upstream sending goroutines have finished and closed their channels, which in turn causes our forwarding goroutines to complete. Without it, closing out too early would cause a panic, and not closing it would deadlock the sum function.

The Big Pitfall: Unbuffered Assumptions

These patterns work flawlessly with unbuffered channels… until they don’t. The entire pipeline operates on a push-and-pull rhythm. If one stage can’t keep up, it blocks the stage before it. For a firehose scenario, this is often what you want—it creates natural backpressure.

But sometimes, a stage might be sporadically slow. You might want a small buffer between stages to absorb tiny bursts, like a shock absorber. The choice between buffered and unbuffered isn’t about performance first; it’s about semantics. Do you want tight synchronization (unbuffered) or a bit of decoupling (buffered)? Start with unbuffered. Only add a buffer if you have a measurable problem and you understand exactly what that buffer is doing for your coordination. Throwing buffers at a design you don’t understand is how you end up with mysterious deadlocks that only happen on Tuesdays.