Errors in Python: Syntax, Runtime and Logical

Three kinds of error, found at three different moments. Only one of them can be caught, and the one that catches nobody out is the most dangerous.

The three kinds

FoundProgram runs?Catchable?
Syntax errorBefore any line executesNot at allNo
Runtime errorWhen the line executesUntil it hits the faultYes
Logical errorOnly by checking the outputCompletely, and happilyNothing to catch

Syntax errors

print("start")

if True
    print("missing colon")
  File "program.py", line 3
    if True
           ^
SyntaxError: expected ':'

Note that print("start") never ran. Python compiles the whole file before executing anything, so a syntax error on the last line prevents the first line from running. That is the practical consequence of the compile step covered in the basics.

# Common syntax errors
# if x = 5:              SyntaxError - assignment in a condition
# print("unclosed        SyntaxError - unterminated string
# def f(:                SyntaxError
# return 5               SyntaxError - return outside a function

# And its close relatives
#     print("hi")        IndentationError - unexpected indent
# def f():
# print("hi")            IndentationError - expected an indented block

IndentationError and TabError are subclasses of SyntaxError. They are all detected before execution.

Reading a syntax error

The caret points at where Python noticed the problem, which is often just after where you made it. A missing bracket on line 10 is frequently reported on line 11. When a syntax error makes no sense, look at the line above.

Runtime errors, called exceptions

print("start")          # this DOES run
print(10 / 0)           # ZeroDivisionError here
print("never reached")
start
Traceback (most recent call last):
  File "program.py", line 2, in <module>
    print(10 / 0)
          ~~~^~~
ZeroDivisionError: division by zero

The ones you will meet most

print(10 / 0)                  # ZeroDivisionError
print(undefined_name)          # NameError
print("2" + 2)                 # TypeError
print(int("abc"))              # ValueError
print([1, 2][5])               # IndexError
print({"a": 1}["b"])           # KeyError
print("abc".nosuchmethod())    # AttributeError
open("missing.txt")            # FileNotFoundError
import nosuchmodule            # ModuleNotFoundError
ExceptionMeansUsually caused by
NameErrorThe name does not existA typo, or use before assignment
TypeErrorWrong type for this operationMixing text and numbers
ValueErrorRight type, unusable valueint("abc")
IndexErrorIndex out of rangeOff by one, or an empty list
KeyErrorKey not in the dictionaryOptional data assumed present
AttributeErrorNo such attribute or methodA typo, or a None where an object was expected
ZeroDivisionErrorDivision by zeroAn unchecked denominator
TypeError and ValueError are easy to confuse. int([1, 2]) is a TypeError - a list is the wrong kind of thing entirely. int("abc") is a ValueError - a string is acceptable, but that particular string is not.

Reading a traceback

def parse(raw):
    return int(raw)


def load(values):
    return [parse(v) for v in values]


load(["1", "2", "x"])
Traceback (most recent call last):
  File "program.py", line 9, in <module>
    load(["1", "2", "x"])
  File "program.py", line 6, in load
    return [parse(v) for v in values]
  File "program.py", line 2, in parse
    return int(raw)
ValueError: invalid literal for int() with base 10: 'x'

Read a traceback from the bottom up:

  1. The last line is what went wrong: ValueError, and the offending value was 'x'.
  2. The frame above it is where it happened: parse, line 2.
  3. The frames above that are how you got there: load called it, and the module called load.

The phrase "most recent call last" is literal: the deepest, most relevant frame is at the bottom, right above the error message.

Logical errors

def average(numbers):
    return sum(numbers) / len(numbers) - 1     # the -1 should not be there


print(average([10, 20, 30]))     # 19.0 - no error, and wrong
def is_adult(age):
    return age > 18              # should be >=; an 18 year old is reported as a minor


def apply_discount(price, percent):
    return price - percent       # subtracting a percentage as if it were an amount


def count_items(items):
    total = 0
    for item in items:
        total = 1                # should be += 1
    return total


print(is_adult(18), apply_discount(100, 10), count_items([1, 2, 3]))
# False 90 1

Nothing raises. The program runs perfectly and produces a wrong answer, which is why logical errors are the expensive kind. They are found by tests, by checking output against a known result, and by review - never by the interpreter.

Finding them

def average(numbers):
    print(f"{numbers=} {sum(numbers)=} {len(numbers)=}")     # inspect the parts
    result = sum(numbers) / len(numbers)
    assert result <= max(numbers), "an average cannot exceed the largest value"
    return result


print(average([10, 20, 30]))
  • Print intermediate values with the f-string = form.
  • Assert things that must be true, so a wrong assumption fails loudly.
  • Test against a case where you already know the answer.
  • Step through with the debugger, covered in the debugging note.

Warnings are not errors

import warnings

warnings.warn("this function will be removed", DeprecationWarning)
print("execution continues")

A warning is printed and the program carries on. It is a message to the developer, not a failure.

Errors are not always failures

for item in [1, 2, 3]:
    pass
# StopIteration is raised internally to end the loop, and handled by Python

def numbers():
    yield 1
    return          # raises StopIteration inside the generator machinery

StopIteration, KeyboardInterrupt and SystemExit are exceptions used for control flow rather than for reporting a fault. That is why a bare except: is dangerous: it catches these too, making a program impossible to interrupt with Ctrl+C.

Common mistakes

  • Reading a traceback from the top and fixing the wrong function.
  • Assuming a syntax error is on the line reported, when it is often the line before.
  • Confusing TypeError with ValueError.
  • Believing that "it ran without errors" means "it is correct".
  • Ignoring a DeprecationWarning until the feature disappears.
  • Catching exceptions to hide a logical error rather than fixing it.

Best practices

  • Read tracebacks bottom up, and read the last line first.
  • Let the program crash while you are developing; the traceback is the most useful debugging output you will get.
  • Test with a known answer, not only with data that does not crash.
  • Use assertions to state assumptions during development.
  • Fix warnings before they become errors.

Practice

  1. Write a file whose first line prints and whose last line has a syntax error. Explain the output.
  2. Produce each of NameError, TypeError, ValueError, IndexError and KeyError in one line each.
  3. Explain the difference between int([1]) and int("a") in terms of which exception is raised and why.
  4. Write a three level call chain that fails at the deepest level and identify each frame in the traceback.
  5. Write a function with a deliberate off by one logical error, then write the assertion that would catch it.

Conclusion

Syntax errors stop the file compiling, runtime errors stop the line executing, and logical errors stop nothing at all. Learn to read a traceback from the bottom, and remember that the errors which do not raise are the ones worth testing for.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

The Exception Hierarchy

Every exception is a class in one inheritance tree. Catching a parent catches all its children, which is what makes handler order and handler width ma...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.