| Tested on | Windows Server 2019 Datacenter Evaluation — cmd.exe (OS build 17763) |
|---|---|
| Applies to | Windows 11, Windows 10, Windows Server 2016 and later |
| Privilege | Normal user; an elevated Command Prompt only for scripts that write outside your profile |
| Scope | Writing, running and debugging .bat scripts with cmd.exe: echo, variables, arguments, conditionals, loops, delayed expansion, exit codes, logging, and a complete backup script. Does not cover PowerShell or Windows Script Host. |
| Related guides | Bash for loop Bash if else statement Bash script arguments Bash functions Bash case statement |
A .bat file is the shortest distance between a command you keep retyping and a file you can double-click. There is no compiler, no runtime to install and no project layout: cmd.exe reads the file from the top and runs each line as if you had typed it. That simplicity is also where the surprises live, because the parser expands variables before it runs a block, treats a missing quote as a syntax change, and reports success in a variable you have to check by hand.
The examples below were written as real files and run with cmd.exe on Windows Server to capture the output on this page, so every transcript is something a script actually printed.
What a .bat file is, and when to reach for one
A batch file is a plain text file whose lines are commands for the Windows command interpreter. Save it as something.bat, and Windows knows to hand it to cmd.exe. That is the whole format: no header, no imports, no indentation rules.
It earns its place when the job is mostly running other programs:
- Copying, archiving or cleaning up folders on a schedule
- Setting environment variables and then launching an application
- Wrapping a long command line with awkward flags behind one file name
- Glue in an installer or build step where PowerShell is unavailable or blocked by policy
It is the wrong tool once you need structured data, HTTP calls, JSON, or error handling worth the name. At that point write PowerShell and, if you like, keep a one-line .bat wrapper that calls it.
.bat or .cmd?
On any Windows still in support both extensions go to cmd.exe and the language is identical. One difference is worth knowing: a handful of internal commands, among them set, path, assoc and prompt, set the errorlevel back to zero when they succeed inside a .cmd file, while inside a .bat file they only touch it on failure. If your script checks the errorlevel right after one of those commands, .cmd behaves more predictably. Everything on this page works in either.
Write and run your first batch file
Create the file in any plain text editor. In Notepad, choose File → Save as, set Save as type to All files, and type the full name including the extension, because Notepad otherwise saves hello.bat.txt and Windows hides the .txt from you. Turn on File name extensions in Explorer's View menu so you can see what you actually created.
Here is a first script that says something and waits before it disappears:
@echo off
echo Hello from your first batch file.
echo Running as: %~nx0
pauseThe three lines each do one job. @echo off stops the interpreter from printing every command before running it, and the leading @ hides that line too. %~nx0 is the name and extension of the running script, which is a habit worth forming early. pause holds the window open, which matters when the script is started by a double-click.
Run it from a Command Prompt you opened yourself, so that error text stays on screen:
C:\bat-lab> hello.bat
Sample output:
Hello from your first batch file.
Running as: hello.batAfter that, pause prints its own prompt and waits for a key. Once the script is finished and scheduled rather than clicked, delete the pause line, because a scheduled task that waits for a keypress never finishes.
Comments, and the one that bites
rem is the documented comment command. The :: form is not a comment at all: it is a label that never matches, which is why it also works. The difference shows up inside a parenthesised block, where a :: line can break the block, so rem is the safer habit:
@echo off
rem This is a comment and always safe.
for %%f in (one two) do (
rem A comment inside a block belongs in this form.
echo item %%f
)Store values with set
Variables are text. set assigns, and %NAME% reads. Quote the whole assignment as set "NAME=value" so that a trailing space or a stray & in the value cannot leak into the command line:
@echo off
setlocal
set "PROJECT=inventory"
set "ROOT=C:\bat-lab\%PROJECT%"
set /a RETRIES=2+1
echo project : %PROJECT%
echo root : %ROOT%
echo retries : %RETRIES%
echo upper cut: %PROJECT:~0,3%
echo swapped : %PROJECT:inventory=orders%
endlocal
echo after endlocal PROJECT is [%PROJECT%]set /a does integer arithmetic, %PROJECT:~0,3% takes a substring by offset and length, and %PROJECT:inventory=orders% replaces text on the way out without changing the variable. Running it shows each form in turn:
project : inventory
root : C:\bat-lab\inventory
retries : 3
upper cut: inv
swapped : orders
after endlocal PROJECT is []That last line is the reason to start scripts with setlocal. Without it, every variable you set stays in the console session after the script ends, so the next script inherits your leftovers. endlocal throws the whole set away, which is why PROJECT reads as empty on the line after it.
Read arguments the script was given
Arguments arrive as %1 through %9, with %* holding all of them. Always read them through the ~ forms: %~1 strips the surrounding quotes, %~f1 expands to a full path, and %~n1 gives just the name. A usage guard as the first real statement saves you from a script that happily deletes the wrong folder when called with no arguments:
@echo off
setlocal
if "%~1"=="" (
echo usage: %~nx0 ^<folder^> [logfile]
exit /b 1
)
set "SOURCE=%~1"
set "LOGFILE=%~2"
if "%LOGFILE%"=="" set "LOGFILE=%~dp0backup.log"
echo source folder : %SOURCE%
echo log file : %LOGFILE%
echo short name : %~n1
echo all args : %*
endlocalNote the ^< and ^> in the usage line. < and > are redirection operators, and ^ escapes them so they print as text. Called with a path that contains a space, the script reports what it received:
C:\bat-lab> args.bat "C:\Users\deepak\My Reports"
source folder : C:\Users\deepak\My Reports
log file : C:\bat-lab\backup.log
short name : My Reports
all args : "C:\Users\deepak\My Reports"%~dp0 is the drive and path of the script itself, with a trailing backslash, so %~dp0backup.log lands the log next to the script no matter which directory the script was started from. %* keeps the original quoting, which is what you want when passing arguments straight through to another program.
Branch with if
if compares strings, tests for files and checks whether a variable is defined. Wrap both sides of a comparison in quotes, because an empty variable otherwise leaves the interpreter looking at if ==, which is a syntax error rather than a false condition:
@echo off
setlocal
set "TARGET=C:\Program Files\App"
if exist "%TARGET%" (echo found) else (echo not found: %TARGET%)
if defined TARGET echo TARGET is set
if not defined MISSING echo MISSING was never set
if /i "%TARGET%"=="c:\program files\app" echo case-insensitive match
endlocalif exist takes a path and, with a trailing backslash, insists that the path is a directory. /i makes the comparison ignore case, which is nearly always what you want for paths on Windows. Numeric comparisons use EQU, NEQ, LSS, LEQ, GTR and GEQ rather than symbols.
Repeat with for
for has several personalities selected by a flag, and the doubled %% is required inside a file. A single % is for the command line only, which is the usual reason a loop that worked when pasted into a prompt fails when saved to a script.
@echo off
setlocal
echo -- fixed list --
for %%s in (web01 web02 db01) do echo checking %%s
echo -- counter --
for /l %%i in (1,1,3) do echo attempt %%i of 3
echo -- files on disk --
for %%f in (logs\*.log) do echo found %%~nxf, %%~zf bytes
echo -- lines that matter --
for /f "tokens=2,* delims= " %%a in ('findstr /i "error warning" logs\app.log') do echo level=%%a detail=%%b
endlocalFour loops, four sources of items: a literal list, a numeric range written as start, step, end, a wildcard that expands to real files, and the output of another command in single quotes. The %%~nxf and %%~zf modifiers work on loop variables exactly as they do on arguments, so a loop can print a file's name and size without calling dir.
-- fixed list --
checking web01
checking web02
checking db01
-- counter --
attempt 1 of 3
attempt 2 of 3
attempt 3 of 3
-- files on disk --
found app.log, 78 bytes
-- lines that matter --
level=warning detail=disk 82%
level=error detail=connection refusedfor /f is the closest thing batch has to a text parser. delims= sets the separators, tokens= picks which fields you get, and tokens=2,* means "the second field, then everything after it as one string". Parsing a CSV is the same idea with a comma:
@echo off
setlocal
for /f "tokens=1,2,3 delims=," %%a in (hosts.csv) do (
echo host %%a at %%b runs %%c
)
endlocalThe first token lands in the variable you named, and the rest continue through the alphabet, so %%a, %%b and %%c here are the three columns:
host web01 at 192.168.10.5 runs ubuntu
host web02 at 192.168.10.6 runs rocky
host db01 at 192.168.10.7 runs debianDelayed expansion, the bug everyone meets first
This is the one behaviour that makes batch files feel broken. The interpreter expands %VAR% when it parses a block, not when it runs each pass, so a counter inside a loop appears frozen:
@echo off
setlocal
set COUNT=0
for %%f in (alpha beta gamma) do (
set /a COUNT+=1
echo inside the block COUNT reads %COUNT%
)
echo after the block COUNT reads %COUNT%
endlocalThe increment is working perfectly. Only the reading is wrong, and the last line proves it:
inside the block COUNT reads 0
inside the block COUNT reads 0
inside the block COUNT reads 0
after the block COUNT reads 3Every %COUNT% inside the parentheses was replaced with 0 before the first pass ran, because that was the value when the block was parsed. The fix is to ask for the value at run time with !VAR!, which requires switching delayed expansion on:
@echo off
setlocal enabledelayedexpansion
set COUNT=0
for %%f in (alpha beta gamma) do (
set /a COUNT+=1
echo inside the block COUNT reads !COUNT!
)
echo after the block COUNT reads !COUNT!
endlocalSame script, one changed sigil, and the counter now reports itself as it climbs:
inside the block COUNT reads 1
inside the block COUNT reads 2
inside the block COUNT reads 3
after the block COUNT reads 3The same trap catches %ERRORLEVEL% inside an if or for block, where it reports the value from before the block started. Use !ERRORLEVEL! there, or restructure so the check happens outside the parentheses. The one cost of delayed expansion is that a literal exclamation mark in text then needs escaping as ^!.
Exit codes and error handling
Every command leaves a number behind. if errorlevel N is true when that number is N or greater, which is why if errorlevel 0 is always true and useless. %ERRORLEVEL% gives you the number itself for equality and threshold tests, and || runs a command only when the previous one failed:
@echo off
setlocal
echo -- checking a folder that is not there --
dir "C:\bat-lab\nope" >nul 2>&1
if errorlevel 1 echo dir set errorlevel to %errorlevel%
echo -- a command that succeeds --
dir "C:\bat-lab" >nul 2>&1
if errorlevel 1 (echo still failing) else (echo dir succeeded, errorlevel %errorlevel%)
echo -- a deliberate exit code --
cmd /c exit 3
echo the child returned %errorlevel%
if %errorlevel% GEQ 2 echo that is above the warning threshold
echo -- the shorthand --
dir "C:\bat-lab\nope" >nul 2>&1 || echo the ^|^| branch ran because dir failed
endlocal>nul 2>&1 throws away both the normal output and the error text, leaving only the exit code, which is what the script is asking about here:
-- checking a folder that is not there --
dir set errorlevel to 1
-- a command that succeeds --
dir succeeded, errorlevel 0
-- a deliberate exit code --
the child returned 3
that is above the warning threshold
-- the shorthand --
the || branch ran because dir failedTo return a code of your own, use exit /b N. Plain exit without /b closes the entire console window, which is fine from a scheduled task and infuriating when you are testing by hand.
Put it together: a backup script that logs and reports
This script takes a source and a destination, validates both, copies with xcopy, writes a timestamped log next to itself, and returns a different exit code for each kind of failure so that a scheduler or a calling script can tell them apart.
@echo off
rem Copies one folder into another, logs every step, and returns a real exit code.
setlocal enabledelayedexpansion
pushd "%~dp0"
set "SOURCE=%~1"
set "DEST=%~2"
set "LOG=%~dp0backup.log"
if "%SOURCE%"=="" goto :usage
if "%DEST%"=="" goto :usage
if not exist "%SOURCE%\" (
call :log "source folder does not exist: %SOURCE%"
set "EXITCODE=2"
goto :finish
)
if not exist "%DEST%\" (
call :log "creating destination %DEST%"
mkdir "%DEST%" || (
call :log "could not create %DEST%"
set "EXITCODE=3"
goto :finish
)
)
call :log "copying %SOURCE% to %DEST%"
xcopy "%SOURCE%\*" "%DEST%\" /s /i /y >>"%LOG%" 2>&1
if errorlevel 1 (
call :log "xcopy failed with errorlevel !errorlevel!"
set "EXITCODE=4"
goto :finish
)
call :log "backup finished"
set "EXITCODE=0"
goto :finish
:log
echo [%TIME%] %~1
echo [%TIME%] %~1>>"%LOG%"
exit /b 0
:usage
echo usage: %~nx0 ^<source-folder^> ^<destination-folder^>
set "EXITCODE=1"
:finish
popd
endlocal & exit /b %EXITCODE%Four patterns in that file are worth copying into your own scripts:
pushd "%~dp0"moves to the script's own folder andpopdputs the caller's directory back, so relative paths behave the same whether the script is double-clicked, called from another folder, or started by Task Scheduler.:logis a subroutine.call :log "text"jumps to the label, the label'sexit /b 0returns, and the message goes to the console and the log file in one place.goto :finishfunnels every path through one exit, so cleanup happens once rather than being repeated in each error branch.endlocal & exit /b %EXITCODE%is deliberate.endlocaldiscards the local variables, and because both commands are on one line,%EXITCODE%was already expanded before the discard happened.
Called with a real source folder, the run reads as a small transcript of its own decisions:
C:\bat-lab> backup.bat "C:\bat-lab\reports" "C:\bat-lab\archive"
[9:32 PM] creating destination C:\bat-lab\archive
[9:32 PM] copying C:\bat-lab\reports to C:\bat-lab\archive
[9:32 PM] backup finishedThe log file is where the detail went. Read it back to see the copy's own report interleaved with the script's messages, because xcopy output was redirected into the same file:
C:\bat-lab> type backup.log
[9:32 PM] creating destination C:\bat-lab\archive
[9:32 PM] copying C:\bat-lab\reports to C:\bat-lab\archive
C:\bat-lab\reports\q1.txt
C:\bat-lab\reports\q2.txt
2 file(s) copied
[9:32 PM] backup finished%TIME% follows the machine's regional settings, which is exactly why you should not build file names out of it. A script that writes backup-%DATE%.log produces something different on a machine configured for another locale, and slashes in a date turn into unwanted folders. When you need a sortable stamp, ask PowerShell for one: for /f %%t in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss"') do set "STAMP=%%t".
Point the same script at a folder that does not exist and it says so and returns 2, which is the part a scheduler can act on:
C:\bat-lab> backup.bat "C:\bat-lab\nope" "C:\bat-lab\archive"
[9:33 PM] source folder does not exist: C:\bat-lab\nopeCheck the code afterwards with echo %errorlevel% in the same window. A script that always exits 0 tells a scheduled task that a failed backup went fine.
Run it as administrator, or on a schedule
Two environment details account for most "it works when I run it, not when Windows runs it" reports.
Elevation. Right-click the file and choose Run as administrator, or start an elevated Command Prompt and call the script from there. A batch file cannot elevate itself; if it needs administrator rights, check for them early and exit with a clear message rather than failing halfway through.
Working directory. A scheduled task starts with whatever directory you configured, often C:\Windows\System32, so every relative path in the script resolves somewhere you did not intend. pushd "%~dp0" at the top removes the whole class of problem. In Task Scheduler, set the action's program to the full path of the .bat file, quote it if the path has spaces, and fill in Start in as well.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The window flashes and disappears | Started by double-click, so the console closes with the last line | Add pause while developing, or run the script from a Command Prompt you opened |
'x' is not recognized as an internal or external command |
Typo, or the program is not on PATH |
Run where x to confirm; call the executable by full path, quoted if it contains spaces |
A variable is empty inside a loop or if block |
%VAR% was expanded when the block was parsed |
setlocal enabledelayedexpansion and read it as !VAR! |
A loop variable prints as %f instead of a value |
Single % used inside a file |
Double it: %%f in a script, single % only at the prompt |
| The script runs against the wrong folder | Started with a different working directory, typically from Task Scheduler | Add pushd "%~dp0" at the top and popd before exit |
| A path with spaces splits into two arguments | Value used unquoted | Quote the whole assignment as set "P=%~1" and every use as "%P%" |
if errorlevel 0 is always true |
if errorlevel N means "N or greater" |
Compare the value: if %errorlevel% EQU 0 |
Notepad saved script.bat.txt |
Save as type left at Text Documents | Set it to All files, and turn on file name extensions in Explorer |
goto or multi-line blocks fail after editing elsewhere |
File saved with LF line endings instead of CRLF | Re-save with CRLF in Notepad or VS Code before you run the script on Windows |
| Accented or box-drawing characters print as mojibake | Console code page does not match the file encoding | Save as ANSI, or add chcp 65001 >nul at the top for UTF-8 |
The console window closes on exit during testing |
exit without /b ends the session, not just the script |
Use exit /b N inside scripts |
Summary
Writing a .bat file is mostly a matter of knowing which of your instincts to distrust. The commands are the ones you already type at a prompt, and the structure is thin: @echo off at the top, setlocal so the script does not leak variables into the session, arguments read through %~1 with a usage guard, if and for for control flow, and exit /b to hand a real code back to whoever called you.
The two things that will cost you an afternoon are worth learning before you need them. Everything inside a parenthesised block is expanded when the block is parsed, so a counter or an errorlevel read as %VAR% inside a loop reports a stale value and the fix is setlocal enabledelayedexpansion with !VAR!. And the working directory is not where the script lives unless you say so, which is why pushd "%~dp0" belongs at the top of anything a scheduler will run.
Start from the backup script above rather than a blank file. It already has the shape that matters: validate the inputs, log with a timestamp, funnel every path through one exit, and return a distinct code per failure so the caller can tell a missing folder from a copy that went wrong. When you outgrow it, and the moment you need JSON, HTTP or real error handling you will, keep the .bat as a thin wrapper and move the work into PowerShell.
References
- Windows Commands reference — Microsoft Learn
- cmd — command interpreter options, including
/cand/k - set — assignment,
/aarithmetic and substring syntax - setlocal — local scope and
enabledelayedexpansion - for — every
forform and its modifiers - if — comparison operators and
errorlevel - xcopy — exit codes returned by the copy
Frequently Asked Questions
How do I write a .bat file?
Type one command per line in any plain text editor, start the file with @echo off, and save it with a .bat extension after setting Save as type to All files, because Notepad otherwise saves hello.bat.txt and Windows hides the .txt from you.
What is the difference between .bat and .cmd?
Both extensions run in cmd.exe on any supported Windows and the language is identical, but a few internal commands such as set, path and assoc reset the errorlevel to zero when they succeed inside a .cmd file while leaving it untouched inside a .bat file, so .cmd behaves more predictably for scripts that check the errorlevel right after one of those commands.
Why does my batch file window close immediately?
A double-click opens a console that closes as soon as the last line finishes, so add pause as the final line while you are developing, or run the script from a Command Prompt you opened yourself so the window and any error text stay on screen.
Why is my variable empty inside a for loop?
Everything between the parentheses of a for or if block is expanded once when the block is parsed, so %VAR% shows the value it held before the loop started; turn on setlocal enabledelayedexpansion and read the variable as !VAR! to get the value it holds during each pass.
Do I need a Windows machine to test a .bat file?
Yes. Batch files run in cmd.exe on Windows, so open Command Prompt on the target system, or RDP into a Windows Server or desktop VM, create the script there, and run it from that console. That is the only way to verify paths, elevation, scheduled tasks, and built-in commands such as pause and timeout behave the way you expect.

