5. setlocal, endlocal & Scope¶
What & why¶
By default, a batch script shares the caller's environment: every set, every cd, every PATH tweak leaks out and persists after the script ends. setlocal creates a private copy that's thrown away at endlocal (or when the script ends). Understanding exactly what it saves and restores — and how to smuggle one value back out — is essential.
What setlocal snapshots¶
@echo off
setlocal
set "TEMPVAR=only visible in here"
cd C:\Windows
set "PATH=C:\tools;%PATH%"
endlocal
rem After endlocal: TEMPVAR is gone, PATH is restored,
rem and the current directory is back to what it was.
setlocal saves and later restores:
- all environment variables (additions, changes, deletions)
- the current drive and directory
- the state of command extensions and delayed expansion
It does not roll back: files you created, registry changes, running processes, title, color.
setlocal options¶
setlocal EnableExtensions EnableDelayedExpansion
setlocal DisableExtensions
setlocal DisableDelayedExpansion
- Command extensions (
EnableExtensions) power almost everything modern:for /F,call :label,if defined,set /a,%~dp0. They're on by default on modern Windows but making it explicit protects you from a machine where they're disabled globally. - Delayed expansion — see Lesson 1.
A bare setlocal inherits the current state of both.
Nesting¶
Each setlocal pushes a new scope; each endlocal pops one:
setlocal &rem scope 1
set "x=1"
setlocal &rem scope 2
set "x=2"
echo %x% &rem 2
endlocal &rem pop scope 2
echo %x% &rem 1
endlocal
If you don't endlocal, batch does it for you when the script exits — one auto-endlocal per outstanding setlocal. Subroutines called with call :label do not get an automatic setlocal; they run in the caller's scope unless they call setlocal themselves.
The endlocal barrier — returning a value¶
Everything set inside a setlocal block dies at endlocal. To carry one value out, expand it on the same line as endlocal, before the barrier drops:
How this works: cmd parses the whole endlocal & set "result=%result%" line first, so %result% becomes 42 while the inner scope is still active; then endlocal runs, then set "result=42" runs in the outer scope.
Multiple values¶
With delayed expansion (careful)¶
Inside a delayed-expansion scope, %result% on the endlocal line still works (it's parse-time). But if the value can contain !, you need the classic dance:
setlocal EnableDelayedExpansion
set "msg=Done!!!"
for %%# in ("!msg!") do (
endlocal
set "msg=%%~#"
)
echo %msg%
The subroutine pattern¶
A well-behaved subroutine isolates itself and returns via the barrier:
call :get_size "C:\big\file.iso" SIZE
echo Size is %SIZE% bytes
exit /b 0
:get_size
setlocal
set "bytes=%~z1"
endlocal & set "%~2=%bytes%"
goto :eof
%~2 is the caller-supplied output variable name; the assignment lands in the caller's scope because it's after endlocal.
cd and pushd/popd¶
setlocal restores the directory at endlocal. Within a scope, prefer pushd/popd for temporary directory changes — pushd also handles UNC paths by mapping a temp drive letter:
Common mistakes¶
- No
setlocalat all — your script pollutes the user's shell: leftover variables, changedPATH, wrong current directory afterward. - Setting a result inside
setlocaland reading it afterendlocal— it's gone. Useendlocal & set "x=%x%". endlocal & set "x=%x%"on two lines — the%x%on its own line is too late; the scope already collapsed.- Unbalanced
setlocal/endlocalin a loop — each iterationsetlocals but the block neverendlocals, so you stack scopes until the script ends (and hit the 32-level limit, or just leak memory/perf). - Expecting
call :subto auto-isolate — it doesn't; addsetlocalinside the sub. setlocal EnableDelayedExpansionglobally then wondering why strings with!break — scope it.- Relying on
endlocalto undo file/registry changes — it only touches env vars and the cwd.
Exercises¶
- Write a script that changes
PATHandcds elsewhere insidesetlocal; afterendlocal, print%PATH%and%CD%to confirm they're restored. - Write
:double n outvarusingsetlocal+ theendlocal & setbarrier. Prove the inner temp variable doesn't leak. - Nest three
setlocalscopes, each redefiningLEVEL; printLEVELafter eachendlocaland confirm it unwinds3 → 2 → 1. - Write a
for /Lloop thatsetlocals each iteration to isolate a per-item variable, andendlocals at the end of the block. Confirm scope count stays flat (addechoof a marker; run withEnableExtensions). - Return a value containing
!out of a delayed-expansion scope correctly. - Convert a script that currently leaks 5 variables into one that leaks nothing but still returns its one real result.
Recap¶
setlocalsnapshots all env vars + the current drive/dir + extension/delayed-expansion state;endlocal(or script end) restores them.- It does not undo files, registry, processes,
title/color. setlocal EnableExtensions EnableDelayedExpansionis a good explicit default.- Return a value past
endlocalwithendlocal & set "out=%out%"on one line. called subroutines don't auto-isolate —setlocalinside them.- Keep
setlocal/endlocalbalanced, especially in loops.