Why are we here? Well, it’s because I was practicing my beloved activity of redirecting image files into aplay so I could listen to them. So, innocently, I ran:

$ aplay < my_image.tga
Playing raw data 'stdin' : Unsigned 8 bit, Rate 8000 Hz, Mono

Then, because I’d rather have my images closer to CD quality:

$ aplay < my_image.tga -r 44100
Playing raw data 'stdin' : Unsigned 8 bit, Rate 44100 Hz, Mono

…wait. This isn’t right, but it did switch to 44100 Hz…

$ echo < my_image.tga hello world
hello world

In ten years I have never done this before? I think? My shell is actually zsh, but bash does the same thing. So do dash and fish.

So what now?#

Some experiments:

$ echo > test.txt hello world
$ cat test.txt
hello world
$ echo 'import sys; print(sys.argv)' > test.py
$ python3 test.py hello < test.txt world
['test.py', 'hello', 'world']
$ python3 test.py hello >> test.txt world
$ cat test.txt
hello world
['test.py', 'hello', 'world']

Ok, so this isn’t that big of a deal. Obviously if you don’t quote a filename with spaces, the shell splits it. Obviously they all keep going and do whatever will prevent throwing an error because that’s the kind of thing shells do.

Well, sure, dash, bash and zsh would do that, but fish? The one that comes built in with nice things? I’m just saying it has to be beholden to something to act like this, and obviously it is…

POSIX? Probably?#

Now instead of listening to my images I’m looking up “posix shell specification” late at night.

At “2.7 Redirection”, I found this bit:

If the redirection operator is << or <<-, the word that follows the redirection operator shall be subjected to quote removal; it is unspecified whether any of the other expansions occur. For the other redirection operators, the word that follows the redirection operator shall be subjected to tilde expansion, parameter expansion, command substitution, arithmetic expansion, and quote removal. Pathname expansion shall not be performed on the word by a non-interactive shell; an interactive shell may perform it, but if the expansion would result in more than one word it is unspecified whether the redirection proceeds without pathname expansion being performed or the redirection fails.

Therefore you can specify several files to redirect at once, and only the last file is redirected:

$ echo test2 > test2.txt
$ python3 test.py hello < test.txt < test2.txt world
['test.py', 'hello', 'world']
$ cat < test.txt < test2.txt
test2
$ cat < test.txt > test2.txt
$ cat < test2.txt
hello world
['test.py', 'hello', 'world']

So actually, this makes sense to me. Consider this command:

$ cat < test.txt > test2.txt

A file redirection is defined as a redirection operator (<, >, etc…) and a single word which may be expanded in various ways1. So, presumably, only the the operator and following word are considered part of the redirection, then whatever comes after is treated as part of the command. So, both redirections here are treated separately from one another.

cat ( < test.txt ) ( > test2.txt )

So the cursed command echo > whatever_file.txt hello world is parsed as echo ( > whatever_file.txt ) hello world, then runs as echo hello world redirected into “whatever_file.txt”.

Other redirection fun: expansions#

The specification specifies that the filename can be subject to various expansions:

  • Tilde expansion:
$ echo "hello world" > ~/file.txt
$ cat "$HOME/file.txt"
hello world
$ echo "hello world" > ~user/file.txt # only if you have a 'user' user on your system
$ cat "/home/user/file.txt"
hello world
  • Parameter expansion:
$ export filename=file.txt
$ echo $filename
file.txt
$ echo "hello world" > $filename
$ cat $filename
hello world
  • Command substitution:
$ echo "hello world" > $(echo file.txt)
$ cat file.txt
hello world
  • Arithmetic expansion:
$ echo "hello world" > file$((3-2)).txt
$ cat file1.txt
hello world
  • Quote removal:
$ echo "hello world" > "file with spaces.txt"
$ cat "file with spaces.txt"
hello world

Bash/zsh/fish extensions#

As I wrote this, I initially used zsh, which has more features than POSIX. I tried all my commands again in dash and moved that didn’t work in it here.

Notably, I regularly use zsh and have it configured, while bash is slightly configured, and neither fish or dash are configured at all. This may be why some things work in zsh and none of the other shells, but I haven’t made sure.

zsh only: multiple redirections into a command#

In zsh, unlike dash, I can redirect several files at once:

$ echo > test.txt hello world
$ echo 'import sys; print(sys.argv)' > test.py
$ python3 test.py hello >> test.txt world
$ echo test2 > test2.txt
$ python3 test.py hello < test.txt < test2.txt world
['test.py', 'hello', 'world']
$ cat < test*.txt
hello world
['test.py', 'hello', 'world']
test2
$ cat < test.txt < test2.txt
hello world
['test.py', 'hello', 'world']
test2
$ cat < test.txt > test2.txt
$ cat test2.txt
hello world
['test.py', 'hello', 'world']

In bash, asterisk expansion doesn’t work:

$ cat < test*.txt
bash: test*.txt: ambiguous redirect

Nor do multiple redirections:

$ echo > test.txt hello world
$ echo test2 > test2.txt
$ cat < test.txt < test2.txt
test2

But it does try to open the first given file:

$ cat < unknown1.txt < unknown2.txt
bash: unknown1.txt: No such file or directory

In fish, neither work:

$ echo > test.txt hello world
$ echo test2 > test2.txt
$ cat < test*.txt
fish: Invalid redirection target: 
cat < test*.txt
    ^~~~~~~~~~^
$ cat < test.txt < test2.txt
test2

But it does try to open all given files:

$ cat < unknown1.txt < unknown2.txt
warning: An error occurred while redirecting file 'unknown1.txt'
warning: Path 'unknown1.txt' does not exist
warning: An error occurred while redirecting file 'unknown2.txt'
warning: Path 'unknown2.txt' does not exist
$ echo "unknown1" > unknown1.txt
$ cat < unknown1.txt < unknown2.txt
warning: An error occurred while redirecting file 'unknown2.txt'
warning: Path 'unknown2.txt' does not exist
$ rm unknown1.txt
$ echo "unknown2" > unknown2.txt
$ cat < unknown1.txt < unknown2.txt
warning: An error occurred while redirecting file 'unknown1.txt'
warning: Path 'unknown1.txt' does not exist

zsh only: multiple redirections from a command#

In zsh, I can redirect a command into several files:

$ echo "hello world" > file1.txt > file2.txt
$ ls
file1.txt  file2.txt
$ cat file1.txt
hello world
$ cat file2.txt
hello world

In dash, bash and fish, redirecting a command into several files creates all of them, but only writes into the last one:

$ echo "hello world" > file1.txt > file2.txt
$ ls
file1.txt  file2.txt
$ cat file1.txt
$ cat file2.txt
hello world

Practical use#

So since now I understand shell file redirections a bit better, here are some things you can do:

  • Write a multiline file from the shell with only cat:
$ cat > stdout_stderr.py <<END
> import sys
> 
> print("stdout", file=sys.stdout)
> print("stderr", file=sys.stderr)
> END
$ cat stdout_stderr.py
import sys

print("stdout", file=sys.stdout)
print("stderr", file=sys.stderr)
  • Write two multiline files from the shell with only cat in a single command invocation:
$ cat > file1.txt <<END1; cat > file2.txt <<END2
> file1 content
> END2
> END1
> file2 content
> END1
> END2
$ cat file1.txt
file1 content
END2
$ cat file2.txt
file2 content
END1
  • Redirect STDOUT into a file and STDERR into another:
$ python3 2> stderr.log > stdout.log stdout_stderr.py
$ cat stdout.log
stdout
$ cat stderr.log
stderr
  • Redirect STDOUT and STDERR into a single file:
$ python3 > stdout_stderr.log 2>&1 stdout_stderr.py
$ cat stdout_stderr.log
stderr
stdout

Conclusion#

My initial, cursed command is just treated as aplay ( < my_image.tga ) -r 44100, or aplay -r 44100 with the “my_image.tga” file redirected in.

Now I know a little more about shell redirections, and hopefully you do too!

Source: POSIX.1-2024, Shell & Utilities, 2.7 Redirection


  1. It’s actually defined as [n]redir-op word, with n being an optional file descriptor number to redirect to or from. ↩︎