1. Home
  2. Blog
  3. Software Development

Software Development

Python Exception Handling: try, except, finally and Custom Exceptions Explained

Learn how to catch errors without hiding bugs. This guide covers every part of the try statement, raising your own exceptions, chaining errors and the habits that keep Python programs easy to debug.

9 min read
Python exception handling guide: try, except, finally and custom exceptions

Key takeaways

  • Use try and except to handle errors you can actually recover from.
  • Catch specific exception types. A bare except: hides real bugs.
  • else runs only when no error occurred, and finally always runs.
  • Raise custom exceptions for errors in your own domain, and use from to keep the original cause.

Every real program meets bad input, missing files and failed network calls. Python exception handling lets you respond to those problems in a controlled way, rather than letting the whole program crash with a stack trace in front of a user.

The syntax takes minutes to learn. The harder skill is knowing what to catch, what to let fail, and how to keep errors useful for whoever debugs them later. We ran every example below on Python 3.13, and the outputs are from those runs.

What is an exception?

An exception is an object Python raises when something goes wrong while code is running. If nothing handles it, the program stops and prints a traceback showing where it happened.

>>> int("abc")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'abc'

Read a traceback from the bottom up. The last line gives you the exception type and message, and the lines above it show the path the code took to get there.

Exceptions differ from syntax errors. A syntax error means Python could not even parse your code, so nothing runs. No try block in that same file can rescue you from one, because you have to fix the code itself.

try and except

Put the risky code in try, and say what to do about a specific failure in except:

try:
    age = int(text)
except ValueError:
    age = None

Python runs the try block. If int() raises a ValueError, it jumps to the matching handler and then continues after the whole statement. If a different exception occurs, this handler ignores it, and the exception keeps travelling up the call stack until something else handles it.

You can handle different failures differently, and read details from the exception using as:

try:
    with open("config.json") as file:
        settings = json.load(file)
except FileNotFoundError:
    settings = {}
except json.JSONDecodeError as error:
    print(f"Bad JSON on line {error.lineno}")
    raise

Python checks handlers from top to bottom and runs the first match, so put specific types before general ones. To treat several types the same way, group them in a tuple: except (KeyError, ValueError):.

else and finally

The full statement has four parts. This function uses them all:

def read_age(text):
    try:
        age = int(text)
    except ValueError:
        print(f"{text!r} is not a number")
        return None
    else:
        print("Parsed OK")
        return age
    finally:
        print("Finished checking")

print(read_age("42"))
print(read_age("abc"))

# Parsed OK
# Finished checking
# 42
# 'abc' is not a number
# Finished checking
# None
Block When it runs Use it for
try Always, first Only the code that may fail
except When a matching exception occurs Recovering or reporting
else When try raised nothing Follow-up work that should not be guarded
finally Every time, even after return Cleanup, such as releasing resources

Notice in the output that finally ran before the caller printed the returned value. That is the point of it: cleanup happens no matter how the block exits.

Why use else at all? It keeps the try block small. If you put follow-up code inside try, your handler might accidentally catch an unrelated error from that code, and you would blame the wrong line.

Common built-in exceptions

Exception Typical cause
ValueError Right type but a bad value, like int("abc")
TypeError Wrong type, like "a" + 1
KeyError A dictionary key does not exist
IndexError A list position is out of range
AttributeError An object has no such attribute
FileNotFoundError A file path does not exist
ZeroDivisionError Dividing by zero
ImportError A module or name cannot be imported

All of these inherit from Exception. Above that sits BaseException, which also covers KeyboardInterrupt and SystemExit. Those two are meant to stop your program, which is a major reason a bare except: is dangerous, since it catches them as well.

Raising exceptions

Use raise when your code detects a problem it cannot sensibly deal with itself:

def set_discount(percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be 0-100, got {percent}")
    return percent

Failing early with a clear message beats returning a strange value that causes confusion ten calls later. Inside a handler, a plain raise with nothing after it re-raises the current exception. That is the right pattern when you want to log a failure and still let it continue to the caller.

Custom exceptions

Built-in types describe general problems. Your application has its own, and a named class lets callers catch exactly those:

class InsufficientFunds(Exception):
    def __init__(self, balance, amount):
        super().__init__(f"Need {amount}, but balance is {balance}")
        self.balance = balance
        self.amount = amount

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFunds(balance, amount)
    return balance - amount

try:
    withdraw(100, 250)
except InsufficientFunds as error:
    print(error, "| short by", error.amount - error.balance)

# Need 250, but balance is 100 | short by 150

Because the exception carries data as attributes, the caller does not have to parse a message string to work out the shortfall. For a larger project, create one base class, such as class ShopError(Exception), and inherit from it, so callers can catch everything from your library in one line if they want to.

Exception chaining with from

When you translate a low-level error into a meaningful one, keep the original cause by using raise ... from:

class ConfigError(Exception):
    pass

def load_port(settings):
    try:
        return int(settings["port"])
    except (KeyError, ValueError) as error:
        raise ConfigError("Invalid port setting") from error

try:
    load_port({"port": "abc"})
except ConfigError as error:
    print(type(error).__name__, "| cause:", repr(error.__cause__))

# ConfigError | cause: ValueError("invalid literal for int() with base 10: 'abc'")

The caller deals with one clear error, while the traceback still shows the original problem. Without the original cause, whoever debugs this later has to guess what went wrong.

ExceptionGroup and except*

Python 3.11 added a way to raise several errors at once, which is useful when concurrent tasks or batch jobs fail in more than one way. The except* syntax handles each type within the group:

try:
    raise ExceptionGroup("batch failed", [ValueError("bad row"), KeyError("id")])
except* ValueError as group:
    print("value errors:", group.exceptions)
except* KeyError as group:
    print("key errors:", group.exceptions)

# value errors: (ValueError('bad row'),)
# key errors: (KeyError('id'),)

Unlike normal handlers, more than one except* block can run. You will mostly meet this through asyncio.TaskGroup, so beginners can safely come back to it later.

Context managers for cleanup

You could close a file inside finally, but the with statement does that for you, even when an exception occurs:

with open("report.txt") as file:
    data = file.read()

Prefer with for files, locks and database connections. Keep finally for cleanup that has no context manager. Handling errors around shared setup and teardown also becomes much neater with Python decorators, such as a retry wrapper.

EAFP vs LBYL

Python programmers often prefer EAFP, “easier to ask forgiveness than permission”. Rather than checking first, you try the operation and handle failure.

# LBYL: look before you leap
if "port" in settings:
    port = settings["port"]

# EAFP: just try it
try:
    port = settings["port"]
except KeyError:
    port = 8080

EAFP avoids race conditions. A file can disappear between checking it exists and opening it, so a check does not protect you. But do not force it. For simple defaults, settings.get("port", 8080) is the clearest of all, and validating user input up front is often more readable than catching errors later.

Tip: Use the logging module’s logger.exception("Payment failed") inside a handler. It records your message and the full traceback, which a plain print does not.

Common mistakes

  • Using a bare except:. It also catches KeyboardInterrupt and typos such as NameError. Name the types you expect.
  • Silencing errors with pass. An empty handler turns a loud failure into mysterious wrong data later.
  • Wrapping too much code in try. Guard the one line that can fail, so you know what actually went wrong.
  • Using exceptions for normal flow. A loop ending or a value being missing is often better handled with a simple condition.
  • Not testing error paths. Failure handling is code too. See how to check it with pytest.raises in our pytest tutorial.

Final thoughts

Good exception handling is selective. Catch what you can recover from, let everything else fail loudly, and keep the original cause when you translate errors. As a next step, look through one of your scripts for any bare except: and replace it with the specific exception you actually expect.

Frequently asked questions

What is the difference between an error and an exception in Python?

A syntax error stops code from running at all. An exception occurs while code runs, and your program can catch and handle it with try and except.

Does finally run if there is a return in try?

Yes. The finally block runs before the function actually returns, which is why it is reliable for cleanup. Avoid putting a return inside finally itself.

Should I catch Exception in Python?

Only at a top-level boundary, such as a web request handler or background worker loop, where you log the error and keep running. Elsewhere, catch specific types.

How do I print the full error message?

Use logger.exception inside a handler, or call traceback.print_exc from the traceback module. Both show the complete traceback, not only the message.

How do I create a custom exception in Python?

Define a class that inherits from Exception, such as class PaymentError(Exception). Then raise it like any other exception and catch it by name.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →