Golang · Aug 30, 2026 · 8 min read

Fan-In / Fan-Out in Golang

Understand the fan-out and fan-in concurrency patterns in Golang through the analogy of a restaurant kitchen with a single serving table.

Fan-out and fan-in are two concurrency patterns in Golang that usually appear together. Fan-out spreads one stream of work across several goroutines so they run in parallel, while fan-in merges the results from all of those goroutines back into a single channel that one consumer can read.

1. What Are Fan-Out and Fan-In?

Both patterns are built on the same primitive: multiple goroutines sharing channels. The difference is the direction of the flow.

  • Fan-out = many goroutines read from the same input channel, so a single queue of tasks is divided among several workers
  • Fan-in = many goroutines write into the same output channel, so results from several workers arrive at one place
  • Put together, they let you parallelize a pipeline stage without changing how the stages before or after it are written

2. Analogy: A Restaurant Kitchen with a Single Serving Table

Imagine a restaurant kitchen. Orders come in one at a time on a rail, there are several cooking stations, and there is exactly one serving table (the pass) where finished dishes are placed for the waiter to pick up.

  • Order rail = the input channel holding incoming tickets
  • Cooking stations (grill, fryer, salad) = the goroutines doing the work; each one grabs the next ticket from the same rail (fan-out)
  • Serving table / the pass = the single output channel where every station places its finished dish (fan-in)
  • Waiter = the one consumer reading from the serving table

Here's how a service runs:

  • The host writes tickets and hangs them on the one shared rail
  • Whenever a station is free, its cook takes the next ticket off the rail — no ticket is cooked twice because each one is physically taken by a single cook (fan-out)
  • When a dish is done, the cook puts it on the single serving table, no matter which station cooked it (fan-in)
  • The waiter stands at the serving table and picks up dishes as they appear, without needing to know which station made each one
  • When the rail is empty and closed, each station finishes its current dish and stops; once every station has stopped, the serving table is closed so the waiter knows service is over

3. Fan-Out: Spreading Orders Across Stations

First we need a source of tickets and a station. A station is just a goroutine that reads from the shared ticket channel and returns its own channel of finished dishes.

fanout.go
// takeOrders is the host hanging tickets on the shared rail
func takeOrders(orders ...int) <-chan int {
	rail := make(chan int)
	go func() {
		defer close(rail)
		for _, o := range orders {
			rail <- o
		}
	}()
	return rail
}

// station is one cook: read a ticket, cook it, send the dish out
func station(id int, rail <-chan int) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out)
		for ticket := range rail {
			time.Sleep(100 * time.Millisecond) // cooking time
			out <- fmt.Sprintf("order #%d cooked at station %d", ticket, id)
		}
	}()
	return out
}

// Fan-out: three stations all read from the same rail
rail := takeOrders(1, 2, 3, 4, 5, 6, 7, 8, 9)
s1 := station(1, rail)
s2 := station(2, rail)
s3 := station(3, rail)

Because all three stations range over the same rail channel, Go's runtime hands each ticket to exactly one station. Faster stations naturally pick up more tickets, so the work balances itself.

4. Fan-In: One Serving Table for Every Station

Now we have three separate dish channels (s1, s2, s3). The waiter only wants to watch one serving table, so we merge them. Each station gets a forwarding goroutine that copies its dishes onto the shared table, and a sync.WaitGroup closes the table once every station is done.

fanin.go
func fanIn(stations ...<-chan string) <-chan string {
	var wg sync.WaitGroup
	table := make(chan string) // the single serving table

	forward := func(dishes <-chan string) {
		defer wg.Done()
		for dish := range dishes {
			table <- dish
		}
	}

	wg.Add(len(stations))
	for _, dishes := range stations {
		go forward(dishes)
	}

	// Close the table only after every station has stopped
	go func() {
		wg.Wait()
		close(table)
	}()

	return table
}

The WaitGroup is what prevents a panic: if we closed table while a station was still forwarding, that station would try to send on a closed channel. Waiting for all forwarders first guarantees the table closes exactly once, after the last dish.

5. Putting It Together

With fan-out feeding the stations and fan-in merging their output, main() reads from a single channel and never has to know how many stations exist.

main.go
func main() {
	start := time.Now()

	// Fan-out: one rail, many stations
	rail := takeOrders(1, 2, 3, 4, 5, 6, 7, 8, 9)
	s1 := station(1, rail)
	s2 := station(2, rail)
	s3 := station(3, rail)

	// Fan-in: the waiter watches one serving table
	for dish := range fanIn(s1, s2, s3) {
		fmt.Println(dish)
	}

	fmt.Printf("service done in %s\n", time.Since(start))
}

Nine orders that would take 900ms one at a time finish in roughly 300ms with three stations, and adding a fourth station is a one-line change.

6. When to Use This Pattern

  • One pipeline stage is much slower than the rest and can be safely run in parallel
  • Tasks are independent — no order is cooked twice and dishes can be served in any order
  • You want to scale throughput by changing the number of stations, not by rewriting the producer or consumer
  • Avoid it when tasks must stay strictly ordered, or when the work is so cheap that channel and goroutine overhead outweighs the gain

7. Practice Exercises

Try solving these on your own before checking the reference solutions. Each one uses fan-out, fan-in, or both.

exercise-1.go
// Exercise 1: Parallel Prime Detection
// Build a program that checks the primality of a set of numbers,
// using a fan-out then fan-in pattern.
//
// Requirements:
// 1. Create 1 input channel holding 20 numbers (e.g. 1 to 20) — this is the
//    single source, sent through 1 generator function.
// 2. Write a function isPrime(n int) bool (ordinary prime logic; a simple
//    approach is fine — it does not need to be the most optimal).
// 3. Fan-out: 3 worker goroutines that all read from that 1 number channel
//    (NOT 3 separate sources like the previous post — here there is ONE source
//    that is SPREAD across 3 workers). Each worker runs isPrime on the number
//    it pulled, then sends the result to its own channel as a struct:
//        type PrimeResult struct {
//            Number   int
//            IsPrime  bool
//            WorkerID int
//        }
//    Because this is fan-out, the workers need their own separate output
//    channels first (e.g. resultsA, resultsB, resultsC — or your own way of
//    representing 3 separate output channels).
// 4. Fan-in: merge the 3 worker result channels into 1 merged channel.
// 5. In main, read from merged, collect every prime number into a single
//    slice, then print that slice at the end (order does not matter; it may
//    be unordered because of concurrency).
//
// Extra conditions:
// 1. The total numbers processed must stay 20 (nothing processed twice or
//    skipped — verify this by counting the items coming out of merged; it
//    must equal 20).
// 2. Also print the WorkerID in each PrimeResult so you can see how the work
//    is distributed across the 3 workers (like the earlier worker pool post).
exercise-2.go
// Exercise 2: Password Validation System
// Build a program that checks the strength of a set of candidate passwords,
// processed in parallel.
//
// Requirements:
// 1. Create 1 input channel holding at least 15 password strings (a hardcoded
//    []string is fine; mix strong and weak ones — e.g. "123456",
//    "P@ssw0rd123", "admin", etc.), sent through a generator.
// 2. Write a function checkStrength(pw string) string that returns one of:
//    "weak", "medium", "strong". Simple rules (you may adjust them; they do
//    not need to be perfect):
//    - "weak":   length < 8 characters
//    - "medium": length >= 8, but no combination of a digit AND an uppercase letter
//    - "strong": length >= 8, has a digit AND an uppercase letter
// 3. Result struct:
//        type PasswordResult struct {
//            Password string
//            Strength string
//            WorkerID int
//        }
// 4. Fan-out: 4 workers all take from the same 1 password channel, each worker
//    checks its strength and sends to its own per-worker output channel.
// 5. Fan-in: merge the 4 output channels into 1 merged channel.
// 6. In main, read from merged, then at the end print a per-category summary —
//    how many passwords are "weak", "medium", "strong" (like the grouping
//    pattern from the failed-orders exercise, using map[string][]PasswordResult
//    or similar).
exercise-3.go
// Exercise 3: Multi-Source Log Processing System
// Build a program that processes logs from 3 different server sources
// (Server-A, Server-B, Server-C), then classifies each log's level with a
// worker pool.
//
// Requirements:
// 1. Create 3 separate generators (like scoreSource), each simulating 1 server
//    that emits logs gradually:
//        func logSource(serverName string, logs []string) <-chan string
//    Log contents are free-form, e.g.:
//        []string{"ERROR: disk full", "INFO: request handled",
//                 "WARNING: high memory", ...}
//    Use at least 5 logs per server, mixing ERROR, WARNING, INFO as the prefix
//    of each string.
// 2. First merge the 3 log sources into 1 channel (think: which pattern fits
//    merging 3 independent sources into 1?).
// 3. Only then, from that 1 merged channel, spread the work across 3 workers
//    (think: which pattern fits many workers taking work from 1 shared
//    source?). Each worker determines the log level by checking the string
//    prefix (ERROR, WARNING, INFO), then wraps it as:
//        type LogResult struct {
//            Server   string
//            Message  string
//            Level    string
//            WorkerID int
//        }
// 4. For the output of these 3 workers — you decide: does each worker need its
//    own output channel (needing another fan-in at the end), or is 1 shared
//    results channel read by all three enough? Think carefully about the
//    reason before coding.
// 5. In main, print every result, plus a summary of the log count per level
//    (ERROR, WARNING, INFO).
exercise-4.go
// Exercise 4: Multi-Branch Transaction Processing System
// A retail company has 3 branches (Branch-Jakarta, Branch-Bandung,
// Branch-Surabaya), each sending daily transaction data that must be validated
// before entering the central system.
//
// Requirements:
// 1. Each branch is an independent source, sending at least 6 transactions per
//    branch. Transaction struct:
//        type Transaction struct {
//            Branch string
//            TxID   string
//            Amount float64
//        }
//    Vary the Amount — some valid (> 0), some invalid (<= 0, simulating
//    corrupt / bad input data).
// 2. Merge the 3 branch sources into a single data stream.
// 3. Spread the merged stream across 5 workers for validation. Validation
//    rule: Amount <= 0 -> invalid. Each worker produces:
//        type ValidationResult struct {
//            Branch   string
//            TxID     string
//            Valid    bool
//            ErrorMsg string
//            WorkerID int
//        }
// 4. Collect all results, then at the end print 3 separate summaries:
//    - Total valid transactions per branch (e.g. Jakarta: 5 valid,
//      Bandung: 4 valid, ...)
//    - Total invalid transactions per branch
//    - A full list of invalid TxIDs together with their ErrorMsg
//      (combined across all branches).
View the reference solutions on GitHub

Conclusion

Fan-out and fan-in turn a single-threaded pipeline stage into a parallel one with very little code. Keep the picture of the kitchen in mind: one rail feeding many stations, many stations feeding one serving table, and a waiter who only ever looks at that table.

Share this article