How to Refactor Legacy Python Code Safely
Learn a disciplined, step-by-step process for refactoring legacy Python code without breaking it. Write characterization tests, make one change at a time, and know when to stop and ship.
You stare at that old Python file. The one nobody wants to touch. The one with functions that stretch across three screens, variables named temp1, temp2, and x, and comments like "# TODO: fix this mess" from 2014.
It's scary. But you can do it safely. Here's how.
Start with Tests (Even Crappy Ones)
Before you change a single line, you need a safety net. The problem is legacy code often has zero tests. So write characterization tests.
# Old function you need to refactor
def process_data(a, b, c, d, e):
# 200 lines of spaghetti
return result
# Write this first
def test_process_data_keeps_old_behavior():
# Call with specific inputs
result = process_data(1, 2, 3, 4, 5)
assert result == 15 # Run it once to know what it actually returns
# Add a few more test cases based on real usage
assert process_data(0, 0, 0, 0, 0) == 0
assert process_data(-1, -2, -3, -4, -5) == -15
The tests don't need to be perfect. They just need to document what the code actually does today. Run them. If they pass, you have a baseline.
The Golden Rule: One Change at a Time
Here is where most people mess up. They try to rename variables, extract functions, fix bugs, and improve performance all at once. Don't.
Pick one thing: - Extract a single function - Rename one variable - Remove one dead code block
Make that change. Run tests. If they pass, commit. If they fail, revert.
I once watched a PythonSkillset team member spend three days untangling a single function that had been "slightly improved" by five different people over two years. The function did one thing badly. But it worked.
The Extract Pattern That Saves You
Legacy functions usually do too many things. Find the natural seams.
# Before
def save_user(name, email, age, address, phone):
# Validate name
if not name or len(name) > 100:
return False
# Validate email
if '@' not in email:
return False
# Validate age
if age < 0 or age > 150:
return False
# Save to database
conn = get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO users ...")
conn.commit()
return True
# After (step by step)
def is_valid_name(name):
return name and len(name) <= 100
def is_valid_email(email):
return '@' in email
def is_valid_age(age):
return 0 <= age <= 150
def save_user(name, email, age, address, phone):
if not is_valid_name(name):
return False
if not is_valid_email(email):
return False
if not is_valid_age(age):
return False
conn = get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO users ...")
conn.commit()
return True
Extract one validation at a time. Test after each extraction. This is boring. It is also how you avoid breaking everything.
The Dangerous Three: Global State, Side Effects, and Mutable Defaults
Legacy Python code loves these. They are landmines.
# Dangerous
def add_item(item, cart=[]):
cart.append(item)
return cart
# Safer
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
Same for global variables. If you see global in a function, your refactoring just got harder. You have three options:
1. Pass it as a parameter
2. Create a class
3. Accept the pain and move slowly
Option 1 is usually best. Option 3 is honest.
When To Stop and Ship
You do not have to refactor everything. Seriously.
Here is a real example from PythonSkillset's own codebase. There was a module that processed CSV files. It was 900 lines long, had no tests, and used eval() in two places. The team spent two weeks refactoring it. A month later, the business stopped using CSV files entirely.
What should they have done? Written tests around the eval() parts (to understand them), extracted the CSV reading logic (because that was stable), and left the rest alone until a feature change forced the issue.
The Pattern for Safely Refactoring Legacy Code
Here is your cheat sheet:
- Write one test that captures current behavior (even if it feels stupid)
- Pick the smallest possible change you can make
- Make the change
- Run tests
- If tests pass, commit. If they fail, revert.
- Repeat until you run out of time or motivation
- Ship what you have
No magic. No "automated refactoring tools will save you." Just disciplined, boring, step-by-step improvement.
The code will still be ugly. But it will be less ugly than when you started. And it will still work.
That is the win.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.