第1回と第2回で書いたコードは、自分の環境で自分が実行する分には動きます。この回で扱うのは、それを他人に渡したり、タスクスケジューラに登録したりしたときに壊れる部分です。
| これまでのコード | 何が起きるか | 扱う章 |
|---|---|---|
| 日本語のメッセージを出している | 呼び出し元のコードページ次第で化ける | 2章 |
%DATE:/=% で日付を作っている | 地域設定が違う PC で壊れる | 3章 |
| 相対パスを使っている | 呼び出され方でカレントが変わり、失敗する | 4章 |
| 失敗の検知がばらばら | 書き忘れた行が黙って通過する | 5章 |
| 実行の記録が残らない | 無人実行で何が起きたか追えない | 6章 |
| 引数を間違えたときの案内がない | 渡した相手が使い方を推測するしかない | 7章 |
各章で1つずつ問題を潰し、最後にそのすべてを組み込んだテンプレートにまとめます。テンプレートは templates\ に置いてあるので、次に書くバッチはそれをコピーして始められます。
日本語が化けるかどうかは、ファイルの文字コードと実行時のコンソールのコードページの組み合わせで決まります。ファイルを正しく保存しただけでは足りません。
| ファイルの文字コード | コンソールのコードページ | 結果 |
|---|---|---|
| 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時台だけファイル名が壊れる、といった時間帯依存のバグになります。
いずれも 20260825_004102 形式のタイムスタンプを作る例です。
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回だけ取得して変数に入れます。
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% をそのまま使うとファイル名やパスが壊れます。
%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回で見たとおり、失敗しても次の行は実行されます。止めたい箇所には明示的に書く必要があります。
| 書き方 | 意味 | 使う場面 |
|---|---|---|
コマンド || 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 move | 0 が成功、1 が失敗 | || exit /b 1 でよい |
robocopy | 1〜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%" | 定型受け取るときに外し、使うときに囲む |
この定型にしておけば、呼び出し側が囲んでいても囲んでいなくても同じコードで扱えます。値に引用符を含めないのが、混乱を避ける唯一の方法です。
templates\ に用途別に3つ置いてあります。用途が違うと必要な定型も違うため、1つに全部詰め込んでいません。
| ファイル | 用途 | 特徴 |
|---|---|---|
tool.cmd | 引数を取るツール | 引数チェック、ヘルプ表示、:finish での後始末 |
launcher.cmd | ps1 や 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 の中を埋めるところから始められます。
| # | 確認すること | 理由 |
|---|---|---|
| 1 | SJIS + CRLF で保存されているか | UTF-8 だと日本語が化けて構文エラーになる |
| 2 | 先頭に pushd "%~dp0" があるか | タスクスケジューラや別フォルダからの実行で失敗する |
| 3 | setlocal があるか | 呼び出し元の環境変数を汚染する |
| 4 | exit に /b が付いているか | 呼び出し元のコンソールまで閉じる |
| 5 | パスがすべて " で囲まれているか | 空白を含むパスで失敗する |
| 6 | 日時の取得が地域設定に依存していないか | 他の PC で壊れる |
| 7 | 失敗時に exit /b 1 を返しているか | 呼び出し元が成功と誤認する |
| 8 | pause の有無が用途と合っているか | 無人実行で永久に止まる/エラーが読めない |
| 9 | 空白を含むパスで実際に試したか | C:\Program Files\... は必ず試す価値がある |
| 10 | ログが増え続けないか | ディスクを埋める |
| 状況 | 対策 |
|---|---|
| 共有フォルダに置く | cd ではなく pushd。popd を忘れるとドライブ割り当てが残る |
| 管理者権限が必要 | 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回のバックアップスクリプトを配布可能な形に仕上げた最終版 |
次のどれかに当てはまったら、バッチをやめて別の道具を使う判断をします。
| 条件 | なぜ限界か |
|---|---|
| 配列や連想配列が必要 | データ構造が存在しない。変数名に連番を埋め込む擬似実装しかできず、すぐ破綻する |
| JSON や XML を扱う | パーサがない。文字列操作で書くと、値に記号が入った瞬間に壊れる |
| エラー処理が数階層になる | 例外がないため、階層ごとに終了コードを手で伝播させることになる |
| 文字列処理が主目的 | 正規表現がない。findstr で代用すると可読性が失われる |
第2回のデモで「拡張子ごとに変数を用意する」形になった時点が、まさにこの兆候でした。変数名を並べ始めたら限界が近いという感覚を持っておくと判断しやすくなります。
逆にバッチが向いているのは、数十行で終わる決まった手順を、環境構築なしで実行したいときです。ランチャー、定型的なファイル操作、他のツールの呼び出し。この範囲に収めるかぎり、バッチは今でも最も手軽な選択肢です。
| 回 | 核心 |
|---|---|
| 第1回 | バッチは1行ずつ置換してから実行する。置換結果はもう一度コマンドとして読み直される |
| 第2回 | 括弧ブロックは1つの論理行。入る前に一括展開されるので、中では !VAR! を使う |
| 第3回 | 環境(文字コード・カレント・地域設定)を固定しないと他人の PC で壊れる |
3回を通して繰り返し出てきたのは、「バッチには暗黙の前提が多く、その前提は環境によって変わる」という一点です。テンプレートの冒頭数行は、そのすべてを明示的に固定するためにあります。
| 資料 | 使う場面 |
|---|---|
| 逆引き参照 | 「これをやりたい」から書き方を引く。他の言語の構文からも引ける |
| 記法早見表 | 記法だけ確認したいとき |
| 落とし穴カタログ | 動かないとき、症状から原因を引く |
templates\ | 新しくバッチを書き始めるとき |