{} The Go Reference

Ipc · Systems · Intermediate

Pipes

The simplest IPC — anonymous pipes (os.Pipe, io.Pipe), named pipes (FIFOs), piping one process's output into another, and back-pressure.

Ipc Intermediate ⏱ 7 min read Complete

🪈 Analogy

A pipe is exactly what it sounds like: a tube with water poured in one end coming out the other. You can only pour one way (it’s unidirectional), there are no “bottles” — just a continuous stream of bytes — and the tube holds a fixed amount, so if the far end stops draining, the pouring backs up and you have to wait (back-pressure). The shell’s ls | grep go | wc -l is three programs joined by two such tubes, each one’s output flowing into the next one’s input.

What a pipe is

A pipe is the oldest, simplest form of inter-process communication (IPC) — the umbrella term for the OS mechanisms that let separate processes exchange data (pipes, sockets, shared memory, signals). A pipe specifically is a one-directional, in-kernel byte stream with a write end and a read end. Bytes written to one end come out the other, in order, with no message boundaries. There are two flavors:

  • Anonymous pipe — no name; shared by handing a file descriptor to a child process. This is what the shell’s | builds.
  • Named pipe (FIFO) — has a filesystem path, so unrelated processes can connect by opening it.

Go also gives a pure-Go, in-process pipe (io.Pipe) for streaming between goroutines.

In-process streaming: io.Pipe

io.Pipe() connects an io.Reader and io.Writer in memory — perfect for feeding one API’s output straight into another’s input without a temp buffer. It’s synchronous (a write blocks until a read consumes it), so it runs on the playground:

iopipe.go — editable & runnable
package main

import (
"fmt"
"io"
"strings"
)

func main() {
pr, pw := io.Pipe() // reader end, writer end

// Producer goroutine writes, then closes the write end (→ EOF for reader).
go func() {
	defer pw.Close()
	for i := 1; i <= 3; i++ {
		fmt.Fprintf(pw, "line %d\n", i) // blocks until the reader reads
	}
}()

// Consumer reads the stream to completion.
var sb strings.Builder
io.Copy(&sb, pr) // reads until the write end is closed
fmt.Print(sb.String())
}

The classic use: stream a large response body through a transform (compress, hash, re-encode) into an uploader, with constant memory — the writer can’t outrun the reader because each Write waits for a Read.

Real kernel pipes: os.Pipe

os.Pipe() returns two real *os.Files backed by an actual kernel pipe — so you can hand the write end to a child process:

pr, pw, _ := os.Pipe()        // real fds

cmd := exec.Command("wc", "-l")
cmd.Stdin = pr                // child reads from the pipe
go func() {
	defer pw.Close()
	fmt.Fprintln(pw, "one")   // we write...
	fmt.Fprintln(pw, "two")
}()
out, _ := cmd.Output()        // ...child counts the lines → "2"

Often you don’t even need os.Pipe directly: exec wires pipes for you when you connect commands.

You can also use the two ends in one process — this is a real kernel pipe (the pipe(2) syscall), with actual file descriptors, and it runs here:

ospipe.go — editable & runnable
package main

import (
"bufio"
"fmt"
"os"
)

func main() {
pr, pw, _ := os.Pipe() // two *os.File over a real kernel pipe
fmt.Printf("read fd = %d, write fd = %d\n", pr.Fd(), pw.Fd())

// Producer goroutine writes to the write end, then closes it (→ EOF).
go func() {
	defer pw.Close()
	for i := 1; i <= 3; i++ {
		fmt.Fprintf(pw, "line %d\n", i)
	}
}()

// Consumer reads the read end until EOF.
sc := bufio.NewScanner(pr)
for sc.Scan() {
	fmt.Println("got:", sc.Text())
}
}

Unlike io.Pipe (pure Go, in-process), these fds are real — you could pass the write end to a child process, and strace would show the pipe2/read/write calls.

graph LR
A["producer (write end)"] -->|"kernel pipe buffer"| B["consumer (read end)"]
B -. "buffer full → writer blocks (back-pressure)" .-> A

Connecting two processes

To reproduce ls | wc -l in Go, connect one command’s StdoutPipe to another’s Stdin:

ls := exec.Command("ls", "-1")
wc := exec.Command("wc", "-l")

wc.Stdin, _ = ls.StdoutPipe() // ls's stdout → wc's stdin
wc.Stdout = os.Stdout

wc.Start()
ls.Run()      // run the producer
wc.Wait()     // wait for the consumer

Named pipes (FIFOs)

When the processes aren’t parent/child, give the pipe a name in the filesystem:

// One-time: create the FIFO (or `mkfifo /tmp/events` in the shell).
syscall.Mkfifo("/tmp/events", 0o644)

// Writer process:
w, _ := os.OpenFile("/tmp/events", os.O_WRONLY, 0)
fmt.Fprintln(w, "event: deploy")

// Reader process (any unrelated program):
r, _ := os.Open("/tmp/events")
io.Copy(os.Stdout, r)

A FIFO looks like a file (ls -l shows a p) but behaves like a pipe: open blocks until both a reader and writer are present, and it carries a stream, not stored contents.

🐹 Pipes are the spine of Go's streaming model

io.Pipe is the in-process cousin of the channel-based pipeline pattern: both connect a producer to a consumer with built-in back-pressure. Reach for io.Pipe when you’re bridging two io-based APIs (one wants an io.Writer, the next wants an io.Reader) without buffering the whole stream in memory — exactly how you’d pipe an archive through gzip into an HTTP upload. For talking to other programs, os/exec’s pipes; for talking to unrelated processes, a FIFO or a Unix socket.

⚠️ Close the write end, mind SIGPIPE and deadlocks

Three traps. The reader only sees EOF when every write end is closed — forget pw.Close() and io.Copy blocks forever. Writing to a pipe whose readers all closed raises SIGPIPE/EPIPE — usually surfaced as a write error in Go; handle it (it’s normal when a downstream like head exits early). And don’t deadlock: with os/exec, if you write to a child’s stdin pipe while it’s blocked writing a full stdout pipe you’re not reading, both hang — read output concurrently (or use cmd.Output(), which manages this for you).

See also

Next: bidirectional local IPC — Unix sockets.

Check your understanding

Score: 0 / 5

1. What is a pipe, in OS terms?

A pipe is a kernel buffer with two file descriptors: write to the write-end, read from the read-end. It's one-directional and stream-oriented (no message boundaries). The shell's `a | b` wires a's stdout to b's stdin through exactly this.

2. What's the difference between os.Pipe and io.Pipe?

os.Pipe() returns real OS pipe fds — you can hand the write end to a child process. io.Pipe() is pure Go: an in-process, synchronous io.Reader/io.Writer pair connecting goroutines, with no kernel involvement (great for streaming between APIs without a temp buffer).

3. What is a named pipe (FIFO)?

An anonymous pipe is shared by passing the fd to a child. A FIFO (mkfifo /tmp/myfifo) has a filesystem path, so any process can open("/tmp/myfifo") — one for writing, one for reading — to talk, even without a parent/child relationship. It behaves like a pipe, not a regular file.

4. What happens when a pipe's buffer fills up and nobody is reading?

A pipe has a fixed kernel buffer (often 64 KB). When it's full, write() blocks until a reader makes room — so a fast producer can't outrun a slow consumer, keeping memory bounded. (Conversely, reading an empty pipe blocks until data arrives or all write ends close → EOF.)

5. What signal can a writer get if all readers of a pipe have closed?

If every read end is closed and you write, the kernel sends SIGPIPE (default: terminate) or, if ignored, write returns EPIPE. Go programs usually get the EPIPE error rather than dying, but it's the mechanism behind a pipeline stopping when a downstream like `head` exits early.

Comments

Sign in with GitHub to join the discussion.