Debugging: Finding and Fixing Errors

4. Debugging: Finding and Fixing Errors#

A crucial skill you’ve hopefully started developing is debugging. Simply put, it’s the process of finding and fixing errors until your code works as expected. In practice, debugging can sometimes feel like you’re a detective in a crime movie where you are also the one who caused the problem.

There are three main types of errors you’ll encounter:

  • Syntax Errors: The code violates the rules of the language (e.g., a typo or a missing parenthesis).

  • Runtime Errors: The code is syntactically correct but fails during execution (e.g., trying to divide by zero or accessing an out-of-bounds array index).

  • Semantic Errors: The code runs without crashing but produces the wrong result. These are often the trickiest, as the computer doesn’t know your code isn’t doing what you intended.

Let’s explore each of these with some examples.

4.1. Syntax Errors#

Syntax errors are fundamental mistakes that prevent the Julia interpreter from understanding your code. Think of them as grammatical errors in a sentence; if the grammar is wrong, the meaning is lost.

They are often caused by simple typos and are usually the easiest to fix. As you gain more experience, you’ll make fewer of them, but they never disappear entirely! A good practice to minimize and isolate these errors is modular programming. In this course, that means writing small, focused functions that each accomplish a single task, often in separate notebook cells. This makes it much easier to pinpoint where an error is coming from.

Let’s look at a few common syntax errors.

# This function is intended to find the square root of a number,
# but the keyword `function` is misspelled.
fnction mySqrt(a)
    return sqrt(a)
end
UndefVarError: `fnction` not defined in `Main`
Suggestion: check for spelling errors or missing imports.

The error message tells us that Julia didn’t recognize fnction as a keyword. This is a strong clue that the keyword function was misspelled.

# This function performs a calculation inside a loop.
# Notice the complex mathematical expression on line 4.
function lots_of_math(n)
    var = 0
    for i = 1:n
        var += sqrt(2*pi*i)*(((i-1)+(i+2))/((n+5)*(5*i))
    end
    return var
end
ParseError:
# Error @ ]8;;file:///home/persson/Programming_for_Mathematical_Applications/textbook/content/Debugging/In[2]#7:5\In[2]:7:5]8;;\
        var += sqrt(2*pi*i)*(((i-1)+(i+2))/((n+5)*(5*i))
    end
#   └ ── Expected `)`

Stacktrace:
 [1] top-level scope
   @ In[2]:7

Mismatched parentheses are another very common syntax error, especially in complex mathematical formulas. The stacktrace points to line 7 (In[2]:7), but the actual mistake is in the long expression on line 6.

Here’s a handy trick for finding the missing parenthesis. Scan the expression from left to right, keeping a running count. Start at 0, add 1 for every (, and subtract 1 for every ). The final count should be 0.

# Expression: sqrt(2*pi*i)*(((i-1)+(i+2))/((n+5)*(5*i))
# Count:          1      0 123   2 3   21 23   2 3   21

The count ends at 1, which confirms we’re missing a closing parenthesis. The right position to put this ) is in the numerator to bring the count back to 0 before the division /.

# This function demonstrates valid and invalid ways to break long lines of code.
function more_math()
    i = 10
    # This assignment is split across two lines incorrectly.
    var1
    = sqrt(2*pi*i)*(((i-1)+(i+2))/((i+5)*(5*i)))
    println(var1)
    
    i = 20
    # This is the correct way to break a line after an operator.
    var2 = 
    sqrt(2*pi*i)*(((i-1)+(i+2))/
        ((i+5)*(5*i)))
    println(var2)
end
ParseError:
# Error @ ]8;;file:///home/persson/Programming_for_Mathematical_Applications/textbook/content/Debugging/In[3]#6:5\In[3]:6:5]8;;\
    var1
    = sqrt(2*pi*i)*(((i-1)+(i+2))/((i+5)*(5*i)))
#   ╙ ── unexpected `=`

Stacktrace:
 [1] top-level scope
   @ In[3]:6

Julia doesn’t expect a line of code to start with an equals sign = in this context, so it throws an error. The fix is to combine lines 3 and 4. However, notice that Julia does allow you to break a line right after an operator like =, as shown correctly in the var2 assignment.

Tip: When debugging, it’s incredibly helpful to see line numbers. In Jupyter, you can enable them by selecting View -> Toggle Line Numbers from the menu.

# This function initializes a 2D array, but contains a small typo
function array_init(n)
    array = Array{Float64,2}(undef, n, n)
    for i = 1:size(array,1):
        for j = 1:size(array,2)
            array[i,j] = i*j
        end
    end
end
ParseError:
# Error @ ]8;;file:///home/persson/Programming_for_Mathematical_Applications/textbook/content/Debugging/In[4]#4:29\In[4]:4:29]8;;\
    array = Array{Float64,2}(undef, n, n)
#                           ┌
    for i = 1:size(array,1):
        for j = 1:size(array,2)
#──────┘ ── line break after `:` in range expression

Stacktrace:
 [1] top-level scope
   @ In[4]:4

One final syntax error example. The stack trace reports a line break after ":" in range expression on line 4, which makes it easy to find the error.


4.2. Runtime Errors#

Runtime errors occur while your program is executing. The code is syntactically valid, but something goes wrong during the process.

These can be more complex than syntax errors. Always read the full error message! It contains valuable clues. If you encounter an error you don’t understand, copy and paste the exact message into a search engine. You are almost certainly not the first person to see it, and you’ll often find solutions on sites like Stack Overflow or the official Julia Discourse forum.

Again, modular programming is your best friend here. If you’ve tested individual components of your program, you can be confident they work correctly, which helps you narrow your search for the bug.

# Compute the first n terms of the Taylor expansion of e^x centered at x=0.
# The formula is sum_{i=0 to n-1} (x^i / i!)
function taylorExpansion(x, n)
    s = 0.0 # It's good practice to initialize sums as floats
    for i = 0:n-1
        s += x^i / factorial(i)
    end
    return s
end

# This will fail because factorial(21) is too large for a standard integer.
taylorExpansion(0.5, 25)
OverflowError: 21 is too large to look up in the table; consider using `factorial(big(21))` instead

Stacktrace:
 [1] factorial_lookup
   @ ./combinatorics.jl:19 [inlined]
 [2] factorial
   @ ./combinatorics.jl:27 [inlined]
 [3] taylorExpansion(x::Float64, n::Int64)
   @ Main ./In[5]:6
 [4] top-level scope
   @ In[5]:12

Here, Julia reports an OverflowError. The stack trace shows that our taylorExpansion function called the built-in factorial function, which failed. Why? Because \(21!\) is a huge number, too large to be stored in a standard 64-bit integer.

Even with the correct logic, the approach is flawed. We are calculating two very large numbers, \(x^i\) and \(i!\), only to divide them. A much more stable and efficient approach, which avoids large intermediate numbers, is to compute each term from the previous one. This was explored in the section on For-Loops.

# This is the same mathematical expression from before, but now with a missing operator.
function lots_of_math2(n)
    var = 0
    for i = 1:n
        # The '*' operator is missing between sqrt(...) and (...) 
        var += sqrt(2*pi*i)(((i-1)+(i+2))/((n+5)*(5*i)))
    end
    return var
end

lots_of_math2(5)
MethodError: objects of type Float64 are not callable
The object of type `Float64` exists, but no method is defined for this combination of argument types when trying to treat it as a callable object.
Maybe you forgot to use an operator such as *, ^, %, / etc. ?

Stacktrace:
 [1] lots_of_math2(n::Int64)
   @ Main ./In[6]:6
 [2] top-level scope
   @ In[6]:11

Julia’s error message here is excellent. It reports a MethodError: objects of type Float64 are not callable and even suggests the most likely cause: Maybe you forgot to use an operator such as *, ^, %, / etc. ?. This is exactly right, we’re missing a * for multiplication.

Why is it a MethodError? Because without the *, Julia interprets the expression A(B) as a function call. It evaluates the part in the sqrt(...), gets a Float64 number, and then tries to call that number as a function with the second parenthesized expression as the argument. Since you can’t “call” a number, it throws an error. It’s trying to do this:

var1 = sqrt(2*pi*i)                # This results in a number
var2 = ((i-1)+(i+2))/((n+5)*(5*i)) # This is also a number
var += var1(var2)                  # ERROR: You can't call var1 like a function!
# This function compares two numbers, x and y.
function compare(x,y)
    if x < y
        relation = "less than"
    elseif x > y
        relation = "greater than"
    end
    # This line will fail if x is equal to y.
    println(x, " is ", relation, " ", y)
end

compare(5,5)
UndefVarError: `relation` not defined in local scope
Suggestion: check for an assignment to a local variable that shadows a global of the same name.

Stacktrace:
 [1] compare(x::Int64, y::Int64)
   @ Main ./In[7]:9
 [2] top-level scope
   @ In[7]:12

We get an UndefVarError, telling us the variable relation is not defined. But wait, don’t we define it on lines 4 or 6?

The key is to trace the program’s execution path. When we call compare(5,5), the condition x < y is false, and the condition x > y is also false. Neither if block is executed, so the variable relation is never created. When the code reaches line 9, it doesn’t know what relation is. The fix is to add a case to handle when x == y, for example, by adding an else block that sets relation = "equal to".

# This function should initialize an m x n matrix where matrix[i,j] = i*j.
# There's a common mix-up between rows and columns.
function array_init2(m, n)
    # In Julia, Matrix(m, n) creates a matrix with m rows and n columns.
    array = Matrix{Float64}(undef, m, n)
    for i = 1:n # Loop over rows
        for j = 1:m # Loop over columns
            # Let's see... what happens if we mix up the loop bounds?
            # Here we loop `i` to `n` and `j` to `m`, which is the reverse of the array dimensions.
            array[i, j] = i*j
        end
    end
    return array
end

# We're creating a 5x4 matrix (5 rows, 4 columns).
array_init2(5, 4)
BoundsError: attempt to access 5×4 Matrix{Float64} at index [1, 5]

Stacktrace:
 [1] throw_boundserror(A::Matrix{Float64}, I::Tuple{Int64, Int64})
   @ Base ./essentials.jl:14
 [2] checkbounds
   @ ./abstractarray.jl:699 [inlined]
 [3] setindex!
   @ ./array.jl:993 [inlined]
 [4] array_init2(m::Int64, n::Int64)
   @ Main ./In[8]:10
 [5] top-level scope
   @ In[8]:17

A classic BoundsError! This is one of the most common errors you’ll encounter when working with arrays.

Let’s dissect the problem. We call array_init2(5, 4), which creates a matrix with m=5 rows and n=4 columns. The valid row indices are 1 through 5, and the valid column indices are 1 through 4.

However, our loops are for i = 1:n (i.e., i goes from 1 to 4) and for j = 1:m (i.e., j goes from 1 to 5). The line array[i, j] = i*j tries to access the element at row i and column j. Eventually, the inner loop will try to access array[1, 5], attempting to access the 5th column. But our matrix only has 4 columns, which causes the BoundsError.

The fix is to ensure the loop bounds match the array dimensions:

for i = 1:m # Loop for rows from 1 to m
    for j = 1:n # Loop for columns from 1 to n
        array[i, j] = i*j
    end
end

Let’s look at one final type of runtime error that highlights a key feature of Julia.

# This function is too general; it doesn't specify the type of its input.
function divide_by_zero(input)
    return input/0
end

# What happens if we pass a string?
divide_by_zero("string")
MethodError: no method matching /(::String, ::Int64)
The function `/` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  /(::Missing, ::Number)
   @ Base missing.jl:123
  /(::BigInt, ::Union{Int16, Int32, Int64, Int8, UInt16, UInt32, UInt64, UInt8})
   @ Base gmp.jl:560
  /(::BigFloat, ::Union{Int16, Int32, Int64, Int8})
   @ Base mpfr.jl:553
  ...


Stacktrace:
 [1] divide_by_zero(input::String)
   @ Main ./In[9]:3
 [2] top-level scope
   @ In[9]:7

This MethodError is unique to languages like Julia that use multiple dispatch. Many other languages would flag this as a syntax error, but Julia allows you to define generic functions that can potentially operate on any type. The error only occurs at runtime when you actually try to call the function with an unsupported type.

The error message no method matching /(::String, ::Int64) is very explicit: Julia doesn’t know how to perform the / operation between a String and an Int64.

We could fix this by either not passing a string, or by being more specific in our function definition to prevent such calls in the first place. We can enforce this by adding a type annotation to the function argument:

# This is a more robust version of the function.
# By specifying `input::Number`, we ensure only numbers can be passed as arguments.
function divide_by_zero_v2(input::Number) 
    return input/0
end

# Now, Julia won't even find a matching function to call.
divide_by_zero_v2("string")
MethodError: no method matching divide_by_zero_v2(::String)
The function `divide_by_zero_v2` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  divide_by_zero_v2(::Number)
   @ Main In[10]:3


Stacktrace:
 [1] top-level scope
   @ In[10]:8

Now the error message is even clearer: no method matching divide_by_zero_v2(::String). This confirms that our program has no version of the divide_by_zero function that accepts a String as an argument, which is exactly what we intended.

Note: In a Jupyter notebook, Julia remembers the previous definition of divide_by_zero. This is why we had to rename it divide_by_zero_v2 to see this error message.


4.3. Semantic Errors#

Semantic errors are the sneakiest bugs. Your code runs perfectly without any error messages, but it produces the wrong output. The program is doing exactly what you told it to do, but what you told it to do was incorrect.

A classic example is an infinite loop. The program doesn’t crash, but it also never finishes, which is rarely the intended behavior.

Debugging semantic errors will likely consume the majority of your programming time. The key to success is to develop good habits:

  • Test early and often. Start with small, simple test cases where you know the correct answer. This helps you verify your logic step-by-step.

  • Embrace modularity. If you’ve tested your smaller functions, you can trust them and focus your debugging efforts on the new code that’s causing the problem.

  • Inspect your variables. Never assume a variable contains the value you think it does. Use println() statements liberally to print out the values of variables at different stages of your program to see what’s really going on. While professional debuggers are more powerful, println() is a simple and effective tool, especially in Jupyter.

# This function is supposed to return the sum of the integers from 1 to n.
# It contains a subtle semantic error.
function array_sum(n)
    # Step 1: Initialize a vector to hold the numbers 1 through n.
    array = Vector{Float64}(undef, n)
    for i = 1:n-1
        array[i] = i
    end
    
    # Step 2: Sum the elements of the vector.
    s = 0.0
    for i = 1:n
        s += array[i]
    end
    return s
end
array_sum (generic function with 1 method)

Take a moment and try to debug the array_sum function yourself. What tests would you run? Start with a small input like n=3. What variables would you print to see what’s happening inside? Can you think of a way to break this function into smaller, more testable parts?

# Your testing cell for array_sum()
# Try calling array_sum with small numbers and see if you get the expected result.
# For example, array_sum(3) should be 1 + 2 + 3 = 6. What does it actually return?

Personally, this is how I would add println statements to investigate:

# This version of the function includes print statements for debugging.
function array_sum_debug(n)
    # Initialization
    array = Vector{Float64}(undef, n)
    for i = 1:n-1
        array[i] = i
    end
    # Let's inspect the array right after it's initialized.
    println("For n=$n, the initialized array is: ", array)
    
    # Summation
    s = 0.0
    for i = 1:n
        s += array[i]
    end
    println("The final sum is: ", s, "\n") # Add a newline for readability
    return s
end

# Run a few test cases.
array_sum_debug(1);
array_sum_debug(3);
array_sum_debug(5);
For n=1, the initialized array is: [6.1549569638067e-310]
The final sum is: 6.1549569638067e-310

For n=3, the initialized array is: [1.0, 2.0, 0.0]
The final sum is: 3.0

For n=5, the initialized array is: [1.0, 2.0, 3.0, 4.0, 6.13260320144e-312]
The final sum is: 10.0

With this debugging output, the problem becomes much clearer! When we initialize the array, the first loop runs from 1:n-1. This is an off-by-one error. It fills every element except the last one. The last element of the array remains an uninitialized, random-looking “garbage” value.

This insight leads us directly to the fix: the initialization loop should run from 1:n. Once we make that change and re-run our tests, we’ll get the correct answers. We can then confidently remove the println statements, knowing our algorithm is correct.