Index Ai Academy
Phase 0 · Tools · Python core

Errors and exceptions

Read tracebacks, and handle the failures you expect.

About 25 minutes · Free lesson

Start this lesson

Why it matters

Code that talks to the outside world fails in ordinary ways all the time. A file is missing, an API times out, a model returns text that isn't valid JSON. How your code handles those failures is what separates a fragile script from a reliable tool. Reading a traceback quickly is also the fastest debugging skill there is, whether you wrote the code or an AI assistant did.

What you'll be able to do

  • Read a traceback from the bottom up and find the line that failed
  • Recognize the common exception types and what usually causes each
  • Handle an expected failure with try and except, plus else and finally
  • Raise your own exception when a function gets input it can't use

What it covers

  1. Reading a traceback
  2. The usual suspects
  3. Handling errors with try and except
  4. else and finally
  5. Don't catch everything
  6. Worked example: messy survey scores
  7. Keep the try small

Reading a traceback

An error that happens while a program runs is called an exception. If nothing handles it, Python stops and prints a traceback. That's a report of what went wrong, and of the chain of calls that led there. Think of a package's tracking history: the last entry tells you where it got stuck, and the ones above show the route it took. Run this and read the result:

def conversion_rate(conversions, clicks):
    return conversions / clicks

def report(name, conversions, clicks):
    rate = conversion_rate(conversions, clicks)
    print(name, rate)

report("Spring Sale", 301, 5470)
report("Holiday Teaser", 0, 0)

Read a traceback from the bottom up:

  1. The last line names the exception type and gives a message: ZeroDivisionError: division by zero.
  2. The line above it shows where it happened: line 2, inside conversion_rate.
  3. The lines above that show how the program got there: line 9 called report, which called conversion_rate on line 5.

Notice the first report printed before the crash. Everything before the failing line ran normally. Python stops at the failure.

On your own computer, tracebacks start with Traceback (most recent call last): and include file names, but they read the same way. The academy shortens them to the lines of your code.

The usual suspects

A handful of exception types cover most of the errors you'll meet:

ExceptionUsually meansExample
NameErrorA name is misspelled or wasn't created yetprint(totl)
TypeErrorThe operation doesn't work on that type"5" + 5
ValueErrorThe type is right but the value isn't usableint("five")
IndexErrorA list position doesn't exist[1, 2][5]
KeyErrorA dictionary has no such key{"a": 1}["b"]
ZeroDivisionErrorDividing by zero10 / 0
AttributeErrorThat value has no such method or attribute(42).upper()
SyntaxErrorPython can't even read the codea missing colon or bracket

SyntaxError is different from the rest. Python checks the whole file before running anything, so a syntax error means no line runs. Every other exception happens partway through, when the bad line is reached.

budgets = [5000, 2000, 8000]
print(budgets[3])
Quick check
Which exception does int("12.5") raise?
  1. None: it returns 12
  2. ValueError
  3. SyntaxError
  4. TypeError
Show the answer

ValueError

int() accepts a string, so the type is fine, but "12.5" isn't a valid whole number. Wrong value, right type: ValueError. Use int(float("12.5")) if you want 12.

Handling errors with try and except

When you expect a particular failure and know what to do about it, wrap the risky code in try and say what to do in except:

raw_values = ["120", "95", "n/a", "300", ""]

total = 0
skipped = 0
for raw in raw_values:
    try:
        total += int(raw)
    except ValueError:
        skipped += 1

print("Total clicks:", total)
print("Skipped:", skipped)
It runs right here in your browser, with nothing to install.

Think of it as a safety net under one particular trick. Python runs the try block. If a ValueError happens anywhere inside it, Python jumps straight to the except block instead of crashing, then carries on after it.

Any other kind of exception still crashes the program. That's what you want: you only handle the failure you planned for.

To see the error itself, give it a name with as:

for raw in ["42", "forty"]:
    try:
        print(int(raw))
    except ValueError as e:
        print("Couldn't read", repr(raw), "-", e)
Quick check
What does this print?
total = 0
for raw in ["5", "x", "7"]:
    try:
        total += int(raw)
    except ValueError:
        total += 100
print(total)
  1. 105
  2. 12
  3. An error
  4. 112
Show the answer

112

"5" and "7" add 12. "x" can't become a number, so the except block runs and adds 100 instead. The loop carries on, and the total is 112.

Try it now

On the first call, line 3 succeeds and the except block is skipped. On the second, line 3 fails and execution jumps straight to line 5.

else and finally

Two optional blocks complete the picture. else runs only if the try block raised nothing. finally runs no matter what, even after a return:

def parse_budget(text):
    try:
        amount = float(text)
    except ValueError:
        print("Not a number:", text)
        return None
    else:
        print("Parsed", amount)
        return amount
    finally:
        print("Done checking", repr(text))

parse_budget("2500")
parse_budget("lots")

How often you'll use each:

  • try/except: constantly.
  • else: now and then. It keeps the try block small, so you don't catch errors from code that wasn't risky.
  • finally: for cleanup that must happen, such as closing a connection. For files, Python's with statement does the cleanup for you, as you'll see in the files lesson.

Raising your own errors

When a function gets input it can't sensibly use, the clearest thing it can do is stop with a message. That's raise:

def set_budget(amount):
    if amount <= 0:
        raise ValueError(f"Budget must be positive, got {amount}")
    return amount

print(set_budget(500))
print(set_budget(-20))

Failing loudly and early beats quietly carrying on with nonsense, like a negative budget that turns into a negative bid three steps later. Code that calls set_budget can still catch the ValueError if it knows how to recover.

Don't catch everything

It's tempting to wrap a big block in except Exception: (or a bare except:) so the program "never crashes". Here's why that backfires:

prices = [28, 64, 18]
try:
    total = sum(prices)
    average = total / len(prise)   # typo: should be prices
except Exception:
    average = 0
print(average)

The typo raised a NameError. The broad except swallowed it, and the program printed a confident, wrong 0. A crash would have shown you the bug in seconds. Silent wrong answers can survive for months.

The rule: catch the specific exceptions you expect and can handle. Let everything else crash where you can see it.

The one common exception is the top of a long batch job, say one that processes 10,000 rows. There you may catch broadly, so one bad row doesn't stop the rest. Even then, record which rows failed and why.

For AI work: when you call an LLM API you'll catch specific failures such as rate limits and timeouts and retry them, while programming mistakes should still crash.

Quick check
A teammate wraps a whole monthly report in try: ... except Exception: pass "so it never crashes". What's the risk?
  1. Python doesn't allow except Exception
  2. There's no risk: fewer crashes is always better
  3. The report will run more slowly
  4. Bugs like typos are hidden, so the report can show wrong numbers with no warning
Show the answer

Bugs like typos are hidden, so the report can show wrong numbers with no warning

A broad except swallows programming mistakes along with the failures you expected. Catch the specific exceptions you can handle, and let the rest show up.

Worked example

Worked example: messy survey scores

A survey asked for a score from 0 to 10, and people typed whatever they liked. Keep the valid scores, report the rest.

answers = ["8", "10", "seven", "", "9", "11", "6"]

def parse_score(text):
    score = int(text)
    if score < 0 or score > 10:
        raise ValueError(f"{score} is outside 0-10")
    return score

scores = []
problems = []
for a in answers:
    try:
        scores.append(parse_score(a))
    except ValueError as e:
        problems.append(repr(a) + ": " + str(e))

print("Valid scores:", scores)
print("Average:", round(sum(scores) / len(scores), 2))
for p in problems:
    print("Skipped", p)
In the lesson you can change this and run it.

It keeps [8, 10, 9, 6], with an average of 8.25, and skips three answers. parse_score can fail in two ways. int() raises a ValueError for text like "seven" or an empty answer. And the function raises its own ValueError for numbers outside the range. Because both use the same exception type, the loop handles them in one place. Nothing is silently dropped: every skipped answer is recorded with the reason. That's exactly what you'd want when the same code runs on 50,000 responses.

Keep the try small

Put only the line that can fail inside try. A long block catches errors you never meant to catch, and hides them.

Practice what you just read

  • 8 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Start this lesson

Try a quiz question

Where should you look first in a traceback?

  1. The first line
  2. The last line: the exception type and message
  3. The middle line
  4. The line of code just after the one that failed

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson