Refactoring Legacy Code With LLMs in October 2026: Pin Behavior With Characterization Tests, Change One Thing at a Time, and Let the Tests Decide

Refactoring is one of the jobs where an LLM can save you hours or quietly break things you will not notice for weeks. The model is fast at renaming, extracting functions, and untangling nested conditionals. It is also happy to "clean up" a strange-looking branch that turns out to be the only thing keeping a billing edge case alive. The difference between those outcomes is almost never the model. It is whether you pinned down the current behavior before anything changed.

This guide walks through a workflow that works with any chat assistant or coding agent: lock behavior with characterization tests, ask for one small change at a time, keep every diff small enough to review, and let the tests, not the model's confidence, decide whether a step is safe.

Tools and parts scattered across a cluttered garage workbench

Legacy code often looks like this workbench: everything works, but nobody wants to touch it. Image: Tools and parts are scattered on a workbench in a garage by Shixart1985, CC BY 2.0, via Wikimedia Commons.

What refactoring actually means here

A refactor changes the structure of code without changing what it does. That definition is the whole point. If a step changes behavior, even to "fix" something, it is not a refactor anymore, and it should be a separate commit with its own review and its own tests.

LLMs blur this line constantly. Ask one to "clean up this function" and you will often get a nicer function that also handles None differently, swallows an exception that used to propagate, or reorders side effects. Each change looks reasonable in isolation. Together they are a behavior change hiding inside a style change. So the first rule is to say it out loud in your prompt: do not change behavior; if you see a bug, list it separately instead of fixing it.

Step 1: Pin current behavior with characterization tests

Legacy code usually has thin or missing tests. Before you refactor, write characterization tests: tests that record what the code does today, not what it should do. If the function returns a slightly odd value for an empty list, the test asserts that odd value. You are building a tripwire, not a specification.

An LLM is genuinely useful for this step, as long as you keep it honest:

  1. Paste the function and its direct callers so the model can see real inputs.
  2. Ask it to propose a list of input cases, including edge cases: empty values, None, boundary numbers, unusual strings, and error paths.
  3. Do not let the model guess the expected outputs. Run the current code on each input and record the actual result. A tiny harness does this for you.
import json
from legacy.pricing import compute_discount

cases = [
    {"total": 0, "tier": "gold"},
    {"total": 99.99, "tier": "silver"},
    {"total": 100, "tier": "silver"},
    {"total": 250, "tier": None},
    {"total": -5, "tier": "gold"},
]

results = []
for c in cases:
    try:
        out = compute_discount(**c)
        results.append({"input": c, "output": out})
    except Exception as e:
        results.append({"input": c, "raises": type(e).__name__})

print(json.dumps(results, indent=2, default=str))

Now turn that recorded output into tests. Expected values come from the real run, so they cannot be invented. If a recorded result looks like a bug, keep the test as is and add a comment such as # current behavior, possibly wrong, see issue. Fixing it is a later, separate change.

Finally, prove the tests bite. Change one line in the original function on purpose, for example flip a >= to >, and confirm at least one test fails. If nothing fails, your safety net has a hole exactly where you are about to cut.

Step 2: Give the model the context it needs, and only that

Models refactor badly when they cannot see how code is used. Before asking for changes, include:

  • The function or class you are changing, in full.
  • Its callers, or at least a few representative call sites, so renamed parameters or changed return types do not break them.
  • Your language and runtime version, and any linters or formatters you run. "Python 3.11, ruff, black" saves a round of fixes.
  • Project conventions that matter, such as "we never use mutable default arguments" or "errors are raised, not returned".

Leave out unrelated files. A huge paste invites the model to "improve" things you did not ask about.

Step 3: Ask for one refactoring at a time

The safest prompts name a single, well-known move. Classic refactorings work well because they have clear definitions:

  • Extract function: pull a block with one job into a named helper.
  • Rename: give a variable, function, or parameter a name that says what it holds.
  • Replace nested conditionals with guard clauses: return early for the special cases so the main path reads straight down.
  • Introduce a parameter object: group arguments that always travel together.
  • Remove dead code: only after you have confirmed, with search and ideally runtime data, that nothing calls it.

A good prompt looks like this:

Refactor compute_discount below using ONE change only:
replace the nested if/else with guard clauses.

Rules:
- Do not change behavior for any input, including None, 0, and negatives.
- Keep the function name, signature, and return types the same.
- Do not touch other functions.
- If you notice a possible bug, list it under "Observations" instead of fixing it.
- Return a unified diff, not the whole file.

Asking for a diff keeps the change small and makes it obvious when the model wandered outside the lines. If the diff touches code you did not mention, reject it and ask again.

Step 4: Run the tests after every step

Apply the diff, run the characterization tests, and run whatever else you have: unit tests, type checker, linter. Then commit. One refactoring, one green run, one commit. The rhythm matters because when something does break, you know exactly which small step did it, and git revert undoes just that step.

When a test fails, paste the failing output back to the model with the diff that caused it and ask what behavior changed. Do not accept "the test was wrong" unless you can explain why yourself. The tests record what production does today; the burden of proof is on the change.

The United States Capitol dome surrounded by renovation scaffolding

A good refactor is like renovating a building that stays open: put up the scaffolding first, then work one section at a time. Image: US Capitol with renovation scaffolding by the U.S. Department of Defense (American Forces Information Service), public domain, via Wikimedia Commons.

Step 5: Watch for the usual silent behavior changes

Some changes slip past tests because the inputs that trigger them are not in your cases. Review every diff for these patterns specifically:

  • Truthiness swaps. if x is not None rewritten as if x now treats 0, empty strings, and empty lists differently.
  • Exception handling. A new try/except that catches more than before, or a removed one that used to convert errors into a return value.
  • Order of side effects. Logging, database writes, or network calls moved before or after a check that can fail.
  • Default values. A default argument changed from None to [] or from a constant to a computed value.
  • Floating point and rounding. Reordered arithmetic or a swapped round call can shift results by a cent.
  • Iteration order. Replacing a list with a set, or a dict comprehension that changes key order where output order matters.

When you spot one, add a characterization test for that exact input against the original code, then rerun it against the refactored version.

Step 6: Keep larger refactors as a series, not a rewrite

For a big module, resist asking the model to "rewrite this file in a cleaner style". You will get a plausible new file and no way to review it line by line. Instead, ask the model to propose a plan: an ordered list of small refactorings, each one independently safe. Review the plan, cut anything that changes behavior, then execute it one item per prompt using the loop above.

If you use a coding agent that can run commands, give it the same rules and require it to run the test suite after each step and stop on the first failure. Agents are good at the mechanical loop; you stay responsible for the plan and the review.

A quick checklist

  1. Write characterization tests from real runs of the current code, then prove they fail when you break something on purpose.
  2. Paste the code, its callers, and your versions and conventions. Leave out the rest.
  3. Ask for one named refactoring per prompt, as a diff, with "do not change behavior" spelled out.
  4. Reject diffs that touch code you did not ask about.
  5. Run tests, type checks, and linters after every step, then commit.
  6. Review for truthiness, exception, side-effect, default, rounding, and ordering changes.
  7. Log any bugs the model notices and fix them later in separate, clearly labeled commits.

Done this way, the LLM handles the tedious typing and you keep the one thing a refactor has to guarantee: the code does exactly what it did yesterday, only easier to read.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work