How to Build a Dry Run Into PowerShell, Python and SQL Scripts
Every script that changes something should be able to tell you what it would change without changing it. That is a dry run, and it is the difference between a tool you can hand to someone else and a tool only you are brave enough to run.
I put one in almost everything I write. Not as a comment at the top saying to be careful, and not as a separate copy of the script with the dangerous lines commented out. A real switch, wired through every operation that writes.
Here is how I do it, and how the same idea translates to Python and SQL.
The rule
Three parts, and all three matter.
Every operation that changes state goes behind the switch. Adds, deletes, modifications, moves, permission changes, service and registry edits, database writes, anything that leaves the machine different than it found it. If the script does five destructive things, all five are gated. Four out of five is not a dry run, it is a trap.
Reads stay real. This is the part people get wrong. A dry run does not fake the environment. It resolves the accounts, walks the folders, queries the database and checks the paths for real, then stops short of writing. If you stub the reads out too, the plan it prints is fiction and proves nothing.
It prints the actual operation, not a description of it. “Would copy files” is useless. would run: robocopy "D:\source" "E:\archive" /S /MOVE can be read, checked and pasted into a shell by a human who wants to verify it themselves.
That is the whole discipline. Gate the writes, run the reads, print the commands.
The code below is the shape I have settled on, generalized from scripts I am running now. I have also included the versions that do not work, because “gate your writes” is easy to agree with and easy to implement wrong, and the wrong ones look almost identical to the right ones.
What counts as a change
The test I use: if the script died halfway through this line, would I need to clean something up? If yes, it belongs behind the switch.
- Creating, moving, renaming or deleting files and folders
- Writing to a share or a remote path
- Permission and ownership changes
- Registry writes, service installs, scheduled task changes
- Package installs and uninstalls
- Database inserts, updates, deletes and schema changes
- Any API call that is not a GET
- Even creating your own destination folders, which is the one people forget
That last one is worth saying plainly. If your script creates the target directory and then copies into it, the directory creation is a change. In a dry run you get an empty folder tree left behind on a machine you were only supposed to be inspecting. Gate it.
What about read only scripts
If a script only reports, a dry run is optional. A report that writes nothing is already safe, and adding a switch that does nothing but print “dry run” is noise.
The exception is worth watching for, and it is the reason I usually add the switch anyway. Read only scripts grow. An audit script gets a “and while you are in there, fix the ones that are wrong” request two months later. If the shape is already there, the person adding the fix has an obvious place to put it. If it is not, they add the write inline and now the script is destructive with no way to preview it.
So: pure report, skip it. Report that anyone might extend, put the switch in and wire the first write to it when the write arrives.
PowerShell: two ways, and when I use each
PowerShell has this built in. Declare SupportsShouldProcess and you inherit -WhatIf and -Confirm without writing them:
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string] $Path
)
if ($PSCmdlet.ShouldProcess($Path, 'Delete folder')) {
Remove-Item -LiteralPath $Path -Recurse -Force
}
Run that with -WhatIf and it prints a standard preview line. The real advantage is that -WhatIf propagates: PowerShell sets $WhatIfPreference for the whole scope, so cmdlets you call that support it inherit the preview without being told. Microsoft’s Everything you wanted to know about ShouldProcess walks through the whole mechanism. That is free safety and it is the right choice for a script built mostly out of cmdlets.
I still write my own switch most of the time. Three reasons:
Native tools do not honor it. My scripts drive robocopy, icacls, reg and dotnet. None of them know what $WhatIfPreference is. The moment your real work happens in an external executable, ShouldProcess stops protecting you and you are back to writing the gate by hand.
The name is taken. With SupportsShouldProcess on, you cannot declare your own -WhatIf parameter without a collision, so borrowing the built in vocabulary pushes you into workaround names like -WhatIfMode. Calling it $DryRun sidesteps the whole problem and reads better in a header.
I want one obvious flag in the header. A $DryRun in the config block, documented in the flowerbox with its default, is the thing an operator reads and understands in five seconds.
So the pattern is a plain switch, checked at every write. In a script that only ever runs by hand, that is a variable in the config block:
# ---- CONFIG -----------------------------------------------------------------
$Roots = @('E:\Data', 'E:\Workgroups')
$Users = @('EXAMPLE\alice', 'EXAMPLE\bob')
$LogDir = 'C:\temp\aclwork'
$DryRun = $false # $true = print the icacls commands only. LIVE as set.
# -----------------------------------------------------------------------------
In anything a runner or another script calls, it is a parameter instead, so the caller sets it without editing the file. $DryRun belongs with the safe defaults, since defaulting to a preview cannot hurt anybody. I went through that parameter surface in detail in The Skeleton and the Two Callers, so I will not repeat it here.
Either way, at the point of the write:
if ($DryRun) {
Write-Host " would run: icacls `"$root`" $($grantArgs -join ' ') /T /C" -ForegroundColor Yellow
continue
}
# No backup, no grants. An unreadable ACL set here means there would be
# nothing to restore from if the apply goes wrong.
icacls "$root" /save "$backup" /T /C *>&1 | Tee-Object -FilePath $applyLog
if ($LASTEXITCODE -ne 0) { throw "ACL backup of $root failed. Refusing to apply grants." }
icacls "$root" @grantArgs /T /C *>&1 | Tee-Object -FilePath $applyLog -Append
Note what the dry run prints: the exact icacls line, arguments and all. Someone reviewing the change can read it, or run it themselves against one folder to see what happens. Note also that the SID resolution and the path checks above this point already ran for real, so if an account name is wrong the dry run tells you, rather than cheerfully printing a plan that could never work.
Use the tool’s own dry run when it has one
Robocopy has a list only mode, /L, documented as “specifies that files are to be listed only (and not copied, deleted, or time stamped)”. It walks the source, decides exactly what it would copy and reports it, without moving a byte. That is far better than skipping the robocopy call entirely, because the real code path runs and the real decisions get made.
So the flag becomes a variable that flows into the command:
$MoveFlag = if ($MoveFiles) { "/MOVE" } else { "" }
$DryRunFlag = if ($DryRun) { "/L" } else { "" }
$cmd = "robocopy `"$SourceRoot`" `"$ArchiveDest`" /S /COPY:DAT /R:2 /W:5 /NP /TEE " +
"$XDString $MoveFlag $DryRunFlag /LOG:`"$Phase2Log`""
The log from that dry run is the same log format as the real thing, with the same file list. You review it, then flip one variable.
Same idea elsewhere: rsync -n, git --dry-run on the commands that change your tree, apt --simulate, and terraform plan, which is this entire concept promoted to a first class command. If the tool ships a simulation mode, use it instead of writing your own guess about what the tool would do.
Do not skip the writes you forgot were writes
Here is the destination setup in that same robocopy script:
# Create destination folders if not in DryRun mode
if (!$DryRun) {
foreach ($Dest in @($ExclusionDest, $ArchiveDest)) {
if (!(Test-Path $Dest)) {
New-Item -ItemType Directory -Path $Dest | Out-Null
Write-Host "Created: $Dest" -ForegroundColor Cyan
}
}
}
Robocopy /L would not have created those folders, but the script would have, before robocopy ever ran. Two lines of housekeeping that exist outside the “real work” and still change the disk. Every dry run implementation I have written has had at least one of these hiding in it.
Three ways people fake a dry run
All three of these get called a dry run. None of them are one.
Commenting out the dangerous lines. Now there are two versions of the script, the one you tested and the one you run, and they differ exactly where the risk is. The uncommenting always happens in a hurry, usually at the end of a long day, and nothing checks that you uncommented all of it.
Keeping a separate preview copy of the script. It drifts the first time you change the real one, and then the preview is reporting on behavior that no longer exists. Same failure as the commented out version, just slower.
Echoing a hand written description instead of the real command. Write-Host "Would copy the web files to the app server" and the actual copy line are now two independent things that have to be kept in sync by hand. Build the command string once, run it or print it, so the preview cannot disagree with the execution.
The fix in all three cases is the same. One code path, one command string, one flag that decides whether it runs.
What does not work: a summary that lies
This one is the most dangerous of the lot, because the gate is correct and the reporting is not. That is worse than having no dry run, since the output looks authoritative. Watch the counter:
if ($running) {
try {
if ($DryRun) {
Write-Log " [Dry run] Would stop process: $proc"
}
else {
Stop-Process -Name $proc -Force -ErrorAction Stop
Write-Log " Stopped process: $proc"
}
$script:ProcessesStopped++ # increments in BOTH branches
}
catch { ... }
}
The gate around the action is correct. The counter outside it is not. At the end the script prints “Processes stopped : 3” after a preview run where it stopped nothing. The individual log lines say “Would stop”, so the truth is in the log, but the summary that a busy person actually reads is wrong.
Two ways out. Either count separately and label it honestly:
if ($DryRun) { $script:WouldStop++ } else { ...; $script:Stopped++ }
Or keep one counter and change the label to match the mode:
$verb = if ($DryRun) { 'Would stop' } else { 'Stopped' }
Write-Log ("{0,-22}: {1}" -f "$verb processes", $script:ProcessesStopped)
Either is fine. What is not fine is a report that reads identically whether or not anything happened. The whole point of the feature is trustworthy output, so the output has to be honest about which mode produced it.
You only catch this by running your own preview and reading the output as though a stranger wrote it. The per action lines were honest and the total was not, and the total is the part people quote to each other.
Exercise the routing, not just the commands
The harder problem with a dry run is that it only proves the parts it reaches. If your script picks a destination based on environment, server role and a config file, printing “would publish to \\server\share” proves the string was built. It does not prove the copy would land where you think.
For that I use a redirect root. One parameter that rewrites every remote target under a local sandbox:
# Testing aid: redirect remote UNC targets under this local folder so the full
# per-server routing can be exercised with no network. Empty for real deploys.
# \\app1\d$\Apps\Web -> <SimulateRoot>\app1\d$\Apps\Web
[string] $SimulateRoot = ""
function Resolve-Target {
param($Server, $Component)
if ($SimulateRoot) {
return (Join-Path $SimulateRoot (Join-Path $Server $Component.TargetPath))
}
return "\\$Server\$($Component.TargetPath)"
}
Now a full run writes real files, in the real per server layout, into a throwaway folder. Every routing decision gets exercised for real and you can go look at the tree afterward. A dry run tells you what the script intends. A redirected live run tells you whether the intent is right.
Python
Same rule, different syntax. argparse for the flag, one guard that every write goes through, reads left alone. This is a single file utility, so it is one module. For anything bigger, the layout I use is in How I Structure Python Projects.
#!/usr/bin/env python3
"""Archive files older than a cutoff. Reads always run; writes are gated."""
import argparse
import logging
import shutil
import time
from pathlib import Path
log = logging.getLogger("archive")
class Runner:
"""Single gate for every state change in this script."""
def __init__(self, dry_run: bool) -> None:
self.dry_run = dry_run
self.planned = 0
self.applied = 0
def do(self, description: str, action):
if self.dry_run:
log.info("[dry run] would %s", description)
self.planned += 1
return None
log.info("%s", description)
result = action()
self.applied += 1
return result
def move_file(src: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(target))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("source", type=Path)
ap.add_argument("dest", type=Path)
ap.add_argument("--days", type=int, default=365)
ap.add_argument("--apply", action="store_true",
help="perform the moves. Without this the script only reports.")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
run = Runner(dry_run=not args.apply)
if run.dry_run:
log.info("*** DRY RUN - no files will be moved ***")
# Reads are real, always. Sorted so the preview is stable and reviewable.
cutoff = time.time() - args.days * 86400
stale = sorted(p for p in args.source.rglob("*")
if p.is_file() and p.stat().st_mtime < cutoff)
log.info("source : %s", args.source)
log.info("dest : %s", args.dest)
log.info("matched: %d file(s) older than %d days", len(stale), args.days)
run.do(f"create {args.dest}", lambda: args.dest.mkdir(parents=True, exist_ok=True))
files = 0
for src in stale:
target = args.dest / src.relative_to(args.source)
# Bind the loop variables as defaults, or every lambda captures the last one.
run.do(f"move {src} -> {target}",
lambda s=src, t=target: move_file(s, t))
files += 1
# Count FILES here, not run.planned. The mkdir is an operation too, and
# reporting it as a file is exactly the lying summary described above.
verb = "would move" if run.dry_run else "moved"
log.info("%s %d file(s)", verb, files)
if run.dry_run:
log.info("Review the list above, then re-run with --apply.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Three things in there are deliberate.
The destructive default is safe. The flag is --apply, not --dry-run, so forgetting it gives you a report instead of a data move. For a script that moves or deletes, make the safe mode the one you get by accident.
Every write goes through run.do. Including the mkdir. One place to audit, and searching for the write calls means searching for one method name.
The summary labels itself. Same rule as PowerShell. “would move 412 files” and “moved 412 files” are different sentences and the script has to pick the right one.
One honest limitation. In dry run, run.do returns None, so any step that depends on the result of an earlier step is not really exercised. If stage two reads the folder stage one was supposed to create, your dry run walks a path that does not exist. When that happens you either simulate the effect (a redirect root, as above) or you accept it and say so in the output, rather than pretending the whole pipeline was validated.
SQL
SQL has the cleanest dry run of the three, because the database already has the mechanism: a transaction you refuse to commit.
SET XACT_ABORT, NOCOUNT ON;
DECLARE @DryRun bit = 1; -- 1 = roll back at the end, 0 = commit
-- A table variable is not enlisted in the outer transaction the way a temp
-- table is, so the evidence survives when the change is rolled back.
DECLARE @Removed TABLE (OrderId int, Status varchar(20));
BEGIN TRY
BEGIN TRAN;
DELETE o
OUTPUT deleted.OrderId, deleted.Status INTO @Removed
FROM dbo.Orders AS o
WHERE o.Status = 'Abandoned'
AND o.CreatedUtc < DATEADD(year, -7, SYSUTCDATETIME());
IF @DryRun = 1
BEGIN
ROLLBACK TRAN;
PRINT 'DRY RUN - transaction rolled back, nothing was deleted.';
END
ELSE
BEGIN
COMMIT TRAN;
PRINT 'COMMITTED.';
END
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRAN;
THROW;
END CATCH
SELECT COUNT(*) AS RowCountAffected FROM @Removed;
SELECT TOP (50) * FROM @Removed ORDER BY OrderId;
The OUTPUT clause is the part worth stealing. It captures exactly which rows the statement touched, as the statement touches them, which is far more reliable than running a SELECT with the same WHERE clause beforehand and hoping the two predicates stay in sync forever. SET XACT_ABORT ON is there so a runtime error aborts the whole transaction instead of leaving you partway through one.
The table variable is doing deliberate work. Microsoft’s table documentation notes that “transactions involving table variables last only for the duration of an update on the table variable”, which is why rows written into one are still readable after the outer transaction rolls back. A #temp table would have been rolled back along with the delete, taking your report with it. Confirm the behavior on your own instance before you depend on it, and if you would rather not, print the report before the ROLLBACK instead.
Then the caveats, because “wrap it in a transaction” is not a complete answer.
Schema changes depend on the engine. SQL Server and PostgreSQL can roll back most DDL inside an explicit transaction. MySQL and MariaDB cannot, because DDL causes an implicit commit (MariaDB lists every statement that does it), and Oracle’s COMMIT documentation says Oracle Database issues an implicit commit before and after any DDL statement. On those engines the transaction trick will not save you and you need a restored copy instead.
Rollback does not un-consume identity values. IDENTITY columns and sequences hand out numbers that are not returned when the transaction rolls back, which is why Microsoft’s identity property documentation says the values are not guaranteed to be consecutive. Your dry run leaves gaps. Usually harmless, occasionally not.
Side effects that leave the database do not roll back. xp_cmdshell, database mail, linked server writes, a trigger that calls out to another system. The transaction cannot recall an email that already went out.
A dry run takes the same locks as the real thing. It holds them for as long as the transaction is open, which means an open dry run on a busy table blocks other work exactly like the real statement would. Do not open one and then wander off to read the output. Keep it short.
This is not the same as checking the plan. SET NOEXEC ON compiles the batch without executing it, and the SHOWPLAN options tell you how it would be executed. Neither tells you how many rows it would hit. Different tool, different question.
Best case, none of this is your first line of defense: run the real script against a recent restore of the database and diff the result. The transaction dry run is what you use when you cannot get a restore, and it is still much better than nothing.
Document it in the flowerbox
A dry run nobody knows about is not a safety feature. It belongs in the header, and specifically the default belongs in the header, because the default decides what happens to the person who runs the file without reading it.
For a script where a plain run applies changes, say so:
# SUMMARY
# Walks each target tree and stamps an explicit ACE for each principal onto
# every file and folder, one icacls pass per root. ACLs are saved to a
# restorable backup before any change.
#
# Roots, rights, principals and the $DryRun switch are set in the CONFIG
# block at the top of the file. $DryRun is $false, so a plain run applies
# changes.
#
# CONTRACT: exit 0 = every item in every root processed with zero failures.
# exit 1 = pre-flight rejected the run, or one or more items failed.
# In a dry run, exit 0 means the plan is valid, not that anything
# was applied.
For a destructive script where the default is safe, make it loud:
# IMPORTANT: $DryRun defaults to $true. No files are moved until you explicitly
# set $DryRun = $false and have reviewed the log output.
Four rules for the header, on top of everything in Flowerboxes: How to Write a Script Header Worth Keeping, which covers the anatomy of the header itself and the comment based help that sits beside it:
- State the default. Not just that the switch exists. Which way it points when nobody touches it.
- Say what the dry run covers. If it previews the copies but still writes a log file, that is worth one line.
- Put the dry run case in the contract. Exit 0 from a preview means the plan validated, not that work was done. Automation authors need that distinction.
- Bump the revision history when the default flips. Changing
$DryRunfrom$trueto$falseis a change in blast radius. It is exactly the kind of thing the history exists to record.
The run banner should agree with the header. Print the mode where the operator can see it, before anything happens:
Write-Host " DryRun Mode : $DryRun"
if ($DryRun) {
Write-Host " *** DRY RUN MODE - No files will be moved or copied ***" -ForegroundColor Yellow
}
And close the loop at the end, with the exact next step:
if ($DryRun) {
Write-Host "Dry run complete. Review the logs above, then set `$DryRun = `$false to execute." -ForegroundColor Yellow
} else {
Write-Host "All phases completed with no failures." -ForegroundColor Green
}
Which way should the default point
It depends entirely on how bad a mistake is.
Default to dry run when the operation is destructive or hard to reverse. File moves and deletes, uninstalls, data purges. Making someone type one extra thing is a fair price for not losing a file share.
Defaulting to live is defensible when the change is additive, idempotent and backed up first. Granting a permission that is already granted changes nothing, and if the script saves a restorable backup before it touches anything, the recovery path is short. Running that one fifty times in a maintenance window should not require flipping a variable each time.
What is not defensible is not knowing which one you shipped. That is why it goes in the header.
One thing a dry run is not, no matter how careful you are with it: a backup. It reduces the odds of running the wrong thing. It does nothing for you once the right thing turns out to have been wrong. That is a separate job, and I covered it in The 3-2-1 Backup Rule.
The checklist
Before I call a script done:
- Every write is behind the switch, including directory creation and log rotation
- Reads run for real in both modes
- The preview prints the exact command or statement, not a summary of it
- Native tools use their own simulation mode where they have one
- Counters and summaries state which mode they are reporting
- The banner shows the mode at the start, and the closing line says how to switch
- The flowerbox documents the switch and its default
- The contract says what exit 0 means in a dry run
- Someone other than me can run the preview and understand the output
The last one is the real test. A dry run is a communication feature as much as a safety feature. If the output only makes sense to the person who wrote the script, it has failed at the thing it exists to do.
The deploy script these patterns came out of is the one I pull apart piece by piece in the PowerShell deploy script series. Testing a tool that can do real damage is the last post in that series, and it goes further than this one does. If the comments in your gates need work more than the gates do, that is How to Actually Comment Code Without Wasting Everyone’s Time.
Sources
Everything specific I claimed above, at the primary source.
- Everything you wanted to know about ShouldProcess, Microsoft, for
SupportsShouldProcess,-WhatIfand how$WhatIfPreferencepropagates - about_CommonParameters, Microsoft, for what
-WhatIfand-Confirmare and which cmdlets get them - robocopy, Microsoft, for the
/Llist only switch - argparse, Python standard library documentation
- OUTPUT clause, Microsoft
- SET XACT_ABORT and SET NOEXEC, Microsoft
- table (Transact-SQL), Microsoft, for table variable transaction behavior
- IDENTITY property, Microsoft, for why identity values are not guaranteed consecutive
- Statements that cause an implicit commit, MariaDB
- COMMIT, Oracle, for the implicit commit around DDL
- BEGIN, PostgreSQL
- rsync manual for
-n, and terraform plan
Your turn
Do you build a dry run into everything, or only the scripts that scare you? If you are on PowerShell, do you use SupportsShouldProcess or roll your own flag, and has the propagation ever saved you? And for the SQL people: transaction and rollback, or do you insist on a restored copy? Tell me how you preview a change before you make it.
// comments