# Reading Legacy Code

> How to make sense of an unfamiliar, undocumented codebase without reading it top to bottom - and how to tell when it actually needs a rewrite.


---

# Reading Legacy Code

You're dropped into a codebase with no comments, no docs, and the person who wrote it left two years ago. This is normal work, not a crisis - and there's a method to it.

Most of a developer's career is spent reading code someone else wrote, not writing new code on a blank page. "Legacy" doesn't mean bad. It means: written by someone who isn't you, for reasons that aren't written down, that you now have to change without breaking.

This guide covers three things:

1. **Where to start** when the whole codebase feels like an undifferentiated wall of files - pick one thread and pull it.
2. **Techniques for building understanding** - using git history as archaeology, writing a safety-net test before you touch anything, and using small refactors to learn by doing.
3. **When a rewrite is actually justified** versus when it's the sunk-cost fallacy wearing an engineering hat, using Chesterton's Fence as the test.

None of this requires the original author, a design doc, or weeks of read-only ramp-up. It requires picking a starting point and being disciplined about how you poke at the unknown.

- [Phase 1: Where to Start When You Don't Understand Any of It](01-where-to-start.md)
- [Phase 2: Techniques for Making the Unknown Known](02-techniques-for-understanding.md)
- [Phase 3: When (and When Not) to Rewrite](03-when-to-rewrite.md)


---

# Where to Start When You Don't Understand Any of It

You clone the repo. It's 400 files, three years old, no README worth trusting. The instinct is to open the folder tree and start reading files in order, top to bottom, hoping it clicks. It won't. You'll forget file #3 by the time you reach file #40, and none of it will connect to anything you actually need to change.

Codebases aren't meant to be read like a book. They're meant to be read like a map you're navigating with a destination in mind. Without a destination, every file looks equally important, which means none of them are.

## Pick one thread, not the whole codebase

Instead of "understand the codebase," start with "understand what happens when a user clicks this one button." Pick something small and concrete:

- A single API route (`POST /api/orders`)
- A button in the UI (the "Cancel Subscription" link)
- A scheduled job (the nightly billing sync)

It doesn't matter which one - pick whichever one you were assigned a bug or ticket for. That's your thread. You're going to follow it from the outside in, through every file it touches, and stop caring about everything else for now.

## Trace one feature end to end

Say your ticket is "canceling a subscription doesn't refund the prorated amount." Here's the actual trace, file by file, the way you'd really do it:

1. **Find the entry point.** Grep the frontend for the button text: `grep -r "Cancel Subscription" src/`. You land on `SubscriptionSettings.jsx`, which calls `cancelSubscription()` on click.
2. **Follow the function.** `cancelSubscription()` lives in `api/subscriptions.js` and does a `POST /api/subscriptions/:id/cancel`.
3. **Find the route handler.** Grep the backend for that route: `grep -r "subscriptions/:id/cancel" server/`. You land on `routes/subscriptions.py`, `cancel_subscription()`.
4. **Read that function, not the whole file.** It calls `SubscriptionService.cancel()`, which sets a status flag and calls `RefundCalculator.prorate()`.
5. **Follow the refund logic.** `RefundCalculator.prorate()` is where the actual bug probably lives - now you have a specific, small target instead of a codebase-sized one.

Five files. Not the 400 in the repo - the five that this one feature actually touches. That's the whole codebase you need for this task.

## Use the tools that do the tracing for you

You don't have to do this by memory or by clicking through folders:

- **Grep for UI text or route strings** to jump from "what the user sees" to "what code runs." Exact button labels and URL paths are the most reliable search terms - they don't get renamed as often as variables do.
- **Set a breakpoint or add a print/log statement** at the entry point and run the flow for real. Watching the actual call stack execute beats guessing from static reading, especially in codebases with dependency injection or dynamic dispatch where grep won't show you the real path.
- **Use "find usages" / "go to definition"** in your editor once you've found the first function. This is faster than grepping for every subsequent hop.
- **Check the tests.** A test file named `test_subscription_cancel.py` often lays out the exact call chain you're trying to reconstruct, minus the incidental UI code.

## Let the thread pull you through the architecture

By the time you've traced one feature end to end, you've learned more than a day of skimming would teach you: how the frontend talks to the backend, what the service layer looks like, where business logic lives versus where it's just plumbing, and what naming conventions the codebase actually follows (not what the style guide claims).

Do this for two or three tickets and patterns repeat. The next trace goes faster because you already recognize the shape: route handler → service → data layer. You're not memorizing the codebase - you're building a mental map one real path at a time, and only the paths you actually needed.

This is also why fixing a real bug is often a better first task than "explore the codebase for a week." A bug gives you a thread to pull. Free exploration gives you nothing to anchor to, so it doesn't stick.

Try tracing one feature in a codebase you're new to (work or personal) using the grep-and-follow approach above.

```exercise
[
  {
    "type": "task",
    "task": "Pick one feature in a codebase you didn't write (work, open source, or a sample repo). Trace it from the entry point (button, route, or CLI command) through every file it touches, down to where the actual logic lives.",
    "reveal": "Start by grepping for user-facing text or the route/URL string, not for variable names - those are the most stable search anchors. Follow one function call at a time; resist opening files 'just in case' that the trace didn't lead you to.",
    "checklist": ["Found the real entry point, not just a guess", "Followed the call chain through at least 3 files", "Could explain the flow out loud without re-reading the code"]
  }
]
```

Quick check before moving on:

```quiz
[
  {
    "q": "You're new to a large, undocumented codebase. What's the best first move?",
    "choices": ["Read every file top to bottom before touching anything", "Pick one real feature or ticket and trace it end to end through the files it touches", "Wait until someone writes documentation"],
    "answer": 1,
    "explain": "A single real thread gives you a destination. Reading files with no destination doesn't stick."
  },
  {
    "q": "When tracing a feature from a UI button to backend logic, what's usually the most reliable first grep target?",
    "choices": ["A generic variable name like `data`", "The exact button text or route/URL string", "The name of the file you assume handles it"],
    "answer": 1,
    "explain": "Button text and route strings change far less often than variable or function names, so they're the most stable anchor to start a trace from."
  }
]
```


---

# Techniques for Making the Unknown Known

Tracing a feature (Phase 1) tells you *what* the code does. It doesn't tell you *why* it's shaped the way it is. That "why" is usually the part that matters most before you change anything - and it's recoverable, if you know where to dig.

## Git blame and git log as archaeology

`git blame` doesn't just tell you who wrote a line. It tells you when, and pointing at a commit gives you the "why" if the author left one.

```bash
git blame -L 40,55 src/refund_calculator.py
```

This shows you the commit hash for each of those lines. Take that hash and look at the full commit:

```bash
git show a3f9c21
```

If you're lucky, the commit message says something like `"fix: don't refund taxes, finance flagged this in Q2 audit"`. Now you know that weird-looking `if not line_item.is_tax:` check isn't an accident - it's a deliberate fix for a real problem, and removing it reintroduces a bug someone already paid to find.

If the commit message is useless ("fix bug", "wip", "updates"), widen the search:

```bash
git log --follow -p -- src/refund_calculator.py
```

`--follow` tracks the file across renames; `-p` shows the actual diff for every change. Skim the messages for the commit that introduced the weird logic, then read the PR or ticket number in that message if there is one - that's often where the real explanation lives, not the commit itself.

One more trick: `git log -S"is_tax"` finds every commit that added or removed the exact string `is_tax`, even in commits that don't mention it in the message. Useful when you're hunting for where a specific check was introduced.

When git history has nothing - squashed history, one giant "initial commit" - that itself is information. It means the answer isn't in the repo. Ask around. A Slack search for the filename or function name sometimes surfaces the original discussion. And if nobody knows, treat the code as a fence you don't understand yet (more on that in Phase 3) rather than something safe to delete.

## Write the safety-net test before you touch anything

Legacy code is often legacy precisely because it has no tests. That makes any change scary - you can't tell if you broke something until it's in production. Fix that before you fix the bug.

Write a test that captures the *current* behavior, even if the current behavior is the bug you're about to fix:

```python
def test_cancel_prorates_refund_excluding_tax():
    sub = make_subscription(plan_price=100, tax=10, days_used=15, days_total=30)
    result = cancel_subscription(sub)
    # current (buggy) behavior: tax gets refunded too
    assert result.refund_amount == 55.0  # should be 50.0 once fixed
```

This test does two things. First, it proves you understand the current behavior well enough to describe it precisely - if you can't write this test, you don't understand the code yet, and that's worth knowing before you start editing. Second, once you fix the bug, you flip the assertion to the correct value and now you have a regression test that stays in the suite forever.

The safety net matters more in legacy code than new code, because you have no author to ask "did I break anything?" The test is the closest thing you get to that answer.

## Small, safe refactors build understanding by doing

Reading code passively only gets you so far. Renaming a variable to what it actually represents, or extracting a tangled block into a named function, forces you to prove you understood it - because if you didn't, the rename will be wrong or the extraction will break.

Two refactors that are close to risk-free and pay for themselves immediately:

- **Rename.** If `calc(x, y, z)` really computes a prorated refund, rename it to `calculate_prorated_refund(price, days_used, days_total)`. Every call site becomes self-documenting, and if your rename doesn't quite fit, that's a signal you misunderstood something.
- **Extract function.** Pull a 20-line block with a comment like `# handle edge case for annual plans` into `handle_annual_plan_proration()`. Now the outer function reads as a list of steps instead of a wall of logic, and the annual-plan behavior is isolated enough to reason about (and test) on its own.

Do these with your editor's built-in rename/extract refactoring tools, not find-and-replace - they update every reference correctly and won't quietly break a string that happened to match. Run the test suite (or your new safety-net test) after each one.

These refactors aren't the goal. They're a forcing function: you can't safely rename something you don't understand, so doing it safely is proof you now do.

```quiz
[
  {
    "q": "You find a strange conditional in legacy code with no comment. What's the best next step?",
    "choices": ["Delete it, since unexplained code is probably dead code", "Run `git blame` on that line, then `git show` the commit it points to", "Assume it's a bug and rewrite the logic"],
    "answer": 1,
    "explain": "git blame + git show recovers the commit message and often the reasoning behind the code, before you risk removing something load-bearing."
  },
  {
    "q": "Why write a test that captures the current (buggy) behavior before fixing a bug?",
    "choices": ["It's required by most style guides", "It proves you understand the current behavior and gives you a regression test once you flip the assertion", "It makes the pull request look more thorough"],
    "answer": 1,
    "explain": "If you can't write down what the code currently does, you don't understand it well enough to safely change it."
  },
  {
    "q": "What's the main value of doing a small rename or extract-function refactor in unfamiliar code?",
    "choices": ["It makes the diff look more active", "It forces you to prove you understood the code, since a wrong rename or broken extraction reveals gaps", "It's required before any bug fix"],
    "answer": 1,
    "explain": "Safe refactors are a comprehension check disguised as cleanup - you can't do them correctly without understanding what the code does."
  }
]
```


---

# When (and When Not) to Rewrite

Two weeks into reading someone else's tangled code, the thought arrives: "This would be so much cleaner if I just rewrote it." That thought is almost always premature, and it's worth knowing why before you act on it.

## Why "just rewrite it" is usually wrong

The rewrite urge shows up right when you're the least qualified to act on it - you've read enough code to see the mess, but not enough to see why the mess exists. That gap is exactly where rewrites fail.

Old code accumulates fixes for real problems: the tax-exclusion check from Phase 2, a race condition patched at 2am, a workaround for a payment provider's broken API. None of that shows up as a clean abstraction. It shows up as a pile of specific, ugly conditionals - and every one of them was somebody's Tuesday.

A rewrite throws all of that away and starts from what the system *looks like it should do*, not what it actually has to do. The bugs you'll reintroduce aren't new bugs. They're the same bugs the original code already fixed, now unfixed again, waiting to be rediscovered by whoever's on call next.

There's also a cost side that's routinely underestimated: a rewrite takes real calendar time in which the old system still needs to work, still needs bug fixes, and now has to be kept in sync with the parallel rewrite. Most rewrite projects don't fail because the new code is bad - they fail because the team runs out of patience or budget to finish the migration, and you end up maintaining both versions.

## Chesterton's Fence

The principle: if you find a fence in a field and don't know why it's there, don't remove it until you find out why it was put there. Maybe it's pointless. Maybe it's keeping a bull from wandering onto a road.

Applied to code: don't delete or rewrite a piece of logic just because you don't understand why it's there. "I don't understand this" and "this is unnecessary" are different claims, and legacy code habitually gets treated as if the first implies the second.

The fix is cheap: do the archaeology from Phase 2 first. Git blame it, read the commit, check for a linked ticket, ask around. Three outcomes:

1. **You find a real reason.** The fence stays. You now understand the system better than you did an hour ago.
2. **You find it was a reason that no longer applies** (a workaround for a payment provider bug fixed years ago). Now you can remove it - with confidence, and ideally with a test proving the old reason is gone.
3. **You find nothing - no commit message, no ticket, no one remembers.** Treat this as "reason unknown," not "no reason." Leave it, or change it cautiously with a safety-net test, rather than deleting outright.

Chesterton's Fence isn't "never change legacy code." It's "earn the right to change it by finding out why it's shaped this way first."

## When a rewrite really is the right call

Rewrites aren't always wrong. They're right when specific conditions hold, not when the code merely looks old:

- **The technology itself is the constraint**, not the logic built on it - a framework that's end-of-life and unpatched for known security vulnerabilities, a database that can't scale to your actual load no matter how it's tuned.
- **You've already done the archaeology and genuinely understand the business rules**, not just the code shape. A rewrite by someone who can list every edge case from memory is a different project than a rewrite by someone who's read the code for a week and is annoyed by it.
- **The system is small and well-bounded enough to rewrite in weeks, not quarters** - a single service, not "the whole platform." The smaller the blast radius, the smaller the risk if the rewrite misses an edge case.
- **You can run old and new side by side and compare outputs** before cutting over - shadow traffic, feature flags, a migration period where both systems run and disagree loudly if they don't match.

Under those conditions, a rewrite is a refactor with extra steps, not a leap of faith. Absent them, the incremental path from Phase 2 - trace, test, small safe change, repeat - gets you to the same clean end state without the all-or-nothing risk.

The clear-eyed default: assume the rewrite urge is sunk-cost thinking in disguise (the code is confusing, so surely starting over is less work) until you've proven otherwise with the checklist above. Most of the time, the fastest way out of confusing legacy code isn't replacing it - it's a series of small, tested changes that turn confusing code into code you understand, one Chesterton's Fence at a time.

```quiz
[
  {
    "q": "Why is the urge to rewrite legacy code often a bad instinct?",
    "choices": ["Rewrites are always slower than fixing bugs one at a time", "It usually happens right when you've seen enough mess to be annoyed but not enough history to know why the mess exists", "Product managers never approve rewrites"],
    "answer": 1,
    "explain": "The rewrite urge peaks at partial understanding - you can see the ugliness but haven't yet uncovered the real reasons behind it."
  },
  {
    "q": "What does Chesterton's Fence actually recommend?",
    "choices": ["Never remove or change anything you don't understand", "Find out why something exists before removing it - then remove it with confidence if the reason no longer applies", "Always keep old code exactly as-is for safety"],
    "answer": 1,
    "explain": "The fence isn't sacred - it's unremovable until you understand why it was built. Once you know, you can act on that knowledge."
  },
  {
    "q": "Which scenario best supports an actual rewrite rather than incremental refactoring?",
    "choices": ["The code looks messy and you personally find it annoying to read", "The framework is end-of-life with unpatched security holes, the system is small, and you can run old and new in parallel to compare", "A new developer joined the team and prefers a different style"],
    "answer": 1,
    "explain": "Real constraints (dead framework, contained scope, a safe comparison path) justify a rewrite. Aesthetic annoyance doesn't."
  }
]
```

## Where to go next

Reading and understanding the code is half the job - the other half is knowing when to ask for help instead of guessing. See [Asking Good Questions](/guides/asking-good-questions) for how to ask the person who wrote it (or anyone else) without wasting their time or yours.

## Your turn: the grace-period ticket is due today

Knowing the rewrite urge is a trap is the easy part. Feeling the deadline while a genuinely tempting rewrite sits right there is the actual test. There is no single right answer below and nothing is scored right or wrong - but the clock is real, and it's the same six hours either way you spend them.

```scenario
{
  "title": "The grace-period ticket is due today",
  "brief": "It's 10am. Ticket: add a 48-hour grace period before a canceled subscription's refund finalizes, so support can reverse an accidental cancellation without reissuing money. You've never opened this codebase before. It's due end of day - six hours from now. You open RefundCalculator.prorate() and find 140 lines of nested conditionals, including one with no comment: `if not line_item.is_tax:`. Nothing else has been touched yet.",
  "prompt": "What do you do first?",
  "clock": { "unit": "hr", "running": "until the ticket is due", "resolved": "shipped, tests green" },
  "resolvedHeading": "PR is up. Here's how the six hours went.",
  "actions": [
    {
      "id": "trace",
      "label": "Trace the cancel flow end to end, file by file",
      "cost": 1,
      "reveals": "$ grep -r \"Cancel Subscription\" src/\nSubscriptionSettings.jsx:  <button onClick={cancelSubscription}>Cancel Subscription</button>\n$ grep -r \"subscriptions/:id/cancel\" server/\nroutes/subscriptions.py:  @app.post(\"/api/subscriptions/:id/cancel\")\n\n# routes/subscriptions.py -> SubscriptionService.cancel() -> RefundCalculator.prorate()",
      "note": "Same five files as the Phase 1 trace. Confirms the grace period belongs inside prorate(), and that the is_tax block a few lines above it is still unexplained."
    },
    {
      "id": "blame",
      "label": "git blame the is_tax check and read the commit",
      "cost": 0.5,
      "reveals": "$ git blame -L 60,75 src/refund_calculator.py\na3f9c21 (Priya Nair 2025-04-02)   if not line_item.is_tax:\n$ git show a3f9c21\ncommit a3f9c21\n    fix: don't refund taxes, finance flagged this in Q2 audit",
      "note": "Not a mystery, a deliberate fix. Costs thirty minutes. If the grace-period edit had moved this line without knowing that, the tax-refund bug comes back and nobody notices until finance does."
    },
    {
      "id": "clarify",
      "label": "Ask your tech lead if the grace period applies to annual plans too",
      "cost": 0.5,
      "reveals": "you: does the 48h grace period apply to annual plans too, or just monthly?\ntech-lead: same rule for both, plan type doesn't matter here",
      "note": "Cheap and useful for scope. It tells you nothing about the is_tax check sitting a few lines above where you're about to edit."
    },
    {
      "id": "guess-fix",
      "label": "Add the grace-period check directly, skip the reading",
      "cost": 0.5,
      "reveals": "# added straight into prorate(), no reading first\nif not grace_period_active(sub):\n    line_item.refund = calculate(line_item)\n$ pytest tests/test_refund.py -q\nFAILED test_cancel_prorates_refund_excluding_tax - assert 55.0 == 60.0",
      "note": "You edited a function you hadn't read yet and it silently pulled tax back into the refund. Now you have to find that failure, work out why, and undo it, on top of the reading you tried to skip."
    },
    {
      "id": "rewrite",
      "label": "Rewrite prorate() cleanly, then add the grace period",
      "cost": 5,
      "reveals": "# 5 hours later\n$ git diff --stat\n refund_calculator.py | 210 ++++++++++++++--------------\n 1 file changed, 105 insertions(+), 105 deletions(-)\n$ pytest tests/test_refund.py -q\nFAILED test_cancel_prorates_refund_excluding_tax\nFAILED test_annual_plan_proration\n2 failed, 4 passed",
      "note": "It felt like real progress the whole time. It's 3pm, the grace period still isn't in, and two edge cases the old code quietly handled are broken because the new version never knew they existed."
    },
    {
      "id": "small-fix",
      "label": "Add the grace period as a small guarded change, with a safety-net test",
      "cost": 1,
      "resolves": true,
      "reveals": "def prorate(sub, line_item):\n    if not line_item.is_tax:\n        ...\n    if within_grace_period(sub):\n        return  # refund finalizes after 48h, not now\n    ...\n\n$ pytest tests/test_refund.py -q\ntest_cancel_prorates_refund_excluding_tax PASSED\ntest_cancel_within_grace_period_does_not_finalize_refund PASSED\n2 passed\n$ git diff --stat\n refund_calculator.py | 8 ++++++--\n tests/test_refund.py  | 9 +++++++++\n2 files changed, 15 insertions(+), 2 deletions(-)",
      "note": "Eight lines, one new test, the is_tax check untouched because now you know why it's there. PR opened with time to spare."
    }
  ],
  "debrief": {
    "ideal": 2.5,
    "text": "The fast path here isn't a small diff by accident, it's a small diff on purpose: find out why the fence is standing before deciding whether to move it, then add a few lines next to it instead of tearing up the field. Rewriting prorate() felt like the productive choice. It cost five hours to relearn what a thirty-minute git blame would have told you for free.",
    "notes": [
      { "when": "if-taken", "action": "rewrite", "text": "You rewrote prorate() before you understood it. Five hours in, the grace period still wasn't added, and two edge cases the old code quietly handled had no test anywhere to remind you they existed. That's the rewrite urge from this phase, playing out in real time: you'd read enough to be annoyed, not enough to know why the mess was there." },
      { "when": "if-taken", "action": "guess-fix", "text": "You edited before tracing or checking history and broke the tax exclusion without knowing it. That's the exact failure Chesterton's Fence warns about: treating 'I don't understand this' as 'this is safe to change'." },
      { "when": "if-not-taken", "action": "blame", "text": "You never checked why the is_tax check exists. This time your change happened to leave it alone, so nothing broke - but that's luck. The whole point of the fence is that you don't get to find out it was luck until after something breaks." },
      { "when": "if-not-taken", "action": "trace", "text": "You never traced the call chain before editing. It worked out because the Phase 1 trace of this same codebase was still fresh in your head. In a codebase you opened for the first time today, skipping that step means guessing which function actually owns the behavior you're changing." }
    ]
  }
}
```
