.bat と .cmd.bat と .cmdより良い言語がいくらでもある2026年に、あえてバッチを学ぶ理由は1つだけです。Windows にはバッチが最初から入っているからです。
この勉強会は「バッチで何でも書けるようになる」ことを目指しません。読めることと、数十行までなら自分で書けることを目標にします。
.bat と .cmd はどちらを使うか最初に決着させます。新しく作るなら .cmd、既存のファイルに合わせるならそれに従う。これだけ覚えれば十分です。
実務上の差は1つだけあります。set や path のような一部の内部コマンドを実行したとき、.cmd は終了コードを 0 にリセットしますが、.bat はリセットしません。
@echo off
call :fail
echo before set: %errorlevel%
set FOO=bar
echo after set: %errorlevel%
goto :eof
:fail
exit /b 1
同じ内容を拡張子だけ変えて実行した結果です。
| 拡張子 | 出力 |
|---|---|
.bat | |
.cmd | |
.bat では失敗の記録が残り続け、.cmd では set を通過した時点で消えます。エラー処理を書くときに .cmd のほうが挙動が素直なので、新規は .cmd を選びます。
これ以外の違いは実質ありません。「.bat は MS-DOS 時代の、.cmd は Windows NT 以降の拡張子」という歴史的な経緯はありますが、現在の cmd.exe はどちらも同じように実行します。ネット上で見かける「.cmd のほうが高機能」という説明は正確ではありません。
JavaScript / Java / C# を知っている人向けに、対応表を先に置きます。重要なのは右端の列です。似ているところより、似ていないところのほうが後で問題になります。
| やりたいこと | JS | Java / C# | バッチ | 対応が破綻する点 |
|---|---|---|---|---|
| 変数に代入 | let n = 5; |
int n = 5; |
set n=5 |
5 は数値ではなく文字列。set /a を使ったときだけ数値として扱われる |
| 変数を読む | n |
n |
%n% |
読むのは実行時ではなく行の解析時。未定義なら空文字ではなく %n% という文字列がそのまま残る |
| スコープ | ブロック / 関数 | ブロック / クラス | setlocal |
ブロックスコープが存在しない。setlocal を書かないとプロセスの環境変数を汚染する |
| エラー通知 | throw |
例外 | 終了コード | 例外がないので伝播もしない。呼び出しごとに自分で確認しないと処理が続行する |
| コメント | // |
// |
rem / :: |
:: は本来ラベル。for や if の括弧内では構文エラーになる |
| 配列 | [1,2,3] |
List |
— | 存在しない。変数名に連番を埋め込んで模倣するしかない |
この章がこの回の中心です。ここを理解すると、後で出てくる「よく分からない例外規則」の大半が説明できます。
JavaScript や Java の処理系は、ソース全体を読んで構文木を作り、それを実行します。バッチは違います。cmd.exe はファイルを1行読んで、文字列を置換して、コマンドを起動する。これを繰り返すだけです。
「変数を読む」が「その位置に値の文字列を貼り込む」であるため、次のようなことが起きます。
@echo off
echo [%UNDEFINED_VAR%]
[%UNDEFINED_VAR%]
JavaScript なら undefined、Java ならコンパイルエラーですが、バッチは置換できないので元の文字列を残します。if "%A%"=="x" で比較が成立しないときは、まずこれを疑います。
これがこの回で一番重要な帰結です。置換は文字列を貼り込むだけなので、貼り込んだ値の中にある記号も、コマンドの記号として解釈されます。
@echo off
set MSG=Hello & echo あとがき
echo %MSG%
まず set の行が壊れています。& はコマンドの区切りなので、この行は「MSG に Hello を代入」と「echo あとがき を実行」の2つのコマンドとして扱われます。
代入だけなら set "名前=値" の形で守れます。
@echo off
setlocal enabledelayedexpansion
set "MSG=Hello & World"
echo A: %MSG%
echo B: !MSG!
echo C: "%MSG%"
A: Hello
'World' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。
B: Hello & World
C: "Hello & World"
変数には Hello & World が正しく入っています。それでも A は壊れます。echo %MSG% が置換されて echo Hello & World という行になり、その行がコマンドとして読み直されるからです。& 以降は別のコマンドとして実行され、World というコマンドを探しに行きます。
B の !MSG! は正しく表示されます。遅延展開は実行の直前に値を差し込み、その結果を読み直しません。C のように引用符で囲む方法もありますが、引用符自体も表示されてしまいます。
set "名前=値" は代入を守るだけで、読み出しは守りません。記号を含む値を扱うときは !VAR! で読みます。第2回で扱う遅延展開は「ループで値が更新されない問題」の対策として説明されることが多いのですが、実はこの再解析を止める役割のほうが本質的です。
rem の行でも解析は行われる次の1行だけでバッチが停止します。コメントなのに、です。
rem 引数と %~ 修飾子の確認
バッチ パラメーターの置き換え中で、パス演算子の次の使用方法は無効です:
%~ 修飾子の確認
%~ の直後は修飾子(f d n など)でなければならず、空白が来ると無効と判断されます。rem はその行を実行しないだけで、行を読み込んで置換する処理はその前に走っています。
コメントの中でも % は特別な文字です。日本語の説明を書くときに %~ や %1 のような表記を含めると、コメントが原因でバッチが動かなくなります。
% はエスケープが必要になるecho 進捗: 50%
echo 進捗: 50%%
進捗: 50
進捗: 50%
バッチファイル内では %% と書きます。コマンドプロンプトに直接打つときは % 1つで済むため、プロンプトで試したコードを貼るとここだけ崩れます。
| 書き方 | 意味 | 備考 |
|---|---|---|
@echo off | 実行するコマンド自体の表示を止める | 1行目に書く。先頭の @ はこの行自身を隠すため |
rem コメント | コメント | 括弧の中でも使える(:: は使えない)。ただしコメント内の % も解析される |
echo 文字列 | 表示 | echo.(ピリオド付き)で空行 |
pause | キー入力を待つ | ダブルクリック実行で結果を読ませたいときに末尾へ |
@echo off を外すとデバッグできます。実行される1行1行が展開後の姿で表示されるため、「変数がどう置換されたか」が目で見えます。動かないバッチを調べるときの最初の手段です。
set "名前=値" の形で書く| 書き方 | 結果 |
|---|---|
set NAME=太郎 | 可動くが、値に & などを含められない |
set NAME = 太郎 | 不可変数名が NAME 、値が 太郎 になる |
set "NAME=太郎" | 推奨これを定型にする |
set "NAME=" | 変数を削除する(空文字の代入ではない) |
= の前後に空白を入れないこと、そして囲むのは値だけでなく「名前=値」の全体であることが要点です。set NAME="太郎" と書くと、引用符が値の一部として保存されてしまいます。
@echo off
set "N=5"
set "M=3"
echo %N% + %M% は %N%%M%
5 + 3 は 53
数値として計算するには set /a を使います。
@echo off
set /a SUM=5+3
set /a DIV=7/2
set /a MOD=7%%2
echo %SUM% %DIV% %MOD%
8 3 1
整数のみで、小数は扱えません(7/2 は 3)。剰余の % はバッチ内では %% と書きます。
set /a の中では変数に % が不要です。set /a COUNT=COUNT+1 と書けます(set /a COUNT=%COUNT%+1 でも動きます)。set /a だけが変数名を直接解釈できる特別な文脈です。
setlocal しかないset はプロセスの環境変数を書き換えます。ブロックスコープも関数スコープもありません。
@echo off
rem これを実行すると、実行後のコンソールでも PATH が変わったまま
set "PATH=C:\mytools"
setlocal を宣言すると、それ以降の変更はスクリプト終了時に破棄されます。
@echo off
setlocal
set "PATH=C:\mytools;%PATH%"
rem …処理…
rem スクリプトが終わると PATH は元に戻る
実務では次の形が標準です。第2回で扱う !VAR! を有効にするオプションを最初から付けておきます。
@echo off
setlocal enabledelayedexpansion
| コマンド | 内容 |
|---|---|
set | すべての環境変数を一覧表示 |
set PA | PA で始まる変数だけを表示(PATH など) |
echo %VAR% | 1つの値を確認 |
if defined VAR | 定義されているかを判定(% で囲まない) |
よく使う組み込み変数です。
| 変数 | 内容 | 注意 |
|---|---|---|
%CD% | カレントディレクトリ | バッチの場所とは別物 |
%DATE% | 日付 | 書式が地域設定に依存する(日本語環境では 2026/08/25) |
%TIME% | 時刻 | 時が1桁だと先頭が空白になる( 0:21:51.61) |
%USERNAME% | ユーザー名 | — |
%TEMP% | 一時フォルダ | — |
%ERRORLEVEL% | 直前の終了コード | 次章で扱う |
%TIME% をファイル名に使うと壊れます。午前0時台は 0:21:51.61 と先頭に空白が入り、: と . はファイル名に使えません。日時をファイル名にする方法は第3回で扱います。
これがバッチで最も事故を生む性質です。コマンドが失敗しても、次の行はそのまま実行されます。
@echo off
copy "C:\notexist\a.txt" "D:\backup\"
del "C:\notexist\a.txt"
echo 完了しました
コピーが失敗してエラーメッセージが出ても、del が実行され、完了しました まで表示されます。JavaScript なら throw で止まり、Java なら例外が呼び出し元へ伝播するところですが、バッチは失敗を数値で記録するだけです。
ERRORLEVEL の読み方| 書き方 | 意味 |
|---|---|
%errorlevel% | 直前のコマンドの終了コード。0 が成功 |
if errorlevel 1 | 終了コードが 1以上なら真(「1と等しい」ではない) |
if "%errorlevel%"=="2" | 特定の値だけを見る |
if errorlevel 1 が「1以上」という点は繰り返し引っかかるところです。終了コードが 5 でも真になります。
&& と ||JavaScript の短絡評価と同じ記号ですが、判定するのは真偽値ではなく終了コードです。
| 書き方 | 意味 |
|---|---|
コマンド && 次 | 成功(終了コード 0)したときだけ次を実行 |
コマンド || 次 | 失敗(0以外)したときだけ次を実行 |
コマンド1 & コマンド2 | 結果に関係なく続けて実行 |
@echo off
rem 失敗したらメッセージを出して終了する定型
copy "%SRC%" "%DST%" || (
echo コピーに失敗しました
exit /b 1
)
echo コピーしました
| 書き方 | 効果 | 使い分け |
|---|---|---|
exit /b 0 | このバッチを成功として終える | ほぼ常にこちら |
exit /b 1 | このバッチを失敗として終える | |
exit 1 | cmd.exe そのものを終了する | 呼び出し元のコンソールまで閉じる。事故のもと |
goto :eof | ファイル末尾へ飛ぶ(終了コードは変えない) | サブルーチンからの復帰 |
/b を付け忘れると開いていたコマンドプロンプトが閉じます。exit は「cmd.exe を終了する」コマンドで、/b(batch)を付けたときだけ「このバッチだけ終了する」に変わります。他のバッチから呼ばれる可能性があるなら必ず /b を付けます。
| コマンド | 終了コードの意味 |
|---|---|
copy / del / move | 0 が成功、1 が失敗。素直 |
robocopy | 1〜7 も成功。8 以上が失敗。|| exit /b をそのまま付けると誤判定する |
find / findstr | 該当なしで 1。「見つからなかった」は失敗として扱われる |
where | 見つからないと 1。コマンドの存在確認に使える |
知らないコマンドを使うときは コマンド /? で終了コードの説明を読んでから、エラー処理を書きます。
| 書き方 | 内容 |
|---|---|
%0 | 実行されたバッチ自身のパス |
%1 〜 %9 | 1〜9番目の引数 |
%* | すべての引数をまとめた文字列 |
%~1 | 1番目の引数から前後の引用符を外したもの |
引数は %~1 で受け取り、使うときに改めて引用符で囲むのが定型です。こうすれば呼び出し側が囲んでいても囲んでいなくても同じ形で扱えます。
@echo off
setlocal
if "%~1"=="" (
echo 使い方: %~nx0 ^<対象ファイル^>
exit /b 1
)
set "TARGET=%~1"
if not exist "%TARGET%" (
echo 見つかりません: %TARGET%
exit /b 1
)
echo 対象: %TARGET%
%~ 修飾子でパスを分解する%1 に C:\work\data\input.txt が渡された場合の展開結果です。
| 書き方 | 意味 | 結果 |
|---|---|---|
%~f1 | フルパス | C:\work\data\input.txt |
%~dp1 | ドライブ+パス | C:\work\data\ |
%~n1 | 拡張子なしのファイル名 | input |
%~x1 | 拡張子 | .txt |
%~nx1 | ファイル名+拡張子 | input.txt |
%~t1 | 更新日時 | 2026/08/25 10:30 |
%~z1 | サイズ(バイト) | 1024 |
他の言語なら path.basename() や Path.GetFileName() を呼ぶところですが、バッチではこの修飾子が同じ役割を果たします。
%~dp0 — 自分がどこにいるかこの修飾子を %0(自分自身)に適用すると、バッチが置かれているフォルダが得られます。これが実務で最も使う記法です。
@echo off
echo バッチの場所: %~dp0
echo カレント : %CD%
この2つは別物です。相対パスの起点はカレントディレクトリであり、バッチの場所ではありません。ダブルクリックで実行すると一致しますが、タスクスケジューラや別フォルダから呼ばれると一致しません。
@echo off
rem 同じフォルダの config.ini を確実に読む
for /f "delims=" %%l in ("%~dp0config.ini") do echo %%l
rem あるいはカレントを移動してしまう
pushd "%~dp0" || exit /b 1
rem …処理…
popd
%~dp0 の末尾には \ が付いています。連結は "%~dp0config.ini" です。"%~dp0\config.ini" と書くと \ が二重になります。また移動には cd ではなく pushd を使ってください。cd は共有フォルダ(\\server\share\...)に移動できません。
ここまでの内容だけで、実際に使えるものを1つ作ります。指定したファイルを、日付付きの名前でバックアップフォルダにコピーするバッチです。
この題材は第2回・第3回でも育てていきます。段階を追って、なぜその1行が必要になるかを見ます。
@echo off
copy "C:\work\data.txt" "C:\backup\data.txt"
echo 完了
動きますが、対象を変えるたびにファイルを書き換える必要があります。
@echo off
setlocal
set "SRC=C:\work\data.txt"
set "DSTDIR=C:\backup"
copy "%SRC%" "%DSTDIR%\"
echo 完了
設定が先頭に集まり、読みやすくなりました。setlocal があるので、実行後にコンソールの環境変数が汚れることもありません。
@echo off
setlocal
set "SRC=C:\work\data.txt"
set "DSTDIR=C:\backup"
rem %DATE% は 2026/08/25 形式。/ を削除して 20260825 にする
set "TODAY=%DATE:/=%"
copy "%SRC%" "%DSTDIR%\%TODAY%_data.txt"
echo 完了
%DATE:/=% は「/ を空文字に置換する」という記法です(第2回で詳しく扱います)。
要注意この方法は地域設定に依存します。区切りが - の環境では効きません。環境に依存しない日付の取得方法は第3回で扱います。
@echo off
setlocal
set "SRC=C:\work\data.txt"
set "DSTDIR=C:\backup"
set "TODAY=%DATE:/=%"
rem コピー元がなければ、その時点でやめる
if not exist "%SRC%" (
echo [エラー] コピー元が見つかりません: %SRC%
exit /b 1
)
rem 保存先フォルダがなければ作る
if not exist "%DSTDIR%\" md "%DSTDIR%"
copy "%SRC%" "%DSTDIR%\%TODAY%_%~nx1" >nul || (
echo [エラー] コピーに失敗しました
exit /b 1
)
echo 完了: %DSTDIR%\%TODAY%_data.txt
exit /b 0
エラー処理が入って、はじめて「他人に渡せる」方向に一歩進みました。>nul は copy の「1 個のファイルをコピーしました。」という出力を捨てるためです。
@echo off
setlocal
rem --- 引数チェック ---
if "%~1"=="" (
echo 使い方: %~nx0 ^<バックアップするファイル^>
exit /b 1
)
set "SRC=%~1"
set "DSTDIR=%~dp0backup"
set "TODAY=%DATE:/=%"
if not exist "%SRC%" (
echo [エラー] コピー元が見つかりません: %SRC%
exit /b 1
)
if not exist "%DSTDIR%\" md "%DSTDIR%"
rem %~nx1 で「ファイル名+拡張子」を取り出して名前を組み立てる
set "DST=%DSTDIR%\%TODAY%_%~nx1"
copy "%SRC%" "%DST%" >nul || (
echo [エラー] コピーに失敗しました
exit /b 1
)
echo 完了: %DST%
exit /b 0
これで、どのファイルにも使える汎用のバックアップツールになりました。%~dp0backup としたので、保存先はバッチと同じ場所の backup フォルダになり、どこから実行しても同じ場所に保存されます。
この20行に、この回の内容がすべて入っています。setlocal(スコープ)、set "…=…"(代入)、%~1(引数)、%~nx1(パス分解)、%~dp0(自分の場所)、if exist(確認)、||(失敗時の処理)、exit /b(終了コード)。読み返して、それぞれがなぜ必要かを説明できれば、この回の目標は達成です。
サンプルは samples\01\ に置いてあります。
| ファイル | 内容 |
|---|---|
01-hello.cmd | @echo off と echo の確認。@echo off を消すと何が起きるか |
02-expand.cmd | 置換であることの確認。未定義変数 / set の書き方の違い / %% / 記号を含む値の再解析 |
03-errorlevel.cmd | 終了コードの確認。&& と || の動作 |
04-args.cmd | 引数と %~ 修飾子の一覧表示 |
05-backup.cmd | 上の Step 5(完成版) |
| # | 覚えること | 理由 |
|---|---|---|
| 1 | バッチは1行ずつ「置換してから実行」する | 後で出てくる罠のほぼすべてがここから説明できる |
| 2 | 値はすべて文字列。計算は set /a | 型がないので、比較も文字列比較になる |
| 3 | set "名前=値" の形で書く | 空白混入と記号の誤解釈を同時に防げる |
| 4 | 先頭に setlocal enabledelayedexpansion | 環境を汚さない。第2回の !VAR! の前提になる |
| 5 | 例外はない。|| で失敗を捕まえる | 書き忘れた行は黙って通過する |
| 6 | exit ではなく exit /b | 付け忘れるとコンソールごと閉じる |
| 7 | 自分の場所は %~dp0、カレントは %CD% | この2つは一致しない |
次回は制御構文です。if と for を扱いますが、中心になるのはこの回の「置換」が引き起こす問題です。
@echo off
setlocal
set count=0
for %%f in (*.txt) do (
set /a count=count+1
echo 現在: %count%
)
echo 合計: %count%
このコードは、.txt が3つあるフォルダで実行すると 現在: 0 を3回表示し、最後に 合計: 3 と表示します。なぜそうなるのかを、この回の実行モデルの図で説明できるようにするのが第2回の前半です。
詳しくは落とし穴カタログを参照してください。