How to Use AI to Refactor Legacy Code
Legacy code refactors go wrong when an agent rewrites with confidence and no safety net. Characterize behavior, refactor in small diffs, verify each one.
Point an AI coding agent at a legacy file and say "refactor this" and you will get something back fast. Whether it still works is a separate question, and on legacy code that question is harder than it sounds, because the reason the code is ugly is often the same reason nobody fully understands what it does anymore. An agent that rewrites with confidence but no safety net is not refactoring. It is rewriting blind.
The fix is sequencing: characterize behavior before you touch it, refactor in diffs small enough to review, and verify against the old behavior, not just against whether the new code looks cleaner.
Why legacy code is a special case, not just old code
A greenfield refactor and a legacy refactor look similar in the prompt and are not similar in risk. Legacy code usually has three properties working against you at once: no tests, or tests that do not cover the actual edge cases in production; behavior that depends on bugs, where some caller quietly relies on the broken thing working exactly the way it is broken; and tribal knowledge that left with whoever wrote it, so the comments, if any, describe intent from three rewrites ago.
An agent cannot see any of that from the file alone. It sees code that looks like it could be simplified, and simplifying it is exactly what you asked for. The gap between "looks simplifiable" and "safe to simplify" is where legacy refactors go wrong.
Characterize before you refactor
Before asking an agent to change a legacy function, ask it to explain what the function does first, as a separate step. This does two things: it surfaces what the agent thinks the behavior is, which you can sanity-check against what you know, and it forces a slower first pass instead of jumping straight to rewriting.
Ask for a plain-language behavior summary, including edge cases the agent can identify from the code: null handling, off-by-one boundaries, error paths.
Ask what callers depend on this function, searched across the codebase, not assumed.
If there are no tests, ask the agent to write characterization tests first: tests that lock in current behavior exactly as it is, bugs included. These are not correctness tests, they are a tripwire for anything the refactor changes.
Characterization tests feel unsatisfying to write, because you are formalizing behavior you may not endorse. That is the point. You are not deciding whether the behavior is right yet, you are making sure a refactor cannot change it silently.
Refactor in diffs small enough to review
The instruction that changes outcomes most is a size limit: ask for one change at a time, reviewed before the next one starts, rather than "clean up this whole file." A 600-line rewrite is not reviewable by a human in any meaningful sense. A 40-line diff is.
Ask for this | Not this |
|---|---|
Extract this one duplicated block into a shared function, nothing else | Refactor this file to be cleaner |
Rename this variable across the file, no other changes | Modernize this module |
Split this 200-line function into two, same behavior, and show me the diff | Simplify this function |
Each row on the left produces a diff you can actually read against the characterization tests. Each row on the right produces a large, plausible-looking change that is expensive to verify and easy to approve without really checking, which is exactly how a legacy refactor introduces a regression nobody catches until it hits production.
Verify against old behavior, not new-code aesthetics
Run the characterization tests after every diff, not once at the end. A refactor that passes tests after step 1 and step 2 but fails after step 3 tells you exactly which change broke something, while a single test pass at the very end tells you only that something in five changes did. Cheap to set up if you have any test runner already; genuinely worth doing manually if you do not, because manual comparison of a handful of known inputs and outputs still beats no verification at all.
This is the same discipline as reviewing AI-generated code before you ship it, applied specifically to the moment where the code was working, badly, and your only job is to not make it stop working while making it less bad.
A worked example
Take a 150-line function handling order discounts, no tests, three nested conditionals, a comment that says "do not touch, breaks checkout" with no further explanation. The staged approach:
Ask the agent to summarize the discount logic in plain language and list every distinct code path it can find.
Ask it to write characterization tests for each path it found, using realistic sample orders, and run them to confirm they pass against the current code.
Extract the first nested conditional into a named function, nothing else. Run tests. Review the diff.
Repeat for the second and third conditionals, one at a time.
Only once every path is extracted and named, revisit whether the discount logic itself has an actual bug worth fixing, as a separate, explicit decision, not something introduced accidentally on the way past.
That comment about checkout breaking usually turns out to mean something specific and discoverable, like a discount code that only applies when the cart has exactly one item because of a payment gateway quirk. Characterization tests catch that if the agent's plain-language summary did not. A one-shot "clean this up" rewrite has no mechanism to catch it at all.
When a full rewrite is actually the right call
Small-diff refactoring is not always the answer. If the function is genuinely disposable, low-traffic, and a clean rewrite would take less time to verify than to incrementally refactor, rewriting from a clear spec can be faster and just as safe. The judgment call is really about blast radius: code with few callers and low usage tolerates a bigger swing, code sitting in a critical path does not. When in doubt, treat it as critical.
If you are working across an entire legacy codebase rather than one file, how to use git with an AI coding agent covers the branching and commit discipline that keeps a multi-step refactor reviewable instead of becoming one enormous, unreviewable branch.
None of this requires exotic tooling. It requires treating the agent as a fast pair programmer who has not read the incident postmortems, not as an oracle who already knows why the code looks the way it does. For the broader set of habits this pairs with, see AI coding tools.
FAQ
What if there really are no tests and I can't write characterization tests either?
Manually record the output of a handful of realistic inputs before you start, and check the same inputs after each diff. It is slower than automated tests but still catches most regressions that a blind rewrite would miss.
Should I let the agent decide how to split the diffs?
Ask it to propose a sequence of small changes and review the sequence before it starts, rather than approving changes one at a time with no plan. A visible plan lets you catch a bad decomposition before any code moves.
How small is small enough?
Small enough that you can hold the entire diff's behavior change in your head while reading it. For most functions that is under 50 lines. For genuinely simple renames, more is fine.
Does this apply to refactoring for performance, not just readability?
Yes, and it matters more there, because a performance refactor that changes behavior at the margins, like altering rounding or ordering, is exactly the kind of thing characterization tests catch and a visual code review does not.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


