← Writing

You Can't Fix What You Can't Read

Published
Reading
15 min
Contents · 23 sections
  1. Why AI makes this worse
  2. Reading code is a different kind of reading
  3. 1. Where does it start? (entry points)
  4. 2. How does a request find its code? (routing)
  5. 3. What happens to the data? (data flow)
  6. 4. Where does state live?
  7. The tools that make reading fast
  8. A worked example: tracing a bug through three files
  9. Step 1: find the front door
  10. Step 2: follow the data
  11. Step 3: read the function it calls
  12. Step 4: find out when and why
  13. Step 5: fix it at the boundary, and check every caller
  14. Reading diffs critically
  15. A reading practice routine (with AI)
  16. Every time you accept code: explain, then verify
  17. Predict before you run
  18. Trace one request a week
  19. Read a real pull request
  20. Ask the AI to quiz you
  21. Keep a “didn’t understand” list
  22. Checklist: before you say “I understand this code”
  23. Key ideas

In the last week of July 2012, Knight Capital, one of the biggest stock traders in the US, rolled out new code to the eight servers that ran its order router.

A technician copied the new code to seven of them. The eighth was missed. Nobody checked.

The new code reused an old on/off flag. Years earlier, that flag had switched on a feature called “Power Peg,” which Knight had stopped using in 2003. The Power Peg code was never deleted. It sat on the servers, still callable, and in 2005 someone had moved a piece of logic it depended on without retesting it.

On 1 August, when the market opened, orders with the reused flag hit the eighth server and woke up nine-year-old code that nobody thought was still alive. In about 45 minutes, it sent millions of orders, resulting in 4 million trades in 154 stocks. Knight lost more than $460 million. The SEC later fined it $12 million.

There was even a warning. Before the market opened, Knight’s systems sent 97 automated emails to staff mentioning “Power Peg disabled.” They weren’t designed as alerts, and staff generally didn’t read them.

I don’t tell this story because AI wrote Knight’s code. It didn’t. I tell it because it’s the clearest example I know of what happens when code runs in production that nobody can read with confidence. Every line of it had been written by a professional. By 2012, nobody in the room understood what it would do.

Vibe coding gets you there much faster.


Why AI makes this worse

When you write code yourself, you build a picture of it in your head as you go. You know why that if is there, because you hit the bug that needed it.

When an AI writes the code, you get the code without the picture. And it arrives fast:

  • Volume. An agent can change twenty files in a minute. Nobody reads twenty files in a minute.
  • Plausibility. AI code looks clean, consistent and confident, even when it’s wrong. The tells that used to warn you (messy naming, odd style) are gone.
  • Ownership. It’s hard to feel responsible for code you didn’t write. So it’s easy to approve without really looking.
  • Review fatigue. The tenth “Accept” of the day gets less attention than the first.

There’s now direct evidence that this costs skill. In January 2026, Anthropic published a randomized study of 52 mostly junior developers learning Trio, a Python library none of them knew. Half could use an AI assistant. Afterwards, everyone took a quiz covering code reading, code writing, debugging and concepts.

The AI group averaged 50%. The hand-coding group averaged 67%. The AI group was only about two minutes faster, and that difference wasn’t statistically significant. The biggest gap was on debugging questions.

But the most useful finding was about how people used the AI. Those who asked it to explain things, or asked conceptual questions, scored about as well as the hand-coders or better. Those who handed the whole task over scored lowest.

The tool wasn’t the problem. Not reading was.


Reading code is a different kind of reading

You don’t read code like a book, top to bottom. You read it like a detective: with a question.

“Where does the price get calculated?” “Who can call this function?” “What happens if this is null?” Each question gives you a path through the code. Without a question, you’ll scroll, feel lost and give up.

For any unfamiliar codebase, I start with four questions, in this order.

1. Where does it start? (entry points)

Every program has a front door. Find it before anything else.

  • package.json: the scripts section tells you how the app starts ("dev": "next dev", "start": "node dist/server.js"). The dependencies tell you which framework you’re in.
  • The framework’s conventions: in Next.js, the URL /api/orders/123 lives in app/api/orders/[id]/route.ts. In Express, look for app.use(...) and router.get(...). In Django, urls.py.
  • Background entry points: cron jobs, queue workers and webhooks are front doors too, and they’re the ones people forget.

2. How does a request find its code? (routing)

Take one real URL and follow it. Search for its path, find the handler, and note any middleware it passes through on the way (login checks, rate limits). This one exercise teaches you more about an app than an hour of browsing folders.

3. What happens to the data? (data flow)

From the handler, follow the data. What comes in (URL params, request body), what gets called, and what goes out. Track one value at a time. For each function you hit, ask: what does it take, what does it return, and what does it change along the way?

4. Where does state live?

“State” means anything that’s remembered between requests. Bugs love state. Look for:

  • The database (find the schema file: schema.prisma, migrations, models.py).
  • Caches (Redis, in-memory maps).
  • Environment variables (process.env.X), which change behaviour without changing code.
  • Module-level variables, a let at the top of a file that every request shares.
  • The browser: cookies, localStorage, client state.

When something is “sometimes wrong,” the answer is usually in one of these.


The tools that make reading fast

You don’t need fancy tools. You need four, and you need to use them without thinking.

Search the whole codebase. ripgrep (rg) is a fast search tool that respects your .gitignore.

rg -n "orders/:id"              # where is this route defined?
rg -n -w "applyDiscount"        # every use of this function, whole word only
rg -l "process.env.STRIPE"      # which files read this secret? (-l = file names only)
rg -n "discountPercent" -g "*.prisma"   # only search schema files

Jump to definitions and references. In VS Code (and Cursor, which is built on it), F12 jumps to where a function is defined, and Shift+F12 lists every place it’s used. “Find all references” is the single most important button for answering “if I change this, what breaks?”

Read the history of a file. Code is a story, and git keeps the drafts.

git log --oneline -- src/lib/pricing.ts   # who changed this file, and when?
git log -p -- src/lib/pricing.ts          # same, with the actual diffs
git log -S "discountPercent" --oneline    # commits that added or removed this text

That last one, -S, is called the “pickaxe.” It finds the commit where a piece of text first appeared or disappeared. It’s the fastest way to answer “when did this start?”

Find out why a line exists.

git blame -L 10,20 src/lib/pricing.ts     # who last changed lines 10 to 20
git show <commit-hash>                    # the full commit, message and diff

Blame tells you the commit. The commit message, linked ticket or pull request tells you the reason. Read the reason before you delete a line that looks pointless. That line might be the only thing keeping Power Peg asleep.


A worked example: tracing a bug through three files

This example is fictional, but the pattern is one I’ve seen many times in code review.

A support ticket says: “Customer says her order summary shows a total of -$540.00.” She has a 10% loyalty discount. The code was mostly written by an AI agent, and the person who prompted it has left.

Here’s how I’d trace it without asking the AI to “fix the bug.”

ONE REQUEST, FOUR STOPS Browser GET /orders/:id routes/orders.ts entry point services/orders.ts data flow lib/pricing.ts expects 0.1 Database discountPercent = 10 where state lives mismatch Each arrow is a question: what goes in, what comes out, and what does the next file assume?
The bug isn't in any single file. It's in the gap between what the database stores and what the pricing function assumes.

Step 1: find the front door

The summary page calls /orders/123/summary. Search for the route:

rg -n "summary" src/routes
# src/routes/orders.ts:6:ordersRouter.get("/orders/:id/summary", async (req, res) => {
// src/routes/orders.ts
import { Router } from "express";
import { getOrderSummary } from "../services/orders";

export const ordersRouter = Router();

ordersRouter.get("/orders/:id/summary", async (req, res) => {
  const summary = await getOrderSummary(req.params.id);
  if (!summary) return res.status(404).json({ error: "Order not found" });
  res.json(summary);
});

Nothing is calculated here. The handler passes the id along and returns whatever comes back. Next stop: getOrderSummary. (I’d also note that I can’t see a login check in this file. Is it in middleware where the router is mounted? That’s a separate question, and an important one. Part 4 is about exactly this.)

Step 2: follow the data

F12 on getOrderSummary:

// src/services/orders.ts
import { db } from "../db";
import { applyDiscount } from "../lib/pricing";

export async function getOrderSummary(orderId: string) {
  const order = await db.order.findUnique({
    where: { id: orderId },
    include: { items: true, customer: true },
  });
  if (!order) return null;

  const subtotalCents = order.items.reduce(
    (sum, item) => sum + item.priceCents * item.quantity,
    0,
  );

  return {
    id: order.id,
    subtotalCents,
    totalCents: applyDiscount(subtotalCents, order.customer.discountPercent),
  };
}

Now I have something to check. The subtotal looks right: prices in cents, times quantity, summed. Then discountPercent goes into applyDiscount. The name says “percent.” What does the function expect?

Step 3: read the function it calls

// src/lib/pricing.ts
/** discount is a fraction: 0.1 means 10% off */
export function applyDiscount(amountCents: number, discount: number): number {
  return Math.round(amountCents * (1 - discount));
}

There it is. The function expects 0.1 for 10%. The service passes discountPercent, which, if the name is honest, is 10.

Predict before you run. Her order is $60.00, or 6,000 cents. 6000 * (1 - 10) is 6000 * -9, which is -54000 cents. That’s -$540.00, exactly what the ticket says. A prediction that matches the bug report is strong evidence you’ve found it.

Step 4: find out when and why

Was it always like this? Ask git:

git log -S "discountPercent" --oneline
# 8c41d2e Rename discountRate to discountPercent for admin UI

git show 8c41d2e shows the story. The database column used to be discountRate, stored as 0.10. Someone asked an agent to make the admin screen show “10” instead of “0.1.” The agent renamed the column, migrated the data to whole numbers, and updated the admin UI. It never touched pricing.ts, because nothing in that file mentioned the column. TypeScript didn’t complain, because a number is a number.

Every file was “correct” on its own. The bug lived between files, in an assumption nobody wrote down.

Step 5: fix it at the boundary, and check every caller

The tempting fix is to change one line in the service: discountPercent / 100. That works. But before changing anything, Shift+F12 on applyDiscount to see who else calls it:

rg -n -w "applyDiscount" src
# src/services/orders.ts:19:    totalCents: applyDiscount(subtotalCents, order.customer.discountPercent),
# src/services/invoices.ts:31:  const due = applyDiscount(totalCents, customer.discountPercent);
# src/services/quotes.ts:12:    return applyDiscount(amount, promo.rate);

Invoices have the same bug. Quotes pass a real fraction and are fine. If I had “fixed” applyDiscount itself to divide by 100, I’d have broken quotes.

So I’d add a function whose name says what it takes, and make it refuse bad input loudly:

// src/lib/pricing.ts
export function applyDiscountPercent(amountCents: number, discountPercent: number): number {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new RangeError(`discountPercent out of range: ${discountPercent}`);
  }
  return Math.round((amountCents * (100 - discountPercent)) / 100);
}

Then switch the two broken callers to it, and add a test that pins the behaviour:

// src/lib/pricing.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscountPercent } from "./pricing";

test("10% off $60.00 is $54.00", () => {
  assert.equal(applyDiscountPercent(6000, 10), 5400);
});

test("rejects a fraction passed as a percent by mistake", () => {
  assert.throws(() => applyDiscountPercent(6000, 1000), RangeError);
});

Total reading time: maybe fifteen minutes. An AI agent given “the total is negative, fix it” could easily have changed Math.round to Math.max(0, ...), made the symptom disappear, and left every discount customer paying full price.


Reading diffs critically

Most of your reading will be diffs: the changes an AI (or a teammate) proposes. A diff is easy to skim and easy to approve. Here’s what I look for, roughly in order of how often it catches something.

  1. Changes you didn’t ask for. You asked for a button colour. Why did auth.ts change?
  2. Deleted lines. Additions get attention. Deletions hide. A removed check, a removed await, a removed test case.
  3. Tests that got “fixed.” If a test was failing and now passes, did the code change, or did the test’s expected value change to match the bug?
  4. Swallowed errors. A new try { ... } catch {} with nothing inside, or catch (e) { return null }. The error didn’t go away. It went silent.
  5. New dependencies. Does this package exist, is it maintained, and do you need it for one function?
  6. Duplicates. A new helper that does what an existing one already does.
  7. Names that lie. discountPercent that holds a fraction. isAdmin that checks something else.

Here’s a real-looking example of number 2. An agent was asked to fix a “403 Forbidden” error that a user hit when opening an order:

 export async function getOrder(user: User | null, orderId: string) {
   const order = await db.order.findUnique({ where: { id: orderId } });
-  if (!user || user.orgId !== order?.orgId) {
+  if (!user) {
     throw new ForbiddenError();
   }
   return order;
 }

The error is gone. So is the check that stopped one company from reading another company’s orders. The diff is one line long and looks like a tidy-up. This is why “it works now” is not a code review.

When the AI and a human both changed something, apply the same checklist. Humans write these bugs too. The AI just writes more code per hour, so you meet them more often.


A reading practice routine (with AI)

You don’t build this skill by reading a book about it. You build it in small, daily reps. Here’s a routine that fits around real work.

Every time you accept code: explain, then verify

Before accepting a non-trivial change, ask the AI: “Explain what this change does, file by file, and what could break.” Then check its explanation against the code. Open each file. Does the code say what the AI says it says?

This is the pattern that scored highest in the Anthropic study: use the AI to explain, but do the understanding yourself. You’ll be surprised how often the explanation and the code disagree.

Predict before you run

Before running a test, a script or the app, write down what you expect to see. One line is enough. “The total should be 5400.” “This should log three rows.” Then run it.

Every wrong prediction marks a gap in your mental model. That gap is exactly where a future bug will hide.

Trace one request a week

Pick a feature in a codebase you use (yours, your employer’s, or an open-source project). Trace one request from the browser to the database and back, using only search, go-to-definition and your own eyes. Draw it on paper as boxes and arrows, like the diagram above. Then ask the AI to trace the same request and compare.

Read a real pull request

Once a week, read a merged pull request in a well-run open-source project you use. Read the diff, then the review comments. You’ll learn what experienced reviewers catch, and what they consider worth arguing about.

Ask the AI to quiz you

After you finish a feature, ask: “Ask me five questions about how this code works. Don’t give me the answers.” Answer from memory, then check against the code.

Keep a “didn’t understand” list

Every time you accept something you couldn’t fully explain, write it down. A regex. A SQL join. A React hook. Spend ten minutes on one item each morning.


Checklist: before you say “I understand this code”

  • I can name the entry point for this feature (route, job, webhook).
  • I can trace one request from start to finish without the AI.
  • I know where the state lives: which tables, caches, env vars, cookies.
  • For each function I changed, I’ve checked every caller (“find all references”).
  • I’ve read the deleted lines in the diff, not just the added ones.
  • I can predict the output of a test before running it.
  • For any line I’m about to delete, I’ve checked git blame and the commit that added it.

Key ideas

  • Code nobody understands still runs. Knight Capital lost over $460 million in 45 minutes to old code everyone had forgotten.
  • AI gives you code without the mental picture. Reading is how you get the picture back.
  • Read with a question. Entry point, routing, data flow, and where state lives, in that order.
  • Learn four tools cold: rg, go-to-definition and find references, git log -p and -S, and git blame.
  • Many bugs live between files, in assumptions about units, formats and names. Check every caller before you change a shared function.
  • Read deletions and “fixed” tests first when reviewing a diff.
  • Use AI to explain, then verify by reading. In Anthropic’s study, that pattern held up. Handing everything over didn’t.

Sources: U.S. Securities and Exchange Commission, Order in the Matter of Knight Capital Americas LLC, Release No. 34-70694 (16 October 2013). Anthropic, “How AI assistance impacts the formation of coding skills” (29 January 2026; arXiv 2601.20245). Git documentation for git log (including -S) and git blame. ripgrep. VS Code docs, Code Navigation.

Related: previous in the series, Vibe Coding Is Real. So Is the Bill., next, Debugging When the AI Is Out of Ideas, and Start With a Modular Monolith, Not Microservices.