シェルスクリプトとは、ターミナルで打つコマンドをファイルにまとめ、1回の実行で順番に動かせるようにしたプログラムです。 ファイルの整理、ログの集計、定期バッチ、デプロイ手順など、手作業で繰り返している操作をそのまま自動化できます。

この記事では、基本構文を早見表にまとめ、すべての例に実行結果を付けました。 さらに、そのままコピーして使える安全なテンプレート(set -euo pipefail、trap での後片付け、getopts、ロックファイル、mktemp)と、実際に再現したつまずきポイント(空白入りファイル名、set -e が効かない場面、パイプで変数が消える、Windowsの改行コード)、shellcheck の警告と直し方、bash と sh(dash)の違いまで扱います。検証環境は Ubuntu 24.04 / bash 5.2.21 / dash 0.5.12 / ShellCheck 0.11.0 です。

シェルスクリプトの書き方早見表

やりたいこと 書き方 ポイント
1行目に書く #!/usr/bin/env bash 使うシェルを指定(shebang)
実行する chmod +x a.sh → ./a.sh または bash a.sh
安全設定 set -euo pipefail エラー・未定義変数・パイプ失敗で止める
変数に代入 name="Taro" = の前後に空白を入れない
変数を使う "$name" / "${name}さん" 原則ダブルクォートで囲む
展開しない文字列 '$name' シングルクォートはそのまま
引数 $1 $2 / "$@" / $# 全引数は "$@"、個数は $#
既定値 ${1:-default} 空なら既定値
必須チェック ${1:?メッセージ} 空ならエラー終了
条件分岐 if [[ 条件 ]]; then ... fi bashでは [[ ]] がおすすめ
パターン分岐 case "$x" in *.csv) ... ;; esac 拡張子などで分岐
繰り返し for x in "${arr[@]}"; do ... done ファイルは for f in ./*
ファイルを1行ずつ while IFS= read -r line; do ... done < file -r を付ける
関数 f() { local v="$1"; ... } 変数は local
配列 arr=("a" "b") / "${arr[@]}" 要素数は ${#arr[@]}
計算 $(( n * 3 + 1 )) 整数のみ
コマンドの結果を使う today="$(date +%F)" バッククォートより $( )
直前の終了ステータス $? 0が成功
複数行の文字列 cat <<EOT ... EOT <<'EOT' で展開なし
文字列の切り出し ${f##*/} ${f%.*} ${f//a/b} ファイル名・拡張子の処理
終了時の後片付け trap cleanup EXIT 一時ファイルの削除など
静的チェック shellcheck a.sh 実行前にミスを見つける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「シェルスクリプトの書き方|基本構文の早見表と安全なテンプレート」

最初のシェルスクリプトを作って実行する

1行目に shebang(#! で始まる行)を書き、実行権限を付けて ./ファイル名 で実行します。

#!/usr/bin/env bash
echo "こんにちは、${1}さん"
$ chmod +x hello.sh
$ ./hello.sh 佐藤
こんにちは、佐藤さん
$ bash hello.sh 鈴木
こんにちは、鈴木さん

実行権限がないと、次のエラーになります(終了コード126)。

$ ./hello.sh x
/bin/bash: line 1: ./hello.sh: Permission denied
実行方法 使われるシェル 実行権限
./hello.sh shebang で指定したもの 必要
bash hello.sh bash 不要
sh hello.sh /bin/sh(Ubuntu では dash) 不要

sh hello.sh で実行すると、shebang に bash と書いてあっても dash で動きます。 bash の機能を使うスクリプトは ./hello.sh か bash hello.sh で実行します(違いは後半の「bashとdashの違い」で実例を示します)。shebang は #!/bin/bash でも動きますが、#!/usr/bin/env bash にすると PATH 上の bash を使うため、bash の場所が異なる環境でも動かしやすくなります。

変数・クォート・引数

変数は 名前=値(= の前後に空白なし)で代入し、使うときは "$名前" とダブルクォートで囲みます。

name="Taro"
greeting='Hello, $name'
echo "1: Hello, $name"
echo "2: $greeting"
echo "3: ${name}さん"
1: Hello, Taro
2: Hello, $name
3: Taroさん
クォート 変数展開 用途
"..."(ダブル) される 基本はこれ。空白を含む値も1つの値として扱える
'...'(シングル) されない $ や \ をそのまま書きたいとき
なし される+空白で分割・* を展開 意図して分割したいときだけ

変数の直後に文字が続くときは ${name}さん のように波かっこで区切ります。 $nameさん と書くと「nameさん」という変数名として解釈されます。

引数:$1、"$@"、$# の違い

set -- "a b" c "d"          # 引数を3つ("a b", c, d)に設定
echo "4: \$# = $#"
for x in "$@"; do echo "5: [$x]"; done
for x in $@; do echo "6: [$x]"; done
echo "7: \$* = $*"
4: $# = 3
5: [a b]
5: [c]
5: [d]
6: [a]
6: [b]
6: [c]
6: [d]
7: $* = a b c d

"$@" はクォートで囲むと引数を元の区切りのまま渡せます。 $@ をクォートなしで使うと "a b" が a と b に分かれ、3つの引数が4つになります。

変数 意味
$0 スクリプト名
$1 〜 $9、${10} 1番目〜の引数
"$@" 全引数(1つずつ別の値)
$# 引数の個数
$? 直前のコマンドの終了ステータス
$$ 実行中のシェルのプロセスID

条件分岐:if・case と [[ ]] / [ ]

count=5
if [[ $count -gt 3 ]]; then
  echo "if: 3より大きい"
elif [[ $count -eq 3 ]]; then
  echo "if: 3"
else
  echo "if: 3より小さい"
fi

file="report.csv"
case "$file" in
  *.csv) echo "case: CSVファイル" ;;
  *.json|*.jsonl) echo "case: JSONファイル" ;;
  *) echo "case: その他" ;;
esac
if: 3より大きい
case: CSVファイル

if の後ろに書くのは「コマンド」で、その終了ステータスが0なら真です。 [[ ]] も [ ] も条件を判定するコマンド(構文)です。

比較したいもの 書き方 意味
数値 -eq -ne -lt -le -gt -ge =、≠、<、≦、>、≧
数値(算術式) (( n > 3 )) 記号で書ける
文字列 == != / -z "$s" / -n "$s" 一致・不一致 / 空 / 空でない
パターン [[ $s == order-* ]] ワイルドカード一致
正規表現 [[ $s =~ ^order-([0-9]+)$ ]] 一致部分は BASH_REMATCH
ファイル -f(ファイル)-d(ディレクトリ)-e(存在)-r(読める)
組み合わせ [[ A && B ]] / [[ A || B ]] かつ / または
s="order-2026"
[[ $s == order-* ]] && echo "[[ ]]: パターン一致"
[[ $s =~ ^order-([0-9]+)$ ]] && echo "[[ ]]: 正規表現 ${BASH_REMATCH[1]}"
[[ -f stock.csv && -r stock.csv ]] && echo "[[ ]]: ファイルがあり読める"
[[ ]]: パターン一致
[[ ]]: 正規表現 2026
[[ ]]: ファイルがあり読める

[ ] と [[ ]] の違い(空の変数で実際に比較)

status=""
if [ $status = "done" ]; then echo "完了"; fi
if [[ $status = "done" ]]; then echo "完了"; else echo "[[ ]] は空でもエラーにならない"; fi
name="a b"
if [ $name = "a b" ]; then echo ok; fi
test_bracket.sh: line 3: [: =: unary operator expected
[[ ]] は空でもエラーにならない
test_bracket.sh: line 6: [: too many arguments

[ ] の中でクォートなしの変数が空や空白入りだと、引数の数がずれてエラーになります。 [ "$status" = "done" ] とクォートすれば [ ] でも正しく動きます。bash 専用なら [[ ]]、sh(POSIX)でも動かすなら [ ] と必ずクォート、と使い分けます。

繰り返し:for・while・ファイルの読み込み

for i in 1 2 3; do echo "for: $i"; done
for ((i = 0; i < 3; i++)); do echo "for((;;)): $i"; done
for f in {01..03}; do echo "連番: task_$f"; done
n=0
while (( n < 3 )); do n=$((n + 1)); echo "while: $n"; done
for: 1
for: 2
for: 3
for((;;)): 0
for((;;)): 1
for((;;)): 2
連番: task_01
連番: task_02
連番: task_03
while: 1
while: 2
while: 3

CSVなどのファイルを1行ずつ読むときは while IFS=, read -r ...; do ... done < ファイル が定番です。

printf 'pen,12\nnote,3\n' > stock.csv
while IFS=, read -r item qty; do
  echo "read: ${item}の在庫は${qty}"
done < stock.csv
read: penの在庫は12
read: noteの在庫は3

-r を付けると \ をエスケープ文字として扱わず、そのまま読み込みます。

関数

関数は 名前() { ... } で定義し、中の変数は local で宣言します。 戻り値(return)は0〜255の終了ステータスで、文字列を返したいときは echo して $( ) で受け取ります。

greet() {
  local who="$1"
  local times="${2:-1}"
  for ((i = 0; i < times; i++)); do echo "func: こんにちは、${who}さん"; done
  return 0
}
greet "佐藤" 2

is_even() { (( $1 % 2 == 0 )); }
if is_even 4; then echo "func: 4は偶数"; fi

result=$(greet "鈴木")
echo "戻り値を受け取る: $result"
func: こんにちは、佐藤さん
func: こんにちは、佐藤さん
func: 4は偶数
戻り値を受け取る: func: こんにちは、鈴木さん

is_even のように「最後のコマンドの成否」がそのまま関数の真偽になるので、if の条件に使えます。

配列・算術・文字列操作

items=("ペン" "ノート" "付箋 大")
echo "19: ${#items[@]} ${items[1]}"
for i in "${items[@]}"; do echo "20: [$i]"; done
items+=("クリップ")
echo "21: ${items[*]}"
declare -A stock=([pen]=12 [note]=3)
echo "22: pen=${stock[pen]} keys=${!stock[*]}"

n=7
echo "8: $(( n * 3 + 1 ))"
(( n++ ))
echo "9: n=$n"
19: 3 ノート
20: [ペン]
20: [ノート]
20: [付箋 大]
21: ペン ノート 付箋 大 クリップ
22: pen=12 keys=note pen
8: 22
9: n=8

配列の全要素は "${items[@]}" とクォートして展開します。 空白を含む「付箋 大」も1要素のまま扱えます。declare -A は連想配列(キーと値の組)で、キーの並び順は保証されません。算術 $(( )) は整数のみで、小数が必要なら awk や bc を使います。

文字列操作(パス・拡張子の処理)

file="/var/log/app/access.log.gz"
echo "10: ${file##*/}"
echo "11: ${file%/*}"
echo "12: ${file%.gz}"
echo "13: ${file%%.*}"
echo "14: ${#file}"
echo "15: ${file/log/LOG}"
echo "16: ${file//log/LOG}"
echo "17: ${undefined:-default}"
v="Task"
echo "18: ${v^^} ${v,,} ${v:0:2}"
10: access.log.gz
11: /var/log/app
12: /var/log/app/access.log
13: /var/log/app/access
14: 26
15: /var/LOG/app/access.log.gz
16: /var/LOG/app/access.LOG.gz
17: default
18: TASK task Ta
書き方 意味 覚え方
${f#パターン} / ${f##パターン} 先頭から最短 / 最長一致を削除 # は左側
${f%パターン} / ${f%%パターン} 末尾から最短 / 最長一致を削除 % は右側
${f/a/b} / ${f//a/b} 最初の1つ / すべてを置換
${#f} 文字数
${f:開始:長さ} 部分文字列 0始まり
${f^^} / ${f,,} 大文字 / 小文字に bash 4以降
${f:-値} / ${f:?msg} 空なら既定値 / エラー終了

${file##*/} は basename、${file%/*} は dirname とほぼ同じ結果を、外部コマンドなしで得られます。

ヒアドキュメントとコマンド置換

cat <<EOT
ヒアドキュメント: count=$count
  インデントもそのまま
EOT
cat <<'EOT'
クォート付き: $count は展開されない
EOT
cat <<-EOT
    <<- はタブのインデントを除去: $count
    EOT
ヒアドキュメント: count=5
  インデントもそのまま
クォート付き: $count は展開されない
<<- はタブのインデントを除去: 5

設定ファイルやSQLのひな形を書き出すときは、変数を展開したいなら <<EOT、そのまま書きたいなら <<'EOT' を使います。<<- で除去されるのはタブだけで、空白のインデントは残ります。

コマンドの結果を変数に入れるときは $(コマンド) を使います。バッククォート `コマンド` も同じ意味ですが、入れ子にしやすく読みやすい $( ) が推奨です。

grep -q root /etc/passwd; echo "24: \$?=$?"
grep -q nosuchuser /etc/passwd; echo "25: \$?=$?"
24: $?=0
25: $?=1

終了ステータスは0が成功、それ以外が失敗です。grep は「見つからない」を1で返します。

コピペで使える安全なテンプレート

実務のスクリプトは、次のテンプレートから書き始めると安全です。 エラーで止まる設定、使い方の表示、オプション解析、ログ、一時ファイルの後片付け、二重起動の防止が入っています。ShellCheck 0.11.0 で指摘なしを確認しています。

#!/usr/bin/env bash
#
# template.sh - 入力ファイルの行数を数えて出力するサンプル
#
set -Eeuo pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME="${0##*/}"
readonly LOCK_FILE="/tmp/${SCRIPT_NAME}.lock"

usage() {
  cat <<EOT
使い方: ${SCRIPT_NAME} [-v] [-o 出力ファイル] 入力ファイル...

オプション:
  -o FILE  結果を FILE に書き出す(省略時は標準出力)
  -v       詳細ログを表示する
  -h       このヘルプを表示する
EOT
}

log()  { printf '%s [INFO] %s\n'  "$(date '+%F %T')" "$*" >&2; }
warn() { printf '%s [WARN] %s\n'  "$(date '+%F %T')" "$*" >&2; }
die()  { printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; }
debug() { if [[ "${VERBOSE}" == true ]]; then log "DEBUG: $*"; fi; }

cleanup() {
  local status=$?
  rm -f -- "${TMP_FILE:-}"
  debug "後片付け完了(終了コード ${status})"
}
trap cleanup EXIT
trap 'warn "エラー: ${BASH_SOURCE[0]}:${LINENO} で \"${BASH_COMMAND}\" が失敗しました"' ERR

VERBOSE=false
OUTPUT=""
while getopts ":o:vh" opt; do
  case "${opt}" in
    o) OUTPUT="${OPTARG}" ;;
    v) VERBOSE=true ;;
    h) usage; exit 0 ;;
    :) usage >&2; die "-${OPTARG} には値が必要です" ;;
    \?) usage >&2; die "不明なオプションです: -${OPTARG}" ;;
  esac
done
shift $((OPTIND - 1))

(( $# >= 1 )) || { usage >&2; die "入力ファイルを1つ以上指定してください"; }

# 二重起動を防ぐ(flock が使える Linux 向け)
exec 9>"${LOCK_FILE}"
flock -n 9 || die "すでに実行中です(${LOCK_FILE})"

TMP_FILE="$(mktemp)"
debug "一時ファイル: ${TMP_FILE}"

main() {
  local f lines
  for f in "$@"; do
    [[ -f "${f}" && -r "${f}" ]] || die "読めないファイルです: ${f}"
    lines="$(wc -l < "${f}")"   # 代入を分けると失敗時に set -e で止まる
    printf '%s\t%s\n' "${lines}" "${f}" >> "${TMP_FILE}"
    log "集計: ${f}"
  done

  if [[ -n "${OUTPUT}" ]]; then
    mv -- "${TMP_FILE}" "${OUTPUT}"
    log "書き出し: ${OUTPUT}"
  else
    cat -- "${TMP_FILE}"
  fi
}

main "$@"

テンプレートの各行の意味

行 役割
set -e コマンドが失敗したらそこで終了する
set -u 未定義の変数を使ったらエラーにする(打ち間違いに気づける)
set -o pipefail パイプの途中のコマンドの失敗も失敗として扱う
set -E 関数やサブシェルの中でも ERR トラップを有効にする
IFS=$'\n\t' 単語の区切りを改行とタブだけにし、空白での分割を防ぐ
trap cleanup EXIT 正常終了でもエラーでも、最後に後片付けを実行する
trap '...' ERR 失敗したコマンドと行番号をログに出す
getopts ":o:vh" -o 値、-v、-h を解析(先頭の : でエラー処理を自前にする)
mktemp 他と重ならない一時ファイルを安全に作る
flock -n 9 同じスクリプトが実行中なら、待たずに終了する
ログを >&2 へ 標準出力は結果だけにして、パイプでつなげやすくする

実行結果

$ ./template.sh -v "在庫 一覧.txt" tasks.txt
2026-09-29 13:06:20 [INFO] DEBUG: 一時ファイル: /tmp/tmp.NHFHJhVU81
2026-09-29 13:06:20 [INFO] 集計: 在庫 一覧.txt
2026-09-29 13:06:20 [INFO] 集計: tasks.txt
2   在庫 一覧.txt
1   tasks.txt
2026-09-29 13:06:20 [INFO] DEBUG: 後片付け完了(終了コード 0)

$ ./template.sh -x a
使い方: template.sh [-v] [-o 出力ファイル] 入力ファイル...
(中略)
2026-09-29 13:06:20 [ERROR] 不明なオプションです: -x

$ ./template.sh -o
(使い方の表示は省略)
2026-09-29 13:06:20 [ERROR] -o には値が必要です

$ ./template.sh tasks.txt nofile.txt
2026-09-29 13:06:20 [INFO] 集計: tasks.txt
2026-09-29 13:06:20 [ERROR] 読めないファイルです: nofile.txt

$ ./template.sh tasks.txt        # 別のプロセスがロック中に実行
2026-09-29 13:06:20 [ERROR] すでに実行中です(/tmp/template.sh.lock)

空白を含む「在庫 一覧.txt」も1つのファイルとして処理され、エラー終了した場合も一時ファイルは残りませんでした(実行後に /tmp/tmp.* が0件であることを確認)。

trap で後片付けとエラー位置の表示

trap の動きだけを取り出した最小例です。

#!/usr/bin/env bash
set -Eeuo pipefail
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"; echo "cleanup: $tmp を削除しました" >&2' EXIT
trap 'echo "ERR: ${LINENO}行目 \"${BASH_COMMAND}\" が失敗(終了コード $?)" >&2' ERR
echo "作業中: $tmp"
cp /nonexistent/master.csv "$tmp"
echo "ここは実行されない"
作業中: /tmp/tmp.qHDpgcIkrV
cp: cannot stat '/nonexistent/master.csv': No such file or directory
ERR: 7行目 "cp /nonexistent/master.csv "$tmp"" が失敗(終了コード 1)
cleanup: /tmp/tmp.qHDpgcIkrV を削除しました

ERR トラップで「どこで失敗したか」を表示し、EXIT トラップで一時ファイルを必ず消す、という2段構えにすると、失敗時の調査と後片付けが楽になります。

よくあるつまずきを実際に再現して直す

1. クォートなしで空白入りのファイル名が分かれる

カレントディレクトリに memo.txt と 月次 報告.txt がある状態で実行します。

# 直す前
for f in $(ls); do
  echo "処理: $f"
done
target="月次 報告.txt"
cp $target /tmp/
処理: memo.txt
処理: 月次
処理: 報告.txt
cp: cannot stat '月次': No such file or directory
cp: cannot stat '報告.txt': No such file or directory
# 直した後
for f in ./*; do
  echo "処理: $f"
done
target="月次 報告.txt"
cp "$target" /tmp/ && echo "コピー成功: $target"
処理: ./memo.txt
処理: ./月次 報告.txt
コピー成功: 月次 報告.txt

ファイルの一覧は $(ls) ではなくグロブ(./*)で取り、変数は "$target" とクォートします。 ./* の ./ は、- で始まるファイル名がオプションと解釈されるのを防ぎます。

2. set -e を付けないと、失敗しても次に進む

#!/usr/bin/env bash
cp /nonexistent/config.ini ./config.ini
echo "設定をコピーしました(本当は失敗しています)"
cp: cannot stat '/nonexistent/config.ini': No such file or directory
設定をコピーしました(本当は失敗しています)
(終了コード 0)

set -e を付けると、cp が失敗した時点で終了コード1で止まります。 さらにパイプの途中の失敗は pipefail がないと検出されません。

#!/usr/bin/env bash
set -e
cat /nonexistent/list.txt | sort
echo "1: パイプの途中の失敗は set -e だけでは止まらない"
set -o pipefail
cat /nonexistent/list.txt | sort
echo "2: ここは実行されない"
cat: /nonexistent/list.txt: No such file or directory
1: パイプの途中の失敗は set -e だけでは止まらない
cat: /nonexistent/list.txt: No such file or directory
(終了コード 1)

set -u は変数名の打ち間違いを止めてくれます。

#!/usr/bin/env bash
set -u
backup_dir="/tmp/backup"
echo "削除対象: ${backup_dri}/*"
unset.sh: line 4: backup_dri: unbound variable

3. set -e が効かない場面がある

set -e は万能ではなく、条件として使われたコマンドや、local と同時に代入したコマンド置換の失敗では止まりません。

#!/usr/bin/env bash
set -euo pipefail
check() {
  false
  echo "  check: false の次の行も実行された"
}
if check; then echo "1: if の条件内では set -e が無効"; fi
check || true
echo "2: || の左側でも無効"
f() { local out=$(false); echo "3: local x=\$(false) は失敗が隠れる"; }
f
g() { local out; out=$(false); echo "ここは実行されない"; }
g
  check: false の次の行も実行された
1: if の条件内では set -e が無効
  check: false の次の行も実行された
2: || の左側でも無効
3: local x=$(false) は失敗が隠れる
(終了コード 1)

コマンドの引数に書いたコマンド置換の失敗も同様です。

#!/usr/bin/env bash
set -Eeuo pipefail
echo "1: 件数 $(wc -l < /tmp)"
echo "2: 引数の中のコマンド置換の失敗では止まらない"
lines="$(wc -l < /tmp)"
echo "3: ここは実行されない"
wc: 'standard input': Is a directory
1: 件数 0
2: 引数の中のコマンド置換の失敗では止まらない
wc: 'standard input': Is a directory
(終了コード 1)
set -e で止まらない場面 対策
if cmd、while cmd、cmd && ...、cmd || ... の条件部分 関数内で cmd || return 1 と明示する
local x=$(cmd) local x と x=$(cmd) の2行に分ける
echo "$(cmd)" のように引数の中のコマンド置換 いったん変数に代入してから使う
パイプの途中(pipefail なし) set -o pipefail

4. パイプの先の while で変数が消える

#!/usr/bin/env bash
count=0
printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)); done
echo "1: パイプ + while: count=$count"
count=0
while read -r line; do count=$((count + 1)); done < <(printf 'a\nb\nc\n')
echo "2: プロセス置換: count=$count"
count=0
shopt -s lastpipe
printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)); done
echo "3: lastpipe: count=$count"
1: パイプ + while: count=0
2: プロセス置換: count=3
3: lastpipe: count=3

パイプ | の右側はサブシェル(別プロセス)で動くため、中で変えた変数は外に戻りません。 done < <(コマンド)(プロセス置換)か done < ファイル にすると、同じシェルで動くので値が残ります。shopt -s lastpipe はスクリプト(ジョブ制御が無効な状態)で有効です。

5. Windowsで保存したスクリプトが動かない(CRLF改行)

Windows のエディタで保存すると改行コードが CRLF(\r\n)になり、Linux では \r が文字の一部として扱われます。

$ ./crlf2.sh
/bin/bash: line 5: ./crlf2.sh: cannot execute: required file not found

$ bash crlf2.sh
crlf2.sh: line 2: $'\r': command not found
crlf2.sh: line 4: cd: $'/tmp\r': No such file or directory

bash 5.2 では cannot execute: required file not found と表示されました。 shebang の末尾に \r が付き、/bin/bash\r というファイルを探しにいくためです。以前の環境では同じ原因で /bin/bash^M: bad interpreter: No such file or directory と表示されます(こちらは今回の環境では再現していません)。

$ file crlf2.sh
crlf2.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
$ sed -i 's/\r$//' crlf2.sh     # CRLF を LF に変換(dos2unix でも可)

VS Code なら右下の「CRLF」をクリックして「LF」に切り替え、Git なら .gitattributes に *.sh text eol=lf と書いておくと再発を防げます。

shellcheck で書き方のミスを見つける

ShellCheck は、シェルスクリプトのよくあるミスを実行前に指摘してくれる静的解析ツールです。 sudo apt install shellcheck や brew install shellcheck、Python環境なら pip install shellcheck-py で入ります。VS Code には拡張機能もあります。

#!/bin/bash
dir=$1
rm -rf $dir/tmp/*
cd $dir
for f in $(ls *.log); do
  echo "処理中: $f"
done
count=0
cat list.txt | while read line; do
  count=$((count + 1))
done
if [ $count == 0 ]; then
  echo "件数: `wc -l list.txt`"
fi
$ shellcheck bad.sh

In bad.sh line 3:
rm -rf $dir/tmp/*
       ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.

In bad.sh line 4:
cd $dir
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

In bad.sh line 5:
for f in $(ls *.log); do
         ^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
              ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.

In bad.sh line 9:
cat list.txt | while read line; do
                     ^--^ SC2162 (info): read without -r will mangle backslashes.

In bad.sh line 10:
  count=$((count + 1))
  ^---^ SC2030 (info): Modification of count is local (to subshell caused by pipeline).

In bad.sh line 13:
  echo "件数: `wc -l list.txt`"
            ^--------------^ SC2006 (style): Use $(...) notation instead of legacy backticks `...`.

(出力の一部を抜粋。実際には SC2034、SC2031 と修正案 Did you mean: も表示されます。)

このスクリプトを、ファイル a.log・b c.log・list.txt(2行)があるディレクトリで実行すると、ファイル名が分割され、行数も0のままになりました。

処理中: a.log
処理中: b
処理中: c.log
件数: 2 list.txt
コード 指摘内容 直し方
SC2086 変数をクォートしていない "$dir"
SC2164 cd の失敗を考慮していない cd "$dir" || exit 1
SC2045 ls の出力をループしている for f in ./*.log
SC2035 - で始まる名前がオプション扱いされうる ./*.log
SC2162 read に -r がない read -r
SC2030 / SC2031 パイプのサブシェルで変数を変更 done < list.txt
SC2006 バッククォート $(...)

直した後のスクリプトは、ShellCheck の指摘がなくなり、正しく動きます。

#!/bin/bash
set -euo pipefail
dir="${1:?使い方: good.sh ディレクトリ}"
rm -rf -- "${dir:?}"/tmp/*
cd "$dir" || exit 1
for f in ./*.log; do
  [[ -e "$f" ]] || continue
  echo "処理中: $f"
done
count=0
while IFS= read -r _; do
  count=$((count + 1))
done < list.txt
if [[ $count -eq 0 ]]; then
  echo "件数: $(wc -l < list.txt)"
fi
echo "行数: $count"
$ shellcheck good.sh && echo "shellcheck: 指摘なし"
shellcheck: 指摘なし
$ bash good.sh d
処理中: ./a.log
処理中: ./b c.log
行数: 2
$ bash good.sh
good.sh: line 3: 1: 使い方: good.sh ディレクトリ

rm -rf に変数を使うときは "${dir:?}" にしておくと、変数が空のときに rm -rf /tmp/* のような意図しないパスになる前にエラーで止まります。

bash と sh(dash)の違い

Ubuntu / Debian の /bin/sh は dash という軽量なシェルで、bash 独自の機能は使えません。 同じコードを両方で実行した結果です(bash 5.2.21 / dash 0.5.12)。

機能 コード bash dash
[[ ]] [[ abc == a* ]] && echo match match [[: not found
配列 arr=(a b c); echo ${arr[1]} b Syntax error: "(" unexpected
ブレース展開 echo {1..3} 1 2 3 {1..3}
べき乗 echo $((2**10)) 1024 arithmetic expression: expecting primary
大文字変換 ${x^^} ABC Bad substitution
部分文字列 ${x:1:3} bcd Bad substitution
置換 ${x//./-} a-b-c Bad substitution
echo -e echo -e "a\tb" a(タブ)b -e a(タブ)b
function キーワード function f { ...; } 動く Syntax error
ヒアストリング cat <<< "x" x Syntax error: redirection unexpected
pipefail set -o pipefail 動く Illegal option -o pipefail
(( )) ((x++)) 動く x++: not found
source source file 動く source: not found(. は動く)
test == test "a" == "a" 動く unexpected operator(= は動く)
共通で動く local、${#x}、${x%c}、printf、. 動く 動く

bash 用のスクリプトを sh で実行すると、次のように途中から動きが変わります。

$ sh ctrl.sh
ctrl.sh: 3: [[: not found
ctrl.sh: 5: [[: not found
if: 3より小さい
case: CSVファイル

[[ が見つからないため条件が偽になり、if: 3より大きい のはずが 3より小さい と表示されました。 bash の機能を使うなら shebang を bash にして ./script.sh か bash script.sh で実行し、どの環境でも動かしたい(Alpine の最小コンテナなど)なら POSIX の書き方([ ]、printf、.)に寄せます。文字列の表示は echo -e より printf を使うと、シェルによる違いが出にくくなります。

補足として、macOS の標準シェルは zsh で、付属の bash は 3.2 系のため、連想配列(declare -A)や ${x^^} は使えません(この環境では未検証)。Homebrew で新しい bash を入れるか、#!/usr/bin/env bash でPATH上の bash を使うようにします。bash の最新版は 5.3(2025年7月公開)で、この記事の例は 5.2 で確認しています。

よくある質問

Q. #!/bin/bash と #!/usr/bin/env bash はどちらがよいですか? A. どちらも一般的です。/usr/bin/env bash は PATH 上の bash を探すため、bash の場所が環境で異なる場合(macOS で Homebrew の bash を使うなど)に移植しやすくなります。サーバー専用で場所が決まっているなら #!/bin/bash でも問題ありません。

Q. set -euo pipefail を付けると、grep で見つからないだけで止まってしまいます。 A. grep は「見つからない」を終了コード1で返すためです。見つからなくても続けたい箇所は grep ... || true とするか、if grep -q ...; then のように条件として使います。

Q. シェルスクリプトとPythonはどう使い分ければよいですか? A. コマンドを順に実行する・ファイルを移動する・既存ツールをつなぐ処理はシェルスクリプト、データ構造や計算、例外処理が多い処理はPythonが向いています。シェルスクリプトが100行を超えたり、配列や連想配列を多用し始めたりしたら、Pythonへの移行を検討する目安になります。

Q. デバッグはどうすればよいですか? A. bash -x script.sh で実行すると、実行する各コマンドが展開後の値つきで表示されます。スクリプトの一部だけ見たいときは、その範囲を set -x と set +x で囲みます。

まとめ

  • 1行目に #!/usr/bin/env bash、2行目に set -euo pipefail から書き始めます。
  • 変数は "$var"、全引数は "$@" とダブルクォートで囲むのが基本です。
  • bash 専用なら条件は [[ ]]、sh でも動かすなら [ ] と必ずクォートします。
  • trap cleanup EXIT と mktemp で、一時ファイルを確実に片付けます。
  • set -e は条件式や local x=$(...) では止まらないので、代入を分けて書きます。
  • パイプの先の while はサブシェルなので、done < file やプロセス置換を使います。
  • 書いたら shellcheck、Windowsで編集したら 改行コードを LF に。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「シェルスクリプトの書き方|基本構文の早見表と安全なテンプレート」

株式会社RJCでは、繰り返しの作業を安全なスクリプトで自動化する工夫を、プロジェクトの中で仲間と教え合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?