Channels in Go: communication between goroutines without overcomplicating it

Go channels explained: buffered, unbuffered, select, closing channels and common patterns. Clear concurrent communication.

Cover for Channels in Go: communication between goroutines without overcomplicating it

Don’t communicate by sharing memory; share memory by communicating.

That is the phrase most repeated when talking about concurrency in Go. And channels are exactly the mechanism that makes it possible. They are the native way Go has of passing data between goroutines safely, without explicit locks and without sharing mutable state.

But there is an important nuance that many tutorials skip: channels are not the universal solution. Sometimes a sync.Mutex is clearer, simpler, and more efficient. Knowing when to use each tool is what makes the difference between concurrent code that works and concurrent code that is understood.

Let us go through channels from scratch: what they are, how they work, the patterns actually used in production, and the traps that will blow up in your face if you do not know them.


What a channel is

A channel in Go is a typed conduit that connects goroutines. Think of it as a pipe: one goroutine puts a value in one end and another takes it out the other. The channel’s type defines what can travel through that pipe.

ch := make(chan string)

That creates a channel that transports strings. You cannot put an int in there; the compiler prevents it. This type restriction is one of the things that make channels predictable: you know exactly what is going to come out the other side.

Internally, a channel is a structure with a buffer (or without one), an internal lock, and queues of goroutines waiting to send or receive. But you do not need to think about that to use them. What you do need to understand is the difference between buffered and unbuffered, because that is where most people get confused.


Unbuffered channels: synchronous communication

When you create a channel without specifying capacity, you get an unbuffered channel:

ch := make(chan int)

The behavior is strictly synchronous. When a goroutine sends a value to an unbuffered channel, it blocks until another goroutine is ready to receive it. And the other way around: a goroutine that tries to receive blocks until someone sends.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "done" // blocks until someone reads
    }()

    msg := <-ch // blocks until someone writes
    fmt.Println(msg)
}

This mutual blocking is an implicit synchronization. You do not need a WaitGroup or a mutex to coordinate the two goroutines: the channel takes care of it. When msg := <-ch completes, you know for certain that the anonymous goroutine already executed ch <- "done".

Unbuffered channels are perfect when you want a direct handoff: “I pass this to you, I wait for you to take it, and then I continue.” It is like handing over a package in person: you do not leave it at the door, you wait for the other person to receive it.


Buffered channels: asynchronous communication with capacity

Buffered channels have an internal queue with fixed capacity:

ch := make(chan int, 5) // buffer of 5 elements

The key difference: a send only blocks when the buffer is full, and a receive only blocks when it is empty. As long as there is space, the sender can keep sending without waiting.

func main() {
    ch := make(chan string, 2)

    ch <- "first"  // does not block, there is space
    ch <- "second" // does not block, still space
    // ch <- "third" // this WOULD block: buffer full

    fmt.Println(<-ch) // "first"
    fmt.Println(<-ch) // "second"
}

The buffer does not make the channel “faster” in a general sense. What it does is temporarily decouple the sender from the receiver. The sender can get a bit ahead without stopping, as long as the buffer does not fill up.

When to use buffered vs unbuffered

There is no universal rule, but there is a pragmatic guide:

  • Unbuffered: when you want explicit synchronization between goroutines. The send is a rendezvous point.
  • Buffered: when you want to absorb work spikes or decouple producer from consumer. The buffer acts as a shock absorber.

A common mistake is to put a large buffer “just in case.” If you do not know what capacity you need, start with unbuffered. If you see that performance is not enough and profiling confirms it, then add buffer. Not the other way around.


Sending and receiving: the arrow syntax

The operation on channels uses the <- operator. The direction of the arrow tells you what is happening:

ch <- value   // send value to channel
value := <-ch // receive value from channel

It is intuitive once you see it: the arrow points to where the data goes. If it points to the channel, you are sending. If it comes out of the channel, you are receiving.

Directional channels

You can restrict a channel to allow only sending or receiving. This is especially useful in function signatures:

func producer(out chan<- int) {
    // can only send
    out <- 42
}

func consumer(in <-chan int) {
    // can only receive
    value := <-in
    fmt.Println(value)
}

chan<- is a write-only channel. <-chan is read-only. The compiler prevents you from misusing them. This is not just aesthetic: it makes the code’s intent obvious and prevents bugs where a function closes or writes to a channel it should not touch.

Go automatically converts a bidirectional chan int to chan<- int or <-chan int when you pass it to a function that expects a directional channel. No explicit casting needed.


Closing channels: when and why

Closing a channel indicates that no more values will be sent through it:

close(ch)

After closing:

  • Receiving still works: it returns any values left in the buffer, and afterwards returns the zero value of the type.
  • Sending causes a panic. There is no going back.
  • Closing again also causes a panic.

You can check if a channel is closed using the two-value form:

value, ok := <-ch
if !ok {
    fmt.Println("channel closed")
}

When ok is false, the channel was closed and no values remain.

Fundamental rule: only the sender closes

The correct pattern is that whoever sends is the one who closes the channel. Never the receiver. The reason is simple: if the receiver closes the channel and the sender tries to send, you get a panic. If the sender closes, the receiver simply detects that ok is false and moves on.

If you have multiple senders, do not close from any of them. Use an external coordination mechanism (a sync.WaitGroup, for example) to know when all senders have finished, and close from a coordinator goroutine.

func main() {
    ch := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id
        }(i)
    }

    go func() {
        wg.Wait()
        close(ch)
    }()

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

The coordinator goroutine waits for the three senders to finish and then closes. Clean, without race conditions.


Range over channels: consume until closed

for range over a channel iterates receiving values until the channel is closed:

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // without this, range blocks forever
}()

for val := range ch {
    fmt.Println(val) // 0, 1, 2, 3, 4
}

This is the idiomatic way to consume a channel when you know the sender will eventually close it. Without the close(), the range would stay blocked waiting for the next value indefinitely, and you would have a deadlock.

for range over channels is clean and expressive. But it requires someone to close the channel. If you cannot guarantee that, use select with a cancellation case.


Select: multiplexing channels

select is like a switch for channel operations. It waits for one of several operations to be ready and executes that case:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("received from ch2:", msg)
case ch3 <- "ping":
    fmt.Println("sent to ch3")
}

If several cases are ready at the same time, Go chooses one at random. There is no priority. This is intentional to avoid starvation.

Default: non-blocking operation

You can add a default case to prevent select from blocking:

select {
case msg := <-ch:
    fmt.Println(msg)
default:
    fmt.Println("nothing available right now")
}

This turns a blocking operation into a non-blocking one. It is useful for polling or for trying to send without getting stuck:

select {
case ch <- value:
    // sent successfully
default:
    // channel is full or nobody is reading, we discard
    log.Println("value discarded")
}

This pattern is common when you have a metrics or logging channel and you prefer to lose a data point rather than block the main goroutine.


Timeout with select and time.After

One of the most useful patterns with select is implementing timeouts. You do not need external libraries or complicated timers:

select {
case result := <-processSomething():
    fmt.Println("result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("timeout: the operation took too long")
}

time.After returns a channel that receives a value after the specified time. If the operation completes first, you use that result. If not, the timeout wins.

Timeout in a loop

If you process messages in a loop and want to detect inactivity:

for {
    select {
    case msg := <-ch:
        fmt.Println("processing:", msg)
    case <-time.After(5 * time.Second):
        fmt.Println("5 seconds without activity, exiting")
        return
    }
}

Be careful with this: each loop iteration creates a new timer. If the loop iterates fast, you are creating thousands of timers that the garbage collector has to clean up. For high-frequency loops, use time.NewTimer and reset it manually:

timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
    select {
    case msg := <-ch:
        fmt.Println("processing:", msg)
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        fmt.Println("inactivity timeout")
        return
    }
}

More verbose, but does not generate garbage on each iteration.


Fan-out, fan-in pattern

Fan-out and fan-in is one of the most commonly used Go concurrency patterns in production. The idea is simple:

  • Fan-out: you launch several goroutines that read from the same input channel.
  • Fan-in: you collect results from multiple goroutines into a single output channel.

Fan-out

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("worker %d processing job %d\n", id, job)
        time.Sleep(time.Second) // simulates work
        results <- job * 2
    }
}

You launch several workers that compete for the jobs:

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

for w := 1; w <= 3; w++ {
    go worker(w, jobs, results)
}

for j := 1; j <= 9; j++ {
    jobs <- j
}
close(jobs)

for r := 1; r <= 9; r++ {
    fmt.Println(<-results)
}

Three workers process 9 jobs in parallel. The jobs channel acts as a work queue and Go ensures that each job is processed by exactly one worker. If you want to go deeper into this pattern, I have a dedicated article on worker pools in Go.

Fan-in

Fan-in combines multiple channels into one:

func fanIn(channels ...<-chan string) <-chan string {
    var wg sync.WaitGroup
    merged := make(chan string)

    output := func(ch <-chan string) {
        defer wg.Done()
        for val := range ch {
            merged <- val
        }
    }

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

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

The function receives N channels, launches a goroutine for each that forwards to the merged channel, and a coordinator goroutine that closes merged when all inputs have been exhausted. Clean and reusable.


When channels are NOT the answer: the mutex alternative

This is where many channel articles stop. But the reality is that not every concurrent problem needs channels. Sometimes a sync.Mutex is clearer.

Channels are better when

  • You are passing ownership of data: one goroutine produces, another consumes, nobody shares state.
  • You need to coordinate flow between goroutines: pipelines, fan-out/fan-in, timeouts.
  • You want to signal events: “I am done”, “cancel this”, “there is new work”.

Mutex is better when

  • You are protecting access to a shared resource: a map, a counter, an in-memory cache.
  • The operation is simple: read or write a value and continue.
  • There is no flow between goroutines, just concurrent access to the same state.
// With mutex: simple and direct
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    c.v[key]++
    c.mu.Unlock()
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

Trying to do this with channels would be forcing an abstraction where it does not fit. You would have to create a request channel, a response channel, a goroutine that listens to requests and updates the map… to do something that a lock solves in three lines.

The rule I use: if the operation is “access shared data”, mutex. If the operation is “pass data or coordinate goroutines”, channels.


Common mistakes: deadlocks, panics, and goroutine leaks

Deadlock: nobody is listening

The most frequent mistake with channels is the deadlock. It happens when all goroutines are blocked waiting on channel operations that will never complete:

func main() {
    ch := make(chan int)
    ch <- 1 // deadlock: main blocks sending, nobody receives
    fmt.Println(<-ch)
}

Go detects this case and terminates the program with fatal error: all goroutines are asleep - deadlock!. But it only detects global deadlocks (when all goroutines are blocked). If you have a partial deadlock where some goroutines are still alive, Go does not warn you. Those goroutines remain hanging consuming memory silently.

Sending to a closed channel: panic

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

There is no way to check if a channel is closed before sending without introducing a race condition. The solution is design: structure your code so that only the sender closes, and the sender knows when to stop sending.

Goroutine leaks

This is the most insidious because it gives no error. A goroutine that stays blocked on a channel that nobody will ever read:

func query(ctx context.Context) string {
    ch := make(chan string)

    go func() {
        result := slowOperation()
        ch <- result // if nobody reads this, the goroutine stays hanging
    }()

    select {
    case res := <-ch:
        return res
    case <-ctx.Done():
        return "cancelled"
    }
}

If the context is cancelled before slowOperation() finishes, the query function returns “cancelled”, but the anonymous goroutine keeps executing. When it tries to send the result to ch, it will stay blocked forever because nobody is listening.

The solution: use a buffered channel with capacity 1:

ch := make(chan string, 1)

Now, even if nobody reads, the send does not block (there is space in the buffer). The goroutine finishes, ch is eventually collected by the garbage collector, and there is no leak.


Practical example: rate-limited API caller with channels

Let us build something that combines several patterns: a system that makes calls to an external API respecting a rate limit, processing results concurrently.

The scenario: you have a list of URLs to query, but the API allows a maximum of 5 requests per second. You want to process them in parallel with a pool of workers, but without exceeding the limit.

package main

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

type APIResult struct {
    URL    string
    Status int
    Body   string
    Err    error
}

func rateLimitedCaller(
    urls []string,
    maxPerSecond int,
    workers int,
) []APIResult {
    jobs := make(chan string, len(urls))
    results := make(chan APIResult, len(urls))

    // Rate limiter: a ticker that fires at the desired frequency
    interval := time.Second / time.Duration(maxPerSecond)
    limiter := time.NewTicker(interval)
    defer limiter.Stop()

    // Workers
    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            client := &http.Client{Timeout: 10 * time.Second}

            for url := range jobs {
                // Wait for the ticker before each request
                <-limiter.C

                resp, err := client.Get(url)
                if err != nil {
                    results <- APIResult{URL: url, Err: err}
                    continue
                }

                body, _ := io.ReadAll(resp.Body)
                resp.Body.Close()

                results <- APIResult{
                    URL:    url,
                    Status: resp.StatusCode,
                    Body:   string(body[:min(len(body), 200)]),
                }
            }
        }(w)
    }

    // Send jobs
    for _, url := range urls {
        jobs <- url
    }
    close(jobs)

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

    // Collect results
    var allResults []APIResult
    for r := range results {
        allResults = append(allResults, r)
    }

    return allResults
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func main() {
    urls := []string{
        "https://httpbin.org/get",
        "https://httpbin.org/status/200",
        "https://httpbin.org/status/404",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/status/500",
        "https://httpbin.org/get",
        "https://httpbin.org/status/200",
        "https://httpbin.org/get",
    }

    start := time.Now()
    results := rateLimitedCaller(urls, 5, 3)
    elapsed := time.Since(start)

    for _, r := range results {
        if r.Err != nil {
            fmt.Printf("[ERROR] %s: %v\n", r.URL, r.Err)
        } else {
            fmt.Printf("[%d] %s\n", r.Status, r.URL)
        }
    }

    fmt.Printf("\n%d requests in %s\n", len(results), elapsed)
}

Let us analyze what is happening:

  1. jobs channel: buffered with capacity for all URLs. Workers read from here.
  2. results channel: also buffered. Workers write results here.
  3. time.NewTicker: fires a tick every 1s / maxPerSecond. Each worker waits for a tick before making the request. This distributes the rate limit across all workers.
  4. sync.WaitGroup: coordinates the closing of results. When all workers finish, the coordinator goroutine closes the channel.
  5. for range results: collects everything until it is closed.

The trick is that the ticker is shared between all workers. When a worker does <-limiter.C, it consumes a tick. If three workers try to consume at the same time, only one receives each tick. This guarantees that the global rate does not exceed maxPerSecond, regardless of how many workers you have.

This pattern scales well: if you need more throughput, raise maxPerSecond. If you need more parallelism in processing responses, raise the workers. Channels make everything fit together without explicit locks.


Knowing when to use a channel and when not to

Channels are one of Go’s most expressive tools, but also one of the most misused. I have seen (and written) code where a channel complicated something that a mutex solved in two lines. The distinction that has always worked for me is this: if I am passing data between goroutines, a channel is the natural tool. If I am protecting access to a shared resource, I probably want a mutex.

What took me longest to internalize was the discipline around closing channels: only the sender closes, never the receiver. And that goroutine leaks are completely silent. Go detects global deadlocks, but partial ones you swallow without noticing. Using buffered channels with capacity 1 when the receiver can bail out is a habit that has saved me hours of debugging.

If you are just starting, my advice is to go with unbuffered channels and select first. Add buffer when you genuinely need it, not by default. And watch out for time.After inside a loop: it creates a new timer on each iteration. time.NewTimer is what you want if the loop is fast.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved