What you'll learn
Quick Answer
Refactoring changes structure while preserving behaviour. Make sure you can verify the behaviour first, then work in small steps, checking after each. If you are also changing what the code does, that is not refactoring.
What it is, precisely
Refactoring is restructuring existing code without changing its external behaviour. Same inputs, same outputs, different internals.
That precision matters because the word is often used for two different activities. Renaming a variable and extracting a function is refactoring. Rewriting a module while also fixing bugs and adding a feature is a rewrite, and it is far riskier, because when something breaks you cannot tell which change caused it.
Do one or the other, never both at once. Refactor, verify it still works, commit. Then change behaviour in a separate commit. This also makes code review possible — a diff mixing a rename across forty files with one logic change is unreviewable.
You need a way to verify behaviour
Refactoring without a way to check the behaviour is unchanged is just editing and hoping. Tests are the usual answer.
If the code has no tests — common with student projects and legacy code — write a few characterisation tests first. These are not tests of what the code should do; they capture what it currently does, including behaviour you consider wrong.
def test_current_behaviour():
# documents today's output, correct or not
assert calculate_fee(1000, "gold") == 850
That gives a safety net. Refactor freely; if a test fails, you changed behaviour. Fix the wrongness afterwards, as a separate deliberate change with its own commit.
Where automated tests are impractical, a manual checklist of the main paths is better than nothing. See TDD explained for the discipline of writing tests first.
Smells worth acting on
A smell is not a bug; it is a hint that something will be hard to change later.
- Long function. If you cannot see it on one screen, it is probably doing several things. Extract the parts you can name.
- Duplicated logic. The same rule in three places will eventually be updated in two.
- Comments explaining what the code does. Usually a name waiting to be extracted —
# check if user can editabove six lines should beif can_edit(user, post):. - Long parameter lists. Six parameters usually means a missing object.
- Deep nesting. Four levels of indentation is hard to follow. Return early instead.
- Names that lie. A
getUserthat also writes to the database. The commonest cause of real bugs on this list.
Not every smell needs fixing. Code that works and nobody touches can stay slightly ugly; the cost of a smell is paid only when someone has to change it.
The techniques you will use most
Extract function — take a block, give it a name. The single highest-value refactoring, because naming forces you to articulate what the block does, and occasionally reveals it does two things.
Rename — trivially safe with editor support and disproportionately valuable. d to days_until_due removes the need for a comment. Use the editor's rename symbol, not find-and-replace, so scope is respected.
Guard clauses — replace nesting with early returns:
# before
def process(order):
if order is not None:
if order.is_valid:
if not order.processed:
do_work(order)
# after
def process(order):
if order is None: return
if not order.is_valid: return
if order.processed: return
do_work(order)
Same behaviour, and the preconditions are now visible at the top instead of being implied by indentation.
Replace magic number with a named constant — if age > 18 becomes if age > LEGAL_ADULT_AGE, which is searchable and self-documenting.
When to refactor, and when not to
The most sustainable approach is refactoring as you go: when you need to change something and the surrounding code makes it hard, tidy that part first, then make your change. Small and continuous beats a scheduled cleanup that never gets approved.
Do not refactor code you are not otherwise touching, immediately before a deadline, or purely because it looks unfamiliar. "I would have written it differently" is not a reason, and every change carries risk.
Be particularly careful with the large rewrite. Replacing a working module wholesale is where projects lose weeks, because the old code contains handling for edge cases nobody remembers — and those cases were real.
Commit each refactoring separately with a message saying it is one. A reviewer who knows the diff should not change behaviour can review it quickly and confidently.
