A refactor that "looks the same" is the dangerous kind. You ask Claude Code to tidy a 15-year-old Python module, the diff reads beautifully, the app still starts, and three weeks later finance notices every invoice is off by one rupee. Nothing crashed. A round() replaced an int().
This post shows how to refactor a legacy Python codebase with Claude Code safely: pin the current behaviour before you touch anything, make small commits, and let a script rather than the model's confidence decide whether behaviour changed. The example module is invented, the scripts were run on 2026-10-09, and the Claude Code facts come from Anthropic's docs read the same day.
What should I do before asking Claude Code to refactor?
Four things, in this order.
- Commit a clean tree. Claude Code's checkpoint feature is useful but not enough. The checkpointing page says checkpoints do not track files changed by Bash commands (
rm,mv,cp), that edits made by most subagents are not restored by rewind, and that checkpoints are "not a replacement for version control". Your undo button isgit. - Work on a branch or a worktree. The docs describe
claude --worktree feature-auth, which gives a session its own checkout on its own branch. It needs at least one commit in the repo, and in an empty repo it fails withFailed to resolve base branch "HEAD". - Start in plan mode for anything touching more than one file.
claude --permission-mode plan, or press Shift+Tab until the status bar shows plan mode. Per the docs, Claude reads files and proposes a plan but makes no edits until you approve. The plan mode refactor workflow covers how to review a plan like a pull request. - Pin behaviour with a baseline. This is the step most refactor guides skip, and the rest of this post is about it.
What is the legacy code we are refactoring?
Here is the invented module. It is deliberately ugly in ways real code is ugly.
TAXRATE = 18
def calc(items, cust, d=None, seen=[]):
t = 0
for i in items:
q = i[1]
p = i[2]
t = t + q * p
if cust == "gold":
t = t - t * 10 / 100
elif cust == "silver":
t = t - t * 5 / 100
if d != None:
if d > t:
d = t
t = t - d
tax = int(t * TAXRATE / 100)
t = t + tax
seen.append(t)
if len(seen) > 3:
seen.pop(0)
return [round(t, 2), tax, len(seen)]
Three quirks hide in there. int() truncates the tax instead of rounding it. A discount larger than the subtotal is clamped. And seen=[] is a mutable default argument, so the list is shared across every call that doesn't pass one. The return value's third element reports its length, which means output depends on how many times the function ran before.
A generic prompt ("clean up calc, add type hints, follow PEP 8") will touch all three, and some of those quirks may be load-bearing.
How do I write a golden-master baseline?
A golden master is a recording. You run the legacy function over a grid of inputs and save every output, including exceptions. Later you replay the same inputs against new code and diff. It is cheap, it needs no understanding of the business rules, and it works on code with zero tests. This script is the whole tool:
"""python golden.py record legacy_invoice -> writes golden.json
python golden.py check refactored -> replays the same inputs"""
import importlib
import itertools
import json
import os
import sys
GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden.json")
ITEM_SETS = {
"empty": [],
"one": [["pen", 1, 10]],
"mixed": [["pen", 3, 9.99], ["book", 2, 149.5]],
"big": [["desk", 4, 2499.0]],
}
CUSTOMERS = ["gold", "silver", "regular", None]
DISCOUNTS = [None, 0, 25, 1e9]
def cases():
for (iname, items), cust, d in itertools.product(ITEM_SETS.items(), CUSTOMERS, DISCOUNTS):
yield f"{iname}|{cust}|{d}", items, cust, d
def run(module_name):
mod = importlib.import_module(module_name)
out = {}
for key, items, cust, d in cases():
try:
out[key] = mod.calc(items, cust, d, seen=[]) # fresh hidden state each call
except Exception as exc: # exceptions are behaviour too
out[key] = f"raises {type(exc).__name__}"
return out
def main():
mode, module_name = sys.argv[1], sys.argv[2]
result = run(module_name)
if mode == "record":
with open(GOLDEN, "w") as fh:
json.dump(result, fh, indent=1, sort_keys=True)
print(f"recorded {len(result)} cases from {module_name}")
return
golden = json.load(open(GOLDEN))
diffs = [(k, golden[k], result[k]) for k in golden if golden[k] != json.loads(json.dumps(result[k]))]
print(f"{len(golden) - len(diffs)}/{len(golden)} cases match")
for k, old, new in diffs[:5]:
print(f" DIFF {k}: golden={old} now={new}")
sys.exit(1 if diffs else 0)
main()
Choosing the grid is where you spend judgment. Include the empty case, each branch value, the boundary where a clamp kicks in (1e9 forces the discount-larger-than-subtotal path), and values that produce fractions. Prefer inputs captured from real logs if you can get them without personal data. Here the grid is 4 item sets x 4 customer types x 4 discounts = 64 cases.
Record against the original and replay once to prove the harness is deterministic:
recorded 64 cases from legacy_invoice
64/64 cases match
Commit golden.json and the script. Now there is a baseline.
What does a silent behaviour change look like?
I hand-wrote a refactor in the style assistants often produce when asked to modernise (it is not captured model output): a dict of customer discounts, a sum() generator, named variables, min() for the clamp, seen=None instead of the mutable default, and round(tax) because rounding "is what you mean". It reads better. Replaying the baseline:
48/64 cases match
DIFF big|None|25: golden=[11765.0, 1794, 1] now=[11766.0, 1795, 1]
DIFF big|gold|25: golden=[10585.4, 1614, 1] now=[10586.4, 1615, 1]
DIFF big|regular|25: golden=[11765.0, 1794, 1] now=[11766.0, 1795, 1]
DIFF big|silver|25: golden=[11175.2, 1704, 1] now=[11176.2, 1705, 1]
DIFF mixed|None|25: golden=[357.97, 54, 1] now=[358.97, 55, 1]
Sixteen of 64 cases changed (the script prints the first five). Every one is the tax line. For big|None|25 the pre-tax amount is 9971, tax is 1794.78, and the legacy int() gives 1794 where round() gives 1795, so the total moves by one. It happens whenever the tax has a fractional part of .5 or more, which is why the plain 10-rupee pen (tax 1.8) also differs. No type checker, linter or "it still runs" check catches that. The legacy behaviour was arguably wrong, but whether to change tax rounding on invoices is a business decision, not a refactoring side effect.
Fix: keep int() with a comment, restore is not None for the discount test (if flat_discount: also treats 0 as no discount, harmless here but a trap for the next edit), and replay:
64/64 cases match
What does a golden master not catch?
It only sees what the grid exercises and what the harness controls. Notice the line seen=[] in run(): I passed a fresh list on every call so outputs don't depend on call order. That makes the baseline stable and also hides the shared-state quirk. A second tiny script calls each version five times without a seen argument:
legacy_invoice history length over 5 calls without a seen argument: [1, 2, 3, 3, 3]
refactor_v2 history length over 5 calls without a seen argument: [1, 1, 1, 1, 1]
The "fixed" version, which passes the 64-case check, behaves differently in production if any caller relies on the shared list. Does anything? Only a search tells you:
grep -rn "calc(" src/
Or ask Claude Code (untested prompt): "Find every caller of calc and say whether any reads the third element of the result or passes seen. Do not edit anything." If nothing reads it, remove the mutable default as its own commit with a test that documents the change. If something does, you've found a hidden dependency before it shipped.
The lesson generalises. After a golden master passes, list what it can't see: hidden global state, time and randomness, files and network calls, logging, and performance. Those need targeted tests, or at least a manual check.
What prompt should I give Claude Code for each step?
Use one structural move per prompt. These are untested templates; I wrote them from the docs' guidance and did not run them against a live model.
1. Survey (plan mode, no edits):
Read src/billing/invoice.py and its callers. Do not edit anything.
List: (a) side effects and global or default-argument state, (b) every
place the output format is relied on, (c) behaviour that looks like a
bug but may be depended on. For each item cite file and line.
2. Baseline (one commit):
Write a golden-master script like golden.py for calc(). Grid: <items>,
<customers>, <discounts>, including boundaries. Record only; do not
change src/. Run it twice and show me both outputs are identical.
3. One refactor step:
Extract the tax calculation into a function compute_tax(subtotal).
Behaviour must be byte-identical, including truncation. Do not rename
anything else. Run `python golden.py check billing.invoice` and show the
output. If any case differs, stop and show the diff instead of fixing it.
4. Review:
Show git diff for this step. List anything in the diff that is not the
extraction I asked for.
The "stop and show the diff" line is the important one. An assistant that is allowed to fix a failing check will happily edit the baseline until it passes, so tell it not to touch golden.json and review that file in every diff.
Which Claude Code features help, and where do they stop?
From the docs pages read on 2026-10-09:
- Plan mode: proposes without editing until you approve. Good for the survey step.
- Subagents: the common-workflows page suggests delegating exploration so file reads stay out of your main context. Useful for "find all callers"; remember rewind will not restore their edits. See the subagents guide.
- Worktrees: separate checkout and branch per session, so a risky experiment can't collide with your main working copy.
/rewind: restores code, conversation or both to an earlier prompt. It is session-level recovery with limits listed above. Snapshots are kept for the 100 most recent checkpoints and swept after about 30 days by default.- CLAUDE.md: the memory page says to target under 200 lines per file, since longer files cost context and reduce adherence. Put the refactor rules there (run the golden check after every edit, never edit
golden.json, no behaviour changes without asking). Practical tips are in how to write a CLAUDE.md for any project.
Also see the hooks guide if you want the golden check to run automatically after edits; I haven't tested a hook for this workflow, so verify the configuration against the docs before relying on it.
Pre-flight checklist for a legacy Python refactor
Copy this into your task tracker.
- Working tree clean, work on a branch or worktree
- Golden master recorded from the untouched code, run twice, identical
- Grid includes empty input, each branch, each boundary, fractional values, and failures
- Hidden state listed (module globals, default arguments, caches, singletons) with a separate test for each
- Callers searched before changing any signature or return shape
- Suspected bugs listed, not fixed, in a separate file
- Each step: one structural move, golden check output pasted in the commit message or PR
-
golden.jsonunchanged in every refactor commit (diff it) - After the refactor, bug fixes land as separate commits whose tests intentionally change behaviour
- Performance, logging and anything outside the grid checked by hand
Once the baseline is green, adding real unit tests is the next step, and checking those tests with a mutation run tells you whether they would catch the next silent change.
When to skip this
If the code is 40 lines with a handful of obvious test cases, write ordinary unit tests and move on. A golden master earns its cost on code that is large, untested, numerical, and has callers you can't see. It is also a poor fit where output includes timestamps, random IDs or network responses unless you can freeze them first.



