Plan an Automation
The Dry Run That Tells You What Would Happen
Separating deciding from doing, so that every change an automation intends is a piece of data you can read, count, and approve before anything is written.
Lesson 3 of 18 in the recommended order · About 30 min (estimate)
On this page
Workflow automation glossary — terms and common confusions
- Dry run
- A run that reports every change it would make and makes none of them.
A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.
- Idempotent
- Running it again leaves the same result as running it once.
"It did not crash the second time" is not the same property; check what the second run changed.
- Run record
- The stored facts about one execution: when it started, what it read, what it changed, how it ended.
A run record is not the log. The log is prose for a person; the record is data the next run reads.
- Transient failure
- A failure that a later identical attempt could succeed at, such as a timeout.
A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.
- Backoff
- Waiting longer between successive retries instead of retrying immediately.
Backoff without a maximum attempt count is an unbounded loop with a politeness delay.
- Request budget
- A hard cap on how many requests one run may make, checked before each request.
A page limit is not a budget if a retry can make extra requests the limit never counts.
- Quarantine
- Setting aside a record a run could not process, with the reason, so the run continues.
Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.
- Reconciliation
- Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.
A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.
- Trigger
- The event or time that causes a run to start.
Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.
- Heartbeat
- A signal a healthy run emits, whose absence is itself the alert.
Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.
- Staleness
- How old the most recent successful run is, compared with how old it is allowed to be.
A green last run is not freshness. Ask when it ran, not whether it passed.
- Atomic replace
- Writing output to a temporary name and renaming it into place in one step.
A reader never sees a half-written file; an interrupted run leaves the previous output intact.
- Transport
- The injected callable that actually performs a request, separate from the client that interprets it.
A client that builds its own connection cannot be tested without a network, which is why the seam exists.
- In-memory filesystem
- The filesystem this course’s browser exercises operate on, which lives only inside the tab.
It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.
Outcome
By the end of this lesson you can split an automation into a step that decides what to change and a step that carries it out, express the intended changes as data you can print and count, and write an apply step whose dry-run mode provably changes nothing.
Why it matters
Every automation in this course changes something eventually. The question is whether you find out what it intends to change before or after it does it.
A dry run is the answer, and it is also the most commonly faked feature in automation. A dry run that logs "would rename 412 files" while having already created the destination folder is not a dry run. A dry run that reports what it would do based on a second code path, written separately from the real one, is worse: it tells you about a program that is not the one that will run.
There is one design that avoids both, and it is the subject of this lesson. Decide first, and record the decision as data. Then hand that data to a step that either describes it or performs it. The dry run and the real run consider exactly the same list, because it is the same list.
The habit pays off well beyond safety. A plan you can print is a plan you can review, count, diff against yesterday's, hand to somebody for approval, and save as evidence of what a run intended. None of that is available when the decision only ever exists as control flow inside a loop that is already writing files.
Concept
Plan, then apply.
A plan function reads the current state and returns a list of intended changes. It writes nothing. An apply function takes that list and either performs it or describes it. It decides nothing.
An intended change is a small dictionary with an action and whatever that action needs:
{"action": "set", "key": "b", "value": 20}
{"action": "delete", "key": "a"}
{"action": "move", "source": "inbox/x.csv", "target": "archive/2026-09/x.csv"}
Data, not a function call. That is what makes it printable, countable, comparable between runs, and reviewable by somebody who does not read Python.
The four-point test for a real dry run. A dry run passes only if all four hold:
- It performs no action from the plan. Nothing is written, moved, deleted, or sent.
- It creates nothing incidentally. No destination folder, no lock file, no log file, no empty output. This is the point most implementations fail: the setup happens before the branch that checks the flag.
- It reports every intended change, individually. A count is not a report. "Would change 412 files" cannot be reviewed; 412 lines can be read, sampled, and searched.
- It is the same code path. The dry run and the real run consume the identical plan and differ only at the moment of performing an action.
Default to the dry run. The safe mode is the one you get by accident. A script whose default is to change things will eventually be run by somebody who did not read the arguments — often you, in a hurry. The flag that changes things should have to be typed: --apply, never --dry-run as an opt-in.
Return what happened. apply should report the number of changes it actually made, which is zero in a dry run by definition. That number is what the run record in Module 5 stores, and comparing "planned" against "made" is how you detect a run that stopped halfway.
One caution about scope. A dry run tells you what the job intends given the state it just read. It is not a promise about what will happen when the real run executes an hour later against state that has changed since. Planning and applying in the same run narrows that gap but does not remove concurrent changes. Recheck important preconditions at the effect boundary when state can change.
Read the code
A tiny store, a plan that decides, and an apply that performs or describes:
STOCK = {"blue-pen": 4, "red-pen": 0, "notebook": 11}
REORDER_LEVEL = 5
def plan(stock, level):
"""Decide what to reorder. Reads state, writes nothing, returns intentions."""
intentions = []
for item, count in sorted(stock.items()):
if count < level:
intentions.append({"action": "order", "item": item, "quantity": level - count})
return intentions
def apply(intentions, stock, dry_run=True):
"""Perform the plan, or describe it. Decides nothing."""
made = 0
for intention in intentions:
line = f"order {intention['quantity']} x {intention['item']}"
if dry_run:
print("would", line)
continue
stock[intention["item"]] += intention["quantity"]
made += 1
return made
intentions = plan(STOCK, REORDER_LEVEL)
print("planned:", len(intentions))
print("made in dry run:", apply(intentions, STOCK, dry_run=True))
print("stock unchanged:", STOCK["red-pen"])
print("made for real:", apply(intentions, STOCK, dry_run=False))
print("stock now:", STOCK["red-pen"])
plan sorts the items, so two runs over the same stock produce the plan in the same order and a diff between them shows real changes rather than dictionary ordering.
apply builds the description string once and uses it in both branches. When the wording of a dry-run line is produced by different code from the action it describes, the two drift, and the drift is invisible until the day the description is wrong.
made counts only actions actually performed, which is why it returns zero from the dry run without any special case: the continue skips the increment along with the change.
One plan, two modes
Both calls receive the same list of intentions
The branch occurs immediately before a side effect; the loop does not build a second plan.
Describe only · dry_run=True
Input- Shared intentions
Report- One stdout description per intention
Effect- No in-memory stock update or incidental file setup
Made- Zero
A real job must also avoid creating logs, locks, output folders, or sends in this branch.
Perform · dry_run=False
Input- The same intentions, in the same order
Effect- Update the sample's in-memory STOCK dictionary
Made- Count after each effective update
This worked sample does not submit a purchase order or write a file.
Predict the output
Predict all seven printed lines, including the two would lines.
Check your prediction
planned: 2
would order 1 x blue-pen
would order 5 x red-pen
made in dry run: 0
stock unchanged: 0
made for real: 2
stock now: 5
blue-pen at 4 is one below the level of 5; red-pen at 0 is five below; notebook at 11 is above it and is not in the plan. The would lines appear before made in dry run: because apply runs, and prints, before its return value reaches the outer print.
stock unchanged: 0 checks the red-pen entry in this sample: the dry run considered it and left it at zero. For a real inertness claim, compare a snapshot of all relevant state and check for incidental files or external effects too.
Modify the code
Move the made += 1 line above the if dry_run: block, so it counts every intention rather than every action.
What changes, and why
made in dry run: becomes 2, and nothing else changes. No exception, no visible damage — the store is still untouched, and the two would lines are still correct.
That is what makes it worth doing deliberately. The number this function returns is the one Module 5 writes into the run record and the one an alert compares against expectations. After this change, a dry run reports that it made two changes, and every downstream consumer of that number is now being told something false by a program that is otherwise behaving correctly.
The general form: a counter placed where the decision is, rather than where the action is, counts intentions and calls them results. Count as late as possible, immediately after the thing you are counting actually happened.
Debug the bug
An assistant was asked to "add a dry-run option to the archiver". It produced this.
def archive(records, destination, dry_run=False):
destination.mkdir(parents=True, exist_ok=True)
log = open(destination / "archive.log", "a", encoding="utf-8")
moved = 0
for record in records:
if dry_run:
log.write(f"DRY RUN: would archive {record['id']}\n")
else:
(destination / f"{record['id']}.json").write_text(record["body"], encoding="utf-8")
moved += 1
log.close()
print(f"archived {moved} records")
return moved
What's actually wrong
This bad archiver creates files during a dry run, defaults to changing files, and reports intentions as completed moves. Those are distinct faults; they are not three separate failures of the four-point list.
- It creates things during a dry run.
mkdirruns before the flag check, creating a destination folder and perhaps parent folders. Opening the log also creates or changes a file, and the loop writes to it. This violates the no-incidental-effects rule. It does not archive any record in dry mode. - The default is wrong.
dry_run=Falsemeans the dangerous mode is the one you get by callingarchive(records, destination). The flag that changes the filesystem should have to be typed. movedcounts intentions, not moves. It is incremented outside the branch, so a dry run reportsarchived 412 recordson standard output. The message says "archived" and no records were archived.- The trap: it loops over records once, and the log gets one line per intended record, but those lines are written to a file instead of inert stdout. The code never creates a reviewable intentions list. Repair it with one shared list and loop rather than separate dry/apply implementations.
The rewrite:
def archive(records, destination, apply_changes=False):
intentions = [{"id": r["id"], "body": r["body"]} for r in records]
if apply_changes and intentions:
destination.mkdir(parents=True, exist_ok=True)
moved = 0
for intention in intentions:
line = f"archive {intention['id']}"
if not apply_changes:
print("would", line)
continue
(destination / f"{intention['id']}.json").write_text(intention["body"], encoding="utf-8")
moved += 1
return moved
The flag is now named for what it does and defaults to safe mode. The same intentions and loop serve both modes; folder setup runs only for a nonempty apply. Dry mode prints each description to stdout without filesystem effects. The count increments only after a successful write. This small example does not preflight existing target files or recover a partially failed apply.
Try it yourself
A store and a plan of intended changes are supplied. Write apply, which performs the plan or describes it. In dry-run mode it must print one would ... line per intention using the supplied describe helper, change nothing, and return 0. In apply mode, a set to the same value or a delete of an absent key is a no-op and does not increase made.
Loading this exercise…
Practical challenge (optional)
Optional, and the transfer task for this lesson: take the dry run one step further, into something you could hand to somebody else.
Extend the exercise so apply returns the number planned, the number of state changes made, and intentions it could not carry out. Add an explicit precondition to a set intention, such as an expected old value. Change the store before apply so that precondition fails, and report the conflict. A plain set of a new key is valid in the supplied fixture; deleting an already-absent key is already satisfied and should not increment made.
What a good answer looks like
The useful distinction is between an intention that is already satisfied and one whose stated precondition failed. Deleting an absent key is already satisfied and makes no new change. A set of a missing key is allowed unless its intention explicitly required an existing key or old value. Report a failed precondition because state changed between planning and applying.
If your report has a skipped list with a reason per entry, you have built the thing Module 5's run record stores and Module 6's capstone reports on.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What does
planreturn, and why is it data rather than a series of calls? - Name the four conditions a genuine dry run satisfies.
- Why should the flag be
--applyrather than--dry-run? - A dry run prints only
would archive 412 recordsand reportsarchived 412 records. Which dry-run reporting rule fails, and what is wrong with the count?
Answers
- A list of intended changes, each a small dictionary. As data it can be printed, counted, diffed against a previous run, reviewed by somebody who does not read Python, and consumed by the same apply step the real run uses.
- It performs no planned action, creates nothing incidentally, reports every intended change individually, and uses the same code path as the real run.
- Because the safe mode should be the one you get by accident. A default that changes things will eventually be triggered by somebody who did not read the arguments.
- The per-intention reporting rule fails: one total does not identify the 412 planned records. Separately,
archived 412counts intentions as completed actions even though this run archived none. The printed lines alone cannot establish whether incidental files were created.
Sign in to track your progress on this exercise.
Summary and next step
Decide first and record the decision as data; then hand that data to a step that either performs it or describes it. A genuine dry run performs nothing, creates nothing incidentally, reports every intended change individually, and shares its code path with the real run. Default to it, and count changes where they happen rather than where they are decided. The next module takes this pattern to the place it is needed most: files, where a rename is instant and a mistake is not always reversible.