DEV Community

Dakota Huang
Dakota Huang

Posted on

Run the Old Function Beside the New One Before You Delete It

A small diff is not a safe diff. Safe means the old code and the new code agree on the same inputs. Characterization tests tell you what the old code does. They do not tell you whether your replacement still does it.

So keep both versions running. Compare them on every fixture you have. Delete the old one only after the mismatch count reaches zero.

This is the smallest safe change: one seam extracted, both paths callable, divergences measured instead of assumed.

Step 1 — Pin inputs, not outputs

Outputs are what you are about to change. Inputs are the contract.

Collect 200 real-ish cases and store them as JSONL. One case per line.

{"users": [{"id": 1, "tz": "UTC", "prefs": {"digest": "daily"}}], "now": "2026-11-01T00:00:00Z", "batch_size": 3}
Enter fullscreen mode Exit fullscreen mode

Every non-deterministic input becomes a parameter: clock, random seed, tenant id, batch size. If the function reads a clock or opens a connection internally, fix that first. That edit is behavior-neutral and small enough to review alone.

Step 2 — Rename the messy body, do not rewrite it

Keep the eighty-line function exactly as it is. Change its name. Add a new entry point that delegates to it.

# digest.py
def digest_buckets(users, now, batch_size):
    return _digest_buckets_v0(users, now, batch_size)


def _digest_buckets_v0(users, now, batch_size):
    # original body, untouched
    ...
Enter fullscreen mode Exit fullscreen mode

That diff is two lines. A reviewer can read it in ten seconds. Nothing about behavior changed, and the shadow harness now has a stable handle on the old implementation.

Step 3 — Canonicalize before you compare

Sets reorder. Dicts reorder in older runtimes. Floats drift in the last digit. Exceptions need to become values.

Normalize both sides through the same function.

# shadow.py
def canon(value, float_digits=6):
    if isinstance(value, float):
        return round(value, float_digits)
    if isinstance(value, dict):
        return {k: canon(value[k]) for k in sorted(value)}
    if isinstance(value, (list, tuple)):
        return [canon(v) for v in value]
    if isinstance(value, (set, frozenset)):
        return sorted(canon(v) for v in value)
    return value


def capture(fn):
    try:
        return ("ok", canon(fn()))
    except Exception as exc:
        return ("err", type(exc).__name__, canon(str(exc)))
Enter fullscreen mode Exit fullscreen mode

Two identical results that canonicalize differently are a harness bug, not a refactor bug. Find those before you trust any mismatch report.

Step 4 — The differential runner

This is the whole artifact. It takes two callables and a list of cases.

# shadow.py (continued)
import json


def diff(old, new, cases_path, limit=None):
    mismatches = []
    with open(cases_path) as handle:
        for index, line in enumerate(handle):
            if limit is not None and index >= limit:
                break
            case = json.loads(line)
            a = capture(lambda: old(**case))
            b = capture(lambda: new(**case))
            if a != b:
                mismatches.append({"case": index, "old": a, "new": b})
    return mismatches
Enter fullscreen mode Exit fullscreen mode

Run it on day one with old and new pointing at the same function. The result must be an empty list. If it is not, your canonicalizer or your fixtures are wrong.

Sketch only. Adapt the signatures to your repo and confirm the harness can report a failure before you rely on a pass.

Step 5 — Build the replacement behind the same signature

The new implementation goes in a second function with an identical signature. Extract pure helpers from the messy body as you go. After each extraction, rerun the differential job.

python -m shadow --cases fixtures/digest_inputs.jsonl
# mismatches: 0
Enter fullscreen mode Exit fullscreen mode

Zero means the step was behavior-preserving. Non-zero means you either found a real bug or introduced one. Both outcomes are useful. Neither is a reason to keep going blind.

Step 6 — Triage every mismatch into one of three buckets

Do not "fix" a mismatch by loosening the canonicalizer. Classify it first.

Mismatch shape Likely cause Action
Only key order or set order differs Canonicalizer gap Fix canon, rerun from scratch
Old returns value, new throws Missing edge branch Add the branch, keep the fixture
Both return values, different content Real behavior change Stop. Decide if it is intended
New throws a different exception type Contract change for callers Treat as breaking, not cosmetic
Mismatch appears on 1 of 200 cases Sampling artifact or rare branch Keep the case, do not delete it

Row three is the important one. If the difference is intentional, write it down as an intended change and record the fixture that proves it. If you cannot articulate why, you have a bug.

Step 7 — Flip the call site, keep the harness for one release

When the mismatch count is zero across the full corpus, point digest_buckets at the new function. Leave _digest_buckets_v0 in the file for one release cycle, unused.

That dead function is cheap insurance. If a production incident points at the digest path, you can restore the old call in one line instead of reverting a refactor commit.

Where free model access actually helps

The work above is mechanical and repetitive, which is exactly the shape a coding model handles well. Two places pay off.

First, drafting the codemod: rename the function, add the delegating wrapper, rewrite call sites. That is a bounded, reviewable transformation, not a design decision. Second, generating fixture variations from a schema: unusual timezones, empty preference dicts, oversized user lists.

MonkeyCode's free model access is useful for those drafts, and its free server option can host the batch comparison job so a large corpus does not tie up your laptop. Keep the free server for the harness run, not for production traffic. Verify current terms before you depend on availability.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The rule stays fixed: the model proposes edits, you approve them, and the harness decides whether they are correct. A model that writes a plausible helper is not evidence the helper is equivalent.

Limitations

Double execution doubles side effects. If the old function writes rows, sends mail, or charges a card, the shadow run fires those twice. Only shadow the pure core, or redirect every side effect to a sink before you start.

Sampling bias is real. Two hundred fixtures do not cover the branch a customer hits on a leap day. Raise the corpus size before you delete the old path, not after.

Non-determinism defeats the comparison. If output depends on hash seed, thread order, or wall time, pin those inputs first or the harness will report noise forever.

Who should skip this

If the change is a one-line typo fix, just fix it. If the function calls a paid API per invocation, doubling calls may cost more than the refactor is worth. If your codebase has no way to inject a clock, spend the first hour on that, not on the harness.

The habit worth keeping is narrow and boring: before you delete code, run it next to its replacement and count the disagreements. Zero is the only acceptable number.

Top comments (0)