6. Performance & Pitfalls¶
What & why¶
Batch is an interpreter with no compilation, no real data types, and a habit of spawning subprocesses. Most scripts are I/O-bound and it doesn't matter. But when a script processes 100,000 lines or runs 10,000 iterations, naive batch can take minutes. This lesson covers where the time goes, how to speed it up, and — the actually important skill — recognising when to stop writing batch.
Where the time goes¶
| Operation | Relative cost | Note |
|---|---|---|
Internal command (set, echo, if) |
cheap | microseconds |
for /L iteration |
cheap-ish | ~1 µs of overhead each, plus the body |
| Variable expansion | cheap | but delayed expansion re-scans the line |
Redirecting >> inside a loop |
expensive | opens + closes the file every iteration |
A pipe | |
expensive | spawns two cmd.exe processes |
Calling an external .exe |
expensive | process creation ~1–5 ms |
powershell -Command |
very expensive | ~150–250 ms each |
call :sub |
moderate | re-parses; ~10× a goto |
Rule 1: don't redirect inside a loop — redirect the loop¶
Slow (opens out.txt 100,000 times):
Fast (opens it once):
This one change can turn a 90-second script into a 2-second one.
Rule 2: avoid pipes in loops¶
Each | is two new cmd.exe processes. for /F "... in ('cmd | findstr ...')" inside a loop that runs 5,000 times = 10,000 process launches. Restructure:
- Run the expensive command once, capture to a temp file, loop over the file.
- Combine filters:
dir /b /s | findstr x | findstr y→dir /b /s | findstr /r "x.*y"(one pipe stage less), orfindstrwith/g:.
rem Instead of piping per iteration:
dir /b /s "%root%\*.log" > "%TEMP%\all.txt"
for /F "usebackq delims=" %%f in ("%TEMP%\all.txt") do call :process "%%f"
del "%TEMP%\all.txt"
Rule 3: minimise powershell calls¶
One powershell -Command per loop iteration is the #1 batch performance killer. If you need PowerShell in a loop, move the loop into PowerShell and call it once:
rem BAD: 1000 × 200ms = 3+ minutes
for /F %%f in (list.txt) do (
for /F %%h in ('powershell -NoProfile -Command "(Get-FileHash '%%f').Hash"') do echo %%f %%h
)
rem GOOD: one call
powershell -NoProfile -Command "Get-Content list.txt | ForEach-Object { $h=(Get-FileHash $_).Hash; \"$_ $h\" }" > hashes.txt
Rule 4: prefer for /F over set /p loops for reading files¶
set /p line=<file in a loop reads only the first line each time; the pattern people use to "read line N" re-opens and re-scans. for /F reads the whole file in one pass.
Rule 5: delayed expansion has a cost¶
With EnableDelayedExpansion, cmd re-scans each executed line for !. On a hot loop with long lines this adds up. If a loop body doesn't need !, you can endlocal the delayed scope around it — but usually the readability win is worth the small cost. Don't over-optimise this.
Rule 6: findstr/find are fast; batch string loops are slow¶
Counting lines: find /c /v "" < file (one process, instant) beats a for /F loop with set /a n+=1 (a line at a time in the interpreter) by orders of magnitude on big files.
Measuring¶
Batch has no time builtin that gives elapsed ms. Options:
rem Coarse (seconds, locale issues):
echo start: %time%
call :work
echo end: %time%
rem Better: PowerShell stopwatch around the whole thing
for /F %%m in ('powershell -NoProfile -Command "(Measure-Command { cmd /c work.bat }).TotalSeconds"') do echo %%m s
Or wrap sections and diff %time% converted to centiseconds (handle the leading space and midnight rollover).
Non-performance pitfalls that bite hard¶
Encoding¶
cmdis an OEM code page (437/850) world by default. UTF-8 files with non-ASCII, or UTF-16 files, come through wrong infor /F,type,findstr.chcp 65001switches to UTF-8 but can break other things and isn't a full fix.- BOMs at the start of a
.batfile break the first command.
Line endings¶
- Batch needs CRLF. A
.batsaved with LF-only endings can fail in subtle ways (labels not found,goto :eofmisbehaving), especially aroundgotoandcall.
The 8191-character line limit¶
- Any single command line over ~8191 chars is truncated. Building a huge
PATHor a long argument list hits this.
% and ! in data¶
- A filename or config value containing
%or (with delayed expansion)!gets partially eaten.for %%floop variables are safe;%var%/!var!round-trips are not.
Locale¶
%date%,%time%, number formatting,sortorder,findstrranges — all locale-sensitive. A script tested onen-USbreaks onde-DE(comma decimal separator in%time%!).
Concurrent runs¶
- Two copies of the same scheduled script writing the same log/temp file corrupt each other. Use a lock file or unique temp names (
%RANDOM%, PID viawmic/PowerShell).
cmd extension state¶
- If
EnableExtensionsis off (rare, but possible via registry orcmd /E:OFF),for /F,call :label,%~dp0all break.setlocal EnableExtensionsat the top.
When to stop writing batch¶
Switch to PowerShell (or Python, or a real program) when you hit any of:
- JSON / XML / CSV parsing beyond trivial splitting.
- Arithmetic with decimals, or numbers over 2^31.
- Any loop calling
powershell— just write the whole thing in PowerShell. - Real text processing — multiline patterns, replace-with-regex, context lines.
- HTTP beyond a single
Invoke-WebRequest/curlcall. - Data structures — you're maintaining
arr[i]pseudo-arrays and a.countand it's getting hairy. - Error handling that matters —
try/catch, structured logging, partial rollback. - The script is over ~200 lines and still growing.
- Unicode input/output.
- You've written
setlocal EnableDelayedExpansionand acall setdouble-expansion trick in the same script to work around the language.
Batch's remaining sweet spot: short glue scripts, launchers, CI entry points, "double-click to run" tools, and anything that must work on a bare Windows box with nothing installed. Use it there; reach for PowerShell everywhere else. It's fine — and common — to have a 5-line .bat whose whole job is powershell -NoProfile -File "%~dp0real-logic.ps1" %*.
Common mistakes¶
>> fileinside a loop — redirect the whole loop instead.- Pipes /
powershellcalls per iteration — capture once, loop over the result; or move the loop into PowerShell. - Counting with a
for /F+set /aloop — usefind /c /v "". - Ignoring encoding until a customer with
éin a path files a bug. - LF line endings in a
.bat. - No concurrency guard on a scheduled script.
- Pushing batch past its limits because rewriting feels like giving up — the rewrite is usually shorter.
Exercises¶
- Write 200,000 lines to a file two ways —
>>per iteration vs redirecting the wholefor /L. Time both (%time%before/after). Report the ratio. - Take a script that pipes
dir /b /s | findstrinside a 1,000-iteration loop and refactor it to onedir+ temp file. Measure. - Convert a "hash every file in a list" script from per-file
powershellto a single PowerShell invocation. Measure. - Count lines in a 500k-line file with
find /c /v ""and with afor /Fcounter loop. Time both. - Create a file whose name contains
%and one containing!; run them through aforloop that echoes%%f(works) and through aset "x=%%f"+echo %x%round-trip (breaks). Document the failure. - Take the intermediate backup capstone and add a lock file so two concurrent runs can't both proceed.
- Pick a batch script you've written that's over 150 lines. Rewrite it in PowerShell. Compare line count, readability, and how the error handling turned out.
Recap¶
- Cost order: internal cmds ≪
foriteration ≪call≪ external exe ≪ pipe (2 procs) ≪powershell -Command(~200 ms). - Redirect the whole loop, not each iteration. Avoid pipes and
powershellinside loops — capture once, or move the loop into PowerShell. find /c /v ""beats a counter loop for line counts.- Non-perf pitfalls: OEM codepage/Unicode, CRLF requirement, 8191-char line limit,
%/!in data, locale-sensitive date/time/sort, concurrent-run collisions. - Stop writing batch when you need real parsing, decimals/bignums, per-iteration PowerShell, structured errors, Unicode, or you're past ~200 lines. A
.batthat just launches a.ps1is a legitimate design.
Next: Hybrid scripts →