Share Memory by Communicating and Where It Breaks Down
What the Go proverb about sharing memory by communicating really asks for, a working ownership hand-off, the race when you keep the pointer, and its limits.

"Do not communicate by sharing memory; instead, share memory by communicating." It is the most quoted line in Go concurrency, it appears in Effective Go, and it is routinely used to justify decisions that the line does not support. Read quickly, it sounds like "avoid mutexes". Read slowly, it is a statement about ownership: arrange the program so that at any moment a piece of data belongs to exactly one goroutine, and move it between goroutines by sending it. Then no two goroutines ever touch it at the same time, and no lock is needed because nothing is shared.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article takes that reading and tests it. It shows the pattern working, shows the one mistake that quietly undoes it, and lists the places where it is the wrong tool. It belongs to the concurrency patterns cluster, goes together with channels or mutexes, and relies on the ordering rules in happens-before. Everything ran on Go 1.25.6.
What the proverb is really about
The sentence comes from the Go blog and Effective Go, where the full thought is that a goroutine can hand a value to another over a channel and the two never access it simultaneously. The channel is the transfer point. Before the send the sender owns the value and may do whatever it likes; after the send the receiver owns it. The memory model makes this safe, because a send happens before the corresponding receive completes, so everything the sender wrote is visible to the receiver.
That is a concurrency design principle disguised as a slogan. It says the first question about shared data is not "which lock?" but "can this data have one owner at a time?" When the answer is yes, ownership transfer is simpler than locking, and the program is easier to reason about, because the question "who can modify this?" has one answer at every point.
The pattern working
A concrete case is a batch builder. One goroutine collects log lines into a slice; when the batch is full it sends the slice to a writer goroutine and starts a fresh one. The slice is never touched by two goroutines at once:
package main
import (
"fmt"
"strings"
"sync"
)
func main() {
batches := make(chan []string)
var wg sync.WaitGroup
// The writer owns each batch once it arrives.
wg.Go(func() {
n := 0
for batch := range batches {
n++
fmt.Printf("batch %d: %s\n", n, strings.Join(batch, ","))
}
})
// The collector owns the slice it is filling.
var current []string
for i := 1; i <= 7; i++ {
current = append(current, fmt.Sprintf("line%d", i))
if len(current) == 3 {
batches <- current // ownership moves to the writer
current = nil // and the collector lets go of it
}
}
if len(current) > 0 {
batches <- current
}
close(batches)
wg.Wait()
}batch 1: line1,line2,line3
batch 2: line4,line5,line6
batch 3: line7No mutex, no atomic, and no race. The important line is current = nil after the send. Setting the variable to nil is not for the garbage collector; it is the collector giving up its claim. The next append allocates a new slice instead of writing into the one the writer is reading. Remove that line and the collector's next append may write into the same backing array the writer is printing.
The mistake: send the pointer, keep using it
The channel transfers a value, and the value of a slice, map or pointer is a reference to memory that both sides can still reach. Sending it does not stop the sender from using its copy of the reference. This version looks almost identical and breaks the ownership rule:
package main
import "fmt"
type report struct {
lines []string
}
func main() {
ch := make(chan *report)
done := make(chan struct{})
go func() {
r := <-ch
fmt.Println("writer saw", len(r.lines), "lines")
close(done)
}()
r := &report{}
r.lines = append(r.lines, "first")
ch <- r
r.lines = append(r.lines, "second") // still using it after handing it off
<-done
}writer saw 1 lines
==================
WARNING: DATA RACE
Write at 0x00c000152018 by main goroutine:
main.main()
example.test/lab/main.go:22 +0x2a4
Previous read at 0x00c000152018 by goroutine 7:
main.main.func1()
example.test/lab/main.go:15 +0x4c
Goroutine 7 (running) created at:
main.main()
example.test/lab/main.go:13 +0xe0
==================
Found 1 data race(s)
exit status 66The program prints a plausible number of lines and the race detector flags the writer's read against the sender's write. The channel did what it promised: it ordered the writes made before the send ahead of the receive. It said nothing about the write made after. Nothing in the language marks a pointer as moved, and nothing stops you from continuing to use it, so ownership is a convention that the code has to follow. Go has no borrow checker.
Three habits keep the convention honest. Nil the variable after sending, as in the batch builder, so the compiler flags later use as a nil dereference during testing rather than a race in production. Send values, not pointers, when the value is small: a struct passed by value has no shared state. And document the transfer in the type or function comment ("the receiver owns the slice") the way io.Writer documents that it must not retain the buffer.
Where it breaks down
The slogan fails in a few recognisable places.
Fine-grained shared state. A counter that every request increments, a map of sessions looked up on every call, a feature-flag table: there is nothing to transfer, because every goroutine needs the same data continuously. Forcing it through an owner goroutine turns each lookup into a request and a reply. In the benchmark in channels or mutexes that cost about 413 ns per map operation against 33 ns for a map behind an RWMutex. A mutex is the correct tool for shared state; the proverb does not forbid it.
Data that cannot move. A database connection, a file handle, a hardware resource, an object with an identity other code points to. Somebody has to hold it, and "pass it around" is not an option. You can wrap it in an owner goroutine that serializes access, which has its uses, but it does not remove the sharing, it only moves the lock into a channel.
Broadcast. One value read by many goroutines at once cannot be owned by any of them. The idiom is to make the value immutable and share it freely, or to atomically publish a new immutable version (copy on write). Immutable data is the other way out of the problem: if nobody writes, nobody races.
Performance-critical paths. A channel send or receive costs more than an uncontended lock, as the measurements show. For a hot inner loop, the extra hand-off matters. For most code it does not, and clarity wins.
Where it works best
It shines when the program is naturally a flow: items arrive, get processed in stages, and leave. A pipeline passes each item along, and each stage owns it for its turn (pipelines). A worker pool receives a job, owns it while working, and sends back a result (worker pools). A goroutine that owns a resource with behavior, such as a rate limiter that refills on a timer, becomes easier to write as a single loop that handles one event at a time, which is the actor style. In each case the question "who owns this?" has a clean answer, and the code shows the answer in its structure.
The way I apply it: ask what moves and what stays. Things that move (jobs, results, events, buffers) go on channels, and the sender lets go. Things that stay (caches, counters, configuration) live behind a lock or an atomic. A program with both is normal. The Go blog post introducing the proverb walks through a small program built around it, and the Effective Go section on sharing by communicating is the original source.


