Debugging Your Code
The Six Stages of Debugging
- That can't happen.
- That doesn't happen on my machine.
- That shouldn't happen.
- Why is that happening?
- Oh, I see.
- How did that ever work?
— $mike, Comment on “Bugs Are Magic Tricks”
Debugging is one of the most important skills you'll develop as a programmer. It's also one of the most challenging, because it requires you to figure out not just what's wrong with your code, but how you're wrong about your understanding of what the code is doing_
The Debugging Mindset
At the heart of every bug is a mismatch between what you think should be happening and what actually happens. Your job when debugging is to discover your own misconception.
Debugging is hard work—you have to question your own assumptions and be willing to be wrong about things you felt certain about. But it can also be deeply satisfying work, because each bug you fix teaches you something new about how programs really behave.
This sounds really hard…
It sometimes is! But debugging is a skill you build with practice. Every bug you fix makes you better at spotting similar issues next time.
Building a good debugging toolkit and developing a systematic approach can make the process much more manageable.
Your Debugging Toolkit
Before we dive into a detailed example, here's a quick overview of the tools and techniques available to you:
- Print Debugging
- Adding
std::cerrstatements to see what your code is actually doing. This is the workhorse of debugging—simple, flexible, and surprisingly powerful. (Usestd::cerrrather thanstd::coutfor debugging output; for one thing,std::cerris unbuffered, so you see the output immediately.) - Valgrind
- A tool for detecting memory errors like out-of-bounds accesses, use-after-free, and memory leaks. See our Valgrind guide for details. Valgrind provides much better evidence than a bare segmentation fault.
- Assertions
- Statements that check your assumptions and crash immediately if they're violated. See the
assertguide for when and how to use them effectively. - Compiler Warnings
- Always compile with
-Wall -Wextra -pedanticto catch potential issues early. The compiler knows a lot about common mistakes! - Reading Backtraces
- When your program crashes, the error message usually includes a backtrace showing the sequence of function calls that led to the crash. Learning to read these is invaluable.
- Rubber Duck Debugging
- Explaining your code out loud (even to an inanimate object) forces you to articulate your assumptions, which often reveals the bug.
Why would I explain things to a rubber duck?
Because explaining forces you to question your assumptions! Often you'll figure out the bug mid-explanation. It's not about the duck listening—it's about you having to put your thoughts into words.
Another Tool: The Right Perspective
As we've said, one thing that makes debugging hard is that you have to question your own assumptions. One way to do so is to apply what we call the expanding island of certainty principle.
When your code behaves in ways that seem baffling, it can make you question everything you thought you knew about how your code works. Something you currently believe is actually false, but what is it? Figuring that out requires us to slowly move things from the realm of mere belief into the realm of certainty. You start by establishing a small toehold of certainty, where you are 100% sure that everything is doing what it is supposed to do. Then you expand that island of certainty, one small step at a time, until you find the point where your beliefs diverge from reality. At each step, the key idea is
When you have a belief about how your code works, try to find a simple, concrete check that can confirm or refute that belief.
One time when I had bug and nothing I was doing seemed to fix it, Prof. Melissa came over to help and the first thing she did was add
std::cerr << "Hello, World\n";at the top ofmain(). I was like, “What? Why would that help?”, and she muttered “I just wanted to be sure.” I guess she wanted to confirm that the source code she was looking at was actually the code that was running. It was, and the problem was somewhere else, but I guess she just wanted to be sure.
That's a great example! Even the simplest checks can sometimes reveal surprising issues, like running the wrong version of the code. Always be willing to question your assumptions, even the ones that seem most basic.
Like, what if I barely understand my own code to begin with?
That's why we mention ideas such as talking through your code, with your partner, or even with a rubber duck. Explaining your code (including drawing memory diagrams) can help you understand it better, which, in turn, helps you debug it.
Okay, let's see these techniques in action through a detailed example.
A CS 70 Grutoring Scenario
Imagine that you're a grutor in a future offering of CS 70, and a student pair comes to you with the following code that they wrote for a homework problem:
// Program to find average placement of the number 42 in random 0..99 array
#include <iostream>
#include <cs70/randuint32.hpp>
#include <vector>
#include <cassert>
// Find the index of the number 42 in a vector of uint32_t
size_t find42(const std::vector<uint32_t>& v) {
size_t index = 0;
// Search for the number 42 in the vector. We know it'll find it because
// the array contains shuffled numbers between 0 and 99, inclusive.
while (v[index] != 42) {
++index;
}
return index;
}
// This function fills a vector with 100 shuffled numbers between 0 and 99
void initArray(RandUInt32& rng, std::vector<uint32_t>& v) {
v.clear();
for (size_t i = 0; i < 100; ++i) {
uint32_t num = rng.get(100);
v.push_back(num);
}
}
// Calculate statistics about the average position of 42 in the vector
int main() {
RandUInt32 rng;
std::vector<uint32_t> v;
const size_t NUM_TRIALS = 10000;
size_t totalIndex = 0;
for (size_t trial = 0; trial < NUM_TRIALS; ++trial) {
initArray(rng, v);
size_t index = find42(v);
totalIndex += index;
}
double averageIndex = static_cast<double>(totalIndex) / NUM_TRIALS;
std::cout << "Average index of 42 over " << NUM_TRIALS
<< " trials: " << averageIndex << std::endl;
return 0;
}
Look over the code briefly just so you have a broad sense of what it's doing.
Oooh, I think I see the bug already!
That's not really the point here, and one reason we've cast the situation as you helping others see the bug. It's not about whether you can look at this code and see the bug, it's about the process of debugging.
The student pair tells you,
We wrote this code to find the number 42 in an array of integers, but it doesn't seem to work. Can you help us figure out what's wrong? We've figured out that if you ask it to find 0 instead of 42, it mostly works (although it doesn't seem like it's averaging at 50 like we expected). But if we put in 42, it segfaults.
Let's begin by looking at the evidence. When the code is hacked to find zero, it runs like this:
cs70 SERVER > clang++ -g -Wall -Wextra -o find42 find42.cpp -lranduint32
cs70 SERVER > ./find42
Average index of 42 over 10000 trials: 63.0418
cs70 SERVER > ./find42
Average index of 42 over 10000 trials: 62.6226
Unfortunately, when we try to find 42, we get
cs70 SERVER > clang++ -g -Wall -Wextra -o find42 find42.cpp -lranduint32
cs70 SERVER > ./find42
[1] 38787 segmentation fault (core dumped) ./find42
cs70 SERVER > ./find42
[1] 38849 segmentation fault (core dumped) ./find42
Improving the Evidence
While this evidence is suggestive, it doesn't really tell us much, and may actually lead us down the rabbit hole of wondering why it is that zero “works” and 42 doesn't. Whenever we have an abnormal program termination, such as a segmentation fault, assertion failure, or uncaught exception, we can quickly get much more informative evidence by running the program under valgrind:
cs70 SERVER > valgrind ./find42
==39893== Memcheck, a memory error detector
==39893== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==39893== Using Valgrind-3.22.0 and LibVEX; rerun with -h for copyright info
==39893== Command: ./find42
==39893==
==39893== Conditional jump or move depends on uninitialised value(s)
==39893== at 0x109244: find42(std::vector<unsigned int, std::allocator<unsigned int> > const&) (find42.cpp:13)
==39893== by 0x10931D: main (find42.cpp:37)
==39893==
==39893== Invalid read of size 4
==39893== at 0x109241: find42(std::vector<unsigned int, std::allocator<unsigned int> > const&) (find42.cpp:13)
==39893== by 0x10931D: main (find42.cpp:37)
==39893== Address 0x4e39650 is 0 bytes after a block of size 512 alloc'd
==39893== at 0x4846FA3: operator new(unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==39893== by 0x109E45: std::__new_allocator<unsigned int>::allocate(unsigned long, void const*) (new_allocator.h:151)
==39893== by 0x109B7F: allocate (alloc_traits.h:478)
==39893== by 0x109B7F: std::_Vector_base<unsigned int, std::allocator<unsigned int> >::_M_allocate(unsigned long) (stl_vector.h:380)
==39893== by 0x109889: void std::vector<unsigned int, std::allocator<unsigned int> >::_M_realloc_append<unsigned int const&>(unsigned int const&) (vector.tcc:596)
==39893== by 0x1094E8: std::vector<unsigned int, std::allocator<unsigned int> >::push_back(unsigned int const&) (stl_vector.h:1294)
==39893== by 0x1092A5: initArray(RandUInt32&, std::vector<unsigned int, std::allocator<unsigned int> >&) (find42.cpp:24)
==39893== by 0x109312: main (find42.cpp:36)
==39893==
==39893==
==39893== Process terminating with default action of signal 11 (SIGSEGV)
==39893== Access not within mapped region at address 0x5227000
==39893== at 0x109241: find42(std::vector<unsigned int, std::allocator<unsigned int> > const&) (find42.cpp:13)
==39893== by 0x10931D: main (find42.cpp:37)
==39893== If you believe this happened as a result of a stack
==39893== overflow in your program's main thread (unlikely but
==39893== possible), you can try to increase the size of the
==39893== main thread stack using the --main-stacksize= flag.
==39893== The main thread stack size used in this run was 8388608.
==39893==
==39893== HEAP SUMMARY:
==39893== in use at exit: 74,240 bytes in 2 blocks
==39893== total heap usage: 9 allocs, 7 frees, 74,748 bytes allocated
==39893==
==39893== LEAK SUMMARY:
==39893== definitely lost: 0 bytes in 0 blocks
==39893== indirectly lost: 0 bytes in 0 blocks
==39893== possibly lost: 0 bytes in 0 blocks
==39893== still reachable: 74,240 bytes in 2 blocks
==39893== suppressed: 0 bytes in 0 blocks
==39893== Rerun with --leak-check=full to see details of leaked memory
==39893==
==39893== Use --track-origins=yes to see where uninitialised values come from
==39893== For lists of detected and suppressed errors, rerun with: -s
==39893== ERROR SUMMARY: 1029769 errors from 2 contexts (suppressed: 0 from 0)
[1] 39893 segmentation fault (core dumped) valgrind ./find42
It's easy to be a bit overwhelmed by the amount of output that valgrind produces, so here are some rules of thumb for reading it:
- Look at the very first error it reports. In this case, it's a “Conditional jump or move depends on uninitialized value(s)”. That's the first moment that
valgrinddetected something wrong. Beneath that line is a backtrace that shows the sequence of function calls that led to the error. (We'll cover reading backtraces in a moment.) - Look at how the program exited—in this case, where it says “Process terminating with default action of signal 11 (SIGSEGV)”. This message tells us that the operating system killed the program because it tried to access memory that it wasn't allowed to access (a.k.a., a segfault). Again, there's a backtrace showing where this issue happened.
Interpreting Backtraces
One thing you'll notice is that backtraces are often cluttered with lots of functions that you didn't write; for example, std::__new_allocator<unsigned int>::allocate(unsigned long, void const*). These are functions from the C++ standard library that your code called, which called other functions, and so on. While it's possible that there's a bug in the library, it's much more likely that the issue is with the students' code, so the first thing to look for is the first function in the backtrace that they wrote. In this case, it's find42(std::vector<unsigned int, std::allocator<unsigned int> > const&), which is the function they wrote to find the number 42 in an array. Most importantly, several of the backtrace lines also say (find42.cpp:13) which tells us that the error occurred on line 13 of find42.cpp. That's a very strong hint that things went awry with the code on that line.
So we know that something went wrong when we were at the line that reads
while (v[index] != 42) {
Hay! I think I've figured it out!
That's good, but remember, we're looking at the debugging process, not just solving this bug. So let's imagine that the students are still mystified and keep going.
An Initial (Partial) Theory
An initial theory is that the array access is out-of-bounds. Adding an assert to check that the index is valid seems like a smart idea, so the loop now becomes
while (v[index] != 42) {
++index;
assert(index < v.size());
}
Now, with this assertion in place, we don't need industrial-strength debugging tools like valgrind to catch the out-of-bounds error; we can just run the program normally:
cs70 SERVER > clang++ -g -Wall -Wextra -o find42 find42.cpp -lranduint32
cs70 SERVER > ./find42
find42: find42.cpp:15: size_t find42(const std::vector<uint32_t> &): Assertion `index < v.size()' failed.
[1] 40091 IOT instruction (core dumped) ./find42
IOT instructionis a very old-fashioned way of saying the program called the C-libraryabort()function to terminate itself, which is whatassertdoes when the assertion fails. It actually stands for the PDP/11IOT(input/output trap) instruction, which was historically used to implement system calls likeabort().
The students now say,
Well, we could just use a bounded loop obviously, but the whole point of this function is that it's supposed to return the index of 42. There's no option for it to say, “I didn't find it”. Somehow 42 isn't there, which is crazy, because
initArraywrites all the numbers into the array.
The students claim expresses a belief, that initArray writes all the numbers into the array. But is that belief true? Let's apply the “expanding island of certainty” principle to this claim. One simple check is just checking whether the numbers do end up in the array.
So we change the loop to
for (size_t i = 0; i < 100; ++i) {
uint32_t num = rng.get(100);
v.push_back(num);
// Check that the number was actually added, two ways just to be sure
assert(v.back() == num);
assert(v[i] == num);
}
Running the code now, we find that neither of these assertions ever fail. So the numbers are definitely getting added to the array. But is 42 one of them? We can check by adding a print statement:
v.push_back(num);
if (num == 42) {
std::cerr << "- Added 42 at index " << i << "\n";
}
Now we see
cs70 SERVER > clang++ -g -Wall -Wextra -o find42 find42.cpp -lranduint32
cs70 SERVER > ./find42
- Added 42 at index 8
- Added 42 at index 34
- Added 42 at index 50
find42: find42.cpp:15: size_t find42(const std::vector<uint32_t> &): Assertion `index < v.size()' failed.
[1] 40809 IOT instruction (core dumped) ./find42
cs70 SERVER > ./find42
- Added 42 at index 57
- Added 42 at index 36
- Added 42 at index 52
- Added 42 at index 93
- Added 42 at index 42
- Added 42 at index 99
find42: find42.cpp:15: size_t find42(const std::vector<uint32_t> &): Assertion `index < v.size()' failed.
[1] 40893 IOT instruction (core dumped) ./find42
Our student pair is even more perplexed now. They say,
Wow, it really is adding 42 to the array, but then when we try to find it, it still fails. This makes no sense!
You ask why it seems to be adding 42 multiple times, and they say,
Well, we're running multiple trials, and each time we run it, we reinitialize the array. So I guess it got more trials done on the second run for some reason.
So you add another print statement to show the trial number,
for (size_t trial = 0; trial < NUM_TRIALS; ++trial) {
std::cerr << "Trial " << trial << ":\n";
initArray(rng, v);
size_t index = find42(v);
totalIndex += index;
}
and when run it produces
cs70 SERVER > ./find42
Trial 0:
- Added 42 at index 20
Trial 1:
- Added 42 at index 95
Trial 2:
find42: find42.cpp:15: size_t find42(const std::vector<uint32_t> &): Assertion `index < v.size()' failed.
[1] 41567 IOT instruction (core dumped) ./find42
The students now say,
See! That's what we said! Multiple trials. We put the numbers in but sometimes it still can't find 42. This is so weird.
Gathering More Evidence
Sometimes we need more insight to see what's going on. One question we might consider is, “If 42 isn't in the array, what is in the array?” Let's find out by adding a print statement to dump the entire contents of the array:
std::cerr << "Array contents: ";
for (size_t i = 0; i < v.size(); ++i) {
std::cerr << v[i] << " ";
}
std::cerr << "\n";
Now we see:
cs70 SERVER > clang++ -g -Wall -Wextra -o find42 find42.cpp -lranduint32
cs70 SERVER > ./find42
Trial 0:
- Added 42 at index 45
- Array contents: 56 51 60 64 99 37 61 30 95 76 25 40 10 24 29 80 14 9 6 10 75 30 47 82 4 20 78 80 30 71 12 52 59 7 10 37 17 43 11 32 19 34 38 62 79 42 76 45 75 65 12 45 52 58 8 35 93 13 71 37 33 43 97 79 64 61 49 0 9 95 51 3 27 37 14 91 84 9 19 3 55 69 90 58 45 66 39 35 11 21 48 6 68 28 49 83 38 52 41 83
Trial 1:
- Array contents: 74 28 21 86 96 36 68 38 76 90 14 72 19 22 58 99 93 37 71 33 55 52 75 14 10 36 5 62 67 17 36 58 31 52 24 90 8 68 9 52 48 74 70 88 51 4 54 92 82 99 24 7 90 60 45 6 64 58 71 86 31 19 84 67 64 81 66 87 44 52 86 47 45 45 40 83 17 27 41 94 17 58 92 74 51 24 70 80 62 5 17 40 0 22 37 88 94 98 98 90
find42: find42.cpp:15: size_t find42(const std::vector<uint32_t> &): Assertion `index < v.size()' failed.
[1] 41898 IOT instruction (core dumped) ./find42
The students look at this output and say,
See, filled with numbers, but no 42 in trial 1. It's got to be there. Wait, why is 51 in there twice in trial 0? That's weird. Oh wait… I— I think I see the problem. Nothing stops there from being repeats, and if some numbers are repeated, then there won't be a space for some others. We have to avoid duplicates.
In just a few moments, a misconception about what it meant to put the “shuffled numbers from 0 to 99” into the array collapses. The students even realize that the assignment mentioned std::shuffle, which is a big hint that they should have been shuffling a list of the numbers 0 to 99, not generating random numbers with replacement. At this point, their original code seems absurdly ill-conceived. They look sheepish and apologetic, saying,
Sorry! That was really dumb of us. We should have realized that sooner. Thanks for helping us figure it out!
And, as a grutor, this is your moment for camaraderie and reassurance, because that experience is everyone's experience in every debugging session ever. Once you understand things properly, it becomes hard to see how you could have been confused.
Key Debugging Principles
The example above illustrates several important principles:
- Start with the Evidence You Have
- Even if it's not very informative (like a bare segfault), it's your starting point.
- Improve Your Evidence
- Run with Valgrind, add assertions, add print statements—gather better information about what's actually happening.
- Check Your Assumptions Systematically
- When the students said “
initArraywrites all the numbers into the array,” they were making an assumption. Testing that assumption revealed the misconception. - The “Expanding Island of Certainty“ Approach
- Start from a point where things work as you expect, then creep forward, checking assumptions until you find where reality diverges from your mental model.
- The Bug Will Make Sense in Hindsight
- Once you understand what's really happening, the bug often seems obvious. That's not because you were foolish—it's because you've learned something new about how your code actually behaves.
- Everyone's Experience Is Like This
- Every programmer—from beginners to experts—goes through this same process of discovery, confusion, and eventual understanding. The main difference is that experienced programmers have seen more patterns and can sometimes recognize issues faster.
I always feel embarrassed when the bug turns out to be something simple…
Everyone does! But that “obvious in hindsight” feeling just means you learned something. You're not supposed to magically know everything before you debug—that's what debugging is for.
When to Ask for Help
Another thing to keep in mind is that you don't have to solve every bug on your own!1 Computer science is a collaborative field, and getting unstuck is an important part of learning.
Good times to ask for help include:
- You've tried the techniques above and are still stuck.
- You've been working on the same bug for more than 30-45 minutes without progress.
- You've found the problem but don't understand why it's a problem.
- You think you might be dealing with undefined behavior and want a second opinion.
When you ask for help (on Piazza, in grutoring hours, or in office hours), be ready to show them what you've already tried and the key pieces of evidence you've gathered. Knowing that information helps others understand where you're stuck and gives them better context for helping you. Not only that, but the process of showing what you've already tried often helps you see the problem more clearly—explaining what you've tried is a form of rubber-duck debugging!
What if I'm embarrassed about not being able to figure it out for myself?
Try not to be—everyone gets stuck sometimes. The grutors and instructors are there specifically to help you with these issues. They've all been through the same frustrations you're experiencing, and they want to help you get unstuck.
Don't forget that helping someone else debug their code is also an opportunity to learn—everyone wins!
Summary
Debugging is fundamentally about discovering your own misconceptions.
- Use the tools at your disposal: print statements, Valgrind, assertions, compiler warnings.
- Improve your evidence systematically until you can see what's really happening.
- Check your assumptions—the bug is hiding in something you're taking for granted.
- Expand your “island of certainty” from what you know works toward what doesn't.
- Ask for help when you're stuck—collaboration is a strength, not a weakness.
With practice, you'll get better at recognizing patterns, asking the right questions, and tracking down bugs efficiently. Every debugging session, as frustrating as it may be in the moment, is making you a better programmer.
Are there MORE POWERFUL debugging techniques than these?
As far as an overall approach goes, this page has covered the main ideas. But there is one more tool we haven't mentioned yet: using a source-level debugger. Debuggers let you run your program step-by-step, inspect variables, and see exactly what's happening at each line of code. They can be very powerful, especially for complex bugs where interactive investigation is helpful, but they also have a steeper learning curve and aren't always faster than traditional methods.
On our server, we have
gdb(the GNU Debugger) installed, which is a popular source-level debugger for C, C++, and other languages. The Using GDB guide provides an introduction to usinggdbeffectively if you want to give it a try.
-
At least for CS 70. If you're working as a programmer, you probably have other coders on your team or on other projects you can ask for help. Even if you're working completely alone, there will be resources available to you, from search engines, to sites like Stack Exchange, to AI assistants. Here in CS 70 we have rules about what resources you can use, but you also have the profs, the grutors, and your peers backing you up!] ↩
(When logged in, completion status appears here.)