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.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The three kinds
| Found | Program runs? | Catchable? | |
|---|---|---|---|
| Syntax error | Before any line executes | Not at all | No |
| Runtime error | When the line executes | Until it hits the fault | Yes |
| Logical error | Only by checking the output | Completely, and happily | Nothing 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 blockIndentationError 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 zeroThe 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| Exception | Means | Usually caused by |
|---|---|---|
NameError | The name does not exist | A typo, or use before assignment |
TypeError | Wrong type for this operation | Mixing text and numbers |
ValueError | Right type, unusable value | int("abc") |
IndexError | Index out of range | Off by one, or an empty list |
KeyError | Key not in the dictionary | Optional data assumed present |
AttributeError | No such attribute or method | A typo, or a None where an object was expected |
ZeroDivisionError | Division by zero | An unchecked denominator |
TypeErrorandValueErrorare easy to confuse.int([1, 2])is aTypeError- a list is the wrong kind of thing entirely.int("abc")is aValueError- 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:
- The last line is what went wrong:
ValueError, and the offending value was'x'. - The frame above it is where it happened:
parse, line 2. - The frames above that are how you got there:
loadcalled it, and the module calledload.
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 wrongdef 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 1Nothing 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 machineryStopIteration, 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
TypeErrorwithValueError. - Believing that "it ran without errors" means "it is correct".
- Ignoring a
DeprecationWarninguntil 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
- Write a file whose first line prints and whose last line has a syntax error. Explain the output.
- Produce each of
NameError,TypeError,ValueError,IndexErrorandKeyErrorin one line each. - Explain the difference between
int([1])andint("a")in terms of which exception is raised and why. - Write a three level call chain that fails at the deepest level and identify each frame in the traceback.
- 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.