Process Substitution ist eine der elegantesten Bash-Erweiterungen über POSIX hinaus: Sie verwandelt die Ausgabe oder Eingabe eines Befehls in einen Datei-Pfad, den andere Tools wie eine reguläre Datei lesen oder beschreiben können. Damit lassen sich diff, paste, tee und viele andere Werkzeuge auf Live-Daten anwenden, ohne temporäre Dateien anzulegen — und while-Schleifen lassen sich ohne lästige Subshell-Falle befüllen. Dieser Artikel zeigt Syntax, Anwendung und die Stolperfallen, die sich bei genauerem Hinsehen auftun.

Was Process Substitution ist

Process Substitution ist eine Bash- und Zsh-Erweiterung, die einen Befehl in einen Pfad zu einer named pipe (FIFO) oder einem /dev/fd/N-Eintrag verwandelt. Der äußere Befehl bekommt damit einen Dateinamen in die Hand, hinter dem aber kein File auf der Platte sitzt, sondern ein laufender Prozess.

FormRichtungWas passiert
<(cmd)Lesencmd wird gestartet, sein stdout ist über den Pseudo-Pfad lesbar
>(cmd)SchreibenWas in den Pseudo-Pfad geschrieben wird, landet auf cmds stdin

Ein einfaches Beispiel: <(echo Hallo) ergibt einen Pfad wie /dev/fd/63. Ein cat darauf liest die Ausgabe von echo:

Bash Pseudo-Pfad in Aktion
cat <(echo "Hallo aus dem FIFO")
echo <(echo "test")
Output
Hallo aus dem FIFO
/dev/fd/63

Wichtig: Process Substitution ist kein POSIX. In dash, ash oder klassischem sh schlägt sie fehl oder wird als „Hintergrund-Prozess plus Klammer-Subshell” missverstanden. Skripte mit #!/bin/sh müssen darauf verzichten — wer es nutzen will, schreibt #!/usr/bin/env bash oder #!/bin/bash.

Syntax <(cmd) — Befehls-Output als Datei

Die Lese-Variante <(cmd) ist mit Abstand am häufigsten. Sie löst ein konkretes Problem: Ein Tool erwartet Dateinamen als Argumente — du hast aber nur die Live-Ausgabe eines anderen Befehls. Der klassische Fall ist diff, das per Definition zwei Dateien vergleicht.

Bash Zwei Verzeichnis-Listings vergleichen
diff <(ls /etc) <(ls /etc.bak)
Output
3a4
> legacy.conf

Ohne Process Substitution müsstest du ls /etc > a.txt; ls /etc.bak > b.txt; diff a.txt b.txt; rm a.txt b.txt schreiben — vier Schritte und zwei Wegwerf-Dateien. Mit <(...) wird daraus eine einzige Zeile, und der Kernel räumt die FIFOs nach Programm-Ende selbst auf.

Der Trick ist, dass <(cmd) syntaktisch ein Wort ist — also überall stehen kann, wo der äußere Befehl einen Dateipfad erwartet. wc -l <(seq 1 100), md5sum <(curl -s https://example.org/), comm -12 <(sort a) <(sort b): Alles funktioniert, solange das Tool seine Eingabe sequenziell liest.

Syntax >(cmd) — Eingabe für einen Befehl

Die Schreib-Variante >(cmd) ist das Spiegelbild: Was in den Pseudo-Pfad geschrieben wird, landet auf der stdin von cmd. Damit kannst du eine einzige Datenquelle gleichzeitig an mehrere Verbraucher schicken — ohne Zwischendatei, in einem einzigen Pipeline-Aufruf.

Bash tee an mehrere Filter
./build.sh 2>&1 | tee >(grep ERROR > errors.log) >(grep WARN > warns.log) > build.log

tee schreibt seine Eingabe normalerweise auf stdout und in beliebig viele Dateien. Mit Process Substitution sind die „Dateien” hier in Wahrheit zwei laufende grep-Prozesse — der Build-Output landet also gleichzeitig in build.log (über den regulären >-Operator), in errors.log (gefiltert auf ERROR) und in warns.log (gefiltert auf WARN). Drei Empfänger, ein Durchlauf.

Ein anderer Klassiker ist >(cmd) als Ziel für Tools, die eine Output-Datei verlangen, du aber direkt weiterverarbeiten willst — etwa tar mit -f:

Bash tar in eine Pipe via Process Substitution
tar -czf >(ssh backup-host "cat > backup.tar.gz") /var/www

tar denkt, es schreibt in eine Datei — tatsächlich landet der gepackte Stream direkt im SSH-Tunnel zum Backup-Host. Spart eine ganze Reihe Zwischenschritte und vermeidet Speicherbedarf für ein temporäres Archiv.

Wann Process Substitution sinnvoll ist

Die Frage „warum nicht einfach Pipe?” stellt sich oft. Die Antwort: Pipes sind single-input — ein Befehl bekommt einen Stream auf stdin, fertig. Sobald du zwei oder mehr Streams gleichzeitig vergleichen, mischen oder verteilen willst, oder ein Befehl partout einen Dateinamen statt stdin will, kommst du mit klassischer Pipe nicht weiter.

SituationKlassischMit Process Substitution
Befehl liest aus stdincmd_a | cmd_bnicht nötig
Befehl will Dateipfad, du hast Streamcmd_a > tmp; cmd_b tmp; rm tmpcmd_b <(cmd_a)
Zwei Streams vergleichenüber zwei Tempdateiendiff <(cmd_a) <(cmd_b)
Stream an mehrere Verbrauchertee mit Tempdateientee >(cmd_x) >(cmd_y)
while-Schleife mit Variablen außerhalbPipe (Subshell-Falle)while ...; done < <(cmd)

Es gibt also drei klare Use-Cases: Mehrere Inputs, Datei-erwartender Befehl und while-Schleifen ohne Subshell-Verlust. Für alles andere ist die einfache Pipe weiterhin das richtige Werkzeug.

Vergleich mit Pipe

Pipe und Process Substitution sehen oberflächlich ähnlich aus, sind aber strukturell unterschiedlich. Die Pipe ist einkanalig und unidirektional: a | b verbindet as stdout mit bs stdin. Process Substitution ist mehrkanalig und kann beidseitig verlaufen — du kannst beliebig viele <(...)- und >(...)-Pseudo-Pfade gleichzeitig in einem Befehl haben.

Bash Drei Pseudo-Files gleichzeitig
paste <(seq 1 5) <(seq 11 15) <(seq 21 25)

Mit klassischen Pipes wäre dieses Pattern nicht direkt machbar — paste braucht drei Datei-Argumente, und Pipes liefern nur einen Stream. Process Substitution lässt jeden seq-Aufruf parallel laufen und übergibt paste drei lesbare Pfade.

Ein zweiter Unterschied betrifft den Exit-Code: Bei einer Pipe steht der finale Exit-Code der Pipeline klassisch nur für den letzten Befehl, mit pipefail für den ersten gescheiterten. Bei Process Substitution ist der Exit-Code des äußeren Befehls ausschlaggebend — das Ergebnis von cmd in <(cmd) geht standardmäßig verloren, was zu schwer auffindbaren Bugs führen kann.

Praxis-Patterns

Die folgenden Muster sind die typischen Anwendungsfälle, in denen Process Substitution den entscheidenden Unterschied macht.

Diff von zwei Befehlen ist der Klassiker schlechthin. Du willst zwei Verzeichnisse, zwei Konfigurationen oder zwei Versionen eines Befehls vergleichen — ohne Tempdateien.

Bash Installierte Pakete vor und nach Update vergleichen
dpkg -l > /tmp/vor.txt
sudo apt upgrade -y
diff <(sort /tmp/vor.txt) <(dpkg -l | sort)

Dasselbe Prinzip funktioniert für sortierte Logs, JSON-Outputs (mit jq-Pipeline) oder Konfigurations-Dumps von zwei Servern (diff <(ssh a "cat /etc/foo") <(ssh b "cat /etc/foo")).

File-Comparison ohne Temp-File geht über diff hinaus: Auch comm, cmp und sdiff arbeiten mit zwei Datei-Argumenten. Damit lassen sich Schnittmengen, Differenzen und Vereinigungsmengen direkt über Pipelines bilden.

Bash Gemeinsame Zeilen finden
comm -12 <(sort -u liste1.txt) <(sort -u liste2.txt)

comm -12 zeigt die Zeilen, die in beiden sortierten Eingaben vorkommen — die Schnittmenge. Ohne Process Substitution müsstest du beide Listen in Tempdateien sortieren, was den Code spürbar aufbläht.

Tee an mehrere Filter spart einen Durchlauf, wenn du denselben Stream auf mehrere Arten verarbeiten willst. Logfiles in mehrere Filter-Buckets aufzuteilen ist der Standard-Anwendungsfall:

Bash Build-Output mehrfach filtern
make 2>&1 \
  | tee build.log \
  | tee >(grep -i error > errors.log) \
        >(grep -i warning > warnings.log) \
  > /dev/null

Der Build läuft genau einmal; die Ausgabe wird parallel in drei Dateien sortiert. Ein zweiter, klassischer Durchlauf wäre nicht nur ineffizient, sondern bei nicht-deterministischen Builds (Timestamps, parallele Tasks) auch nicht reproduzierbar.

While-Loop ohne Subshell-Trap ist der vielleicht wichtigste Pattern überhaupt. Wer eine while read-Schleife mit Pipe befüllt, läuft in die Subshell-Falle: Variablen, die in der Schleife gesetzt werden, sind außerhalb verloren.

Bash Zähler über Befehls-Output
zaehler=0
while read -r datei; do
    zaehler=$((zaehler + 1))
done < <(find . -type f -name "*.log")
echo "Gefunden: $zaehler Logfiles"
Output
Gefunden: 17 Logfiles

Die Konstruktion done < <(cmd) umleitet stdin der Schleife auf den Output von cmd — und weil das keine Pipe ist, läuft die Schleife in der aktuellen Shell. zaehler bleibt nach dem Loop erhalten. Mit find ... | while read; do ...; done wäre zaehler außen weiterhin 0.

Paste von Befehls-Outputs kombiniert mehrere parallele Streams spaltenweise:

Bash Hostnamen und Lasten in Tabelle
paste <(cut -d' ' -f1 /etc/hosts) \
      <(uptime | awk '{print $(NF-2)}')

Beide Befehle laufen parallel; paste legt ihre Ausgaben in zwei Spalten nebeneinander. Ohne Process Substitution wäre derselbe Effekt nur mit Tempdateien oder einer komplizierten awk-Lösung machbar.

Häufige Stolperfallen

Kein POSIX — funktioniert nicht in `sh` oder `dash`

Process Substitution ist eine Bash- und Zsh-Erweiterung, die in dash (Debian/Ubuntu /bin/sh), ash (Alpine, BusyBox) und klassischem POSIX-sh schlicht fehlt. Schlimmer: Manche Shells interpretieren <(cmd) schweigend falsch — etwa als Hintergrund-Job in Klammern. Skripte, die Process Substitution nutzen, müssen mit #!/usr/bin/env bash oder #!/bin/bash starten und dürfen niemals #!/bin/sh haben. Andernfalls läuft das Skript scheinbar, produziert aber Müll.

Pseudo-Files können nicht seek() — manche Tools brechen ab

FIFOs und /dev/fd/N-Pfade sind sequenziell lesbar. Tools, die seek() oder mmap() auf der Eingabe brauchen — manche grep-Builds mit -m, viele Mediaplayer, einige Archivierer — scheitern mit „Illegal seek” oder produzieren falsche Resultate. Wenn ein Tool unerwartet auf einer regulären Datei funktioniert, aber mit Process Substitution stumm bleibt: Verdacht auf Seek-Anforderung. Workaround ist meist eine echte Tempdatei (mit mktemp).

`<(cmd)` startet `cmd` asynchron — Reihenfolge nicht garantiert

Alle Process-Substitution-Prozesse starten gleichzeitig mit dem äußeren Befehl. Wer auf eine bestimmte Reihenfolge angewiesen ist — etwa „erst muss A laufen, dann B” — wird vom Scheduler überrascht: Bash garantiert keine Reihenfolge, in der die Sub-Prozesse gestartet, gelesen oder beendet werden. Bei paste &lt;(cmd_a) &lt;(cmd_b) gibt es keine Garantie, dass cmd_a vor cmd_b startet. Wer Sequenz braucht, baut sie explizit über eine Pipe oder ein &amp;&amp;-Idiom.

Errors aus `<(cmd)` werden oft verschluckt — pipefail greift nicht

Der Exit-Code des inneren Befehls in &lt;(cmd) oder &gt;(cmd) geht verloren. Selbst mit set -euo pipefail wird ein Fehler in cmd nicht an die Hauptshell durchgereicht — der äußere Befehl bekommt einfach einen leeren oder unvollständigen Stream. Das macht Fehler heimtückisch: Ein diff &lt;(broken_cmd) &lt;(other_cmd) zeigt einfach „kein Diff” an, obwohl broken_cmd gar nicht gelaufen ist. Wer es genau wissen muss, schreibt vorab cmd &gt; tmp || exit 1 und nutzt dann die Tempdatei.

Linux nutzt `/dev/fd/N`, BSD und alte Systeme nutzen FIFOs

Auf Linux verwendet Bash standardmäßig /dev/fd/N-Pfade — das sind File-Deskriptor-Verweise, die ohne Schreibzugriff auf das Dateisystem funktionieren. Auf Systemen ohne /dev/fd (manche BSDs, gechrootete Umgebungen) fällt Bash auf named pipes in /tmp zurück, die explizit angelegt und nach Gebrauch entfernt werden. In Containern, in Restricted Shells oder in Sandboxes mit Read-only-/tmp kann Process Substitution dadurch unerwartet scheitern — typische Meldung: „cannot make pipe for process substitution”.

Unterschied zwischen `>(cmd)` und Pipe übersehen

cmd_a | cmd_b und cmd_a &gt; &gt;(cmd_b) sehen ähnlich aus, sind aber nicht gleich: Bei der Pipe wartet die Shell auf das Ende von cmd_b, bei &gt;(cmd_b) läuft cmd_b im Hintergrund und die Shell zieht weiter. Konsequenz: Wenn der Hauptbefehl beendet ist, kann cmd_b noch nicht fertig sein — du liest also möglicherweise die Output-Datei, bevor cmd_b sie geschrieben hat. Wer auf das Ergebnis angewiesen ist, ergänzt wait oder kapselt mit { ... } &gt;(cmd_b); wait.

Race Conditions bei mehrfachen `>(cmd)`-Schreibern auf dieselbe Datei

Wer mit mehreren &gt;(cmd)-Substitutions parallel auf dieselbe Datei schreibt — etwa tee &gt;(grep A &gt; out.log) &gt;(grep B &gt; out.log) — bekommt Race-Conditions: Beide grep-Prozesse haben separate File-Deskriptoren für out.log, und ihre Schreibzugriffe vermischen sich unvorhersehbar. Lösung: jedes Filter-Ergebnis in eine eigene Datei schreiben, oder die Filter sequenziell zusammenfassen und gemeinsam in eine Datei leiten.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Shell-Scripting

Zur Übersicht