Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 011 / 100

Python error handling done right: lessons from production

Exceptions that help you debug instead of hiding problems.

Expona Admin6 min read

We've all been there: a production error arrives at 2 AM, and the stack trace tells you nothing useful because somewhere upstream, an engineer caught every exception with a bare except: clause. Error handling is one of those disciplines that separates solid backend systems from fragile ones that bleed into on-call rotations. Let's talk about Python error handling done right: lessons from production—patterns that actually help you debug instead of masking the real problems.

Catch specific exceptions — Never catch bare except

A bare except: is a silent killer. It swallows KeyboardInterrupt, SystemExit, and GeneratorExit along with legitimate business errors. In production, this means your operator can't even Ctrl+C to stop a runaway process, and bugs hide under layers of silence.

Consider a common pattern in web handlers:

python

# Anti-pattern: bare except hides everything
def process_payment(order_id: int):
    try:
        order = db.get_order(order_id)
        charge = stripe.charge(order.amount)
        db.save_charge(charge)
    except:
        logger.error("Payment failed")
        return {"status": "error"}

Did the order not exist? Did the database connection die? Is Stripe down? You won't know. Worse, if there's a KeyboardInterrupt, your process gets stuck silently.

Instead, catch the exceptions you actually expect:

python

# Better: specific exceptions with clear intent
def process_payment(order_id: int):
    try:
        order = db.get_order(order_id)
        charge = stripe.charge(order.amount)
        db.save_charge(charge)
    except OrderNotFound:
        logger.warning("Order %d not found", order_id)
        return {"status": "not_found"}
    except stripe.CardError as e:
        logger.warning("Card declined: %s", e.user_message)
        return {"status": "card_declined", "message": e.user_message}
    except (ConnectionError, TimeoutError) as e:
        logger.error("Infrastructure error during payment", exc_info=True)
        raise  # Let the global handler retry or alert

Now the flow is clear. Known business errors are handled explicitly. Infrastructure problems bubble up so your monitoring can catch them. And your operator can still Ctrl+C.

Custom exception classes — Domain errors like NotFound make handlers explicit

Generic exceptions like ValueError or Exception don't tell you what went wrong. Custom exceptions name the problem, making it obvious what you're handling and why.

In a ride-hailing backend, we might have:

python

# Domain-specific exceptions
class RideNotFound(Exception):
    pass

class DriverOffline(Exception):
    pass

class InsufficientBalance(Exception):
    def __init__(self, account_id: str, required: float, available: float):
        self.account_id = account_id
        self.required = required
        self.available = available
        super().__init__(
            f"Account {account_id}: need {required}, have {available}"
        )

class PaymentError(Exception):
    """Gateway or processor error—transient, retry-able."""
    pass

Now handlers are declarative:

python

def request_ride(user_id: str, origin: str, dest: str) -> dict:
    try:
        user = db.get_user(user_id)
        validate_balance(user)
        ride = create_ride(user, origin, dest)
        return {"ride_id": ride.id, "eta": ride.eta}
    except InsufficientBalance as e:
        return {
            "error": "insufficient_balance",
            "required": e.required,
            "available": e.available
        }
    except DriverOffline:
        return {"error": "no_drivers_available"}
    except PaymentError:
        # Retry logic or queue for async retry
        logger.error("Payment processor down", exc_info=True)
        raise

Each exception class maps to a specific business outcome. Tests are easier because you can mock specific failures. Monitoring becomes natural—you alert on PaymentError differently than InsufficientBalance.

Re-raise with context — raise NewError from err keeps the original traceback

Sometimes you need to transform an exception to match your domain. Raw database errors or HTTP client exceptions leak implementation details upward. But don't lose the original traceback—it's gold when debugging.

Use raise ... from ... to preserve the chain:

python

# Before: traceback is lost
try:
    user = db.query(User).filter_by(id=user_id).one()
except sqlalchemy.orm.exc.NoResultFound:
    raise UserNotFound(user_id)

# After: traceback preserved
try:
    user = db.query(User).filter_by(id=user_id).one()
except sqlalchemy.orm.exc.NoResultFound as e:
    raise UserNotFound(user_id) from e

When this exception is logged or caught upstream, you'll see the full chain:

python

UserNotFound: user_id=12345

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "app.py", line 45, in request_handler
    user = get_user(user_id)
  File "models.py", line 102, in get_user
    raise UserNotFound(user_id) from e
  ...
sqlalchemy.orm.exc.NoResultFound: ...

This matters in production. You see both the business-level error and the underlying cause. You know it wasn't a network timeout—it was truly a missing record.

Fail fast at boundaries — Validate input early, let unexpected errors propagate to a global handler

Production robustness comes from a layered strategy: validate at entry points, handle known business errors explicitly, and let truly unexpected errors bubble to a global handler.

In a web handler, this looks like:

python

from pydantic import BaseModel, ValidationError

class CreateOrderRequest(BaseModel):
    user_id: str
    items: list[dict]
    delivery_address: str

@app.post("/orders")
def create_order(req: CreateOrderRequest):
    # Input validation happens automatically via Pydantic
    # If it fails, the framework returns 422 Unprocessable Entity
    try:
        order = Order.create(
            user_id=req.user_id,
            items=req.items,
            delivery_address=req.delivery_address
        )
        return {"order_id": order.id}
    except InsufficientBalance as e:
        return {"error": "insufficient_balance"}, 402
    except InventoryError as e:
        return {"error": "out_of_stock", "items": e.unavailable}, 409
    # Everything else bubbles up to the global exception handler

The global handler catches surprises:

python

import traceback

@app.errorhandler(Exception)
def handle_unexpected_error(error: Exception):
    logger.error(
        "Unexpected error in %s",
        request.path,
        exc_info=True,
        extra={"user_id": g.get("user_id")}
    )
    # Alert ops, capture to error tracking (Sentry, etc.)
    sentry.capture_exception(error)
    return {"error": "internal_server_error"}, 500

This strategy keeps your code lean: expected errors are handled crisply. Unexpected errors go somewhere you'll notice them. The distinction forces clarity about what you actually know vs. what might break.

Common mistakes

  • Catching too broad, too early: except Exception: in library code or middleware swallows errors that the caller should handle. Let exceptions propagate unless you have a concrete reason to stop them.
  • Losing context in logging: logger.error("Failed") without exc_info=True loses the traceback. Always use logger.error(..., exc_info=True) or logger.exception(...) when inside an except block.
  • Creating too many exception classes: You don't need one for every edge case. Stick to exceptions that represent distinct business or system outcomes that handlers will deal with differently.
  • Not testing error paths: Test the except blocks as rigorously as the happy path. Mock the failures, verify the response and logging.
  • Swallowing and logging instead of raising: If you log an error and continue, you're hiding a bug. Either handle it (return a safe default) or raise it to let the caller decide.

Key takeaways

  • Never use bare except: or except Exception: without a very clear reason. Catch specific exceptions so bugs and interrupts aren't hidden.
  • Define custom exception classes for domain-level errors. They make handlers obvious and tests cleaner.
  • Always preserve tracebacks with raise NewError from original_error when transforming exceptions. You'll need that context later.
  • Validate input at boundaries. Handle expected business errors explicitly. Let infrastructure and logic errors propagate to a global handler where you monitor them.
  • Log with exc_info=True in except blocks. Test your error paths the same way you test happy paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles