第3回 実務で使えるバッチを書く

全3回シリーズ(3/3)  |  この回のゴール: 他人に渡して壊れないバッチの条件を知り、テンプレートから書き始められる

📅 作成: 2026-08-25 / 更新: 2026-08-25

目次

  1. この回の位置づけ — 「動く」から「渡せる」へ
  2. 文字コードとコードページ
  3. 日時を確実に取得する
  4. 実行環境を固定する
  5. エラー処理の型
  6. ログ出力
  7. 引数処理とヘルプ表示
  8. テンプレートと配布時の注意
  9. バッチの限界とシリーズのまとめ

この回の位置づけ — 「動く」から「渡せる」へ

第1回と第2回で書いたコードは、自分の環境で自分が実行する分には動きます。この回で扱うのは、それを他人に渡したり、タスクスケジューラに登録したりしたときに壊れる部分です。

いま残っている問題

これまでのコード何が起きるか扱う章
日本語のメッセージを出している呼び出し元のコードページ次第で化ける2章
%DATE:/=% で日付を作っている地域設定が違う PC で壊れる3章
相対パスを使っている呼び出され方でカレントが変わり、失敗する4章
失敗の検知がばらばら書き忘れた行が黙って通過する5章
実行の記録が残らない無人実行で何が起きたか追えない6章
引数を間違えたときの案内がない渡した相手が使い方を推測するしかない7章

この回の進み方

各章で1つずつ問題を潰し、最後にそのすべてを組み込んだテンプレートにまとめます。テンプレートは templates\ に置いてあるので、次に書くバッチはそれをコピーして始められます。

文字コードとコードページ

2つの設定が噛み合う必要がある

日本語が化けるかどうかは、ファイルの文字コード実行時のコンソールのコードページの組み合わせで決まります。ファイルを正しく保存しただけでは足りません。

ファイルの文字コードコンソールのコードページ結果
SJIS (CP932)932正常日本語 Windows の既定の組み合わせ
SJIS (CP932)65001 (UTF-8)化けるファイルは正しいのに化ける
UTF-8 (BOMなし)932化ける最も多いパターン
UTF-8 (BOMなし)65001条件付きCP932 で出力する外部コマンドの結果が化ける
UTF-8 (BOM付き)どちらでもエラーBOM が1行目のコマンドの一部として解釈される

安定して動く組み合わせは1行目だけです。バッチファイルは SJIS(CP932)+ CRLF で保存するのが結論になります。

コードページは継承される

コードページは親プロセスから受け継がれます。エクスプローラからダブルクリックすればシステムの既定(日本語 Windows なら 932)ですが、UTF-8 に設定されたターミナルや CI から呼ばれると 65001 のまま実行されます。

「自分の環境では動くのに、CI やタスクランナー経由だと化ける」という現象はこれが原因です。現在値は chcp で確認できます。

C:\>chcp
現在のコード ページ: 932

化けると「表示が崩れる」だけでは済まない

これが文字コードを軽視できない理由です。化けた結果のバイト列に &| に相当するものが現れると、構文エラーや意図しないコマンド実行になります。日本語のコメント行1つでバッチ全体が動かなくなることもあります。

呼び出し元を制御できない場合の方針

方針内容評価
メッセージを英数字だけにする 画面出力とログを ASCII のみで書き、日本語は rem のコメントだけに使う 確実コードページに一切依存しない。ログを他のツールで処理する場合にも有利
冒頭で chcp 932 する 想定するコードページに揃えてから処理する 条件付き下記の制約がある

chcp で揃える場合、終了時に元へ戻したくなりますが、chcp の出力書式(現在のコード ページ: 932 / Active code page: 932.)は OS の表示言語で変わるため、元の値を取り出す処理はロケール依存になります。setlocal の対象外なので自動で戻ることもありません。無人実行の専用バッチのように「このコンソールは自分専用」と言える場合に限って使います。

エンコーディングの変換は1回だけ行ってください。SJIS のファイルを UTF-8 として読み直して保存すると、不正バイトが ? に置換されて日本語が復元不能になります。変換する前に、そのファイルが現在何で保存されているかを必ず確認してください。

エディタの設定

エディタ設定箇所
VS Code右下のエンコーディング表示 → 「エンコード付きで保存」→ Japanese (Shift JIS)。改行も右下で CRLF にする
メモ帳「名前を付けて保存」→ エンコード ANSI
サクラエディタ等文字コード SJIS、改行 CRLF

UTF-8 が必要な処理がある場合は、バッチ側は SJIS のまま保ち、その処理だけを PowerShell スクリプトに切り出して powershell -File で呼ぶほうが安全です。

日時を確実に取得する

%DATE%%TIME% の問題

変数この環境での値問題
%DATE%2026/08/25書式が地域設定に依存する。曜日が付く環境もある
%TIME% 0:41:02.48時が1桁だと先頭が空白:. はファイル名に使えない

この2つをそのままファイル名に使うと、午前0時台だけファイル名が壊れる、といった時間帯依存のバグになります。

3つの方法を比較する

いずれも 20260825_004102 形式のタイムスタンプを作る例です。

方法1: PowerShell を呼ぶ 推奨

for /f "usebackq delims=" %%d in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd_HHmmss"`) do set "STAMP=%%d"
echo %STAMP%
20260825_004102

書式を -Format で明示するため、地域設定に一切依存しません。読んで意図が分かるのも利点です。欠点は PowerShell の起動コストで、1回あたり数百ミリ秒かかります。ループの中で毎回呼ぶのではなく、スクリプトの先頭で1回だけ取得して変数に入れます。

方法2: wmic を使う 非推奨

for /f "tokens=2 delims==" %%d in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set "LDT=%%d"
set "STAMP=%LDT:~0,8%_%LDT:~8,6%"

取得できる生の値は 20260825004102.484000+540 です。地域設定に依存しない固定書式なので、かつては定番でした。しかし wmic は非推奨となり、将来の Windows で削除される予定です。新規に書くなら方法1を選びます。

また for /f で読むと値の末尾に余分な CR が混入します。上のように部分取得(%LDT:~0,8%)で必要な桁だけ切り出せば影響を避けられますが、%LDT% をそのまま使うとファイル名やパスが壊れます。

方法3: %DATE%%TIME% を加工する

set "D=%DATE:/=%"
rem 先頭の空白を 0 に置換してから、コロンとピリオドを削除する
set "T=%TIME: =0%"
set "T=%T::=%"
set "T=%T:.=%"
set "STAMP=%D%_%T:~0,6%"

外部プロセスを起動しないので高速です。%TIME: =0%(空白を 0 に置換)が先頭空白への対策になっています。ただし地域設定に依存するため、配布するバッチには向きません。自分の PC 専用のスクリプトに限って使います。

まとめ

方法地域設定への依存速度使う場面
PowerShellなし遅い(数百 ms)配布するもの・タスクスケジューラ登録するもの
wmicなしやや遅い既存のコードの保守のみ。新規では使わない
%DATE% 加工あり速い自分専用の使い捨てスクリプト

実行環境を固定する

カレントディレクトリは信用できない

相対パスの起点は実行時のカレントディレクトリであり、バッチが置かれている場所ではありません。呼び出し方によって変わります。

呼び出し方カレントディレクトリ
エクスプローラでダブルクリックバッチのある場所(一致する)
別フォルダから path\to\x.cmd と実行実行した場所(一致しない)
タスクスケジューラ設定次第。既定では C:\Windows\System32
他のバッチから call呼び出し元のカレント

先頭で pushd する

@echo off
rem このバッチの場所へ移動する。失敗したらすぐ終了する
pushd "%~dp0" || exit /b 1

rem …処理…

popd

cd ではなく pushd を使う理由は、共有フォルダ(UNC パス)でも動くことです。cd\\server\share\... をカレントにできず、UNC パスはサポートされません。 というメッセージを出して C:\Windows に留まります。pushd は UNC パスに一時的なドライブレターを割り当てるため、そのまま移動できます。

pushd で割り当てたドライブは popd で解放されます。必ず対で書いてください。

カレントを変えたくない場合

パスを %~dp0 で明示します。

rem 同じフォルダの config.ini を読む
for /f "usebackq delims=" %%l in ("%~dp0config.ini") do echo %%l

rem 同じフォルダの別のバッチを呼ぶ
call "%~dp0sub.cmd"

%~dp0 の末尾には \ が付いているので、連結は "%~dp0config.ini" です。"%~dp0\config.ini" と書くと \ が二重になります。多くの場合は動いてしまいますが、UNC パスや一部の API で失敗します。

管理者権限が必要な場合

バッチ自身で権限を昇格させることはできません。判定して案内するのが現実的です。

rem 管理者権限があるかを確認する(net session は管理者でないと失敗する)
net session >nul 2>&1 || (
    echo [エラー] このバッチは管理者として実行してください
    echo         右クリック ^> 管理者として実行
    pause
    exit /b 1
)

ダブルクリック実行を想定するなら pause を入れます。エラーで終わるとウィンドウが即座に閉じ、メッセージを読めません。逆にタスクスケジューラから使うバッチに pause があると、入力待ちで永久に止まります。同じバッチを両方の用途で使わないのが安全です。ランチャー(pause あり)と本体(pause なし)を分けます。

エラー処理の型

例外がないので、1行ごとに書く

第1回で見たとおり、失敗しても次の行は実行されます。止めたい箇所には明示的に書く必要があります。

書き方意味使う場面
コマンド || exit /b 1失敗したら即終了最も簡潔。これが基本形
コマンド || ( … )失敗したらブロックを実行メッセージを出してから終了する
if errorlevel 1 ( … )終了コードが1以上ならブロック内で判定するとき
if not exist "…" ( … )実行前の事前確認失敗させる前に弾く

基本形

@echo off
setlocal enabledelayedexpansion
pushd "%~dp0" || exit /b 1

rem --- 事前確認: 実行する前に弾けるものは弾く ---
if not exist "%SRC%" (
    echo [エラー] コピー元が見つかりません: %SRC%
    exit /b 1
)

rem --- 各コマンドに失敗時の処理を付ける ---
md "%DSTDIR%" 2>nul
copy "%SRC%" "%DSTDIR%\" >nul || (
    echo [エラー] コピーに失敗しました
    exit /b 1
)

echo 完了しました
exit /b 0

md "%DSTDIR%" 2>nul は「すでにあってもエラーにしない」書き方です。md は既存フォルダに対して失敗しますが、この場合は失敗しても構わないので、エラー出力だけ捨てて続行します。

終了コードの意味はコマンドごとに違う

コマンド終了コードエラー処理の書き方
copy del move0 が成功、1 が失敗|| exit /b 1 でよい
robocopy1〜7 も成功、8以上が失敗if errorlevel 8 exit /b 1
find findstr該当なしで 1「見つからない」を失敗として扱うかを決める
where見つからないと 1コマンドの存在確認に使える
自作バッチ自分で決めるexit /b 0 / exit /b 1 を明示する
rem robocopy は成功でも 0 以外を返すので || では判定できない
robocopy "%SRC%" "%DST%" /e /r:1 /w:1
if errorlevel 8 (
    echo [エラー] robocopy が失敗しました
    exit /b 1
)

後始末を確実に通す

try/finally がないので、終了処理を1か所に集めて goto で合流させます。

@echo off
setlocal enabledelayedexpansion
pushd "%~dp0" || exit /b 1

set "RC=0"

if not exist "%SRC%" (
    echo [エラー] 見つかりません: %SRC%
    set "RC=1"
    goto :finish
)

call :main
set "RC=!errorlevel!"

:finish
rem --- 成功・失敗にかかわらずここを通る ---
if exist "%TMPFILE%" del "%TMPFILE%"
endlocal & set "RC=%RC%"
popd
exit /b %RC%

:main
rem 実際の処理。失敗したら exit /b で 0 以外を返す
exit /b 0

endlocal & set "RC=%RC%" は、endlocal で破棄される前に RC の値を持ち出すための定型です。この行は解析時に %RC% が展開されるため、endlocal の実行より先に値が確定します。

エラーメッセージには「何が」「どこで」を入れてください。echo エラー だけでは、渡された相手は何も調べられません。echo [エラー] コピー元が見つかりません: %SRC% のように、対象の値まで出します。バッチはデバッガが使えないので、メッセージが唯一の手がかりになります。

ログ出力

リダイレクトの順序に注意

書き方結果
> log.txt標準出力をファイルへ(毎回上書き)
>> log.txt標準出力をファイルへ(追記)
>> log.txt 2>&1正しい標準出力とエラーの両方がファイルへ
2>&1 >> log.txt誤り標準出力だけがファイルへ。エラーは画面に残る

2>&1 は「その時点で標準出力が向いている先」を意味します。先に >> log.txt でファイルへ向けてから 2>&1 と書く必要があります。

サブルーチン全体の出力をログへ回す

rem call の行にリダイレクトを付けると、:main 内のすべての出力が対象になる
call :main >> "%LOGFILE%" 2>&1
set "RC=%errorlevel%"

1行ずつ >> "%LOGFILE%" を書く必要がなくなります。無人実行するバッチではこの形が便利です。

画面とログの両方に出す

tee がないので、素直に2回書きます。

call :log "処理を開始します"
goto :eof

:log
echo [%DATE% %TIME%] %~1
echo [%DATE% %TIME%] %~1 >> "%LOGFILE%"
goto :eof

日付ごとにログを分ける

@echo off
setlocal enabledelayedexpansion
pushd "%~dp0" || exit /b 1

rem 日付を確実に取得する(3章の方法1)
for /f "usebackq delims=" %%d in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd"`) do set "TODAY=%%d"

set "LOGDIR=%~dp0log"
set "LOGFILE=!LOGDIR!\job_!TODAY!.log"
if not exist "!LOGDIR!" md "!LOGDIR!"

call :log "==== 処理を開始します ===="
call :main >> "!LOGFILE!" 2>&1
set "RC=!errorlevel!"

if "!RC!"=="0" (
    call :log "==== 正常終了しました ===="
) else (
    call :log "==== 異常終了しました 終了コード=!RC! ===="
)

endlocal & set "RC=%RC%"
popd
exit /b %RC%

:main
echo 本処理を実行中...
exit /b 0

:log
echo [%DATE% %TIME%] %~1
echo [%DATE% %TIME%] %~1 >> "!LOGFILE!"
goto :eof

ログが増え続けることを忘れないでください。日付ごとに分けるとファイルが無限に増えます。forfiles /p "%LOGDIR%" /d -30 /c "cmd /c del @path" のような削除処理を入れるか、運用として定期的に消すことを決めておきます。無人実行のバッチでディスクを埋めるのは、よくある事故です。

引数処理とヘルプ表示

引数チェックの定型

@echo off
setlocal enabledelayedexpansion

rem --- ヘルプの要求と引数なしを同じ扱いにする ---
if "%~1"=="" goto :usage
if "%~1"=="/?" goto :usage
if /i "%~1"=="-h" goto :usage
if /i "%~1"=="--help" goto :usage

set "TARGET=%~1"
set "OPTION=%~2"

if not exist "!TARGET!" (
    echo [エラー] 対象が見つかりません: !TARGET!
    exit /b 1
)

rem …処理…
exit /b 0

:usage
echo 使い方: %~nx0 ^<対象パス^> [オプション]
echo.
echo   対象パス    処理するファイルまたはフォルダ
echo   オプション  省略可
exit /b 1

%~nx0 でファイル名を出しているので、バッチをリネームしてもヘルプが正しく表示されます。<> はリダイレクト記号なので ^ でエスケープします。

オプションを解析する

本格的な引数解析が必要なら、shift でループします。

set "VERBOSE="
set "TARGET="

:parse
if "%~1"=="" goto :parse_done
if /i "%~1"=="-v" (
    set "VERBOSE=1"
) else if /i "%~1"=="-o" (
    rem 次の引数を値として取る
    set "OUTPUT=%~2"
    shift
) else (
    set "TARGET=%~1"
)
shift
goto :parse

:parse_done
if not defined TARGET (
    echo [エラー] 対象を指定してください
    exit /b 1
)

ここまで書くならバッチ以外を検討する合図です。引数解析のループは長くなり、shift の位置を1つ間違えると無限ループになります。オプションが3つ以上あるなら、PowerShell の param() のような仕組みを持つ言語のほうが確実です。

引数の引用符の扱い

書き方結果
%1渡されたまま。呼び出し側が "…" で囲んでいれば引用符が含まれる
%~1前後の引用符を外したもの
set "V=%~1""%V%"定型受け取るときに外し、使うときに囲む

この定型にしておけば、呼び出し側が囲んでいても囲んでいなくても同じコードで扱えます。値に引用符を含めないのが、混乱を避ける唯一の方法です。

テンプレートと配布時の注意

3種類のテンプレート

templates\ に用途別に3つ置いてあります。用途が違うと必要な定型も違うため、1つに全部詰め込んでいません。

ファイル用途特徴
tool.cmd引数を取るツール引数チェック、ヘルプ表示、:finish での後始末
launcher.cmdps1 や exe の起動pause あり。ダブルクリック前提
batchjob.cmd無人実行のバッチ処理日付ごとのログ、pause なし

tool.cmd の骨格

@echo off
rem --- カレントをこのファイルの場所に固定する(UNC パスでも動く pushd を使う) ---
pushd "%~dp0" || exit /b 1

rem --- 変数の変更をこのスクリプト内に閉じ、遅延展開を有効にする ---
setlocal enabledelayedexpansion

rem --- 引数チェック ---
if "%~1"=="" goto :usage
if /i "%~1"=="/?" goto :usage

set "TARGET=%~1"

if not exist "!TARGET!" (
    echo [エラー] 対象が見つかりません: !TARGET!
    set "RC=1"
    goto :finish
)

rem --- 本処理 ---
call :main
set "RC=!errorlevel!"

if "!RC!"=="0" (
    echo 完了しました。
) else (
    echo [エラー] 処理に失敗しました。終了コード: !RC!
)

:finish
rem --- 後始末。RC を endlocal の外へ持ち出してから戻る ---
endlocal & set "RC=%RC%"
popd
exit /b %RC%

:main
rem ここに処理を書く。失敗したら exit /b で 0 以外を返す
exit /b 0

:usage
echo 使い方: %~nx0 ^<対象パス^>
popd
exit /b 1

この20行が、この回で扱ったすべての対策を含んでいます。次に書くバッチはこれをコピーして :main の中を埋めるところから始められます。

配布時のチェックリスト

#確認すること理由
1SJIS + CRLF で保存されているかUTF-8 だと日本語が化けて構文エラーになる
2先頭に pushd "%~dp0" があるかタスクスケジューラや別フォルダからの実行で失敗する
3setlocal があるか呼び出し元の環境変数を汚染する
4exit/b が付いているか呼び出し元のコンソールまで閉じる
5パスがすべて " で囲まれているか空白を含むパスで失敗する
6日時の取得が地域設定に依存していないか他の PC で壊れる
7失敗時に exit /b 1 を返しているか呼び出し元が成功と誤認する
8pause の有無が用途と合っているか無人実行で永久に止まる/エラーが読めない
9空白を含むパスで実際に試したかC:\Program Files\... は必ず試す価値がある
10ログが増え続けないかディスクを埋める

その他の落とし穴

状況対策
共有フォルダに置くcd ではなく pushdpopd を忘れるとドライブ割り当てが残る
管理者権限が必要net session >nul 2>&1 || で判定して案内する。昇格はできない
長時間動くtitle でウィンドウタイトルに進捗を出す
他のバッチから呼ばれるpause を入れない。終了コードを正しく返す
パスが260文字を超えるバッチでは回避が難しい。ロボコピーや別の言語を使う

サンプルは samples\03\ にあります。

ファイル内容
01-datetime.cmd日時取得の3つの方法を並べて実行し、結果を見比べる
02-errorhandling.cmdエラー処理の型。robocopy の終了コードの扱いも含む
03-logging.cmd日付ごとのログ出力。リダイレクトの順序の違いも確認できる
04-args.cmd引数解析(shift ループ)とヘルプ表示
05-backup.cmd第1回のバックアップスクリプトを配布可能な形に仕上げた最終版

バッチの限界とシリーズのまとめ

バッチで書くべきではない4条件

次のどれかに当てはまったら、バッチをやめて別の道具を使う判断をします。

条件なぜ限界か
配列や連想配列が必要データ構造が存在しない。変数名に連番を埋め込む擬似実装しかできず、すぐ破綻する
JSON や XML を扱うパーサがない。文字列操作で書くと、値に記号が入った瞬間に壊れる
エラー処理が数階層になる例外がないため、階層ごとに終了コードを手で伝播させることになる
文字列処理が主目的正規表現がない。findstr で代用すると可読性が失われる

第2回のデモで「拡張子ごとに変数を用意する」形になった時点が、まさにこの兆候でした。変数名を並べ始めたら限界が近いという感覚を持っておくと判断しやすくなります。

逆にバッチが向いているのは、数十行で終わる決まった手順を、環境構築なしで実行したいときです。ランチャー、定型的なファイル操作、他のツールの呼び出し。この範囲に収めるかぎり、バッチは今でも最も手軽な選択肢です。

シリーズ全体のまとめ

核心
第1回バッチは1行ずつ置換してから実行する。置換結果はもう一度コマンドとして読み直される
第2回括弧ブロックは1つの論理行。入る前に一括展開されるので、中では !VAR! を使う
第3回環境(文字コード・カレント・地域設定)を固定しないと他人の PC で壊れる

3回を通して繰り返し出てきたのは、「バッチには暗黙の前提が多く、その前提は環境によって変わる」という一点です。テンプレートの冒頭数行は、そのすべてを明示的に固定するためにあります。

この先の参照先

資料使う場面
逆引き参照「これをやりたい」から書き方を引く。他の言語の構文からも引ける
記法早見表記法だけ確認したいとき
落とし穴カタログ動かないとき、症状から原因を引く
templates\新しくバッチを書き始めるとき

この回に関連する落とし穴

#症状
3日本語が化ける・実行が途中で止まる
4ダブルクリックでは動くのに別の場所から呼ぶと失敗する
5パスに空白が含まれると失敗する
6エラーが出ているのに処理が続行する
8exit したらコンソールごと閉じた
14共有フォルダ(UNC パス)でカレント移動が失敗する