Channels (buffered, unbuffered, close)

Reviewed & published by Brayan K

Channels are how goroutines talk to each other safely. By the end of this lesson you'll send and receive values, tell unbuffered from buffered channels, close a channel and range over it, and use select to wait on several channels at once — Go's "communicate to share memory" philosophy in action.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

📊 Channel Operations

OperationSyntaxBlocks until…
Createmake(chan int)— (n/a)
Sendch <- va receiver takes it (or buffer has room)
Receivev := <-cha value is available
Closeclose(ch)— (signals no more sends)
Receive (ok)v, ok := <-chok is false when closed & drained

Only the sender should close a channel, and never send on a closed channel — both rules keep you out of panics.

1️⃣ Send and Receive (Unbuffered)

Make a channel with make(chan T), send with ch <- v, receive with v := <-ch. An unbuffered channel is a rendezvous: the send blocks until a receiver is ready, and the receive blocks until a value arrives. That handshake also synchronizes the two goroutines for free.

package main

import "fmt"

// A channel is a typed pipe between goroutines. One goroutine SENDS
// with ch <- v, another RECEIVES with v := <-ch.
func main() {
    ch := make(chan string)        // unbuffered channel of strings

    go func() {
        ch <- "ping"               // send blocks until someone receives
    }()

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

Channels shine for returning a value out of a goroutine — there's no return across the go boundary, so you send the result back instead.

package main

import "fmt"

// Channels are the idiomatic way to get a RESULT back from a goroutine.
func square(n int, out chan int) {
    out <- n * n                   // send the answer back
}

func main() {
    results := make(chan int)

    go square(3, results)
    go square(4, results)

    // Receive two values. Order depends on which goroutine finishes first.
    a := <-results
    b := <-results
    fmt.Println("got two results:", a+b) // 9 + 16 = 25 (in some order)
}

Your turn. Fill in the send and the receive so the value travels through the channel.

package main

import "fmt"

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

    go func() {
        ___ 42                     // 👉 ch <- 42   (send 42 into the channel)
    }()

    value := ___                   // 👉 <-ch       (receive from the channel)
    fmt.Println("received:", value)
    // ✅ Expected output:  received: 42
}

2️⃣ Buffered Channels

Give make a second argument and you get a buffered channel that holds that many values without a receiver waiting. Sends only block once the buffer is full; receives only block once it's empty. len(ch) tells you how many values are queued, cap(ch) the capacity.

package main

import "fmt"

func main() {
    // A BUFFERED channel holds up to N values without a receiver waiting.
    // make(chan T, capacity)
    ch := make(chan string, 2)     // capacity 2

    ch <- "first"                  // does NOT block — buffer has room
    ch <- "second"                 // still room
    // ch <- "third"               // WOULD block: buffer is full

    fmt.Println(<-ch)              // first
    fmt.Println(<-ch)              // second
    fmt.Println("len after drain:", len(ch), "cap:", cap(ch))
}

3️⃣ Closing and Ranging

close(ch) announces that no more values are coming. A receiver can then range over the channel and the loop ends automatically once every queued value has been read. The v, ok := <-ch form sets ok to false when the channel is closed and empty.

package main

import "fmt"

// Closing a channel says "no more values will be sent". Receivers can
// then RANGE over it until it's drained, and the loop ends automatically.
func main() {
    nums := make(chan int, 3)

    go func() {
        for i := 1; i <= 3; i++ {
            nums <- i * 10
        }
        close(nums)                // signal: done sending
    }()

    // range stops cleanly when the channel is closed AND drained.
    for n := range nums {
        fmt.Println("received", n)
    }

    // Receiving from a closed channel gives the zero value and ok=false.
    v, ok := <-nums
    fmt.Println("after close:", v, ok) // after close: 0 false
}

4️⃣ Waiting on Many with select

select is like a switch for channels: it blocks until one of its cases is ready, then runs that case. It's the heart of timeouts, fan-in, and cancellation patterns. If several cases are ready at once, Go picks one at random to keep things fair.

🧩 Reorder Challenge

These lines send three numbers, close the channel, and range over it. Put them in the correct order:

A.     for i := 1; i <= 3; i++ { ch <- i }
B. ch := make(chan int, 3)
C. for n := range ch {
D.     fmt.Println(n)
E. go func() {
F.     close(ch)
G. }()
H. }

B, E, A, F, G, C, D, H. Make the channel, then a goroutine that sends the three values and closes the channel (so the range can terminate), then range over it in main and print each value. Closing must happen or the range blocks forever.

🧠 Quick Recall

Predict the output before revealing the answer.

ch := make(chan int)
ch <- 1
fmt.Println(<-ch)

Deadlock. The channel is unbuffered, so ch <- 1 blocks forever waiting for a receiver — but the receive is on the next line in the same goroutine, which never runs. Send from a separate goroutine, or make the channel buffered.

ch := make(chan int, 1)
ch <- 7
close(ch)
v, ok := <-ch
fmt.Println(v, ok)

7 true — closing doesn't discard buffered values. You can still receive the 7 (and ok is true). Only once the buffer is drained does a receive give 0 false.

3. What does this do?

ch := make(chan int)
close(ch)
ch <- 1

Panics: "send on closed channel". Once closed, a channel must never be sent to again. This is why only the sender should close, and only when it's truly done.

Pro Tips

Common Errors (and the fix)

Frequently Asked Questions

Q: When should I use a buffered channel?

When the producer and consumer run at different speeds and you want the producer to keep working without waiting for each value to be picked up. Pick a capacity that matches your batch size; an oversized buffer just hides backpressure problems.

Q: Do I have to close every channel?

No. Close only when receivers need to know the stream is finished — typically so a range can stop. If nobody ranges and the channel is simply garbage-collected, you don't need to close it.

Q: Channels or a Mutex?

Use channels to pass ownership of data between goroutines ("communicate to share memory"). Use a Mutex when several goroutines must read and write one shared structure in place. Both are valid; pick the one that makes the data flow clearest.

Q: What does a select with a default case do?

It makes the select non-blocking: if no channel is ready, the default runs immediately instead of waiting. Handy for polling without blocking.

Mini-Challenge: Summing Worker

No blanks — just a brief. Feed numbers into a jobs channel, let a worker goroutine sum them, and send the total back on a done channel. This producer/worker/result shape is the backbone of real Go pipelines.

package main

import "fmt"

func main() {
    // 🎯 MINI-CHALLENGE: a worker that sums numbers
    // 1. Make a channel jobs := make(chan int, 5) and a channel done := make(chan int).
    // 2. Start a goroutine that ranges over jobs, adds them up, and sends
    //    the total into done.
    // 3. In main, send 1..5 into jobs, then close(jobs).
    // 4. Receive the total from done and print "sum = 15".
    //
    // ✅ Expected output:  sum = 15

    // your code here
}

🎉 Lesson Complete!

Practice quiz

How do you create an unbuffered channel of ints?

  • make(chan int)
  • make(chan int, 0)
  • new(chan int)
  • Both make(chan int) and make(chan int, 0)

Answer: Both make(chan int) and make(chan int, 0). make(chan int) and make(chan int, 0) both create an unbuffered channel — capacity zero means no buffer.

On an unbuffered channel, a send (ch <- v) blocks until...

  • The buffer is full
  • A receiver is ready to take the value
  • The channel is closed
  • It never blocks

Answer: A receiver is ready to take the value. An unbuffered channel is a rendezvous: the send blocks until a receiver is ready to receive.

What happens with: ch := make(chan int); ch <- 1; fmt.Println(<-ch) in one goroutine?

  • Prints 1
  • Deadlock
  • Prints 0
  • Panics

Answer: Deadlock. The unbuffered send blocks waiting for a receiver, but the receive is on the next line in the same goroutine — deadlock.

After ch := make(chan string, 2); ch <- "a"; ch <- "b", what is len(ch) and cap(ch)?

  • len 2, cap 2
  • len 0, cap 2
  • len 2, cap 0
  • len 1, cap 2

Answer: len 2, cap 2. Two values are buffered without blocking, so len is 2 and the capacity stays 2.

What does receiving from a closed AND drained channel with v, ok := <-ch give?

  • The last value, true
  • The zero value, false
  • A panic
  • Blocks forever

Answer: The zero value, false. Once a channel is closed and empty, receives return the zero value and ok is false.

Given c := make(chan int, 1); c <- 7; close(c); v, ok := <-c — what is v, ok?

  • 0, false
  • 7, false
  • 7, true
  • 0, true

Answer: 7, true. Closing doesn't discard buffered values, so you still receive the 7 with ok true; only once drained do you get 0, false.

What does sending on a closed channel do?

  • Returns an error
  • Silently drops the value
  • Panics: send on closed channel
  • Reopens the channel

Answer: Panics: send on closed channel. Sending after close panics with 'send on closed channel'. Only the sender should close, and only when done.

Why might a for v := range ch loop never end?

  • The channel is buffered
  • The channel was never closed
  • The values are nil
  • select is missing

Answer: The channel was never closed. A range over a channel only stops when the channel is closed. Forget to close it and the loop blocks forever.

What does select do?

  • Runs every ready case
  • Blocks until one case is ready, then runs that one
  • Picks the first case in source order
  • Loops over channels

Answer: Blocks until one case is ready, then runs that one. select waits until one of its cases is ready, then runs that case; if several are ready it picks one at random.

What does adding a default case to a select do?

  • Makes it loop
  • Makes it block longer
  • Makes it non-blocking — runs default if nothing is ready
  • Closes all channels

Answer: Makes it non-blocking — runs default if nothing is ready. A default case makes select non-blocking: if no channel is ready right now, default runs immediately.

Continue this course