AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Python Error Handling: Writing Robust Code

📚 Programming⏱️ 21 min read🎓 Grade 11
✍️ AI Computer Institute Editorial Team Updated: September 2026 CBSE-aligned · Reviewed for accuracy · 21 min read
Mapped to the CBSE/NCERT syllabus and reviewed for accuracy in a separate pass. Spotted an error? Tell us on the contact page.

During the Tatkal booking window, IRCTC's seat-inventory service takes thousands of requests a second. Most ask for a train and class that genuinely has seats. Some ask for a class that has just sold out. Some ask for a train number that doesn't exist, or that got typed wrong by a flaky mobile app. If the function that looks up seat counts is written naively, the very first "sold out" request raises an exception that Python cannot find a handler for, and the interpreter does the only thing it can: it unwinds the entire call stack, prints a traceback, and terminates the process. Every other passenger's booking request that happened to be in flight on that process dies with it. A production-grade version of the same function treats "no seats left" and "no such train" as expected, nameable conditions — not different in kind from "seats available" — and responds to each one with a sensible outcome (waitlist the passenger, reject the request) while the service keeps serving everyone else. That distinction — between an unanticipated crash and a handled, recoverable condition — is the entire subject of this chapter. Error handling in Python is not about avoiding failure; failure is guaranteed at scale. It is about deciding, in advance and by category, what should happen when each kind of failure occurs.

What actually happens when Python raises an exception

Every Python function call pushes a stack frame onto the call stack: a record of that function's local variables and the exact line it is currently executing. When code inside a frame hits a raise statement — either written explicitly or triggered implicitly by an operation like dividing by zero or indexing a missing dictionary key — Python constructs an exception object and immediately abandons normal execution in that frame. It does not return a value. It does not run any remaining statements in that function. Instead, the interpreter asks a single question: does the current frame have a try block whose except clauses match this exception's type? If yes, execution jumps to the first matching except clause and the exception is considered handled. If no, the current frame is popped off the stack entirely — permanently, along with all of its local variables — and the interpreter asks the exact same question of the frame that called it. This repeats, frame by frame, moving outward from where the exception occurred toward the original caller, until either a matching handler is found or the stack is exhausted. If it is exhausted, Python prints the traceback you've likely already seen and the program terminates.

This process is called stack unwinding, and it is the actual mechanism underneath every try/except you will ever write. It has two consequences worth internalizing now. First, an exception raised three function calls deep can be caught by a try block anywhere between that point and the top level — you do not need to handle an error at the exact line where it can occur, only somewhere on the path back to a caller that knows what to do about it. Second, once a frame is popped during unwinding, its local state is gone; you cannot resume execution partway through a function that raised. Robust error handling is therefore always about deciding where on the call chain a given failure should be caught, not about patching the one line that failed.

The anatomy of try, except, else, and finally

A full Python exception-handling statement has four parts, and each runs under a different condition:

try:
    risky_operation()
except SomeError as e:
    handle_it(e)
else:
    only_runs_if_no_exception()
finally:
    always_runs_no_matter_what()

The try block is the code being watched. If it completes with no exception, Python skips every except clause and runs the else block — code that should only execute on the success path, kept separate from the try body so that a bug inside else itself is never accidentally caught by the surrounding except clauses. If the try block raises, Python checks each except clause in the order they are written and runs the body of the first one whose exception type matches (using a subclass check, not exact-type equality — this detail matters and is the subject of the misconception below). The else block is skipped entirely in this case. Regardless of which path was taken — clean success, or an exception that got caught — the finally block runs last, immediately before control actually leaves the statement. This holds even if the try or except block contains a return: Python computes the return value, then executes finally, and only then hands the value back to the caller. finally is the one clause guaranteed to execute even when an exception occurs that no except clause catches — it runs during unwinding, then the exception continues propagating outward. This makes it the correct place for cleanup that must happen unconditionally, such as closing a database connection or releasing a lock, independent of whether the operation succeeded.

Common misconception: "except Exception catches everything, so the order of except blocks doesn't matter"

Nearly every built-in error in Python — ZeroDivisionError, KeyError, ValueError, TypeError, and the custom exceptions you write yourself by convention — is a subclass of Exception. Because of this, students often reason that once you've written except Exception, you've covered every case, and any more specific except clauses after it are just extra documentation. This is wrong in a way that silently breaks code. Python evaluates except clauses top to bottom and commits to the first one whose type matches, using an isinstance-style check — meaning a subclass matches its parent class's clause. Consider this:

try:
    result = 10 / 0
except Exception:
    print("Caught by the general handler")
except ZeroDivisionError:
    print("Caught by the specific handler")

This prints Caught by the general handler. ZeroDivisionError is a subclass of Exception, so it satisfies the first clause, and Python stops looking — the second clause never runs, for this exception or any other exception that Exception also covers. Python does not warn you that the second clause is unreachable; it is simply dead code, silently, forever. If that second block was meant to do something specific — log a different message, retry the division with a default denominator, whatever — it never will. The fix is to order clauses from most specific to most general:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Caught by the specific handler")
except Exception:
    print("Caught by the general handler")

This now prints Caught by the specific handler, because Python checks ZeroDivisionError first and it matches directly. The general rule: when you write multiple except clauses, they must go from the narrowest, most specific exception type down to the broadest. A single except Exception anywhere before a more specific clause silently swallows it.

How the search up the call stack meets a specific handler

The diagram below traces exactly this mechanism for a three-function chain: an innermost function that raises, a middle function with no handler at all, and an outer function whose except clause finally matches.

How a raised exception finds its handler Trace of book_ticket("12951", "3A") across the call stack raised caught FRAME 1 book_ticket(train_no, seat_class) — outermost frame try: seats = check_seat_availability(train_no, seat_class) except SeatUnavailableError as e: MATCH FOUND Python scans this frame's except clauses top-to-bottom; first match stops the search. FRAME 2 check_seat_availability(train_no, seat_class) return query_inventory_db(train_no, seat_class) no try/except in this frame — exception passes straight through; frame popped FRAME 3 query_inventory_db(train_no, seat_class) — innermost frame if seats == 0: raise SeatUnavailableError(train_no, seat_class) exception object created and raised HERE — this frame ends immediately propagating — no handler matched in this frame matching except clause found — unwinding stops Frames 2 and 3 are popped with no handler check succeeding; only frame 1's except clause matches.

Custom exceptions: naming your own failure conditions

Python's built-in exceptions describe language-level problems — a missing key, a wrong type, an out-of-range index. They say nothing about your domain. If query_inventory_db simply let a KeyError escape whenever a train number wasn't found, every caller up the chain would have to know that "route lookup failed" happens to manifest as a KeyError — an implementation detail of how you happened to store the inventory, not a fact about the booking system. Defining your own exception classes turns an implementation detail into a named, documented contract:

class SeatUnavailableError(Exception):
    """Raised when the requested class has zero seats left."""
    def __init__(self, train_no, seat_class):
        self.train_no = train_no
        self.seat_class = seat_class
        super().__init__(f"Train {train_no}: no seats left in {seat_class}")

class UnknownRouteError(Exception):
    """Raised when the train/class combination is not in inventory."""
    pass

Both classes inherit from Exception, which is what makes them participate correctly in except matching and traceback printing. SeatUnavailableError overrides __init__ to store the train number and class as attributes — e.train_no and e.seat_class — so that a handler further up the stack can build a specific response (which train, which class) without parsing the error message as a string. UnknownRouteError needs no extra data, so its body is just pass; it inherits everything it needs from Exception, including the message-storage behavior triggered by calling super().__init__(message) implicitly through the default constructor. Calling str() on either exception instance returns the message passed to Exception.__init__ — this is what a bare print(e) or an f-string {e} displays.

Worked example: tracing every branch of a real handler

Here is the complete booking function, combining custom exceptions with a full try/except/else/finally statement:

INVENTORY = {
    ("12951", "3A"): 0,
    ("12951", "2A"): 4,
    ("12622", "SL"): 12,
}

def query_inventory_db(train_no, seat_class):
    key = (train_no, seat_class)
    if key not in INVENTORY:
        raise UnknownRouteError(f"No such train/class combination: {key}")
    seats = INVENTORY[key]
    if seats == 0:
        raise SeatUnavailableError(train_no, seat_class)
    return seats

def check_seat_availability(train_no, seat_class):
    return query_inventory_db(train_no, seat_class)

def book_ticket(train_no, seat_class):
    try:
        seats = check_seat_availability(train_no, seat_class)
    except SeatUnavailableError as e:
        print(f"Booking failed: {e}. Adding to waitlist.")
        return "WAITLISTED"
    except UnknownRouteError as e:
        print(f"Booking failed: {e}")
        return "INVALID_REQUEST"
    else:
        seats -= 1
        print(f"Confirmed. {seats} seats now remain in {seat_class}.")
        return "CONFIRMED"
    finally:
        print(f"Booking attempt logged for train {train_no}.")

for train, cls in [("12951", "3A"), ("12951", "2A"), ("99999", "1A")]:
    result = book_ticket(train, cls)
    print("Result:", result)
    print("-" * 40)

Trace the first loop iteration, ("12951", "3A"). query_inventory_db finds the key with seats == 0 and raises SeatUnavailableError("12951", "3A"), whose stored message is "Train 12951: no seats left in 3A". check_seat_availability has no try, so the exception passes through it unchanged. Inside book_ticket, the except SeatUnavailableError as e clause matches. It prints Booking failed: Train 12951: no seats left in 3A. Adding to waitlist. and reaches return "WAITLISTED" — but the return value is held, not delivered yet, because finally must still run. finally prints Booking attempt logged for train 12951. Only then does the function actually return, so the loop prints Result: WAITLISTED followed by the separator line.

The second iteration, ("12951", "2A"), finds seats == 4, which is neither absent nor zero, so query_inventory_db returns 4 with no exception at all. Because the try block succeeded, both except clauses are skipped and the else block runs: seats -= 1 makes the local variable 3, and it prints Confirmed. 3 seats now remain in 2A. finally still runs unconditionally, printing Booking attempt logged for train 12951., and the function returns "CONFIRMED".

The third iteration, ("99999", "1A"), has a key that is not in INVENTORY at all, so query_inventory_db raises UnknownRouteError with message "No such train/class combination: ('99999', '1A')" — the tuple is embedded via an f-string, which calls str() on it and produces Python's standard tuple representation. The matching except UnknownRouteError as e clause prints Booking failed: No such train/class combination: ('99999', '1A'), finally prints Booking attempt logged for train 99999., and the function returns "INVALID_REQUEST".

EAFP over LBYL, and two habits that quietly break robustness

Python's idiomatic style for handling uncertain operations is called EAFP — "easier to ask forgiveness than permission." Instead of checking every precondition before acting (LBYL, "look before you leap"), you attempt the operation directly and catch the exception if it fails. Rewritten in LBYL style, query_inventory_db might check if key in INVENTORY and INVENTORY[key] > 0 before proceeding. This looks safer, but in any system where the data can change between the check and the use — a second booking request arriving between your if and your access — LBYL introduces a race condition that EAFP's single atomic attempt avoids. EAFP is also simply less code for the common case, since the exception path is written once, separately, rather than threaded through every branch.

Two habits undermine everything above. The first is the bare except: clause, with no exception type at all. It does not catch Exception — it catches BaseException, which also includes KeyboardInterrupt (raised when a user presses Ctrl+C) and SystemExit (raised by sys.exit()). A bare except: silently absorbs a user's attempt to stop your program and swallows a deliberate shutdown call, turning both into a no-op. Always write except Exception: at minimum, and prefer naming the specific type you expect. The second habit is discarding the original exception when raising a new one. Inside query_inventory_db, if the lookup itself were wrapped in a try that caught a lower-level error and re-raised a custom one, writing raise UnknownRouteError(msg) from e — rather than a bare raise UnknownRouteError(msg) — attaches the original exception e as the new one's __cause__. Python's traceback then shows both: the domain-specific error you raised and the root cause underneath it, which is often the only clue that explains why the failure happened at all.

Cleanup deserves the same rigor. A finally block guarantees code runs regardless of outcome, but for resources like files or database connections, Python's context-manager protocol (the with statement) is the more robust default, because it cannot be bypassed by forgetting to write the finally:

with open("booking_log.txt", "a") as f:
    f.write(f"{train_no},{seat_class},{result}\n")

This guarantees f.close() runs even if f.write() raises partway through, without any explicit try/finally in your own code.

Active recall

Attempt each question before reading its answer.

1. What does book_ticket("12622", "SL") print, given the original INVENTORY above?

2. If a try block's return statement executes, does the enclosing finally block still run before the caller receives the value?

3. Rewrite this so ZeroDivisionError is actually reachable: except Exception: ... / except ZeroDivisionError: .... What was wrong with the original order?

4. Suppose INVENTORY[("12951", "2A")] is changed from 4 to 1 before the three-iteration loop runs. Retrace all three iterations, listing every printed line and returned value in order.

5. What is wrong with using a bare except: instead of except Exception: in book_ticket?

6. Does the line seats -= 1 inside book_ticket's else block update INVENTORY itself? What real-world bug could this cause?

Answers.

1. ("12622", "SL") is in INVENTORY with 12 seats, which is neither missing nor zero, so query_inventory_db returns 12 with no exception. The else block runs: seats becomes 11, printing Confirmed. 11 seats now remain in SL. finally prints Booking attempt logged for train 12622., and the function returns "CONFIRMED".

2. Yes. Python evaluates the return expression and holds the value, then executes finally in full, and only after that delivers the value to the caller. If finally itself contains a return, that would override the held value — which is exactly why placing a return inside finally is considered bad practice: it can silently discard the outcome of the try or except block.

3. Swap the order so the specific type comes first: except ZeroDivisionError: ... / except Exception: .... In the original order, ZeroDivisionError is a subclass of Exception, so the first clause always matches it and the second clause is unreachable dead code — Python never raises an error to warn you of this.

4. Iteration 1, ("12951","3A"), is unaffected — it uses a different key. It still prints Booking failed: Train 12951: no seats left in 3A. Adding to waitlist., then Booking attempt logged for train 12951., then Result: WAITLISTED, then the separator. Iteration 2, ("12951","2A"), now finds seats == 1, still neither missing nor zero, so the else branch still runs, but seats -= 1 now produces 0 instead of 3: it prints Confirmed. 0 seats now remain in 2A., then Booking attempt logged for train 12951., then Result: CONFIRMED, then the separator. Iteration 3, ("99999","1A"), is unaffected — unchanged key, unchanged output. Only the single printed number in iteration 2 changes; nothing else ripples, because book_ticket never writes the decremented value back into INVENTORY (see question 6), so the inventory dictionary itself is identical going into iteration 3 regardless of what iteration 2 computed locally.

5. A bare except: catches BaseException, not just Exception. That includes KeyboardInterrupt, so a passenger — or an operator — pressing Ctrl+C to stop the booking service would have that interrupt silently absorbed by book_ticket's handler instead of stopping the program, and any deliberate sys.exit() call elsewhere in the process would be swallowed the same way.

6. No. seats is a local variable inside book_ticket; seats -= 1 rebinds that local name to a new integer and never assigns back into INVENTORY[(train_no, seat_class)]. The dictionary entry is untouched. In a real system this is a serious bug: two back-to-back booking requests for the same train and class would both read the same seat count, both see it as available, and both get "Confirmed" — effectively double-booking a seat that should only have gone to one passenger. A correct version must write the decremented value back with INVENTORY[(train_no, seat_class)] = seats, and in a genuinely concurrent system that write also needs to happen atomically with the read (a database transaction or a lock), or the same race reappears at a lower level.

Think About It

Think about this: How would you explain python error handling: writing robust code to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.

Key Takeaways — Summary and Recap

Let us recap what we covered: the core ideas behind python error handling: writing robust code, how they connect to real-world applications, and why they matter for your journey in computer science. Remember these key points as you move forward. For competitive exam preparation (CBSE, JEE, BITSAT), focus on understanding the WHY behind each concept, not just the WHAT.

← Building a Simple Chat ApplicationFile Handling in Python: Modes, Buffering and the with Statement →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn
Share