第1回 バッチの実行モデル

全3回シリーズ(1/3)  |  この回のゴール: 渡された .bat を読んで何をしているか説明できる

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

目次

  1. はじめに — .bat.cmd
  2. 実行モデル — 1行が実行されるまで
  3. 変数 — すべては文字列
  4. 終了コード — 例外のない世界
  5. 引数と自分の居場所
  6. デモ — 最初のバッチを書く
  7. まとめと第2回の予告

はじめに — .bat.cmd

なぜ今バッチを学ぶのか

より良い言語がいくらでもある2026年に、あえてバッチを学ぶ理由は1つだけです。Windows にはバッチが最初から入っているからです。

この勉強会は「バッチで何でも書けるようになる」ことを目指しません。読めることと、数十行までなら自分で書けることを目標にします。

.bat.cmd はどちらを使うか

最初に決着させます。新しく作るなら .cmd、既存のファイルに合わせるならそれに従う。これだけ覚えれば十分です。

実務上の差は1つだけあります。setpath のような一部の内部コマンドを実行したとき、.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
before set: 1
after  set: 1
.cmd
before set: 1
after  set: 0

.bat では失敗の記録が残り続け、.cmd では set を通過した時点で消えます。エラー処理を書くときに .cmd のほうが挙動が素直なので、新規は .cmd を選びます。

これ以外の違いは実質ありません。.bat は MS-DOS 時代の、.cmd は Windows NT 以降の拡張子」という歴史的な経緯はありますが、現在の cmd.exe はどちらも同じように実行します。ネット上で見かける「.cmd のほうが高機能」という説明は正確ではありません。

既知の言語との対応(と、対応が破綻するところ)

JavaScript / Java / C# を知っている人向けに、対応表を先に置きます。重要なのは右端の列です。似ているところより、似ていないところのほうが後で問題になります。

やりたいことJSJava / C#バッチ対応が破綻する点
変数に代入 let n = 5; int n = 5; set n=5 5 は数値ではなく文字列。set /a を使ったときだけ数値として扱われる
変数を読む n n %n% 読むのは実行時ではなく行の解析時。未定義なら空文字ではなく %n% という文字列がそのまま残る
スコープ ブロック / 関数 ブロック / クラス setlocal ブロックスコープが存在しない。setlocal を書かないとプロセスの環境変数を汚染する
エラー通知 throw 例外 終了コード 例外がないので伝播もしない。呼び出しごとに自分で確認しないと処理が続行する
コメント // // rem / :: :: は本来ラベル。forif の括弧内では構文エラーになる
配列 [1,2,3] List 存在しない。変数名に連番を埋め込んで模倣するしかない

実行モデル — 1行が実行されるまで

この章がこの回の中心です。ここを理解すると、後で出てくる「よく分からない例外規則」の大半が説明できます。

バッチは言語ではなく、コマンドの並び

JavaScript や Java の処理系は、ソース全体を読んで構文木を作り、それを実行します。バッチは違います。cmd.exe はファイルを1行読んで、文字列を置換して、コマンドを起動する。これを繰り返すだけです。

1行を読む ファイルを閉じずに 1行ずつ逐次読む 変数を文字列に置換 %VAR% をここで展開 実行前に値が確定する コマンドを実行 内部コマンド または外部の exe 終了コードを残す ERRORLEVEL を 上書きする 2番目の「置換」は、括弧で囲まれたブロックがあればブロック全体に対して一括で先に行われる → これが第2回で扱う「遅延展開」の原因になる

置換であることの帰結

「変数を読む」が「その位置に値の文字列を貼り込む」であるため、次のようなことが起きます。

1. 未定義の変数は空にならない

@echo off
echo [%UNDEFINED_VAR%]
[%UNDEFINED_VAR%]

JavaScript なら undefined、Java ならコンパイルエラーですが、バッチは置換できないので元の文字列を残しますif "%A%"=="x" で比較が成立しないときは、まずこれを疑います。

2. 置換した結果が、もう一度コマンドとして読み直される

これがこの回で一番重要な帰結です。置換は文字列を貼り込むだけなので、貼り込んだ値の中にある記号も、コマンドの記号として解釈されます

@echo off
set MSG=Hello & echo あとがき
echo %MSG%

まず set の行が壊れています。& はコマンドの区切りなので、この行は「MSGHello を代入」と「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回で扱う遅延展開は「ループで値が更新されない問題」の対策として説明されることが多いのですが、実はこの再解析を止める役割のほうが本質的です。

3. rem の行でも解析は行われる

次の1行だけでバッチが停止します。コメントなのに、です。

rem 引数と %~ 修飾子の確認
バッチ パラメーターの置き換え中で、パス演算子の次の使用方法は無効です:
%~ 修飾子の確認

%~ の直後は修飾子(f d n など)でなければならず、空白が来ると無効と判断されます。remその行を実行しないだけで、行を読み込んで置換する処理はその前に走っています。

コメントの中でも % は特別な文字です。日本語の説明を書くときに %~%1 のような表記を含めると、コメントが原因でバッチが動かなくなります。

4. % はエスケープが必要になる

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/23)。剰余の % はバッチ内では %% と書きます。

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 PAPA で始まる変数だけを表示(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 1cmd.exe そのものを終了する呼び出し元のコンソールまで閉じる。事故のもと
goto :eofファイル末尾へ飛ぶ(終了コードは変えない)サブルーチンからの復帰

/b を付け忘れると開いていたコマンドプロンプトが閉じます。exit は「cmd.exe を終了する」コマンドで、/b(batch)を付けたときだけ「このバッチだけ終了する」に変わります。他のバッチから呼ばれる可能性があるなら必ず /b を付けます。

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

コマンド終了コードの意味
copy / del / move0 が成功、1 が失敗。素直
robocopy1〜7 も成功。8 以上が失敗。|| exit /b をそのまま付けると誤判定する
find / findstr該当なしで 1。「見つからなかった」は失敗として扱われる
where見つからないと 1。コマンドの存在確認に使える

知らないコマンドを使うときは コマンド /? で終了コードの説明を読んでから、エラー処理を書きます。

引数と自分の居場所

引数の受け取り

書き方内容
%0実行されたバッチ自身のパス
%1%91〜9番目の引数
%*すべての引数をまとめた文字列
%~11番目の引数から前後の引用符を外したもの

引数は %~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%

%~ 修飾子でパスを分解する

%1C:\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行が必要になるかを見ます。

Step 1: とにかく動かす

@echo off
copy "C:\work\data.txt" "C:\backup\data.txt"
echo 完了

動きますが、対象を変えるたびにファイルを書き換える必要があります。

Step 2: 変数にする

@echo off
setlocal

set "SRC=C:\work\data.txt"
set "DSTDIR=C:\backup"

copy "%SRC%" "%DSTDIR%\"
echo 完了

設定が先頭に集まり、読みやすくなりました。setlocal があるので、実行後にコンソールの環境変数が汚れることもありません。

Step 3: 日付を付ける

@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回で扱います。

Step 4: 失敗を検知する

@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

エラー処理が入って、はじめて「他人に渡せる」方向に一歩進みました。>nulcopy の「1 個のファイルをコピーしました。」という出力を捨てるためです。

Step 5: 引数で受け取る

@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 offecho の確認。@echo off を消すと何が起きるか
02-expand.cmd置換であることの確認。未定義変数 / set の書き方の違い / %% / 記号を含む値の再解析
03-errorlevel.cmd終了コードの確認。&&|| の動作
04-args.cmd引数と %~ 修飾子の一覧表示
05-backup.cmd上の Step 5(完成版)

まとめと第2回の予告

この回の要点

#覚えること理由
1バッチは1行ずつ「置換してから実行」する後で出てくる罠のほぼすべてがここから説明できる
2値はすべて文字列。計算は set /a型がないので、比較も文字列比較になる
3set "名前=値" の形で書く空白混入と記号の誤解釈を同時に防げる
4先頭に setlocal enabledelayedexpansion環境を汚さない。第2回の !VAR! の前提になる
5例外はない。|| で失敗を捕まえる書き忘れた行は黙って通過する
6exit ではなく exit /b付け忘れるとコンソールごと閉じる
7自分の場所は %~dp0、カレントは %CD%この2つは一致しない

第2回で扱うこと

次回は制御構文です。iffor を扱いますが、中心になるのはこの回の「置換」が引き起こす問題です。

@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回の前半です。

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

詳しくは落とし穴カタログを参照してください。

#症状
2比較が成立しない / 値の末尾に空白が入る
5パスに空白が含まれると失敗する
6エラーが出ているのに処理が続行する
8exit したらコンソールごと閉じた
10% を含む文字列が消える
13実行後、環境変数が書き換わっている

記法の確認は記法早見表、目的から引くときは逆引き参照を使ってください。