AlgoMaster Logo

Channel Basics

Last Updated: May 22, 2026

High Priority
11 min read

A channel is a typed conduit that lets one goroutine send a value and another goroutine receive it. It's Go's built-in answer to the question "I have two goroutines, how do they talk to each other safely?" This chapter introduces what a channel is, how to declare and create one, how send and receive work, and what happens when you close it.

Why Channels Exist

Two goroutines running side by side don't share state cleanly. If both write to the same variable, you get a data race. If one waits on the other by spinning in a loop or sleeping, you get a guess instead of a guarantee. time.Sleep is a crutch, and a goroutine that mutates a shared variable while another reads it is broken.

Channels solve both problems with one mechanism. A channel is a typed first-in-first-out queue with synchronization built in. One goroutine sends a value, another receives it, and the runtime makes sure the send and the receive line up correctly. There's no shared variable to protect, no sleep to tune. The channel itself is the synchronization point.

Go's slogan for this style is "share memory by communicating, don't communicate by sharing memory." Instead of two goroutines reaching into the same memory cell with a mutex around it, one goroutine owns the value and hands it to the other through a channel. The hand-off is the synchronization. Once the value crosses the channel, the receiver owns it, and the sender is free to forget about it.

The diagram shows the model. Goroutine A produces a value and sends it on the channel. Goroutine B receives it on the other end. The channel sits between them and carries values of one specific type, in this case Order. Neither goroutine reaches into the other's memory. They communicate through the channel.

A channel has three properties worth pinning down up front:

  • It's typed. A chan int only carries int values. A chan Order only carries Order values. You can't put a string on a chan int. The compiler enforces this.
  • It's directional in use. One goroutine sends, another receives. The same channel value is used from both sides.
  • It synchronizes. A send and its matching receive happen at well-defined points in time relative to each other, which is what makes channels safe to use across goroutines without extra locks.

A channel isn't just a queue with a type. It's a queue that imposes ordering between the goroutines that use it.

Declaring a Channel

A channel type looks like chan T, where T is the element type. You declare a channel variable the same way you declare any other variable.

var ch chan int declares ch as a channel of int. Because it hasn't been initialized, its value is the zero value for channel types, which is nil. A nil channel is the same kind of zero value as a nil map or a nil slice: a usable variable whose underlying machinery doesn't exist yet.

A nil channel isn't ready for traffic. Sending on it or receiving from it blocks forever. That sounds like a bug, and most of the time it is, but there's one place where blocking forever is actually useful (it lets you disable a case in a select statement). For this chapter, treat nil as "not yet a real channel."

To get a working channel, you create one with make.

make(chan int) allocates a channel of int and returns its value. The printed address will vary from run to run; what matters is that the channel is no longer nil. This form, make(chan T), creates an unbuffered channel. There's also make(chan T, capacity), which creates a buffered channel. For everything in this chapter, the unbuffered form is enough.

Send and Receive Syntax

Once you have a channel, you put values on it with the send operator and take values off with the receive operator. Both use the <- arrow.

The arrow always points in the direction the value is moving. A useful way to read it: ch <- value says "value goes into ch", and v := <-ch says "value comes out of ch and into v". The position of the arrow relative to the channel name tells you which operation it is.

In the program below, one goroutine sends an order ID and another receives it.

Walk through what happens. main creates an unbuffered channel of int. It launches a goroutine that sends 101 on the channel. Then main itself receives from the channel and assigns the result to id. The send in the goroutine and the receive in main line up, the value 101 crosses the channel, and the program prints it.

main doesn't use time.Sleep to wait for the goroutine. The receive does the waiting. <-orders blocks until something is sent on orders, and the goroutine's send unblocks it. That's the synchronization channels were built for. Channels replace time.Sleep as a synchronization primitive.

You can also send on a channel from one named function and receive in another. The function passes the channel value just like any other parameter.

prices is the same channel value in main and in sendPrice. Channels are reference-like in the sense that copying a channel value (such as passing it as an argument) doesn't copy the underlying queue. Both goroutines refer to the same channel.

A send statement on its own is a complete statement. A receive can be used in two places: on its own as a statement (which discards the value), or in an expression where the value is used.

The bare <-signals is a valid statement; it receives a value and throws it away. This pattern shows up a lot when the value itself doesn't matter and you only care about the synchronization (the done-channel pattern is built on it).

The Blocking Model

Channels in Go have one rule that ties the whole feature together: a send and a receive on an unbuffered channel synchronize. Neither completes until the other is ready. If a goroutine tries to send and nobody is receiving, the send blocks. If a goroutine tries to receive and nobody is sending, the receive blocks. Both sides wait for each other.

This program looks like it should print 42, but it never gets that far. The send ch <- 42 happens in main, and there's no other goroutine to receive it. main blocks on the send. With every goroutine in the program now blocked (just main, and it's stuck), the runtime detects a deadlock and aborts the program with the error above.

The fix is to put the send in one goroutine and the receive in another.

Now there are two goroutines: main and the anonymous one. The anonymous goroutine sends, main receives, and the two operations pair up. The send unblocks the receive and the receive unblocks the send.

The blocking goes the other way too. If a goroutine tries to receive and there's no sender, the receive waits.

main reaches the receive, sees there's nothing on the channel, and parks itself. About 200 milliseconds later, the goroutine wakes up from its sleep and sends 7. The receive in main unblocks, takes the value, and prints it. The receive did the waiting; main never had to guess how long the goroutine would take.

An unbuffered channel is a meeting point. Sender and receiver each arrive at their own pace, the slower one makes the faster one wait, and once both are present the value crosses and they both continue. The rule: both sides wait for each other on an unbuffered channel.

Closing a Channel

A channel can be closed with the built-in close function. Closing signals "no more values will ever be sent on this channel." It doesn't destroy the channel or free it; the receiver can still drain any values that were already sent, and after that any further receive returns the zero value immediately.

The goroutine sends two values and then closes the channel. main receives the first two and prints them. The third receive finds the channel closed and returns the zero value of string, which is "". That's why the third line of output is blank.

The zero value comes from the element type. Receiving from a closed chan int returns 0. Receiving from a closed chan Order returns the zero Order struct. Receiving from a closed chan *User returns nil. The receive doesn't block and doesn't error; it just hands you the zero value.

That behavior is useful for some patterns and confusing for others, because a real zero value from a closed channel looks identical to a zero value somebody actually sent. To tell them apart, use the two-value receive form.

v, ok := <-ch returns two values. v is the received value (or the zero value if the channel is closed and empty). ok is true if the value came from a real send, and false if the channel is closed and has no more buffered values to deliver. A receiver that needs to know when the stream ends uses the ok value to decide when to stop.

This loop is doing manual work that the range form does for you. for msg := range updates { ... } reads values until the channel is closed and then exits, no ok check needed. The manual two-value form shown here is the building block underneath it.

A few rules about close:

  • Only the sender should close a channel. Closing tells the receiver "no more values will come." The receiver doesn't know when the sender is done; the sender does. By convention, the goroutine that owns the sending end of a channel is the one that closes it.
  • Sending on a closed channel panics. The runtime error is send on closed channel. There's no way to recover from this except by not doing it, which is why only the sender should call close.
  • Closing a closed channel panics. Same idea: close of closed channel. Don't double-close.
  • Closing a nil channel panics too (close of nil channel). Don't close a channel you never created.
  • Receivers can always receive from a closed channel. They get the buffered values first, then the zero value with ok == false, forever. That's safe.

The "send on closed channel" mistake looks like this:

The panic is unconditional. There's no if-the-channel-isn't-closed guard you can put around a send, because the channel could be closed between your check and your send by another goroutine. The right design is to make sure only one goroutine sends on a given channel, and to have that goroutine call close when it's done.

A common follow-up question is "do I have to close every channel?" No. A channel is just a value; the garbage collector cleans it up when no goroutine refers to it anymore. You close a channel when you want receivers to learn that no more values are coming. If the receivers know that some other way (for example, they only expect one value and they read it), there's nothing to gain from closing. Closing is a signaling tool, not a resource-cleanup step.

OperationOn a nil channelOn an open channelOn a closed channel
Send (ch <- v)blocks foreversends (may block until receive)panics
Receive (<-ch)blocks foreverreceives (may block until send)returns zero value immediately
Close (close(ch))panicsclosespanics

Most channel bugs are some combination of forgetting one of these rows or confusing two of them.

Channels as First-Class Values

A channel value is an ordinary Go value. You can store it in a variable, put it in a struct, pass it as a function argument, return it from a function, or send it through another channel. The only restriction is the type system: a chan int only fits where a chan int is expected.

Passing a channel to a function is how you typically wire a producer and a consumer together.

produceOrders and consumeOrders share the channel orders. The producer sends three order IDs and closes the channel. The consumer reads in a loop and stops when ok becomes false. The two functions don't know about each other; they only know the channel. That's the decoupling channels give you, the producer and consumer can be written separately and connected wherever you need them.

Returning a channel from a function is also common, especially in factory-style helpers that wrap a goroutine.

makePriceFeed creates a channel, starts a goroutine that fills the channel and closes it, and hands the channel back to the caller. The caller doesn't see the goroutine or the slice; it just sees a channel of prices that ends when the channel closes. This pattern, "return a channel that the caller drains", is the foundation for the pipeline and fan-out patterns.

Channels can also live in structs.

This example uses a buffered channel with capacity 1 so a single goroutine can send and then receive without deadlocking. The point here is just that the channel is stored as a struct field and accessed through methods like any other piece of state.

Putting It Together

The pieces from this chapter combine into a small but useful pattern: a goroutine that does work and sends a result back through a channel. This is the answer to the question "how do I get a value out of a goroutine?"

computeCartTotal runs in its own goroutine, adds up the cart, and sends the total on the result channel. main receives from result, which blocks until the goroutine actually sends. No time.Sleep, no shared variable, no race. The channel's blocking behavior is what makes the wait correct: main waits exactly as long as the goroutine takes.

Compare this to a broken version where main slept for 100 milliseconds and read a shared total variable. That version was racing and guessing. This version is doing neither. The channel handles the synchronization, and the value crossing the channel is the answer the caller needed.

The rest of the section is variations on this pattern: buffered channels let the sender run ahead of the receiver, unbuffered channels enforce a strict rendezvous, direction types restrict who can send and who can receive, range cleans up the receive loop, select lets you wait on multiple channels at once, and the fan-in, fan-out, and done patterns compose channels into bigger structures.