Debugging is a search problem, not a guessing game — the tools below just make the search faster.
A general method
Every debugger and every language changes, but the method doesn't. Before opening any tool:
Reproduce it reliably. A bug you can't trigger on demand is a bug you can't verify you've fixed. Find the smallest input/steps that reproduce it every time.
Form a hypothesis. Don't just stare at the code — state, in one sentence, what you think is wrong and why.
Design a test that would prove the hypothesis false. Not one that confirms it — confirmation bias is the single biggest time-waster in debugging.
Narrow by half, not by guessing. Binary-search the problem space: is the bad value coming from before or after the midpoint of the function? Before or after the last commit that worked? This is why git bisect and breakpoints in the middle of a call stack are so effective — they turn an O(n) hunt into an O(log n) one.
Change one thing at a time. Fixing three suspected causes at once means you never learn which one it actually was — and sometimes you introduce a second bug while "fixing" the first.
Print statements vs a debugger
Neither is universally "better" — they solve different problems.
Print/log statements win when you need to see a value change across many iterations or calls (a loop running thousands of times, a race condition, timing-sensitive code where pausing execution changes the behavior).
An interactive debugger wins when you need to explore — inspect arbitrary variables you didn't think to log ahead of time, walk up and down the call stack, or try evaluating an expression on the spot to test a hypothesis.
A good rule of thumb: reach for a debugger first. Reach for logging when the debugger itself would change the bug's behavior (timing, concurrency), or when you need a permanent record across a long-running or production process.
Reading a stack trace
A stack trace lists the chain of function calls active when an error happened, usually with the most
recent call last (Python) or first (many other tools) — check which convention your language uses
before you start reading top to bottom. The bottom-most frame (Python) is almost always where to start:
it's the line that actually raised the exception. Frames above it show how you got there, which
matters once you've confirmed the immediate cause but need to know why it was called with bad input in
the first place.
python3 -X dev <script.py> || runs Python with extra runtime checks (asserts, warnings) that surface bugs earlier and with a clearer trace | 'st1'
gdb (GNU Debugger, C/C++)
Compile with debug symbols first — without them, gdb can't map addresses back to your source lines.
gcc -g -O0 -o <binary> <file.c> || compiles with debug symbols (-g) and optimizations off (-O0) so line numbers and variables match your source exactly | 'gdb1'
gdb ./<binary> || starts gdb on a compiled binary | 'gdb2'
gdb -args ./<binary> <arg1> <arg2> || starts gdb, passing arguments through to the program | 'gdb3'
break <function> || sets a breakpoint at the start of <function> | 'gdb4'
break <file>:<line> || sets a breakpoint at a specific file and line number | 'gdb5'
run || starts (or restarts) the program under gdb | 'gdb6'
next || executes the next line, stepping over function calls | 'gdb7'
step || executes the next line, stepping into function calls | 'gdb8'
continue || resumes execution until the next breakpoint | 'gdb9'
print <expr> || evaluates and prints an expression or variable (short form: p) | 'gdb10'
backtrace || shows the full call stack at the current point (short form: bt) | 'gdb11'
frame <n> || switches to stack frame <n> so you can inspect its local variables (short form: f) | 'gdb12'
watch <variable> || pauses execution the instant <variable>'s value changes, anywhere in the program | 'gdb13'
list || shows the source code around the current line (short form: l) | 'gdb14'
quit || exits gdb (short form: q) | 'gdb15'
pdb (Python Debugger)
breakpoint() || drops into pdb at this exact line — add it directly in your source (Python 3.7+, replaces import pdb; pdb.set_trace()) | 'pdb1'
python3 -m pdb <script.py> || runs a script under pdb from the very first line | 'pdb2'
python3 -m pdb -c continue <script.py> || runs under pdb but doesn't stop until a breakpoint() call or an unhandled exception | 'pdb3'
n || executes the next line, stepping over function calls (next) | 'pdb4'
s || executes the next line, stepping into function calls (step) | 'pdb5'
c || continues execution until the next breakpoint (continue) | 'pdb6'
p <expr> || evaluates and prints an expression (print) | 'pdb7'
pp <expr> || pretty-prints an expression — much more readable for dicts/lists | 'pdb8'
l || shows the source code around the current line (list) | 'pdb9'
w || shows the full call stack at the current point (where) | 'pdb10'
u / d || moves one frame up/down the call stack (up/down) | 'pdb11'
b <line> || sets a breakpoint at a line number in the current file (break) | 'pdb12'
q || exits the debugger (quit) | 'pdb13'
For post-mortem debugging (inspect the state after a crash, without re-running):
python3 -m pdb -c continue <script.py> || on an unhandled exception, drops you into pdb at the point of failure instead of just printing a traceback | 'pdb14'
Core dumps
A core dump is a snapshot of a process's memory at the moment it crashed. It lets you debug a crash
after the fact, on a machine where you can't easily reproduce it live — the usual case for
production incidents.
ulimit -c unlimited || removes the size limit on core dumps for the current shell session (dumps are disabled by default on most systems) | 'core1'
cat /proc/sys/kernel/core_pattern || shows where the system writes core dump files | 'core2'
gdb ./<binary> <core_file> || opens a core dump in gdb against the binary that produced it | 'core3'
bt full || inside gdb on a core dump, shows the full backtrace with all local variables at every frame | 'core4'
Binary-search debugging with git bisect
When you know a bug wasn't there at some point in history but is there now, don't scroll through commits
by eye — let git binary-search the history for you.
git bisect start || begins a bisect session | 'bis1'
git bisect bad || marks the current commit as containing the bug (usually HEAD) | 'bis2'
git bisect good <commit> || marks a known-good commit — git then checks out the midpoint between good and bad | 'bis3'
git bisect good || after testing the checked-out commit and confirming it's fine, marks it good and moves to the next midpoint | 'bis4'
git bisect bad || same, but for a commit that still has the bug | 'bis5'
git bisect reset || ends the bisect session and returns to your original branch | 'bis6'
git bisect run <script> || fully automates the above by running <script> at each step; a non-zero exit code marks the commit bad | 'bis7'