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 edit above six lines should be if 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 getUser that 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 constantif 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.

Frequently Asked Questions

What is the difference between refactoring and rewriting? Refactoring preserves behaviour and proceeds in small verified steps. A rewrite replaces the implementation and usually changes behaviour too, which makes it far riskier and harder to review.
Can I refactor without tests? You can, but you have no way to know you preserved behaviour. Write a few characterisation tests capturing what the code currently does, then refactor against them.
How do I convince a team to allocate time for refactoring? Do it as part of the work rather than asking for separate time. Tidy the part you are already changing. Large scheduled cleanups are usually cut when priorities move.
Is renaming really refactoring? Yes, and it is among the most valuable. Names are how code is understood, and a misleading name causes more bugs than an ugly implementation.
How small should a refactoring step be? Small enough that you can verify it immediately. Extract one function, run the tests, commit. Long uninterrupted sequences of changes are where you lose track of what broke.