Watch the video (12 min)
On this page26 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. The Debugging Mindset
  5. 2. Reproduce with Small, Known Input
  6. 3. Reading a Stack Trace
  7. 4. Print Debugging Done Well
  8. 5. Print the Type and Show the Edges
  9. 6. log() from dart:developer
  10. 7. debugger(): a Breakpoint Written in Code
  11. 8. assert as a Debugging Aid
  12. 9. The VS Code Debugger: Breakpoints
  13. 10. Stepping Through Code
  14. 11. Variables, Watch and Call Stack
  15. 12. Conditional Breakpoints and Logpoints
  16. 13. dart run --observe
  17. 14. A Real-World Example
  18. Code Examples
  19. Common Beginner Mistakes
  20. Best Practices
  21. Mini Challenge
  22. Practice Questions
  23. Practice Answers
  24. Quick Revision
  25. Key Takeaways
  26. What's Next?

Lecture 115 — Debugging Dart

Category19 — Dart Developer Tools
Lecture115 of 140
Estimated video length12 min
TargetDart 3.12.2
LevelAdvanced

The analyzer catches code that is written wrong. Debugging finds code that runs wrong: it compiles, prints a number, and the number is not the right one.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every output and error message in this lecture is real, produced on Dart 3.12.2. File paths are shortened to …, and Dart's start-up frames at the end of stack traces are left out. The VS Code steps are described in words; try them yourself.


Prerequisites

Students should already understand, from Lectures 001–114:

  • VS Code with the Dart extension and its Run | Debug links (Lecture 002)
  • list indexes and for loops (Lectures 026, 040)
  • reading a stack trace (Lecture 083) and assert with --enable-asserts (Lecture 086)
  • importing dart: libraries (Lecture 101) and dart run (Lecture 113)

Connection With Previous Lectures

Run dart analyze (Lecture 114) on this program and it reports "No issues found!":

dart
double averageOfLast(List<int> steps, int days) {
  var total = 0;
  for (var i = steps.length - days; i < steps.length - 1; i++) {
    total += steps[i];
  }
  return total / days;
}

void main() {
  var steps = [4000, 6000, 8000, 10000, 12000];
  print('Average of last 3 days: ${averageOfLast(steps, 3)}');
}
output
Average of last 3 days: 6000.0

Sara walked 8000, 10000 and 12000 steps on her last three days, so the answer should be 10000.0. Nothing crashed and the analyzer is happy, yet the result is wrong. This lecture shows how to track the bug down instead of guessing.


Learning Objectives

By the end of this lecture you will be able to:

  • Follow the four steps: reproduce, isolate, hypothesise, verify
  • Read a stack trace to find the line that failed
  • Write debug prints with labels, values and types
  • Explain what log() and debugger() from dart:developer do under dart run
  • Use assert to check an assumption while debugging
  • Set breakpoints and step through code in the VS Code debugger
  • Use the Watch, Variables and Call Stack panels, conditional breakpoints and logpoints
  • Find and fix an off-by-one bug

1. The Debugging Mindset

A bug is a difference between what the program does and what you expected.

Analogy — debugging is detective work. You do not arrest the first suspect; you collect clues until only one explanation is left.

StepQuestionFor Sara's program
ReproduceCan I make it fail every time?Same five numbers, always 6000.0
IsolateWhere is the smallest failing piece?averageOfLast, not print
HypothesiseWhat exact cause would explain it?"The loop adds the wrong days"
VerifyDoes a test prove or disprove it?Print each day the loop adds

Change one thing at a time, or you won't know which change fixed it.


2. Reproduce with Small, Known Input

Pick input where you can do the arithmetic in your head:

dart
double averageOfLast(List<int> steps, int days) {
  var total = 0;
  for (var i = steps.length - days; i < steps.length - 1; i++) {
    total += steps[i];
  }
  return total / days;
}

void main() {
  print(averageOfLast([1, 2, 3], 3)); // expect 2.0
  print(averageOfLast([1, 2, 3], 1)); // expect 3.0
}
output
1.0
0.0

0.0 for the last day alone is a strong clue: the loop added nothing.


3. Reading a Stack Trace

Suppose Ali "fixes" the loop by guessing i <= steps.length. Now the program crashes:

output
Unhandled exception:
RangeError (length): Invalid value: Not in inclusive range 0..4: 5
#0      List.[] (dart:core-patch/growable_array.dart)
#1      averageOfLast (file:///…/main.dart:4:19)
#2      main (file:///…/main.dart:11:36)

As in Lecture 083, skip Dart's own frame (#0) and start at the first frame in your file: line 4, column 19 is steps[i]. Index 5 does not exist in a five-item list. Guessing turned a wrong answer into a crash; that is why we hypothesise and verify.


4. Print Debugging Done Well

A bare print(total) prints 8000 with no context. A good debug print has a label, the values, and a marker so you can find and delete it later:

dart
void main() {
  var steps = [4000, 6000, 8000, 10000, 12000];
  var total = 0;
  for (var i = 2; i < steps.length - 1; i++) {
    print('DEBUG i=$i steps[i]=${steps[i]} total=$total');
    total += steps[i];
  }
  print('DEBUG final total=$total');
}
output
DEBUG i=2 steps[i]=8000 total=0
DEBUG i=3 steps[i]=10000 total=8000
DEBUG final total=18000

The loop runs for i = 2 and i = 3 only. Index 4, the 12000-step day, is never added.


5. Print the Type and Show the Edges

Two values can print the same and still be different. Add runtimeType, and wrap text in brackets so hidden spaces show up:

dart
void main() {
  var qty = '3';
  var name = 'Ali ';
  print('Items: ${qty * 2}');
  print('DEBUG qty=$qty (${qty.runtimeType})');
  print('DEBUG name=[$name] length=${name.length}');
}
output
Items: 33
DEBUG qty=3 (String)
DEBUG name=[Ali ] length=4

qty is the text '3', so * 2 repeats it. And name == 'Ali' would be false because of the trailing space.


6. log() from dart:developer

dart:developer has a log() function that sends a message, tagged with a name, to a connected debugger or DevTools instead of the terminal:

dart
import 'dart:developer';

void main() {
  print('before');
  log('total is 18000', name: 'steps');
  print('after');
}
output
before
after

Under dart run the log line is not shown at all. Started with Debug in VS Code, it appears in the Debug Console. So log() is for messages you want while debugging, kept out of the program's normal output.


7. debugger(): a Breakpoint Written in Code

debugger() pauses the program at that line, as if a breakpoint were set there. That is only useful when a debugger is attached. Under a plain dart run it pauses anyway, with nothing to resume it:

dart
import 'dart:developer';

void main() {
  print('before');
  debugger();
  print('after');
}
output
before

after never appears and the program never ends; press Ctrl + C. (A program compiled with dart compile exe ignores debugger().) Use debugger(when: condition) to pause only in one case, and delete every debugger() call when you are done.


8. assert as a Debugging Aid

Your hypothesis is "the loop adds fewer than days values". Turn it into an assert, so the program stops at the exact moment the assumption breaks:

dart
double averageOfLast(List<int> steps, int days) {
  var total = 0;
  var added = 0;
  for (var i = steps.length - days; i < steps.length - 1; i++) {
    total += steps[i];
    added++;
  }
  assert(added == days, 'added $added days, expected $days');
  return total / days;
}

void main() {
  print(averageOfLast([4000, 6000, 8000, 10000, 12000], 3));
}
output
$ dart run --enable-asserts main.dart
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 8 pos 10: 'added == days': added 2 days, expected 3

The hypothesis is verified: one day is missing. Without --enable-asserts, the assert is skipped and 6000.0 prints as before (Lecture 086).


9. The VS Code Debugger: Breakpoints

Prints make you edit, rerun and delete. A debugger freezes the running program so you can look at everything at once.

  1. Click in the gutter left of a line number (or press F9). A red dot appears: a breakpoint.
  2. Click Debug above main(), or press F5.
  3. The program runs until it reaches the breakpoint, then stops before running that line. The line is highlighted, and the debug toolbar appears at the top.

Put a breakpoint on total += steps[i]; in Sara's program and press F5.


10. Stepping Through Code

While paused, the toolbar moves you forward one piece at a time:

ButtonKeyWhat it does
ContinueF5Run to the next breakpoint (or the end)
Step OverF10Run this line, stop on the next one
Step IntoF11Go inside the function this line calls
Step OutShift + F11Finish this function, stop where it was called
StopShift + F5End the program

On print(averageOfLast(steps, 3)), Step Into enters averageOfLast. Step Over on the same line runs the whole call and lands on the next line of main. By default, Step Into skips Dart's own library code and stays in your files.


11. Variables, Watch and Call Stack

The Run and Debug side bar shows three panels while paused:

  • Variables — every local variable and its current value: steps, days, total, i. Expand steps to see each index.
  • Watch — click + and type an expression, like i < steps.length - 1. It is recalculated each time the program pauses.
  • Call Stack — the same list of frames as a stack trace, but for a live program: averageOfLast on top, main below. Click a frame to see that function's variables.

Pressing Continue with that watch shows it is true at i = 3 and false once i reaches 4: the loop condition is the culprit. You can also type an expression into the Debug Console while paused.


12. Conditional Breakpoints and Logpoints

Stopping on every pass of a 1000-item loop is slow. Right-click the gutter instead:

  • Add Conditional Breakpoint… → type i == 4. The program pauses only when it is true. If it never pauses, index 4 is never reached: one more clue.
  • Add Logpoint… → type i={i} total={total}. Expressions go in { }. Each time the line runs, the message goes to the Debug Console and the program does not stop.

A logpoint is a debug print that never touches your code, so there is nothing to delete afterwards.


13. dart run --observe

Outside VS Code, --observe starts the program with the Dart VM service, the connection debuggers and DevTools use:

output
$ dart run --observe main.dart
before
after
vm-service: isolate(…)  'main' has no debugger attached and is paused at exit.  Connect to the Dart VM service to debug.
The Dart VM service is listening on http://127.0.0.1:8181/…/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/…/devtools/?uri=ws://127.0.0.1:8181/…/ws

The program stays paused at the end so a tool can connect; press Ctrl + C to quit. We will open that DevTools link in Lecture 116 — DevTools.


14. A Real-World Example

Let's close Sara's case with all four steps.

  1. Reproduce — [1, 2, 3] with days = 1 gives 0.0 instead of 3.0 (section 2).
  2. Isolate — only averageOfLast is involved; main just prints.
  3. Hypothesise — the labelled prints (section 4) and the assert (section 8) both show the loop stops one index early: the last day is never added.
  4. Verify — the last valid index is steps.length - 1, so the condition must let i reach it. i < steps.length - 1 stops before it. The fix is i < steps.length.
dart
double averageOfLast(List<int> steps, int days) {
  var total = 0;
  for (var i = steps.length - days; i < steps.length; i++) {
    total += steps[i];
  }
  return total / days;
}

void main() {
  print(averageOfLast([1, 2, 3], 3)); // expect 2.0
  print(averageOfLast([1, 2, 3], 1)); // expect 3.0
  var steps = [4000, 6000, 8000, 10000, 12000];
  print('Average of last 3 days: ${averageOfLast(steps, 3)}');
}
output
2.0
3.0
Average of last 3 days: 10000.0

This is an off-by-one bug: a loop boundary one step too short or too long. Verify with the small cases and the original input, then remove every DEBUG print.


Code Examples

Example 1 — A Labelled Print

dart
void main() {
  var price = 250;
  var quantity = 4;
  print(price * quantity);
  print('DEBUG price=$price quantity=$quantity total=${price * quantity}');
}
output
1000
DEBUG price=250 quantity=4 total=1000

Example 2 — Brackets Reveal Spaces

dart
void main() {
  var name = 'Sara ';
  print(name == 'Sara');
  print('DEBUG name=[$name] length=${name.length}');
  print(name.trim() == 'Sara');
}
output
false
DEBUG name=[Sara ] length=5
true

Example 3 — Printing the Type

dart
void main() {
  var average = 170 / 2;
  var count = 170 ~/ 2;
  print('DEBUG average=$average (${average.runtimeType})');
  print('DEBUG count=$count (${count.runtimeType})');
}
output
DEBUG average=85.0 (double)
DEBUG count=85 (int)

Example 4 — Tracing a Loop

dart
void main() {
  var marks = [70, 80, 90];
  var total = 0;
  for (var i = 0; i < marks.length; i++) {
    total += marks[i];
    print('DEBUG i=$i mark=${marks[i]} total=$total');
  }
}
output
DEBUG i=0 mark=70 total=70
DEBUG i=1 mark=80 total=150
DEBUG i=2 mark=90 total=240

Example 5 — log() Stays Out of the Terminal

dart
import 'dart:developer';

void main() {
  var balance = 1000;
  log('balance before withdrawal: $balance', name: 'bank');
  balance -= 250;
  print('Balance: $balance');
}
output
Balance: 750

Run it with Debug in VS Code to see the bank message in the Debug Console.

Example 6 — An Assert That Checks an Assumption

dart
int lastIndex(List<int> values) {
  assert(values.isNotEmpty, 'lastIndex() needs at least one value');
  return values.length - 1;
}

void main() {
  print(lastIndex([5, 6, 7]));
  print(lastIndex([]));
}
output
$ dart run --enable-asserts main.dart
2
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 2 pos 10: 'values.isNotEmpty': lastIndex() needs at least one value

Without the flag, the second line quietly prints -1.

Example 7 — Student Marks: The Boundary Bug

Three of these students reached the pass mark of 50, but the program counts one.

dart
void main() {
  var marks = [50, 72, 49, 50];
  var passed = 0;
  for (var mark in marks) {
    print('DEBUG mark=$mark passes=${mark > 50}');
    if (mark > 50) passed++;
  }
  print('Passed: $passed');
}
output
DEBUG mark=50 passes=false
DEBUG mark=72 passes=true
DEBUG mark=49 passes=false
DEBUG mark=50 passes=false
Passed: 1

How it works

  1. The debug line shows 50 failing, so the bug is in the comparison.
  2. "At least 50" means mark >= 50. With that fix, Passed: 3.
  3. > versus >= is an off-by-one bug in a comparison instead of a loop.

Example 8 — Shopping Total: The First Item Is Missing

dart
void main() {
  var prices = [300, 450, 250];
  var total = 0;
  for (var i = 1; i < prices.length; i++) {
    print('DEBUG adding prices[$i]=${prices[i]}');
    total += prices[i];
  }
  print('Total: $total');
}
output
DEBUG adding prices[1]=450
DEBUG adding prices[2]=250
Total: 700

How it works

  1. Expected 1000; got 700, which is 300 short.
  2. The trace never mentions index 0: the loop starts at 1.
  3. Lists start at index 0. Changing to var i = 0 gives Total: 1000.

Example 9 — Bank Balance: A Total That Resets

dart
void main() {
  var deposits = [200, 300, 500];
  var balance = 0;
  for (var amount in deposits) {
    balance = 0;
    balance += amount;
    print('DEBUG amount=$amount balance=$balance');
  }
  print('Balance: $balance');
}
output
DEBUG amount=200 balance=200
DEBUG amount=300 balance=300
DEBUG amount=500 balance=500
Balance: 500

How it works

  1. The balance should grow, but each debug line equals the deposit alone.
  2. Hypothesis: something resets it. balance = 0; inside the loop does.
  3. Delete that line and the result is Balance: 1000.

Example 10 — Temperature: Assert a Known Answer

dart
double toFahrenheit(double celsius) => celsius * 5 / 9 + 32;

void main() {
  assert(toFahrenheit(0) == 32, 'freezing point');
  assert(toFahrenheit(100) == 212, 'boiling: got ${toFahrenheit(100)}');
  print(toFahrenheit(25));
}
output
$ dart run --enable-asserts main.dart
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 5 pos 10: 'toFahrenheit(100) == 212': boiling: got 87.55555555555556

How it works

  1. 0 passes, which tells us the + 32 is right.
  2. 100 fails, so the multiplication is wrong: it should be * 9 / 5.
  3. After the fix both asserts pass and 77.0 prints.

Common Beginner Mistakes

1. Guessing instead of verifying — changing < to <= "to see if it helps" turned a wrong answer into a RangeError (section 3).

2. Unlabelled prints — five lines of bare numbers can't be told apart. Label every value.

3. Expecting log() in the terminal — under dart run it prints nothing (section 6).

4. Leaving debugger() in the code

dart
// import 'dart:developer';
//
// void main() {
//   debugger(); // ✗ under dart run the program freezes here
//   print('Report ready');
// }

Nothing is printed and nothing crashes, which makes it confusing. Remove it after debugging.

5. Forgetting --enable-asserts — the debugging assert silently never runs.


Best Practices

  • Reproduce with the smallest input whose answer you know.
  • Write down the hypothesis before changing code; change one thing at a time.
  • Prefix debug prints with DEBUG and include labels, values and, when in doubt, types.
  • Prefer breakpoints and logpoints to edits you have to undo.
  • Keep --enable-asserts on while developing.
  • After the fix, remove debug prints and every debugger() call.

Mini Challenge

Zayan wants his last 3 game scores, but this prints 4. Add a labelled debug print, find the off-by-one, and fix it.

dart
List<int> lastScores(List<int> scores, int n) {
  return scores.sublist(scores.length - n - 1);
}

void main() {
  print(lastScores([10, 20, 30, 40, 50], 3)); // expect [30, 40, 50]
}
output
[20, 30, 40, 50]

Try it yourself before opening the solution.

Show solution
dart
List<int> lastScores(List<int> scores, int n) {
  var start = scores.length - n;
  print('DEBUG length=${scores.length} n=$n start=$start');
  return scores.sublist(start);
}

void main() {
  print(lastScores([10, 20, 30, 40, 50], 3)); // expect [30, 40, 50]
}
output
DEBUG length=5 n=3 start=2
[30, 40, 50]

How it works

  1. The buggy start was 5 - 3 - 1, which is 1: one index too early.
  2. The last n items start at length - n, here index 2.
  3. Delete the DEBUG line once the output is right.

Practice Questions

Work through these in order — they run from beginner to application to challenge.

Beginner

  1. Name the four steps of the debugging mindset, in order.

  2. In the trace below, which line of main.dart should you look at first?

    output
    #0      List.[] (dart:core-patch/growable_array.dart)
    #1      lastMark (file:///…/main.dart:2:15)
    #2      main (file:///…/main.dart:6:9)
  3. Rewrite print(total); as a labelled debug print that also shows the type.

  4. What does log('saved', name: 'db') print under dart run?

Application

  1. What happens when a plain dart run reaches debugger()?

  2. Find and fix the bug. Expected Sum: 30.

    dart
    void main() {
      var values = [5, 10, 15];
      var sum = 0;
      for (var i = 0; i < values.length - 1; i++) {
        sum += values[i];
      }
      print('Sum: $sum');
    }
  3. You are paused on var avg = average(marks);. Which button takes you inside average, and which brings you back to this line's function?

  4. Add an assert to double average(List<int> marks) that fails on an empty list, and show the output for average([]) with --enable-asserts.

Challenge

  1. A loop runs 1000 times and breaks only when i is 742. What breakpoint would you set, and what would you type?

  2. countWarm should count temperatures from 25 to 30 inclusive. It prints 1 for [25, 27, 30, 31]. Find the bug with a debug print and fix it.

    dart
    int countWarm(List<int> temps) {
      var count = 0;
      for (var t in temps) {
        if (t > 25 && t < 30) count++;
      }
      return count;
    }
    
    void main() {
      print(countWarm([25, 27, 30, 31]));
    }

Practice Answers

Question 1 — Solution

Reproduce, isolate, hypothesise, verify.

Question 2 — Solution

Line 2 (#1 lastMark … main.dart:2:16): the first frame in your own file. #0 is Dart's code.

Question 3 — Solution

dart
void main() {
  var total = 240;
  print('DEBUG total=$total (${total.runtimeType})');
}
output
DEBUG total=240 (int)

Question 4 — Solution

Nothing. The message goes to a connected debugger, such as VS Code's Debug Console.

Question 5 — Solution

The program pauses there and waits for a debugger that is not attached. The next line never runs; quit with Ctrl + C.

Question 6 — Solution

The loop stops before the last index. Use i < values.length:

dart
void main() {
  var values = [5, 10, 15];
  var sum = 0;
  for (var i = 0; i < values.length; i++) {
    sum += values[i];
  }
  print('Sum: $sum');
}
output
Sum: 30

Question 7 — Solution

Step Into (F11) enters average. Step Out (Shift + F11) finishes it and returns to the calling line.

Question 8 — Solution

dart
double average(List<int> marks) {
  assert(marks.isNotEmpty, 'average() needs at least one mark');
  var total = 0;
  for (var mark in marks) {
    total += mark;
  }
  return total / marks.length;
}

void main() {
  print(average([80, 90]));
  print(average([]));
}
output
$ dart run --enable-asserts main.dart
85.0
Unhandled exception:
'file:///…/main.dart': Failed assertion: line 2 pos 10: 'marks.isNotEmpty': average() needs at least one mark

Question 9 — Solution

A conditional breakpoint inside the loop (right-click the gutter → Add Conditional Breakpoint…) with the expression i == 742.

Question 10 — Solution

dart
int countWarm(List<int> temps) {
  var count = 0;
  for (var t in temps) {
    print('DEBUG t=$t warm=${t >= 25 && t <= 30}');
    if (t >= 25 && t <= 30) count++;
  }
  return count;
}

void main() {
  print(countWarm([25, 27, 30, 31]));
}
output
DEBUG t=25 warm=true
DEBUG t=27 warm=true
DEBUG t=30 warm=true
DEBUG t=31 warm=false
3

"Inclusive" means >= and <=. With > and <, the edges 25 and 30 were left out. Remove the debug line afterwards.


Quick Revision

  1. What are the four steps of debugging?
  2. Which stack-trace frame do you read first?
  3. What three things make a good debug print?
  4. Where does log() output go?
  5. Why must you remove debugger() calls?
  6. What is the difference between Step Over and Step Into?
  7. How does a logpoint differ from a breakpoint?
  8. What is an off-by-one bug?

Key Takeaways

  • Debug in four steps: reproduce, isolate, hypothesise, verify. Change one thing at a time.
  • A stack trace's first frame in your own file is where to look.
  • Good debug prints have a label, the value, and sometimes runtimeType or [brackets].
  • log() writes to a debugger, not the terminal; debugger() pauses, and freezes a plain dart run.
  • An assert turns a hypothesis into a check (run with --enable-asserts).
  • VS Code: breakpoints, Step Over/Into/Out, Variables, Watch, Call Stack, conditional breakpoints and logpoints.
  • Off-by-one bugs hide in loop bounds, sublist starts and > vs >=.

What's Next?

Lecture 116 — DevTools. dart run --observe printed a DevTools link. Next we open it to see a running program's memory, performance and logs, and find problems that a breakpoint alone cannot show.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.