結論
- パイプの終了コードは、既定では末尾のコマンドのもの。
set -o pipefailを立てると、最後に非 0 で終わったコマンドのものになる ||とifはパイプ全体の終了コードを見る。cmd | grep -v X || trueは cmd の失敗も 0 にし、if cmd | grep -q Xは取得の失敗を「一致なし」と同じ偽にする- 読み手が終わった後に書き手が書くと、書き手は SIGPIPE で 141 になり、
pipefailがそれを全体の終了コードにする。| head、| grep -q、stdin を読まない読み手で起き、出力が小さくても書く順番しだいで起きる mapfile < <(cmd)はパイプではないので、cmd の失敗はどこにも残らない- 判定に使う出力は先に変数へ取り、here-string で渡す。fallback 付きのコマンドは、成功したときだけ stdout に出す
以下は GNU bash 5.3.15 / GNU coreutils 9.11 / GNU grep 3.12 / Linux x86_64 で実測した。回数を書いたものはタイミングで決まるので、環境によって数字が変わる。
前提: パイプの終了コードはどのコマンドのものか

a | b の $? は b の終了コードで、a がどう終わったかは入らない。
pipefail を立てると、最後 (右端寄り) に非 0 で終わったコマンドの終了コードになる。各コマンドの終了コードは PIPESTATUS に残る。
false | true; echo "既定: rc=$?"
(exit 2) | (exit 3) | true; echo "既定: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
set -o pipefail
false | true; echo "pipefail: rc=$?"
(exit 2) | (exit 3) | true; echo "pipefail: rc=$? PIPESTATUS=${PIPESTATUS[*]}"既定: rc=0
既定: rc=0 PIPESTATUS=2 3 0
pipefail: rc=1
pipefail: rc=3 PIPESTATUS=2 3 0pipefail が返すのは 1 つだけで、(exit 2) も失敗したことは PIPESTATUS を見ないと分からない。
検査のパイプは、末尾のコマンドの終了コードが判定になるように並べる。 PNG の先頭 8 バイトの判定は cmp の結果だけで決まり、ファイルが無いときも「PNG でない」と同じ 1 になる。
printf '\x89PNG\r\n\x1a\n' > sig.bin
head -c 8 missing.png | cmp -s - sig.bin; echo "missing.png: rc=$? PIPESTATUS=${PIPESTATUS[*]}"head: cannot open 'missing.png' for reading: No such file or directory
missing.png: rc=1 PIPESTATUS=1 1- PITFALL:
head -c 8は 8 バイトを 1 回の write で書き終えるので、後述の SIGPIPE は起きない。pipefailの下で 200 回回して、非 0 は 0 回だった
|| と if はパイプ全体の終了コードを見る
cmd | grep -v X || true の || true は cmd の失敗も消す

a | b || c は (a | b) || c と読まれる。grep の「一致 0 件 = 1」だけを許すつもりの || true が、前段の失敗も 0 にする。
make の .SHELLFLAGS := -eu -o pipefail -c も、この 1 行では効かない。
成否に関わらず Created version を stdout に出し、失敗時は stderr にエラーを出して 1 で終わる CLI の代役で試す。
cli() {
echo "Created version [3]"
[[ $1 == ok ]] && return 0
echo "ERROR: permission denied" >&2
return 1
}
export -f cli
# make の .SHELLFLAGS := -eu -o pipefail -c と同じ条件で 1 行ずつ流す
run() { echo "--- $1"; bash -eu -o pipefail -c "$1"; echo "rc=$?"; }
run 'cli ok 2>&1 | grep -v "Created version"'
run 'cli ng 2>&1 | grep -v "Created version" || true'
run 'cli ok 2>&1 | { grep -v "Created version" || true; } >&2'
run 'cli ng 2>&1 | { grep -v "Created version" || true; } >&2'--- cli ok 2>&1 | grep -v "Created version"
rc=1
--- cli ng 2>&1 | grep -v "Created version" || true
ERROR: permission denied
rc=0
--- cli ok 2>&1 | { grep -v "Created version" || true; } >&2
rc=0
--- cli ng 2>&1 | { grep -v "Created version" || true; } >&2
ERROR: permission denied
rc=11 つ目は成功しているのに、全行を消した grep が 1 を返す。これを避けるために || true を足すと、2 つ目のように失敗も 0 になる。
|| true を grep だけに掛けるには { } で括る。cli の失敗は pipefail で伝わる (4 つ目)。
- PITFALL: 次の行に無条件の成功メッセージがあると、失敗が「成功しました」と表示される。間引く出力が stderr 由来なら
>&2で戻さないと、エラーが stdout に混ざる
if cmd | grep -q X は取得の失敗を「一致なし」と同じ偽にする

if の条件では set -e が効かず、パイプの終了コードは grep のものになる。
cmd が認証切れやネットワーク断で落ちても、grep の「一致なし」と同じ 1 になり、「無いので作る」の側へ進む。
lookup() { echo "error: token expired" >&2; return 1; } # 認証切れで落ちる CLI の代役
if lookup | grep -q '^my-item$'; then echo "exists"; else echo "既定: not exists (rc=$?)"; fi
set -o pipefail
if lookup | grep -q '^my-item$'; then echo "exists"; else echo "pipefail: not exists (rc=$?)"; fierror: token expired
既定: not exists (rc=1)
error: token expired
pipefail: not exists (rc=1)pipefail を立てても、lookup の 1 と grep の 1 は区別できない。取得を先に済ませ、失敗はそこで止める。
if out=$(lookup); then
if grep -q '^my-item$' <<<"$out"; then echo "exists"; else echo "not exists"; fi
else
echo "分けた形: lookup failed (rc=$?)"
fierror: token expired
分けた形: lookup failed (rc=1)- PITFALL:
pipefailの下では、一致しても偽になることがある。grep -qは最初の一致で終わるので、まだ書いている上流が SIGPIPE で 141 になる (次の節)
set -o pipefail
list() { echo my-item; seq 200000; }
if list | grep -q '^my-item$'; then echo "exists"; else echo "pipefail: NOT-EXISTS (rc=$?) PIPESTATUS=${PIPESTATUS[*]}"; fipipefail: NOT-EXISTS (rc=141) PIPESTATUS=141 0読み手が終わった後に書くと、書き手は 141 で終わる

読み手が終わってパイプの読み口が閉じた後に書き手が write すると、書き手は SIGPIPE を受け、終了コード 141 (128 + 13) で終わる。メッセージは出ない。
読み手が 0 で終わっても、pipefail は書き手の 141 を採る。
起きるかどうかは、読み手が終わった後に書き手がまだ write するかで決まる。
- 読み手が終わる前に書き終えていれば起きない
- 出力がパイプのバッファ (Linux 既定 64KiB) を超えると、書き手は読み手が読むのを待つ。その間に読み手が終われば起きるので、ほとんど毎回起きる
- バッファに収まる量でも、何回かに分けて write する書き手では、どちらが先かで起きたり起きなかったりする
fzf で選んだ後に producer が 141 になる件は、 スクリプトから呼ぶ fzf が返す値と終了コード に書いた。
stdin を読まない読み手は、小さな出力でもまれに 141 にする
読み手が stdin を読まずに終わると、出力が大きければ毎回、小さくてもまれに書き手が 141 になる。
set -o pipefail
count() {
local n=0 i
for i in $(seq "$2"); do eval "$1" || n=$((n + 1)); done
echo "$1: $2 回中 $n 回 非 0"
}
head -c 1000000 /dev/zero | tr '\0' x | true; echo "1MB | true: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
count "printf 'a\nb\n' | true" 2000
count "printf 'a\nb\n' | cat >/dev/null" 20001MB | true: rc=141 PIPESTATUS=141 141 0
printf 'a\nb\n' | true: 2000 回中 7 回 非 0
printf 'a\nb\n' | cat >/dev/null: 2000 回中 0 回 非 0テストで外部コマンドを関数に差し替えるときに踏む。
秘密を argv に載せないため printf ... | curl --config - で stdin から渡す関数を、stdin を読まない偽の curl で試すと、まれに 141 で落ちる。
send() { printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" | curl --config - https://api.example.invalid/; }
# NG: stdin を読まない
curl() { echo '{"success":true}'; }
# OK: 実物と同じく stdin を読み切る
curl() { cat >/dev/null; echo '{"success":true}'; }それぞれを count 'send >/dev/null' 2000 と、CPU 1 個に固定した count 'taskset -c 0 bash -o pipefail -c "send >/dev/null"' 200 で回した。
# NG の偽物
send >/dev/null: 2000 回中 0 回 非 0
taskset -c 0 bash -o pipefail -c "send >/dev/null": 200 回中 2 回 非 0
# OK の偽物
send >/dev/null: 2000 回中 0 回 非 0
taskset -c 0 bash -o pipefail -c "send >/dev/null": 200 回中 0 回 非 0CPU を固定しないとほとんど落ちず、固定すると落ちる。偽物は実物と同じく stdin を読み切る。
- PITFALL: 出力を
out=$( (fn) 2>&1 )で受けていると、エラー文言も残らずにset -eで止まる。「CI でたまに落ちて、手元では再現しない」形になる
| head で切ると、head が終わった後に上流が書けば 141

head は N 行を読んだら終わる。上流がその後に write すると 141 になる。
set -o pipefail
line=$(printf 'a%.0s' $(seq 2000)) # 2000 文字の行
for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null
echo "44 行 (約 88KB): rc=$? PIPESTATUS=${PIPESTATUS[*]}"
n=0
for r in $(seq 50); do
for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null || n=$((n + 1))
done
echo "44 行 (約 88KB): 50 回中 $n 回 非 0"
# バッファ未満でも、head が終わった後に grep が書けば 141 になる
n=0
for r in $(seq 50); do
for i in $(seq 20); do echo "$line"; done | grep a | head -n 15 >/dev/null || n=$((n + 1))
done
echo "20 行 (約 40KB): 50 回中 $n 回 非 0"44 行 (約 88KB): rc=141 PIPESTATUS=141 141 0
44 行 (約 88KB): 50 回中 49 回 非 0
20 行 (約 40KB): 50 回中 38 回 非 0バッファを超える 88KB ではほぼ毎回 141 になり、grep が落ちた後に書こうとしたループも 141 になる。 バッファ未満の 40KB でも、head の後に grep が残りを書くと 141 になる。
量でなく、上流の書き方で決まる。シェルのループで 1 行ずつ echo する上流は 292 バイトしか出さなくても大半が 141 になり、1 回の write で書き終える seq は 0 になる。
set -o pipefail
echo "seq 100 の出力: $(seq 100 | wc -c) バイト"
printf '行ごとに echo する上流: '
for r in $(seq 20); do
for i in $(seq 100); do echo "$i"; done | head -n 1 >/dev/null; printf '%s ' $?
done
echo
printf 'seq (1 回の write): '
for r in $(seq 20); do
seq 100 | head -n 1 >/dev/null; printf '%s ' $?
done
echoseq 100 の出力: 292 バイト
行ごとに echo する上流: 141 141 141 141 0 141 0 141 141 141 141 141 141 141 141 0 141 141 141 141
seq (1 回の write): 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 最後まで読む awk で切れば、上流は書き終えられる。grep がファイルを直接読むなら、grep -m で grep 自身が止まるので 141 にならない。
ただし grep の前にさらに上流があると、その上流が SIGPIPE を受ける。
n=0
for r in $(seq 50); do
for i in $(seq 44); do echo "$line"; done | grep a | awk 'NR <= 15' >/dev/null || n=$((n + 1))
done
echo "44 行 | grep | awk 'NR <= 15': 50 回中 $n 回 非 0"
grep -m 15 a in.txt >/dev/null; echo "grep -m 15 a in.txt: rc=$?"
seq 200000 | grep -m 15 . >/dev/null; echo "seq 200000 | grep -m 15: rc=$? PIPESTATUS=${PIPESTATUS[*]}"44 行 | grep | awk 'NR <= 15': 50 回中 0 回 非 0
grep -m 15 a in.txt: rc=0
seq 200000 | grep -m 15: rc=141 PIPESTATUS=141 0- PITFALL: 入力が小さいうちや、1 回試して 0 が出たときは安全に見える。件数が日々増える入力では、ある日から落ち始める。数十回回すか、上流の書き方 (1 行ずつか、まとめてか) を見て判断する
終わらない入力を head で切ると、毎回 141
yes や /dev/urandom は終わらないので、head が終わった後も必ず書く。
set -o pipefail
yes | head -n 1 >/dev/null; echo "yes | head -n 1: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16 >/dev/null; echo "tr < /dev/urandom | head -c 16: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
# set -e の下ではメッセージ無しで止まる
bash -euo pipefail -c 'p=$(LC_ALL=C tr -dc "A-Za-z0-9" </dev/urandom | head -c 16); echo "p=$p"'
echo "set -euo pipefail: rc=$?"yes | head -n 1: rc=141 PIPESTATUS=141 0
tr < /dev/urandom | head -c 16: rc=141 PIPESTATUS=141 0
set -euo pipefail: rc=141set -e の下では p= の行で止まり、echo まで届かない。
入力を先に有限にすると 141 にならない。
for i in $(seq 5); do
p=$(head -c 256 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9'); echo "rc=$? 長さ=${#p}"
donerc=0 長さ=57
rc=0 長さ=62
rc=0 長さ=50
rc=0 長さ=68
rc=0 長さ=59- PITFALL: 有限の入力では、残る文字数が毎回変わる。長さと文字種を検査し、足りなければ引き直す
echo "$big" | grep -q PAT は一致しても 141 になりうる

grep -q は最初の一致で終わる。大きな文字列を echo で流すと、一致した後も echo が書いていて 141 になる。一致行の後ろの量で頻度が変わる。
set -euo pipefail
for size in 4096 16384 65536 131072; do
big="match
$(head -c "$size" /dev/zero | tr '\0' x)"
n=0 m=0
for i in $(seq 30); do
echo "$big" | grep -q match || n=$((n + 1))
grep -q match <<<"$big" || m=$((m + 1))
done
echo "一致行の後ろ ${size} バイト: echo | grep -q は 30 回中 $n 回 非 0 / <<< は 30 回中 $m 回 非 0"
done一致行の後ろ 4096 バイト: echo | grep -q は 30 回中 0 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 16384 バイト: echo | grep -q は 30 回中 0 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 65536 バイト: echo | grep -q は 30 回中 3 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 131072 バイト: echo | grep -q は 30 回中 30 回 非 0 / <<< は 30 回中 0 回 非 0here-string は書き手のプロセスを作らないので、SIGPIPE が起きない。
HTML やページの snapshot、API の応答を変数に持って if echo ... | grep -q で分岐するところは、grep -q PAT <<<"$big" にする。
- PITFALL: 「続きがあるか」の判定に使うと、エラーにならずに処理を打ち切る。「さらに表示」の有無を誤って判定し、古い分を数えずに終わった例がある。
| head -1も同じ形で書き手を 141 にするので、最後まで読むawkなどに置き換える
パイプでない経路
mapfile < <(cmd) は cmd の失敗を捨てる

プロセス置換 <(cmd) はパイプではないので、pipefail も PIPESTATUS も cmd を見ない。
終了コードは mapfile のもので、cmd が途中で落ちても短い配列が返るだけになる。
$ bash -c 'set -euo pipefail
mapfile -t arr < <(printf "a\nb\n"; exit 3)
echo "rc=$? count=${#arr[@]} PIPESTATUS=${PIPESTATUS[*]}"'
rc=0 count=2 PIPESTATUS=0
塞ぎ方は 2 つ。末尾に番兵を流して受けた側で有無を見るか、一時ファイルを経由して生成器の終了コードを直接見る。
# 生成器の代役。FAIL を付けると途中で落ちる
gen() { printf 'a\nb\n'; [[ -n ${FAIL:-} ]] && return 3; printf 'c\n'; }
# 1) 末尾に番兵を流し、受けた側で有無を見る
gen_with_end() { gen && printf '__END__\n'; }
mapfile -t rec < <(FAIL=1 gen_with_end)
[[ ${rec[-1]:-} == __END__ ]] && echo "番兵あり: 全部届いた (番兵を含め ${#rec[@]} 行)" || echo "番兵あり: generator died (${#rec[@]} 行)"
# 2) 一時ファイルを経由し、生成器の終了コードを直接見る
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if FAIL=1 gen >"$tmp"; then mapfile -t rec <"$tmp"; echo "一時ファイル: ${#rec[@]} 行"; else echo "一時ファイル: generator died (rc=$?)"; fi番兵あり: generator died (2 行)
一時ファイル: generator died (rc=3)- PITFALL: 件数の検査だけでは足りない。1 レコードが複数フィールドの形式では、フィールド数の倍数で切れると件数の検査を通る。番兵で「全部届いた」を、件数で「区切りがずれていない」を確かめる
heredoc と here-string は、大きさで pipe か一時ファイルかが変わる

bash 5.1 からは、展開した中身がパイプのバッファより小さければ pipe で、超えると一時ファイルで渡す (bash の NEWS の 5.1 の項)。5.0 までは常に一時ファイルだった。
probe() { [[ -p /dev/stdin ]] && echo pipe; [[ -f /dev/stdin ]] && echo file; }
printf 'heredoc 小: '; probe <<EOF
small
EOF
big=$(head -c 200000 /dev/zero | tr '\0' a)
printf 'heredoc 大: '; probe <<EOF
$big
EOF
printf 'here-string 小: '; probe <<<"small"
printf 'here-string 大: '; probe <<<"$big"
# 切り替わる大きさ (改行 1 バイトを含む)
for n in 65534 65535 65536; do
s=$(head -c "$n" /dev/zero | tr '\0' a)
printf 'here-string %s+1 バイト: ' "$n"; probe <<<"$s"
doneheredoc 小: pipe
heredoc 大: file
here-string 小: pipe
here-string 大: file
here-string 65534+1 バイト: pipe
here-string 65535+1 バイト: pipe
here-string 65536+1 バイト: fileこの環境では、改行を含めて 65536 バイトまでが pipe、65537 バイトからが一時ファイルだった。
- PITFALL: 「heredoc は一時ファイルなので
-pが偽」という説明は 5.0 までの話。小さな入力で試すと-pだけで足りるように見え、大きな heredoc を渡した日に読まれなくなる。両方を拾うには[[ -p /dev/stdin || -f /dev/stdin ]]
出力の行き先
echo x 1>&2 | tee f は f に何も書かない

シェルはパイプを繋いでから、各コマンドのリダイレクトを適用する。
左辺の 1>&2 は stdout をパイプから stderr へ付け替えるので、tee の stdin には何も来ない。
log=$(mktemp); trap 'rm -f "$log"' EXIT
echo hello 1>&2 | tee -a "$log"
echo "echo hello 1>&2 | tee -a log: log は $(wc -c <"$log") バイト"
: >"$log"
echo hello | tee -a "$log" 1>&2
echo "echo hello | tee -a log 1>&2: log は $(wc -c <"$log") バイト"hello
echo hello 1>&2 | tee -a log: log は 0 バイト
hello
echo hello | tee -a log 1>&2: log は 6 バイト画面にはどちらも hello が出る。「画面には stderr で出しつつファイルにも残す」ログ関数は、tee の側で 1>&2 する。
- PITFALL: 画面の表示は正しいので、ファイルが空なことは、後で調べるためにファイルを開くまで分からない
primary || fallback は、失敗した primary が書いた分を残す

|| は primary が終わってから fallback を始めるだけで、それまでに stdout へ書いた分は取り消せない。
パイプやコマンド置換は stdout を流れてきた順に受け取り、終了コードは最後に分かる。
primary() { echo '{"error":"rate limited"}'; return 1; } # エラー本文を stdout に出して非 0 で終わる CLI の代役
fallback() { echo "answer from fallback"; }
out=$(primary || fallback)
printf '%s\n' "--- primary || fallback" "$out"
run_with_fallback() {
local out
if out=$(primary "$@"); then
printf '%s\n' "$out"
return 0
fi
printf '%s\n' "$out" >&2 # 失敗した分は stderr へ
fallback "$@"
}
out=$(run_with_fallback 2>/dev/null)
printf '%s\n' "--- run_with_fallback (stdout だけ)" "$out"--- primary || fallback
{"error":"rate limited"}
answer from fallback
--- run_with_fallback (stdout だけ)
answer from fallbackprimary の stdout をいったん変数に受け、成功したときだけ出す。
- PITFALL: 受けてから出すので、成功したときも出力が逐次には流れなくなる。fallback の無い経路では primary を直接実行する
PIPE_BUF を超える write には、同じパイプへの別の write が割り込む

POSIX がパイプへの write の原子性を保証するのは、PIPE_BUF バイト以下の write だけ。Linux の PIPE_BUF は 4096 で、POSIX が最低限として保証するのは 512。 stdout と stderr が同じパイプを指すとき、それを超える write の途中に別の write が入りうる。
2 つのプロセスが同じパイプへ、1 回の write で 1 行ずつ書く。A と B が同じ行に混ざった行を数える。
getconf PIPE_BUF /
w() { python3 -c 'import os, sys
c, n, k = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
b = (c * (n - 1) + "\n").encode()
for _ in range(k): os.write(1, b)' "$@"; }
for size in 4096 8192; do
mixed=$({ w A "$size" 2000 & w B "$size" 2000 & wait; } | awk '/A/ && /B/ {m++} END {print m + 0}')
echo "1 行 ${size} バイト x 2000 行 x 2 プロセス: 混ざった行 ${mixed}"
done4096
1 行 4096 バイト x 2000 行 x 2 プロセス: 混ざった行 0
1 行 8192 バイト x 2000 行 x 2 プロセス: 混ざった行 440構造化された出力 (JSON の行やテスト結果) とログを同じパイプに流し、別のプロセスが行単位で解析するときに踏む。1 回の write を PIPE_BUF 以下に保つか、出力先を分ける。
- PITFALL: 4097 バイトの結果ブロックの途中に stderr のログ行が入り、
--- PASSの行頭が分断されて、解析する側が合否を読めなかった例がある。1 行のログが単独で PIPE_BUF を超えても同じことが起きる
まとめ
| 場面 | 起きること | 対策 |
|---|---|---|
a | b の $? | b の終了コードだけ | pipefail を立てる |
pipefail | 最後に非 0 で終わったコマンドの値 1 つ | 原因は PIPESTATUS で見る |
cmd | grep -v X || true | cmd の失敗も 0 | cmd | { grep -v X || true; } |
if cmd | grep -q X | 取得の失敗が「一致なし」になる | out=$(cmd) || ... の後に grep -q X <<<"$out" |
| stdin を読まない読み手 | 書き手がまれに 141 | 偽物も stdin を読み切る |
生成 | head | head の後に上流が書くと 141 | 最後まで読む awk、ファイルに grep -m |
yes | head、/dev/urandom | head | 毎回 141 | 入力を先に有限にする |
echo "$big" | grep -q | 一致しても 141 | grep -q PAT <<<"$big" |
mapfile < <(cmd) | cmd の失敗が残らない | 番兵か一時ファイル |
| heredoc / here-string | 65536 バイトまでは pipe、超えると一時ファイル | -p || -f で判定する |
echo x 1>&2 | tee f | f が空 | echo x | tee f 1>&2 |
primary || fallback | primary の stdout が残る | 変数で受け、成功時だけ出す |
| PIPE_BUF を超える write | 別の write が割り込む | write を小さくするか、出力先を分ける |