Error Handling in Python: try, except and Best Practices

How to handle errors in Python so failures are visible and recoverable: the exact order try, except, else and finally run in, why a broad except hides bugs, designing custom exceptions, chaining with raise from, logging tracebacks, and retrying only what is worth retrying.

GloryTecks Team

Training & Placement Faculty

6 May 2026 7 min read

Last updated: 24 September 2026

Share:

Error handling is not about preventing failures. Files go missing, networks drop and users type "three" into a quantity box. It is about deciding, for each failure, whether your code can do something useful about it, and making sure that everything else fails loudly and clearly. Python gives you four keywords and a hierarchy of exception classes for that. This guide covers how they behave and the habits that separate robust code from code that hides its bugs.

The order try, except, else and finally run in

python
def load_quantity(raw: str) -> int:
    try:
        qty = int(raw)
    except ValueError:
        print(f"  except: {raw!r} is not an integer")
        return 0
    else:
        print(f"  else: parsed {qty}")
        return qty
    finally:
        print("  finally: always runs")

for raw in ["3", "three"]:
    print(raw, "->", load_quantity(raw))
text
  else: parsed 3
  finally: always runs
3 -> 3
  except: 'three' is not an integer
  finally: always runs
three -> 0
BlockRuns whenPut here
tryAlways, until an exception is raisedOnly the lines that can fail in the way you are handling
exceptA matching exception was raised in tryThe recovery for that failure
elsetry finished without an exceptionCode that should run only on success
finallyAlways, even after return or an unhandled exceptionClean-up: closing files, releasing locks

The else block is underused. Code in else runs only on success, but its own exceptions are not caught by the except above it, so you do not accidentally handle an error you never meant to (Python tutorial: errors and exceptions). For clean-up, a with statement usually beats finally: files, locks and database connections close themselves when the block exits.

Catch what you can handle, and nothing more

A broad except turns bugs into silent wrong answers:

python
def total_with_tax(prices):
    try:
        return sum(prices) * TAX_RATE          # TAX_RATE was never defined
    except Exception:
        return 0

print(total_with_tax([100, 250]))
text
0

The NameError is a programming mistake, and the program reports a total of zero instead of crashing where the bug is. Catch the specific exception you expect, ValueError for bad conversions, FileNotFoundError for a missing file, KeyError for a missing key, and let everything else propagate.

A bare except: is worse still. It catches KeyboardInterrupt and SystemExit, which deliberately sit outside the Exception branch of the hierarchy so that Ctrl+C and sys.exit() keep working (Python docs: built-in exceptions). If you truly need a last-resort handler, say at the top of a worker loop, catch Exception, log it with the traceback, and keep the scope as small as possible.

Custom exceptions that carry meaning

When your own code detects a problem, raise an exception that says what went wrong in your domain's terms. A small hierarchy lets callers catch everything from your module, or just one case:

python
class OrderError(Exception):
    """Base class for errors this module raises on purpose."""

class OutOfStock(OrderError):
    def __init__(self, sku: str, requested: int, available: int):
        super().__init__(f"{sku}: requested {requested}, only {available} left")
        self.sku, self.requested, self.available = sku, requested, available

STOCK = {"A1": 2}

def reserve(sku: str, qty: int) -> None:
    try:
        available = STOCK[sku]
    except KeyError as exc:
        raise OrderError(f"unknown SKU {sku!r}") from exc
    if qty > available:
        raise OutOfStock(sku, qty, available)
    STOCK[sku] = available - qty

for sku, qty in [("A1", 5), ("Z9", 1)]:
    try:
        reserve(sku, qty)
    except OutOfStock as exc:
        print("out of stock:", exc, "| can offer", exc.available)
    except OrderError as exc:
        print("order error:", exc, "| caused by", type(exc.__cause__).__name__)
text
out of stock: A1: requested 5, only 2 left | can offer 2
order error: unknown SKU 'Z9' | caused by KeyError

Three details make this work:

  • Attributes, not just a message. OutOfStock carries available, so the caller can offer the two units in stock without parsing a string.
  • Order of except clauses. Python uses the first clause that matches, so the specific OutOfStock comes before its parent OrderError.
  • raise ... from exc. The internal KeyError becomes the new exception's __cause__. The traceback shows both, joined by "The above exception was the direct cause of the following exception", so the translation into domain terms loses no debugging information.

Ask forgiveness, not permission

Python style often prefers trying the operation and handling the failure (EAFP) over checking first (LBYL). Checking first has a race: the file can disappear between exists() and open() (Python glossary: EAFP).

python
from pathlib import Path

try:
    text = Path("settings.json").read_text(encoding="utf-8")
except FileNotFoundError:
    text = "{}"                     # no settings file yet: use defaults
print(text)
text
{}

Log it with the traceback, once

When you catch an exception you cannot fix, but must not crash on, such as one failed record in a batch of 10,000, record it properly. logging.exception() logs at ERROR level and appends the full traceback; call it inside the except block (Python docs: logging):

python
import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("orders")

try:
    reserve("A1", 99)
except OrderError:
    log.exception("reservation failed for order %s", 1042)
text
ERROR reservation failed for order 1042
Traceback (most recent call last):
  File "...", line ..., in <module>
    reserve("A1", 99)
  File "...", line ..., in reserve
    raise OutOfStock(sku, qty, available)
OutOfStock: A1: requested 99, only 2 left

(The file paths and line numbers are elided here; yours will show your own.) Log each failure once, at the level that handles it. Logging and re-raising at every layer produces the same traceback five times. A logging configuration for real projects is covered in Python logging: a practical setup.

Retry only what is transient

Some failures go away on their own: a dropped connection, a timeout, a rate limit. Retrying those, with a growing delay, is reasonable. Retrying a ValueError is not; the same input fails the same way every time.

python
import time

def with_retries(fn, attempts=3, base_delay=0.1, retry_on=(ConnectionError, TimeoutError)):
    for attempt in range(1, attempts + 1):
        try:
            return fn()
        except retry_on as exc:
            if attempt == attempts:
                raise                           # give up: let the caller see the real error
            delay = base_delay * 2 ** (attempt - 1)
            print(f"attempt {attempt} failed ({exc}); retrying in {delay:.1f}s")
            time.sleep(delay)

outcomes = iter([ConnectionError("reset by peer"), TimeoutError("timed out"), "OK"])

def flaky_call():
    result = next(outcomes)
    if isinstance(result, Exception):
        raise result
    return result

print(with_retries(flaky_call))
text
attempt 1 failed (reset by peer); retrying in 0.1s
attempt 2 failed (timed out); retrying in 0.2s
OK

A bare raise inside except re-raises the current exception with its original traceback, which is what you want after the last attempt. Only retry operations that are safe to repeat; retrying a payment that may already have gone through needs an idempotency check first.

Batch jobs: collect failures, then decide

Data pipelines process thousands of records, and one malformed row should not stop the other 9,999. Handle errors per record: catch the specific exceptions a bad record can cause, write the record and the error message to a quarantine list or file, and continue. At the end, report the counts, and fail the whole job only if the failure rate crosses a threshold you chose in advance, say 1%. A job that never fails hides data problems; a job that fails on the first bad row cannot finish. Deciding the threshold up front turns an argument during an incident into a setting.

Rules of thumb

  • Keep try blocks short: only the lines whose failure you are handling.
  • Catch specific exceptions; never use a bare except:.
  • Never write except ...: pass without a comment that explains why ignoring the error is correct.
  • Raise your own exception classes for your own failure cases, and chain with from.
  • Prefer raising a clear exception to returning None on failure. A function that returns None forces every caller to check, and the caller that forgets fails far from the cause.
  • Let unexpected errors crash, loudly, where they happen. A clear traceback is a gift to whoever fixes it.
  • Test the failure paths too: pytest.raises checks that code raises what it should; see unit testing with pytest.

Concurrent code adds exception groups and except*, which the asyncio guide demonstrates with TaskGroup. Missing configuration is a classic startup failure; environment variables and secrets shows a loader that fails fast. Exception handling, custom exceptions and logging basics form one module of the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course