---
title: Channels
source: 'https://academia.sh/en/courses/go-concurrency/channels'
course: Concurrency
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a channel carries a value and synchronizes the sender with the receiver at the same time'
    - 'Shows, with select and default and without using a duration, that a send attempt does not go through on an unbuffered channel with no receiver'
    - "Proves, by running it, that a value carried through a channel is copied, and that the sender's later change never reaches the receiver"
---

# Channels

A channel does two jobs at once in the same line: it carries a value from one goroutine to another, and it forces the two goroutines to meet at the same point. A send on an unbuffered channel never completes until a receiver is ready on the other side; if there is never a receiver, the attempt never goes through. Bounding measurement: a channel is not a queue, because the value carried is copied, and any change the sender makes after the send never reaches the receiver.

The previous lesson sorted twelve goroutines into four ways of stopping, and set up the
first of the five "arrived" lines with a receive on an unbuffered channel: `unbuffered <- 1`
was written, the goroutine on the other side received it and stopped. In that one line, two
separate jobs actually happened at once — an integer passed from one goroutine to another,
and two goroutines met at the same point at the same time. This lesson separates the two: a
channel does both jobs in the same line, but without measuring them separately, what the
word "synchronization" says and what the word "transfer" says run together.

A **channel** is the one way a typed value passes from one goroutine to another; it is
written `chan int`, `chan string`, and the direction of the `<-` operator separates a send
from a receive: `channel <- value` is a send, `value := <-channel` is a receive. But a
channel is not merely a carrier. It does a second job in the same line: it **synchronizes**
the sender and the receiver. This second job is what sets a channel apart from a queue or a
mailbox, and it is exactly what this lesson measures.

These two jobs coming together sits at the center of Go's approach to concurrency: instead
of reaching a shared variable through a lock, sending the value directly from one goroutine
to another. Locks and shared state are the subject of the Shared State topic; this lesson
measures only the mechanism of communication itself — what a channel does, and what it does
not.

A channel created with `make(chan int)` starts out empty and is bound to no goroutine; the
number of parties using it can be zero, one, or many. This lesson measures the simplest
case, a single sender and a single receiver. Cases where more than one goroutine shares the
same channel — many goroutines writing to the same result channel, or many goroutines
reading from the same work channel — are the subject of the Concurrency Patterns topic; the
channel's own rules are established here first, then carried over there.

## A Send with No Receiver Never Goes Through

A send on an unbuffered channel (`make(chan T)`, no capacity) does not complete until a
goroutine is ready to receive that value on the other side. Instead of letting time pass and
assuming "probably no one is there," it is possible to **try**, using `select` together with
a `default` branch: if the send can go through right then, `select` chooses that branch; if
it cannot, it falls to `default` without waiting at all.

```go
// rendezvous.go — the two jobs of an unbuffered channel: carrying a value and blocking a receiverless send
package main

import "fmt"

func tryToSend(ch chan string, v string) bool {
	select {
	case ch <- v:
		return true
	default:
		return false
	}
}

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

	fmt.Println("did the send attempt go through with no receiver at all:", tryToSend(channel, "first attempt"))

	delivered := make(chan string)
	go func() {
		v := <-channel
		delivered <- "receiver got: " + v
	}()

	channel <- "hello"
	fmt.Println("send completed, receiver took delivery of the value")
	fmt.Println(<-delivered)
}
```

```
did the send attempt go through with no receiver at all: false
send completed, receiver took delivery of the value
receiver got: hello
```

**GC4.** — In the first line, no goroutine is yet waiting on `channel`; `tryToSend` returns
`false` on **every** run in this case, because there is no one waiting — this is not a
timing issue, it is the direct consequence of what an unbuffered channel is defined to be.
In the second part, a goroutine is started, and `channel <- "hello"` is this time written
directly, not inside a `select`: this line blocks until a receiver arrives and completes
once the receiver arrives. The difference matters: `select`+`default` polls a **momentary
state**, while a plain `channel <- value` waits however long it takes and stops only once it
is **certain to complete**.

It is worth noting that the rest of the program runs in a single goroutine (the main
goroutine): the receiver goroutine prints nothing to the screen at all, it only sends the
value it received back on the `delivered` channel. Every `fmt.Println` call is in the main
goroutine, and there is an order between them forced through a channel — this is why the
output is byte-for-byte the same on every run.

This measurement style is the rule for this lesson and the rest of the course: a goroutine
never prints to the screen on its own, it only writes onto a channel; printing to the screen
is always done in the main goroutine, through the value read from the channel. If two
goroutines printed to the screen at the same time, the order the lines came out in would be
just as undetermined as the goroutines themselves — a channel here is used not only to carry
a value, but also to gather the output's order into a single goroutine.

## The Value Carried Is Copied

When a channel carries a value, it carries a **copy** of that value, not a reference to it.
This is the channels' counterpart of the copy/share distinction seen in array and slice
assignments in the Go Fundamentals course: when a struct value is sent on a channel, the
sender changing that variable afterward does not affect the copy the receiver is holding.

```go
// copied.go — a value carried through a channel is copied, the sender's later change never reaches the receiver
package main

import "fmt"

type record struct {
	Name  string
	Value int
}

func main() {
	channel := make(chan record)
	result := make(chan record)

	go func() {
		received := <-channel
		result <- received
	}()

	r := record{Name: "counter", Value: 1}
	channel <- r
	r.Value = 999
	fmt.Println("sender's own value now  :", r)
	fmt.Println("value the receiver took :", <-result)
}
```

```
sender's own value now  : {counter 999}
value the receiver took : {counter 1}
```

**GC5.** — When the line `channel <- r` completed, `r`'s value **at that moment** was
copied and handed to the receiving goroutine. The instant afterward, when `r.Value = 999`
is written, the only thing that changes is the sender's own local variable; the copy the
receiver holds knows nothing of this, and its `Value` field stays `1`. This behavior comes
from `record` being a value type, not a pointer — had a pointer been passed over the
channel (`chan *record`), what gets copied would be the pointer itself, not the data it
points to, and in that case the sender's change would also be visible to the receiver. This
lesson measures only value transfer; what does and does not get shared through copying
pointers was a separate axis in the Arrays and Slices lesson and is not repeated here.

This copying rule carries no exception: whatever the channel's own type is — an integer, a
string, a struct, even another channel — what is sent is always a copy of that value's bit
pattern at that moment. A channel never adds a pointer of its own to the value sent; if
sharing is wanted, exactly as with sending `*record` instead of `record`, the party that
wants that sharing has to choose its own type for it.

## The Copy Is Shallow: The Slice Inside It Is Shared

What gets copied is always only the **struct itself**, not every field inside it. If a
struct carries a slice field, when the channel copies that struct it copies the slice's
**header** — length, capacity, and a pointer to the backing array — but it does not copy
the backing array itself. This is the channel-transfer counterpart of the shallow-copy
behavior seen with arrays and slices in the Go Fundamentals course.

```go
// shallowcopy.go — the struct sent over a channel is copied, but its slice field shares the same backing array
package main

import "fmt"

type basket struct {
	Label string
	Items []int
}

func main() {
	channel := make(chan basket)
	result := make(chan basket)

	go func() {
		received := <-channel
		received.Items[0] = 999
		result <- received
	}()

	s := basket{Label: "source", Items: []int{1, 2, 3}}
	channel <- s
	receivedCopy := <-result
	fmt.Println("receiver's copy      :", receivedCopy)
	fmt.Println("sender's own variable:", s)
}
```

```
receiver's copy      : {source [999 2 3]}
sender's own variable: {source [999 2 3]}
```

**GC6.** — `received` is its own copy of the `basket` value coming from the channel, and
its `Label` field is independent of the sender — changing one does not affect the other,
just as with `record.Value` in the previous measurement. But since `Items` is a slice, what
gets copied is only the three-word slice header; the backing array the header points to is
the same one the sender has. When `received.Items[0] = 999` is written, the memory location
that changes is that one backing array both variables share — this is why `s.Items[0]` also
shows `999`, even though no goroutine ever touched the `s` variable itself directly. The
reason this line can be safely read in the main goroutine is that the `<-result` receive, by
the channel operation's own synchronization promise, also makes the write to the backing
array visible; the channel carries not only the `result` value, but everything in memory
leading up to it.

This shows the limit of the sentence "a value carried through a channel is copied": the
copying **ends with the struct itself**, it does not extend to the data pointed to by a
slice, map, or pointer field inside it. Code that wants to send a struct as a copy fully
independent of the channel has to separately copy the slice field with `copy` too — the
channel does not do this on its own.

## A Channel's Direction Can Be Part of Its Type

A channel variable can be used in both directions, but a function's signature can restrict a
channel's direction: `chan<- int` is a channel type that allows only sending, `<-chan int`
only receiving. This restriction is part of the type itself — a bidirectional channel,
created with the same `make(chan int)`, can only be used in the allowed direction inside a
function once it is passed as a directional parameter.

```go
// directional.go — a channel's direction can be part of its type: send-only, receive-only
package main

import "fmt"

func produce(target chan<- int, count int) {
	for i := 1; i <= count; i++ {
		target <- i
	}
	close(target)
}

func sum(source <-chan int) int {
	total := 0
	for v := range source {
		total += v
	}
	return total
}

func main() {
	channel := make(chan int)
	go produce(channel, 5)
	fmt.Println("sum of the five produced values:", sum(channel))
}
```

```
sum of the five produced values: 15
```

**GC7.** — The channel passed to the `produce` function, even though it is a bidirectional
variable, appears inside `produce` only as `target chan<- int`; trying to write
`<-target` in `produce`'s body does not compile. Likewise, a send attempt like
`source <- 1` inside `sum` does not compile either. This is a writing decision that makes
which side does which job with the channel readable straight from the signature: someone
looking at `produce`'s signature knows, without reading the body, that it will only write to
the channel, never read from it. `produce` itself also closes `target` — who is responsible
for closing is not this lesson's subject, it is what the topic's closing lesson measures.

The direction restriction changes nothing at runtime; `chan<- int` and `<-chan int` share
the same underlying channel value, they are not a different channel type. What is gained is
readability at compile time alone: a function's signature commits to what that function will
do with the channel, and a call that breaks this commitment does not compile. The `channel`
variable inside `main` is passed to both `produce` and `sum` in the same bidirectional
form; the restriction only applies **inside** those functions themselves.

## Two Measurements Together: Carrying and Meeting

**GC8.** — When these two measurements are read together, a channel's real promise comes
into view: a channel operation both freezes and carries a value's state **at that moment**,
and meets the sender and the receiver at the same point. Likening a channel to a queue gives
a useful intuition at first glance — values go in one end, come out the other — but a queue
never meets the sender with the receiver, it only separates them through a buffer. An
unbuffered channel has no such buffer; the meeting takes the buffer's place. The next two
lessons measure how this meeting gets **deferred** once a buffer is added.

These two jobs do not always carry the same weight. In some uses, only the value itself
matters — the result of a computation, a record's content. In others, what the value is does
not matter at all, only the information that **something happened**; in that case a type
carrying no data at all, like `chan struct{}`, is preferred, and the channel serves only the
synchronization job. This lesson's `delivered` channel in `rendezvous.go` is of the first
kind; the closing signals we will see a few times across the rest of the measurement are of
the second.

A channel also has an unwritten state: a channel variable's zero value is `nil`, and a send
or receive on a `nil` channel never completes, it blocks forever. This behavior is defined,
and it passed by in a single sentence in the context of zero values in the Go Fundamentals
course; this course does not build it up again. But a `nil` channel's property of "never
being ready" produces a useful result inside `select`: a branch containing a `nil` channel
is never chosen unless that channel is replaced with another value — so to temporarily
"close off" a branch, it is enough to turn that branch's channel into `nil`.

```go
// nilchannel.go — every operation on a nil channel blocks forever, always passed over inside select
package main

import "fmt"

func main() {
	var nilChannel chan int
	ready := make(chan int, 1)
	ready <- 7

	select {
	case v := <-nilChannel:
		fmt.Println("received from the nil channel:", v)
	case v := <-ready:
		fmt.Println("received from the ready channel:", v)
	default:
		fmt.Println("neither is ready")
	}
}
```

```
received from the ready channel: 7
```

`nilChannel` was never initialized with `make`, so it stayed at its zero value, `nil`.
`select` polled all three branches: the `nilChannel` branch is ruled out because it can
never be ready; the `ready` branch is full and gets chosen. Had `<-nilChannel` been written
directly and called outside a `select`, the program would have stayed there forever; this
lesson never runs that case, because its result (blocking forever) produces no observable
output. The `select` statement itself is measured in detail in the next lesson; what matters
here is that a `nil` channel can be used as a way to make a branch ineffective.

## Summary

- A channel does two jobs in the same line: it carries a value and synchronizes the sender
  with the receiver; the two always come together.
- On an unbuffered channel with no receiver, a send attempt (`select`+`default`) never
  goes through; this is not a timing coincidence, it is the definition itself.
- A send written directly (`channel <- value`) blocks until a receiver arrives and
  completes for certain once the receiver arrives.
- A value carried through a channel is copied; the sender's change after the send never
  reaches the receiver.
- Every operation on a `nil` channel blocks forever; this behavior is the channels'
  counterpart of a definedness established in the Go Fundamentals course.

## Next Step

The unbuffered channel measured in this lesson kept the sender waiting until the receiver
was ready — the meeting was set up at exactly that moment, every time. Adding capacity to a
channel does not remove this requirement, it only defers it. The next lesson measures where
the buffer changes blocking behavior and where it does not: a send goes through without
waiting at all until capacity fills, and once it fills, it waits again.

The question to ask is this: does the requirement to meet disappear entirely, or does only
**when** it happens change? The answer lies in separating a send to a buffered channel
returning **immediately** from the fact that the value is **still waiting to be read**.
