If you measure honestly, you spend much more of your day reading code than writing it. Reading the codebase you are about to change. Reading the function you are about to call. Reading a teammate’s pull request. Reading a stack trace from production. Even when you are nominally “writing”, most of the keystrokes are pauses while you re-read the surrounding context to figure out what to type next.
And yet, almost nobody trains reading. We treat it as an osmosis skill, something you absorb by being around code for long enough. Compare that to writing, where we have entire books, courses, style guides, code reviews, and pair programming sessions dedicated to making us better. The asymmetry is bizarre. You can become a measurably better engineer in six months by deliberately practising how you read, and most people never try.
These are the techniques I lean on when I drop into a codebase I have never seen.
Top-down or bottom-up: pick on purpose
There are two ways into an unfamiliar codebase, and most engineers default to one without realising the other exists.
Top-down means starting from the entry point: the main function, the FastAPI app, the Airflow DAG file, the index.ts of the frontend. You follow the call graph downward and build a tree in your head. The strength is that you understand the system as a whole. The weakness is that you can spend an hour following calls and still not have changed any code, because the function you actually need to touch is twelve frames deep.
Bottom-up means starting from the function you care about, the one your ticket mentions, the one in the stack trace. You read it in isolation, then ask: who calls this? Your IDE’s “find references” answers that. The strength is speed: you might fix the bug in twenty minutes without ever knowing what the rest of the system does. The weakness is that you can ship a fix that is locally correct but globally wrong, because you never saw how the pieces fit together.
The right move is usually a mix, in this order: a five-minute top-down skim to learn the shape, then bottom-up from the function you care about, then back to top-down once you understand the function in context. The first step is the one people skip and the one that pays for itself the most.
Ignore until forced
The trap of reading a new codebase is the urge to understand everything before you change anything. That is a guarantee of slow, anxious work. Real codebases are too big for it. The dbt-core repo has hundreds of thousands of lines. FastAPI has tens of thousands. You are going to read the parts you have to and leave the rest alone.
The technique I have settled on is “ignore until forced”. When I hit a call to something I don’t recognise, I do not follow it immediately. I assume, based on its name and the context, what it probably does. I keep reading. I only follow the call when something I am doing actually depends on the details of that function. If compute_user_segment(user) returns something that goes into the next call, I do not need to know how segments are computed unless my bug is in there.
This feels like cheating. Surely I should understand compute_user_segment before I trust it? You should not. The whole point of abstraction is that you are not supposed to need to. If the abstraction is leaky enough that you do need to look inside, the act of needing to look will tell you. Until then, you are decorating your own brain with information you do not need yet.
After a session, your understanding of the codebase looks like a tree with a few branches deeply explored and most still unread. That is the correct shape. You are not building a textbook, you are building enough understanding to do today’s task.
Run the code to learn it
Reading static code is a worse teacher than reading code that is running. If a function is a black box on the page, it stops being one the moment you put a breakpoint on it and watch the values flow through.
I do this constantly. Ten minutes of reading and I am still not sure what shape the input takes. The cheap move: stop reading. Run the test that exercises this function, with a debugger attached, and step through it. In two minutes you will see exactly what user is, what segment is, what gets returned. Reading the code is now ten times more productive because you have concrete examples, not abstract types.
For Python, this often means running pytest path/to/test_file.py::test_specific_thing -s with a breakpoint() planted in the function under test. For a service, it means hitting an endpoint with a known payload. For a SQL transformation, it is running the model on a small subset and looking at the actual rows.
The inverse: read test files first to understand intent. Tests are where the author wrote down “here is what this code is supposed to do, with examples”. When I open a new module, I open the test file before the implementation. It tells me what the function is for, what the edge cases are, what the author thought was worth checking. Sometimes that is all I need.
The IDE features people don’t use
A surprising number of engineers navigate a codebase by Ctrl-F and tab-switching. There is a much faster way, and it has been in your IDE for a decade.
Go-to-definition. Cmd or Ctrl click on a symbol, jump to where it is defined, then go-back when done. If you are not doing this reflexively, you are paying a real productivity tax.
Find-references (or “find usages”). When you are about to change a function, this is the first thing to check. It is also the fastest way to learn what something is for. If parse_config has thirty-seven references all in tests, you are looking at a function the rest of the system does not use. The shape of the call graph is information.
Symbol search across the project (often Ctrl-T or Cmd-Shift-O) is faster than file search when you are looking for a class or function whose exact location you have forgotten.
Outside the IDE, two command-line tools are essential: ripgrep and ast-grep. ripgrep is fast enough that you grep a monorepo in a second, which means you do it more often. ast-grep lets you search by syntax pattern, the right tool when you want “all the places we call requests.get without a timeout” and a regex would miss or over-match.
I also lean on Cursor (or any LLM-aware editor) for “explain this function” and “where in this file is the part that handles X?”. A two-minute LLM summary of a 600-line file gives you the orientation you would have built up in twenty minutes on your own. Treat it like a colleague who skimmed the code an hour before you.
Git blame as documentation
The most under-used reading tool is git itself.
When you find a strange line of code, a weird conditional, a comment that says “TODO: fix later”, do not just sit and stare at it. Run git blame on it. Find the commit. Read the commit message. Open the pull request. Read the description. Nine times out of ten, the answer to “why does this code look like this?” is in the commit message of the change that introduced it.
Better still, look at the history of the file. git log -p <file> shows you every change to that file in order. If a function has been touched seven times in two years and the changes are all variations of “fix the edge case where X”, you have just learned a lot. The function is fragile around X. Whatever you are about to change had better not break X again.
This is the part of reading that everyone tells you is a thing and almost nobody does. Practise it once and you will not stop. Most “weird” code is not weird; it is the residue of a real production incident that a tired engineer had thirty minutes to fix at 2 a.m. The story of that incident is in git, if you bother to read it.
A concrete example of all of this in one go: last year I dropped into a Python codebase I had never seen, to fix a failing data quality check. I had ninety minutes between meetings. I started with the failing test, not the implementation. It made the intent clear: validate that a list of records had no duplicates by (user_id, day). I ran the test with a debugger and watched the validator. It was using a set for deduplication, and the (user_id, day) tuples were built from a pandas Series. The day field, on the broken records, was a Timestamp in some rows and a date in others. Two values that were “the same day” hashed differently, so the set had two entries, so the dedup check missed the duplicate. Forty-five minutes from clone to fix. The other forty-five I spent writing a new test that would have caught it earlier. I would not have found that bug by reading the file top to bottom; I found it by reading the test, running it, and watching the values. The path to understanding unfamiliar code is rarely “read everything”, it is “read just enough, run, observe, narrow, fix”. Pick an open-source repo you admire (dbt-core, FastAPI, sqlite’s amalgamation file) and spend an hour a week reading it without a goal. After a few months you will read your own team’s codebase faster, ship changes with more confidence, and say “I’m not familiar with this part of the system” a lot less often.