Refactor Python Code Safely

Learn how to refactor Python code safely using tests, small changes, and proven techniques that prevent introducing bugs during restructuring.

7 min read

Refactoring means changing the structure of code without changing its behavior. You rename variables, extract functions, simplify conditionals, and reorganize modules. The external behavior stays identical, but the internal structure becomes cleaner and easier to change.

Refactoring without safety nets is dangerous. A renamed variable that misses one occurrence creates a bug. An extracted function with a subtle behavior difference breaks callers. This article covers the discipline that makes refactoring safe, building on the clean code principles from Write Clean and Readable Python Code.

Write tests before you refactor

The golden rule of refactoring: never change code unless you have tests that verify its current behavior. Without tests, you cannot know whether your changes broke something.

If the code already has tests, read them to understand what behaviors are covered. Run them to confirm they pass before you start. If the code has no tests, write characterization tests that capture the current behavior, even if that behavior seems wrong:

pythonpython
def test_calculate_discount_ten_percent():
    result = calculate_discount(100, "standard")
    assert result == 10
 
def test_calculate_discount_zero_for_unknown_type():
    result = calculate_discount(100, "nonexistent")
    assert result == 0

These tests do not judge whether the behavior is correct. They record what the code currently does. After refactoring, you may decide the behavior should change, but that is a separate step.

Extract functions one at a time

The most common refactoring is extracting a block of code into a named function. A long function with mixed responsibilities becomes several small functions, each with a clear name.

Start with the innermost block that has a clear purpose. Copy it to a new function, replace the original with a call, and run the tests. If the tests pass, commit. Then extract the next block:

pythonpython
# Before: one function does three things
def process_orders(filepath):
    with open(filepath) as fh:
        orders = json.load(fh)
    valid = []
    for order in orders:
        if order.get("status") == "paid" and order.get("items"):
            order["total"] = sum(item["price"] for item in order["items"])
            valid.append(order)
    report = []
    for order in valid:
        report.append(f"{order['id']}: ${order['total']:.2f}")
    with open("report.txt", "w") as fh:
        fh.write("\n".join(report))

Extract the loading step first. Give it a name that says what it does.

Run the tests. Commit. Then extract validation, then reporting:

pythonpython
def load_orders(filepath):
    with open(filepath) as fh:
        return json.load(fh)
 
def filter_valid_orders(orders):
    valid = []
    for order in orders:
        if order.get("status") == "paid" and order.get("items"):
            order["total"] = sum(item["price"] for item in order["items"])
            valid.append(order)
    return valid
 
def write_report(orders, output_path):
    lines = [f"{o['id']}: ${o['total']:.2f}" for o in orders]
    with open(output_path, "w") as fh:
        fh.write("\n".join(lines))
 
def process_orders(filepath):
    orders = load_orders(filepath)
    valid = filter_valid_orders(orders)
    write_report(valid, "report.txt")

Each function is small enough to understand at a glance. The main function reads like a three-step recipe.

Rename safely with editor tools

Renaming a variable or function with find-and-replace is risky. A variable named data appears in many places, and replacing all of them blindly will break code. Use your editor's rename symbol feature instead.

In VS Code, right-click a name and select "Rename Symbol" or press F2. The editor uses the language server to find every reference to that specific variable and renames only those. A variable data in one function is not confused with a different data in another.

If your editor does not support symbol-based rename, use a multi-step approach. Search for the name, review each occurrence manually, and accept or skip each one. This is slower but safer than a global replace.

Simplify conditionals

Complex conditional logic is a common source of bugs during refactoring. Replace nested if-else chains with guard clauses, and extract complex conditions into named functions.

A nested conditional with multiple levels is hard to reason about. Each level adds a mental branch the reader must track:

pythonpython
def get_shipping_cost(order):
    if order.total > 0:
        if order.is_express:
            return 25
        else:
            if order.total > 100:
                return 0
            else:
                return 10
    return 0

Flatten with guard clauses and descriptive names. Each condition is a single decision. The happy path is clear at the bottom:

pythonpython
def get_shipping_cost(order):
    if order.total <= 0:
        return 0
    if order.is_express:
        return 25
    if order.total > 100:
        return 0
    return 10

For more on simplifying conditionals, see writing conditional statements in Python.

Remove dead code

Dead code is code that never runs. Commented-out blocks, unreachable branches after a return, and functions that nothing calls anymore. Version control remembers the history; delete the dead code.

Before deleting, search the codebase for references to the function or variable. If nothing imports or calls it, it is safe to remove. If you are unsure, add a deprecation warning for one release before deleting.

Commit after every successful step

The most important refactoring habit is committing after each small change that passes tests. If the next step breaks something, you can revert to the last good state without losing all your progress.

A typical refactoring session looks like this: rename a variable, run tests, commit. Extract a function, run tests, commit. Simplify a conditional, run tests, commit.

Each commit is a safe rollback point. After ten small commits, you have clean code without ever being more than one step away from a working state.

The final article in this section, refactor a real Python project, walks through applying these techniques to a complete codebase.

Rune AI

Rune AI

Key Insights

  • Write tests for existing behavior before making any changes.
  • Make one small refactoring at a time and run tests after each step.
  • Use editor tools for renaming and extracting to avoid manual errors.
  • Commit after each successful step to create rollback points.
  • Refactor before adding features, not as a separate project.
RunePowered by Rune AI

Frequently Asked Questions

When should I refactor code?

Refactor when you are about to add a feature to code that is hard to understand, or when you have fixed the same kind of bug twice in the same area. Do not refactor working code just because it is imperfect. The best time is right before making a related change.

How do I refactor without breaking things?

Write tests for the existing behavior first. Make one small change at a time. Run the tests after every change. Use your editor's rename and extract-function tools instead of manual find-and-replace. Commit after each successful step so you can roll back individual changes.

Conclusion

Safe refactoring is a discipline, not a hackathon. Write tests first, make one change at a time, run the tests after every step, and commit frequently. The goal is not to rewrite the codebase. It is to leave every piece of code slightly better than you found it.