When a Dry Run Disagrees With the Real Run — the check that vanishes with the side effect

You add --dry-run to a batch job to suppress side effects and see what would happen first. But if a check itself depends on the result of a side effect, suppressing the effect also removes the check. The real run behaves correctly while only the dry run prints the wrong answer. Based on a case hit on 16 August 2026, here is the cause, the fix and the regression test.

About the measurements: the output below was produced by the author on 16 August 2026 with Node.js v22. The code is reproduced in full, so you can run it yourself.

What happened

The job renames files carrying an older date to today's date before processing them, which is a common shape. The new name is "today's date plus whatever follows the date in the original name".

2026-08-09-report-blogger.md  →  2026-08-16-report-blogger.md
2026-08-11-report-blogger.md  →  2026-08-16-report-blogger.md   // the same name

Two files that differ only in their date collide at the destination. The implementation had a guard for that: just before renaming, it checked the destination with fs.existsSync and skipped if it was already taken.

const to = path.join(dir, promotedName(base, today));
// skip if the destination already exists
if (fs.existsSync(to)) { warn(`skip: ${to}`); continue; }
if (!DRY) fs.renameSync(from, to);
log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${path.basename(to)}`);

In a real run the guard works. The moment the first renameSync completes, the destination exists, so the second iteration's existsSync returns true and that file is skipped. Nothing is lost.

The dry run is the problem. if (!DRY) skips renameSync, so the destination is never created. The second existsSync stays false and both files are reported as being renamed.

$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
promote [dry]: 2026-08-11-report-blogger.md -> 2026-08-16-report-blogger.md
This is the real point: anyone reading that output concludes that two files land on the same name and one of them is about to be overwritten. It is not, because the guard holds in the real run. What was broken was the output, not the processing. And since people act on output, the outcome was a decision to stop using a feature that worked. As a defect it is milder than losing data, yet the operational impact was the same.

Generalising the cause

The shape is not specific to renaming. Any code that checks "what I am about to create" against external state has the same hole.

What the check readsWhat disappears in a dry run
fs.existsSync(path)The file created earlier in the loop is missing, so everything looks uncreated
Re-listing a directorySame thing: creations are never reflected, so the same name passes repeatedly
A SELECT for duplicate keysThe INSERT is suppressed, so the second row with the same key also looks new
Port or lock availabilityNothing is reserved, so a plan that gives the same port to several processes passes
Looking up issued IDsNothing is issued, so the plan hands out the same ID more than once

What they share is that the check reads the current state of the world. In a real run the world advances as the loop proceeds, so the check holds; in a dry run it never advances, so the check does not.

The fix: check against the plan, not the world

The fix is simple. Record the results of the operations you intend to perform in your own ledger and check both the external state and the ledger. The ledger is updated in both modes, so both reach the same conclusion.

const claimed = new Set();   // destinations claimed by this run

for (const base of targets) {
  const toBase = promotedName(base, today);
  const to = path.join(dir, toBase);
  // consult both the world and this run's plan
  if (fs.existsSync(to) || claimed.has(toBase)) {
    warn(`skip: ${toBase} (${base} stays under its original name)`);
    continue;
  }
  claimed.add(toBase);
  if (!DRY) fs.renameSync(from, to);
  log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${toBase}`);
}

After the fix the dry run prints the same conclusion as the real run.

$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
skip: 2026-08-16-report-blogger.md (2026-08-11-report-blogger.md stays under its original name)

It also matters that the skip message says what happens to the rejected item. "Skipped" alone leaves the reader unsure whether that file was deleted or kept. Here it states explicitly that the file stays under its original date.

Stated as a design rule: a dry run is not "a mode that performs no side effects" but "a mode that performs side effects inside the ledger only". Suppress only the final write to the outside world, and let every other state transition proceed as normal. Then everything that reaches the output — checks, branches, counts — matches the real run.

Regression test: run both modes on the same input

Because this class of bug appears in one mode only, exercising both modes in a single test is the reliable approach. What you assert is that the number of eligible items is the same.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');

test('two targets differing only by date promote exactly once (dry agrees)', () => {
  for (const dry of [true, false]) {
    const dir = makeWorkspace(['2026-08-09-z.md', '2026-08-11-z.md']);
    const args = ['promote.js', '--date', '2026-08-16'];
    if (dry) args.push('--dry');
    const r = spawnSync('node', args, { cwd: dir, encoding: 'utf8' });
    const out = (r.stdout || '') + (r.stderr || '');   // warnings go to stderr

    const promoted = out.split('\n').filter((l) => /^promote/.test(l));
    assert.equal(promoted.length, 1, `exactly one promotion expected (dry=${dry})`);
    assert.match(out, /^skip:/m);

    if (!dry) {
      // the rejected file is still there under its original name
      const left = fs.readdirSync(dir).filter((f) => /^2026-08-(09|11)-z\.md$/.test(f));
      assert.equal(left.length, 1);
    }
  }
});

One thing that tripped us up

The first version of the test used execFileSync, and the warning was invisible in the non-dry case only. The reason is where it is written. execFileSync returns stdout alone when the process succeeds, and the warning goes to console.warn, that is stderr. The dry case passed because it happened to exit non-zero and take the exception path, where the helper concatenated stdout and stderr.

When a test needs both streams, use spawnSync and concatenate stdout and stderr explicitly. A test that inspects less when the command succeeds is exactly the kind that lets a quiet failure through.

Tool Text Diff Paste the dry-run output and the real output side by side and compare them line by line. Runs in your browser; nothing is uploaded.

Checklist

If your batch job has a dry-run mode, three things are worth checking.

  1. Is there a check inside the loop that assumes what the previous iteration created? existsSync, re-listing, and SELECT-based duplicate checks are the usual suspects.
  2. Does that check still hold in a dry run? If not, add a ledger that records the plan.
  3. Is there a test that runs both modes on the same input and asserts an equal count? If not, add one in the shape above.

A dry run exists so that a person can decide before anything is executed. When the material for that decision diverges from the real run, the feature works against you. "The real run is correct, so it is minor" is the wrong reading: the accuracy of the output deserves the same weight.

Reproduction steps

  • Put 2026-08-09-z.md and 2026-08-11-z.md in a working directory
  • Run the pre-fix code with and without --dry and compare the output lines (before the fix, the dry run prints two promotions while the real run prints one promotion and one skip)
  • Run the fixed code, with the claimed ledger, both ways and confirm the two outputs agree
  • Measured on 16 August 2026 with Node.js v22

Frequently asked questions

Why would a dry run and a real run produce different results?

Because the check itself depends on the result of a side effect. A duplicate check based on fs.existsSync works in a real run, since the preceding rename has already created the file, but in a dry run the rename is skipped, so the file does not exist and both entries look eligible. Suppressing the side effect also removed the check that relied on it.

How do I fix it?

Move the check from the state of the world to the state of the plan. Record the names you are about to create in a ledger such as a Set, and check both the external state and the ledger. The ledger is updated in both modes, so the two runs reach the same conclusion.

How should the regression test be written?

Run the same input through both the dry and the real mode and assert that the number of eligible items matches. Beyond the count, also assert that in the real run the rejected item is still there under its original name, which catches the case where something disappears silently.

Why does the test sometimes fail to see the warning message?

Because the warning goes to stderr, and a helper that captures only stdout on success will not see it. Node.js execFileSync returns just stdout when the process exits zero, so use spawnSync and concatenate stdout and stderr when the test needs to inspect both.

← Back to Tech Blog