Skip to content

A program knows it is piped

Level: 101 · you have seen ls print columns on the screen, and one name per line after | wc -l or | less

One line: A program can ask whether its stdout is a terminal, and ls does: it prints columns to a terminal and one name per line into a pipe, which is why ls | wc -l counts files. less asks too, and passes its input straight through like cat when its stdout is not a terminal. In fish, ls is a function that ships with fish, and it asks the same question to add a / after directory names on a terminal only.

Measured

Every example in this library writes into a pipe, because the runner reads its output. So the pipe side is the easy one to show:

Verified output of knows_piped_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ touch alma darkfi fedora; mkdir isos

$ ls
alma
darkfi
fedora
isos

$ ls | less
alma
darkfi
fedora
isos

$ if [ -t 1 ]; then echo "stdout is a terminal"; else echo "stdout is not a terminal"; fi
stdout is not a terminal

Plain ls printed one name per line with no | cat after it, because its stdout already was a pipe. ls | less printed the same four lines and returned. The book's ls -l | less opens a pager because there less writes to your terminal. Here it writes into the runner's pipe, so it copied its input through. (-l prints one entry per line either way, so columns never come into that example.) [ -t 1 ] is the same question asked by a script: true when file descriptor 1 is a terminal.

The terminal side needs a terminal, and a test runner has none. script makes one: it runs a command on a new pseudo-terminal and copies what arrives there to its own stdout. | cat -v then shows the carriage return, ^M, that the terminal puts at the end of every line:

Verified output of knows_piped_tty_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ script -qc 'ls' /dev/null </dev/null | cat -v
alma  darkfi  fedora  isos^M

$ script -qc 'ls | cat' /dev/null </dev/null | cat -v
alma^M
darkfi^M
fedora^M
isos^M

$ script -qc 'ls -1' /dev/null </dev/null | cat -v
alma^M
darkfi^M
fedora^M
isos^M

$ script -qc '[ -t 1 ] && echo terminal || echo not a terminal' /dev/null </dev/null | cat -v
terminal^M

$ ls -C
alma  darkfi  fedora  isos

Verified output of knows_piped_tty_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ script -q /dev/null ls </dev/null | cat -v
^D^H^Halma	darkfi	fedora	isos^M

$ script -q /dev/null sh -c 'ls | cat' </dev/null | cat -v
^D^H^Halma^M
darkfi^M
fedora^M
isos^M

$ script -q /dev/null ls -1 </dev/null | cat -v
^D^H^Halma^M
darkfi^M
fedora^M
isos^M

$ script -q /dev/null sh -c '[ -t 1 ] && echo terminal || echo not a terminal' </dev/null | cat -v
^D^H^Hterminal^M

$ ls -C
alma	darkfi	fedora	isos
  • ls on a terminal prints columns: all four names on one line.
  • ls | cat on the same terminal prints one name per line. ls asked about its own stdout, which was the pipe into cat. That cat then wrote to a terminal made no difference.
  • -1 and -C override the question. ls -1 printed one name per line on a terminal, and ls -C, the last line, printed columns into a pipe.
  • [ -t 1 ] said terminal inside script, and not a terminal in the runner above.

Colour follows the same rule. The Encodings library measures it with grep --color=auto, in the page linked below.

On a Mac

BSD ls asks the same question and answers it the same way. Three things differ around it, and they are why the terminal key is split:

  • script takes its arguments in a different order. util-linux on Linux: script -qc 'CMD' /dev/null. BSD on macOS: script -q /dev/null CMD ARGS…, with no -c, so a pipeline inside the terminal needs sh -c '…'. Each one rejects the other's spelling. The Encodings page has both error messages in a table.
  • BSD ls separates its columns with tabs, and GNU ls with spaces. The last line, ls -C into a pipe, shows it on both machines, so a script that splits ls -C output on runs of spaces works on only one of them.
  • ^D^H^H at the start of the macOS lines is not from ls. script was given an empty stdin (< /dev/null, which is also what the runner gives every example). BSD script passed the end of that input on to the terminal as a Ctrl-D, and the terminal echoed it as ^D and two backspaces. util-linux script did not.

The pipe side, the first key on this page, is shared.

In zsh and fish

zsh asks with the same test, and with no startup files its ls is the command itself:

Verified output of knows_piped_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ touch alma darkfi fedora; mkdir isos

$ whence -w ls
ls: command

$ ls | less
alma
darkfi
fedora
isos

$ [[ -t 1 ]] && echo "stdout is a terminal" || echo "stdout is not a terminal"
stdout is not a terminal

whence -w ls says command: there is nothing between zsh and the ls program. If ls is coloured in your own zsh, the colour comes from an alias or a function in your startup files, since zsh -f has neither. [[ -t 1 ]] asks the question in zsh.

fish does put something between them. ls in fish is a function that ships with fish, and the function asks the question itself:

Verified output of knows_piped_fish.fish on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ touch alma darkfi fedora; mkdir isos

$ type -t ls
function

$ ls
alma
darkfi
fedora
isos

$ isatty stdout; and echo "stdout is a terminal"; or echo "stdout is not a terminal"
stdout is not a terminal

$ script -qc 'fish --no-config -c ls' /dev/null </dev/null | cat -v
alma  darkfi  fedora  isos/^M

$ script -qc 'fish --no-config -c "command ls"' /dev/null </dev/null | cat -v
alma  darkfi  fedora  isos^M

Verified output of knows_piped_fish.fish on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ touch alma darkfi fedora; mkdir isos

$ type -t ls
function

$ ls
alma
darkfi
fedora
isos

$ isatty stdout; and echo "stdout is a terminal"; or echo "stdout is not a terminal"
stdout is not a terminal

$ script -q /dev/null fish --no-config -c ls </dev/null | cat -v
^D^H^Halma	darkfi	fedora	isos/^M

$ script -q /dev/null fish --no-config -c 'command ls' </dev/null | cat -v
^D^H^Halma	darkfi	fedora	isos^M

type -t ls says function. Into a pipe it printed the names plain. On a terminal the same ls printed isos/, while command ls (the program, skipping the function) printed isos. The / came from the function, which passes -F to ls only when isatty stdout succeeds, as functions ls shows. isatty stdout is fish's spelling of [ -t 1 ]. So in fish, a person at the prompt sees isos/ and a script reading ls | … gets isos.

This key is split for the same reasons as the bash one: the script spelling, the column separators, and the ^D^H^H. The first four commands printed the same on both machines.

If you are coming from another library

  • Encodings. A pipe is not a terminal ↗ is the long version of this page: isatty in the shell, in Python and in Rust; what grep --color puts in the byte stream; the script table; and output buffering, which also changes when stdout is a pipe.
  • Python. Opening a file, who decides when the output leaves ↗ runs one program into a pipe and into a real terminal. stderr overtakes stdout into the pipe, and the terminal adds the same \r as the ^M above.

See also