Refactoring legacy Python code is rarely a greenfield experience. You have tangled dependencies, incomplete tests, and a lingering fear that a small change will break something three modules away. The usual advice—turn on strict type checking and fix all errors—usually stalls before it starts, leaving teams stuck with thousands of unresolved mypy errors. There is a better path: speed up legacy code refactoring with incremental type checking by running mypy in non-strict mode on dirty files first, then tightening per folder as you go. This approach gives you a practical safety net without demanding a heroic, all-at-once migration.
Why All-or-Nothing Typing Fails in Legacy Codebases
The traditional approach to introducing type checking sounds logical: enable strict mode, fix every error, and enjoy the benefits. In practice, legacy codebases rarely cooperate. You might be facing 5,000 type errors spread across 200 modules. Many errors come from missing stubs, dynamic patterns, or old APIs that were never designed with typing in mind.
More importantly, the all-or-nothing strategy slows down feature work. Rather than shipping your refactor, you spend days chasing “reveal_type” outputs and arguing about variance. The team loses trust in the tool because it feels like a blocker, not a helper. This is especially problematic when your goal is to refactor a specific subsystem—you need confidence in that subsystem, not a company-wide type-safe utopia.
The Incremental Type Checking Strategy
The key is to shift from a big-bang migration to a targeted, incremental workflow. Instead of making mypy pass across the whole repository, you restrict type checking to the files that actually matter for your current refactor. These are the “dirty files”—the ones you modify, delete, or deeply touch in your branch. You run mypy in non-strict mode on just those files, giving you immediate feedback on data flow and obvious mistakes without drowning in pre-existing debt.
Once the refactor is complete and your dirty files are clean, you extend the typing scope outward. Tighten the configuration folder by folder, gradually increasing strictness as the code becomes more self-consistent. This turns type checking from a gatekeeper into a refining process, and it aligns perfectly with the rhythm of shipping refactors safely.
Step 1: Run Mypy in Non-Strict Mode on Dirty Files
Mypy’s default mode is already less strict than --strict. It allows untyped functions, ignores missing imports, and doesn’t check annotations on third-party libraries. That’s exactly what you want when starting with messy code. The trick is to limit the damage to the files you’ve actually changed. Use a command like this:
mypy --follow-imports=skip --ignore-missing-imports path/to/your/dirty/files.py
You can also configure mypy in your pyproject.toml to always ignore missing imports and set a “non-strict” baseline. This does not mean you abandon annotations—it means you let mypy focus on what it can infer and flag inconsistencies that are likely to cause real bugs.
When running on dirty files, you’ll still see errors, but they will be far fewer and more relevant. Fix those before proceeding. You’ll catch issues like Optional values used without a check, arguments passed in the wrong order, or functions returning inconsistent types. These are exactly the kind of errors that make legacy refactoring dangerous.
Step 2: Tighten Per Folder Incrementally
Once your dirty files are under control, you can expand the safety net. Instead of globally enabling --strict, use mypy’s per-directory overrides in your configuration file. For example:
[[tool.mypy.overrides]]
module = "myapp.billing.*"
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
no_implicit_optional = true
strict_equality = true
This block says: “For the billing package, I am ready for a stricter setup.” Other folders remain on the default, easier mode. You can add overrides for each folder as its code becomes well-typed. This respects the natural boundaries of your refactoring work. If you’re refactoring the payment service, you don’t need to tighten the reporting module on the same day.
A folder-by-folder approach also makes code review easier. Reviewers can see that the type rules for a folder were deliberately tightened in the same commit that cleaned up the code. The configuration itself becomes a roadmap of technical debt repayment, which is motivating for the whole team.
Building a Safety Net for Refactoring
What exactly does mypy buy you when you’re refactoring legacy code? It’s not about proving your code is mathematically correct. It’s about catching the silly, data-flow mistakes that happen when you move functions between classes, change return types, or introduce a new abstraction. With mypy in non-strict mode on dirty files, you get a fast feedback loop while you edit. You don’t need to run the entire test suite to spot a likely issue; mypy tells you instantly if a variable might be None where a string is expected.
When you tighten per folder, you create a growing set of invariants that future refactors must respect. That makes your next refactor easier, not harder. It’s a compounding benefit: every time you clean up a folder, you convert implicit assumptions into explicit, checked contracts. Over a few quarters, the once-legacy codebase becomes a place where refactoring feels enjoyable.
Practical Tips for Shipping Refactors Faster
Here are a few tactics to make this workflow as effective as possible in a busy team environment:
- Wire mypy into CI for dirty files only. Instead of running mypy over the entire repository on every pull request, run it over the files changed in the branch. You can compute the list using
git diff --name-onlyand pass it to mypy. This keeps the feedback loop fast and prevents unrelated errors from blocking your feature. - Use a baseline file for the rest of the codebase. Mypy supports
--disable-error-code, but a more robust method is to setfollow_imports = skipfor most modules. This keeps the scope small while still catching direct errors in your dirty files. - Make the tightening threshold explicit in your repo documentation. Add a short note in the README or ADR describing which folders are “safe” and which are “transitioning.” This shared vocabulary helps teams coordinate.
- Pair non-strict mode with a code coverage check on the refactored area. Type checking finds the easy mistakes; tests should prove behavior. Together, they give you a strong safety net without demanding perfection.
One more thing: don’t be afraid to use # type: ignore when you hit a third-party library that truly doesn’t support typing. The goal is not zero ignores; it’s to keep them isolated and justified. With per-folder strictness, you can enable warn_unused_ignores and remove stale annotations over time.
Refactoring With Confidence at Your Own Pace
The beauty of this workflow is that it scales with your confidence. One week you might only have time to refactor a single helper function; run mypy on that file, add a folder override, and move on. The next month, you can tackle an entire package. The tooling bends to your schedule, not the other way around.
If you’re about to start a refactor on a legacy Python codebase, resist the urge to run mypy --strict across everything. Instead, identify the dirty files in your branch, run mypy in non-strict mode on those files, and fix the real issues they surface. Then, once the refactor lands, tighten the typing rules for that folder. It’s a calm, methodical cycle that keeps your team moving and your codebase improving.
Conclusion
Refactoring legacy code doesn’t have to be an all-or-nothing grind. By using mypy in non-strict mode on dirty files and tightening per folder, you can ship refactors safely while paying down type debt incrementally. This approach turns type checking into a practical tool for everyday work, not an unattainable standard. Start small, stay scoped, and let the safety net grow with your codebase.
