Backend
How to Debug Python Code You Didn't Write
Srdan Borović DEV Community
3 views
You open the repository. Nobody who wrote it still works here. There are no tests. The one README file describes a feature that got deleted two years ago. And something is broken in production.
Welcome to the job.
Debugging code somebody else wrote is one of the least talked-about skills in software engineering, yet most of us spend more time doing it than writing anything fresh. The hard part is that you have no mental map of how the thing works. Python makes this trickier than most languages, because its dynamic typing, runtime metaprogramming, and loose scoping rules mean the code can behave in ways the source doesn't obviously reveal.
So before you touch a single line, slow down. Here's a field-tested sequence for making sense of a Python codebase that feels like someone else's messy garage.
First, Pin the Ground You're Standing On
Legacy Python projects love to rot at the edges. You'll find an unpinned requirements.txt, a couple of setup scripts that contradict each other, and zero record of which Python version the thing actually ran on. Fire it up on the wrong interpreter and you get phantom defects: crashes caused by a minor version mismatch or a dependency that silently updated itself into incompatibility.
Lock everything down first. A tool like uv resolves a reproducible dependency tree fast and writes a deterministic lockfile, so you're not fighting drift from upstream packages that moved on without you. If the project straddles a big historical gap, say a Python 2 to 3 jump, pin the exact interpreter with pyenv or run it inside a container. Get a stable environment before you try to reproduce anything. Everything downstream depends on it.
Find the Front Door
New codebases tempt you to read widely. Resist that. Opening thirty files and skimming each one gives you almost nothing except a headache.
Pick one real transaction instead and follow it all the way through. A single incoming request, one task, one command. Trace it from the outside edge down through the service layers to the database and back out. This vertical slice teaches you more in an hour than a week of horizontal skimming.
Where you start depends on what kind of program it is. Web services route through an ASGI or WSGI application factory, where the routing table and middleware chain lay out the order of operations. Task pipelines built on Celery or Dramatiq organize themselves around task-registration decorators and broker config. Command-line tools funnel through an argument dispatcher like argparse, click, or typer. Find that hub, follow one concrete path through it, and suddenly the whole system has an anchor.
Read the History, Not Just the Code
When documentation is missing or lying to you, the version control history becomes your best source of truth. It records what people did and, if you dig, why they did it.
A few Git moves earn their keep here:
git log -L tracks the line-level history of a single function, showing you how it evolved and which edge cases earlier developers kept patching. git log -S runs a "pickaxe" search, hunting down the exact commit where a variable, error code, or bit of logic first appeared or vanished. And git blame links a suspicious line straight back to its commit, so you can trace it to a pull request, a ticket, or a code review that explains the weird construct you're staring at.
That last tool comes with a warning that will save you real pain. Do not run an automated formatter across the whole codebase in a single sweep. It feels tidy, but it rewrites every line and torches the usefulness of git blame forever. If you must reformat, do it in one isolated commit, then drop that commit's SHA into a .git-blame-ignore-revs file at the repo root. Git-hosting platforms respect that file and skip the formatting commit during blame, so history stays honest.
Respect the Fence You Don't Understand
There's an old parable about a fence across a road. A reformer wants to tear it down because it seems pointless. A wiser person says: if you can't see why it's there, you're the last person who should remove it. Go learn why it exists first.
Legacy Python is full of these fences. That gnarly conditional, that oddly specific retry loop, that comment saying "don't remove, breaks billing" with no further detail. What looks like sloppy code is often a hard-won fix for some production nightmare the original author spent two weeks diagnosing. Rewrite it blind and you'll happily reintroduce the exact bug they killed.
Turn On the Lights with Types
Python's flexibility lets a variable be a string in one function and a dictionary three calls later. Bugs love to hide in that ambiguity.
You don't have to fix it all at once, though. Flip on strict type checking across an old untyped codebase and you'll drown in hundreds of harmless warnings. Start in a relaxed mode instead. A checker running gently will still flag the high-value problems, like misspelled attributes, mismatched return types, or a function called with the wrong number of arguments, without burying you in noise from every unannotated module.
Now Watch It Actually Run
Reading only takes you so far. At some point you have to see the code move.
For a localized, synchronous bug where you already know the rough neighborhood, an interactive debugger is your friend. Since Python 3.7, the built-in breakpoint() drops you straight into a debugging session. Plain pdb works everywhere; ipdb adds syntax highlighting and autocomplete; pudb gives you a full-screen visual console. You can even make the breakpoint conditional, so it only fires when things go sideways:
if target_record.status == "UNEXPECTED" and iteration_count > 500:
breakpoint()
A few debugger tricks stay weirdly underused. Post-mortem debugging with pdb.pm() or pytest --pdb freezes the interpreter at the exact frame where an unhandled exception blew up, so you can poke at variables without rerunning a slow setup. The u and d commands walk you up and down the call stack to see how bad input crossed a boundary earlier. And typing interact drops you into a live shell right inside that scope, a scratchpad for testing theories without editing a thing.
If your code isn't synchronous and tidy, though, breakpoints get awkward. They stall loops, mess with thread timing, and can trigger socket timeouts in networked services. For those cases, tracing beats stepping. A tool like VizTracer runs alongside your program, records every function entry and exit, and builds a visual timeline of the whole run without you editing a single line:
viztracer --log_function_args --log_return_value --run entrypoint_script.py
For quick inline checks, icecream prints an expression, its value, the filename, and the line number with almost no ceremony. Wrap anything in ic() and you get clean, labeled output instead of a graveyard of bare print statements.
And when you meet an object you can't identify, Python's introspection tools open it up. inspect.getsource(obj) shows you the actual source of any loaded function or class, handy for tracking down dynamically imported code. rich.inspect(obj, methods=True) lays out an object's attributes, docstrings, and methods in a readable terminal panel.
Freeze the Behavior Before You Change It
Before you fix anything, lock in what the system already does.
Michael Feathers put a sharp name to the problem: code without tests is legacy code, no matter how new it is. The cure is a characterization test. You feed the component real inputs, capture whatever it spits out, and assert that exact result going forward. You're not judging whether the output is correct. You're building a tripwire that screams the moment behavior changes.
def test_legacy_pricing_engine_characterization(snapshot):
payload = {"account_tier": "enterprise", "units": 150, "region_id": 4}
actual_result = legacy_calculate_discounts(payload)
assert actual_result == snapshot
Snapshot tools like syrupy handle the tedious part, serializing messy dictionaries or dataframes to disk automatically. And if the code depends on external APIs, vcrpy records real network calls into local cassettes and replays them, so your tests run fast and deterministic without hitting anything live.
Let Git Find the Regression for You
If the bug is a regression, meaning it worked in some earlier version, don't read hundreds of commits by hand. Make Git do it.
git bisect runs a binary search through your history. Mark a known-bad commit and a known-good one, then hand it a test script:
git bisect start
git bisect bad HEAD
git bisect good v2.4.0
git bisect run pytest tests/test_isolated_bug.py
Git checks out the midpoint, runs your test, and narrows the range based on pass or fail until it points at the exact commit that broke things. From there you get the diff, the author, and the ticket that explains it all.
A Note on AI Assistants
Reaching for a language model to explain a strange file is tempting, and it can help. But dump thousands of lines of tangled legacy code into one, and its reasoning falls apart. The context gets saturated, the model can't tell live logic from dead paths, and it starts inventing confident nonsense. Worse, ask it to fix a bug and it may cheerfully suggest a sweeping refactor that quietly rips out one of those fences from earlier, the ones holding up something you couldn't see.
The fix is the same principle that runs through this whole process: keep the scope small. Hand the assistant one module and a single failing test, not the entire codebase.
Conclusion
Debugging other people's Python rewards patience over cleverness. Every step in this piece bends toward one habit: understand the code well enough to change one thing without breaking ten others.
That habit has an order to it. Pin the environment so you're debugging reality. Trace one path end to end to build a map. Read the Git history for the intent behind the strange parts. Freeze what the system does with characterization tests. Then apply the smallest fix that works and check it against that frozen baseline.
If your Python foundations feel shaky while you're doing all this, working through something structured like Mimo's Python course can sharpen the fundamentals that make reading unfamiliar code less of a guessing game.
Read original: https://dev.to/srdan_borovi_584c6b1d773/how-to-debug-python-code-you-didnt-write-5a01
← Previous
I let an AI make phone calls, then took the word "booked" away from it
Next →
Step by Step help for simple website
Related
Azure Function App Stuck on "Runtime Unreachable"? How VNet Integration and Private Endpoints Fixed It
Backend
0
DEV Community
Cross-Chain Bridge Risk Assessment: Tether Gold
Backend
0
DEV Community
I Tested AI Coding Agents for 30 Days - Here's What Actually Changed
Backend
0
DEV Community
how do you handle legacy code you wrote yourself that you no longer understand
Backend
3
Reddit r/webdev
Comments0
No comments yet — be the first