Pipes

Published: (December 30, 2025 at 02:57 PM EST)
3 min read
Source: Dev.to

Source: Dev.to

Cover image for Pipes

The problem

I wanted to run a pipeline in Go that behaves like the shell command:

tail -f test.log | head -3

tail -f never exits on its own, so the pipeline must handle the situation where the first command keeps running while the second (head) finishes after reading the required number of lines.

Demonstration in the shell

# In one terminal
echo line1 > test.log
tail -f ./test.log | head -3

In another terminal:

echo line2 >> ./test.log

head prints the line as soon as it appears. When a third line is written, head exits after receiving its third line. On the fourth write, tail receives a SIGPIPE because the read end of the pipe has been closed, causing tail to terminate.

The Bash documentation confirms this behavior:

The output of each command in the pipeline is connected via a pipe to the input of the next command. Each command reads the previous command’s output.
Each command in a multi‑command pipeline is executed in its own subshell, which is a separate process.
Bash Reference Manual – Pipelines

Mimicking the behavior in Go

Creating the commands

cmd1 := exec.Command("tail", "-f", "./test.log")
cmd2 := exec.Command("head", "-n", "5")

Connecting the pipeline

outPipe, err := cmd1.StdoutPipe()
if err != nil {
    // handle error
}
cmd2.Stdin = outPipe
cmd2.Stdout = os.Stdout // forward head's output to the Go program's stdout

Starting the processes

if err := cmd1.Start(); err != nil {
    // handle error
}
if err := cmd2.Start(); err != nil {
    // handle error
}

Waiting for completion

cmd2 (the head command) will finish first. After it exits we must close the read end of the pipe in the parent process; otherwise the pipe remains open and tail never receives SIGPIPE.

cmd2.Wait()          // wait for head to finish
outPipe.Close()      // close the pipe in the parent
cmd1.Wait()          // now tail can exit

Complete example

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    cmd1 := exec.Command("tail", "-f", "/Users/skuf_love/study/go/test_pipes/test.log")
    cmd2 := exec.Command("head", "-n", "5")

    outPipe, err := cmd1.StdoutPipe()
    if err != nil {
        fmt.Printf("StdoutPipe error: %v\n", err)
        return
    }

    cmd2.Stdin = outPipe
    cmd2.Stdout = os.Stdout

    if err = cmd1.Start(); err != nil {
        fmt.Printf("cmd1 start error: %v\n", err)
        return
    }
    if err = cmd2.Start(); err != nil {
        fmt.Printf("cmd2 start error: %v\n", err)
        return
    }

    // Wait for head to finish, then close the pipe so tail receives SIGPIPE
    cmd2.Wait()
    fmt.Println("cmd2 wait done")
    outPipe.Close()
    cmd1.Wait()
    fmt.Println("cmd1 wait done")
}

This program reproduces the shell pipeline behavior: head reads the first five lines written to test.log, exits, and tail receives a SIGPIPE and terminates.

Back to Blog

Related posts

Read more »

Why Bash Syntax Feels So Arcane

Bash’s Historical Roots Bash runs deep beneath the surface of Linux. With only a line or two, it can take full control of your machine. It is often viewed by b...

2026: The Year of Java in the Terminal

Article URL: https://xam.dk/blog/lets-make-2026-the-year-of-java-in-the-terminal/ Comments URL: https://news.ycombinator.com/item?id=46445229 Points: 39 Comment...