Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 31 of 899 min

Catching an error, and deciding whether you should

The shape

python
try:
    value = int(entry)
except ValueError:
    print(f"not a number: {entry!r}")
    value = 0

try holds the code that might fail. except names the failure you expected and says what to do instead. If nothing fails, the except block never runs.

There are two more clauses, and both are underused:

python
try:
    f = open(path)
except FileNotFoundError:
    print("missing")
else:
    data = f.read()      # runs only if no exception was raised
finally:
    print("done")        # runs either way, always

else holds the code that should run only on success. Keeping it out of the try means an unrelated failure inside data = f.read() is not accidentally caught by your except FileNotFoundError. finally runs whatever happens, including when the function returns or a different exception propagates, which makes it the place for cleanup.

Catch the specific exception

python
try:
    result = risky()
except Exception:
    pass

This is the single most damaging pattern in Python. It hides typos, hides logic errors, hides the failure you did not think of, and leaves the program running on values that are quietly wrong. Every one of those bugs then surfaces somewhere else, with no traceback pointing at the real cause.

A bare except: is worse still, because it also catches KeyboardInterrupt and SystemExit — so Ctrl-C stops working and your program cannot be killed politely.

The rule: catch the exceptions you have a plan for. If you cannot say what you will do differently, do not catch it. An uncaught exception stops the program at the point of the mistake and prints exactly where — which is the most useful thing that can happen.

When you genuinely must catch broadly — a worker loop that must survive one bad row — log it and keep the detail:

python
for row in rows:
    try:
        process(row)
    except Exception:
        logger.exception("row failed: %s", row["id"])
        failures += 1

logger.exception records the full traceback. The loop continues, and you can still find out what happened.

Keep the try block small

python
try:
    config = json.loads(text)
    total = config["a"] + config["b"]
    send(total)
except json.JSONDecodeError:
    ...

Only the first line can raise JSONDecodeError, but a reader has to check three lines to know that. Worse, as the block grows someone adds a fourth line that raises the same error for a different reason, and the handler silently covers it. Put the risky call alone in the try and everything else in else or after it.

Ask forgiveness, not permission

Two ways to handle a key that might be missing:

python
if "name" in row:              # look before you leap
    name = row["name"]
else:
    name = "unknown"
python
try:                           # ask forgiveness
    name = row["name"]
except KeyError:
    name = "unknown"

Python leans towards the second, for a concrete reason: the first checks and then acts, and between those two moments the world can change — a file that existed can be deleted, a key another thread was removing can disappear. For a dictionary in one thread it rarely matters, and row.get("name", "unknown") is better than both. For files, it matters a great deal, and try: open(...) except FileNotFoundError: is genuinely more correct than if path.exists().

Keeping the cause

When you catch one error and raise another, say what caused it:

python
try:
    data = json.loads(text)
except json.JSONDecodeError as err:
    raise ValueError(f"config file {path} is not valid JSON") from err

from err chains them, so the traceback shows both: your readable message and the original parse failure with its position. Without from, Python still prints "During handling of the above exception, another exception occurred", which is noisier and reads like a bug in your handler.

To re-raise the same exception after doing something with it, use a bare raise:

python
except TimeoutError:
    metrics.count("timeout")
    raise            # preserves the original traceback exactly

raise err also works but restarts the traceback from this line, losing where it actually happened.

The finally trap

python
def get():
    try:
        return 1
    finally:
        return 2      # returns 2, and swallows any exception

A return in finally overrides everything, including an exception that was on its way up. Never return from finally. Use it for cleanup only — and for file and network cleanup, a with statement does the job better, which is a later lesson.

The one thing to keep

Catch only the exceptions you have a plan for, keep the `try` block down to the line that can actually raise, and chain with `from err` so the original cause survives in the traceback.

Before you move on

A nightly import wraps its whole 200-line body in `try: ... except Exception: logger.error("import failed")`. It reports failures but nobody can ever work out which record caused one. What is the core problem?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly