Python Error Handling: Writing Robust Code
The Program That Worked Perfectly — Until It Didn't
Suppose you have written a Python program for your school's annual day. It asks each student for their marks in five subjects, adds them up, and prints the average. You test it with your own marks, it works beautifully, and you feel proud. On the actual day, a teacher is entering data live on the projector for forty students. One student was absent for a unit test and the teacher types AB instead of a number. Another student's record has zero subjects entered because of a data-entry mix-up. Your program does not calmly say "please check this entry" — it stops dead, and the projector fills with a wall of red text ending in a line like ValueError: invalid literal for int() with base 10: 'AB'. Everyone in the hall sees it.
This chapter is about preventing exactly that moment. Writing a program that produces the right answer when everything goes as planned is only half the job. Writing a program that behaves sensibly when something unexpected happens — bad input, a missing file, a division by zero, a lookup that fails — is what separates a script you wrote for yourself from software you can hand to someone else. Python gives you a precise, structured way to do this, and once you understand it, you will never again let one bad input crash an entire program.
Three Very Different Kinds of Mistakes
Before we can "handle" errors, we need to be exact about what kind of error we mean, because Python programmers deal with three genuinely different categories, and mixing them up is the source of a lot of confusion.
- Syntax errors happen when the code itself is not valid Python — a missing colon, a mismatched bracket, a misspelled keyword. Python's interpreter reads your file before running any of it, and if the grammar is wrong, it refuses to start at all. You will see a
SyntaxErrorand nothing executes, not even the correct lines. This is the easiest category, because you find out immediately, before your program ever runs. - Runtime errors — formally called exceptions in Python — happen while the program is running, when a line of code that is grammatically perfect asks Python to do something it cannot do.
int("AB")is valid syntax, but there is no sensible integer version of the text "AB", so Python raisesValueErrorthe moment that line executes.10 / 0is valid syntax, but division by zero has no answer, so Python raisesZeroDivisionError. These errors depend on the data your program sees, not on how you typed the code, so testing with "nice" input will never reveal them. - Logical errors are the sneakiest kind: the program runs from start to finish without crashing, but produces the wrong answer — for example, computing an average by dividing by the wrong number of subjects. Python cannot detect these for you, because as far as the interpreter is concerned, nothing went wrong.
Error handling — the subject of this chapter — is specifically about the second category, runtime exceptions. It gives you a way to say, in advance, "if something goes wrong on this line, don't crash — do this instead." It cannot fix logical errors (those need careful thinking and testing, not try/except), and it cannot run at all if there is a syntax error, because the program never starts. Keeping these three apart will save you from a very common Grade 8 mistake: wrapping a syntactically broken line in try/except and expecting it to somehow run anyway. It will not — a SyntaxError happens before try even gets a chance to watch for trouble.
Meeting an Exception Up Close
Let us actually produce one, so we know exactly what we are trying to catch. Here is a two-line program and the marks-average idea from the hook, boiled down to its simplest form:
marks = int(input("Enter marks: "))
print("Marks entered:", marks)
If the teacher types AB, Python tries to run int("AB"), cannot convert it, and immediately stops the entire program with output that looks like this:
Traceback (most recent call last):
File "marks.py", line 1, in <module>
marks = int(input("Enter marks: "))
ValueError: invalid literal for int() with base 10: 'AB'
Notice the very last line: ValueError: invalid literal for int() with base 10: 'AB'. This is the key piece of information. ValueError is the type of the exception, and the text after the colon is the specific message. Every runtime error in Python has a type like this — ValueError, ZeroDivisionError, TypeError, IndexError, KeyError, and many more. Once your program hits a line that raises one of these and nothing is watching for it, execution stops on the spot — none of the lines after it run, even if they had nothing to do with the problem. That "everything after this line is abandoned" behaviour is exactly what we want to prevent.
The try/except Block: Telling Python What to Do Instead
Python's fix for this is the try/except block. You put the risky code — the line that might fail — inside a try block. Directly underneath, you write an except block naming the exception type you expect, and the code inside it runs only if that exact kind of trouble occurs. If nothing goes wrong, the except block is skipped entirely and never even looked at.
try:
marks = int(input("Enter marks: "))
print("Marks entered:", marks)
except ValueError:
print("That's not a valid number. Please enter digits only.")
Now if the teacher types AB, here is exactly what happens, line by line: Python enters the try block, runs int(input(...)), and that call raises ValueError. As soon as the exception is raised, Python immediately stops running the rest of the try block — the print("Marks entered:", marks) line never executes, because the failure happened one line above it — and instead jumps to check whether any except clause matches. ValueError matches the except ValueError clause, so Python runs that block and prints "That's not a valid number. Please enter digits only." The program then continues normally with whatever comes after the whole try/except structure — it does not crash and does not stop. If instead the teacher types a proper number like 78, the try block completes without any exception, the except block is skipped completely, and "Marks entered: 78" is printed.
Catching the Right Exception, Not Just "Any" Exception
A well-written except clause names a specific exception type, because different problems need different responses, and naming the type also protects you from accidentally hiding a bug that has nothing to do with what you were expecting. Here are the exception types you will meet constantly in CBSE-level Python programs, each with the situation that actually triggers it:
ValueError— the type is right but the value cannot be used the way you asked, such asint("AB")orint("12.5")(a string that looks numeric but isn't a whole number).ZeroDivisionError— any division or modulus where the denominator turns out to be zero, such astotal / 0.TypeError— an operation is used on data types that do not support it, such as"5" + 5(you cannot add text and a number directly) or10 / "two".IndexError— you ask a list (or string) for a position that does not exist, such asstudents[10]when the list only has 3 elements.KeyError— you ask a dictionary for a key it does not contain, such asfees_paid["Meera"]when "Meera" was never added to the dictionary.FileNotFoundError— you try to open a file, such as a saved marks sheet, that does not exist at that location.
Here are two of these in action, so the pattern is completely concrete rather than just a list of names:
students = ["Aditi", "Rohan", "Meera"]
roll_no = 5
try:
print(students[roll_no])
except IndexError:
print(f"No student with roll number {roll_no}.")
Tracing this: students has exactly three valid positions, index 0, 1 and 2. Asking for students[5] goes beyond the end of the list, so Python raises IndexError instead of returning anything. The except IndexError clause catches it, and the program prints "No student with roll number 5." instead of crashing. The same reasoning applies to dictionaries:
fees_paid = {"Aditi": True, "Rohan": False}
try:
print(fees_paid["Meera"])
except KeyError:
print("Meera's fee record was not found.")
Since "Meera" was never stored as a key in fees_paid, the lookup raises KeyError, which is caught, and the program prints a sensible message instead of stopping.
Handling More Than One Kind of Trouble
Real programs usually face more than one possible failure on the same line. Python lets you write several except clauses after a single try, and it checks them in order from top to bottom, running the first one that matches. You can also group several exception types into one clause using a tuple, and you can capture the exception object itself — using as — to read its actual message.
def safe_divide(a, b):
try:
result = a / b
except (ZeroDivisionError, TypeError) as error:
print(f"Could not divide: {error}")
return None
return result
print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide(10, "two"))
Tracing all three calls: safe_divide(10, 2) computes 10 / 2, which succeeds and gives 5.0, so the function returns 5.0 and "5.0" is printed. safe_divide(10, 0) raises ZeroDivisionError, which matches the tuple in the except clause; the caught exception object is stored in error, whose text is "division by zero", so the program prints "Could not divide: division by zero" and the function returns None. safe_divide(10, "two") tries to divide a number by a string, which Python cannot do, so it raises TypeError — this also matches the same tuple — and the message printed is "Could not divide: unsupported operand type(s) for /: 'int' and 'str'", again returning None. One except clause has quietly handled two completely different failure types, because we grouped them.
else and finally: The Two Clauses Everyone Forgets
Beyond try and except, Python offers two optional clauses that make your intent much clearer. An else block runs only when the try block completed with absolutely no exception — it is the place to put code that should happen only on success, kept separate from the risky code itself. A finally block runs no matter what happened — whether the try succeeded, whether an exception was caught, or even if the exception was never caught at all and is about to crash the program. It always executes, which makes it the right place for cleanup work like closing a file.
def average_marks(total, subject_count):
try:
average = total / subject_count
except ZeroDivisionError:
print("No subjects to average!")
return None
else:
print(f"Average marks: {average:.2f}")
return average
average_marks(255, 3)
average_marks(0, 0)
Tracing the first call: total=255, subject_count=3, so 255 / 3 is 85.0 with no exception at all — the except block is skipped, and because the try block succeeded, the else block runs, printing "Average marks: 85.00" and returning 85.0. The second call has subject_count=0, so 0 / 0 raises ZeroDivisionError; the except clause catches it, prints "No subjects to average!", and returns None — the else block is never reached, because it only runs after a try that raised nothing.
Now let's see finally guarantee its own execution even through a caught exception:
def read_marks_file(filename):
try:
file = open(filename, "r")
data = file.read()
except FileNotFoundError:
print(f"{filename} does not exist yet.")
return None
finally:
print("Attempted to access the file.")
return data
If marks.txt does not exist, open(filename, "r") raises FileNotFoundError. The except block runs first, printing "marks.txt does not exist yet." and preparing to return None — but before the function actually hands control back to whoever called it, Python runs the finally block, printing "Attempted to access the file." Only after that does the function actually return None. The order on screen is exactly: the except message first, then the finally message, then the function ends — finally gets the very last word, every single time, which is exactly why it is the right place to release resources like open files or database connections.
Visualising the Decision Path
All of this — try, the possible exception, except, else, and finally — follows one single decision path every time Python runs such a block. The diagram below traces that path exactly the way the interpreter does.
Raising Your Own Exceptions
So far, Python has raised exceptions for us automatically. But you can also raise one deliberately, using the raise keyword, whenever your own code detects that something is wrong even though Python itself would not have complained. This is essential for validating input that is technically the "right type" but still makes no sense — for example, a negative age, or a negative number of seats booked.
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative.")
if age > 120:
raise ValueError("Age seems unrealistic.")
return age
try:
student_age = set_age(-5)
except ValueError as error:
print(f"Invalid input: {error}")
Tracing this: set_age(-5) checks age < 0, which is true for -5, so the function itself executes raise ValueError("Age cannot be negative."). This creates and raises a ValueError exactly as if Python had done it internally, carrying the message we chose. Because the call to set_age(-5) sits inside a try block, the exception is caught by except ValueError as error, and error holds our custom message, so the program prints "Invalid input: Age cannot be negative." instead of crashing. raise is how you extend Python's error-checking to cover the rules of your own program, not just the rules built into the language.
A Misconception Worth Correcting Directly
A very common habit among beginners is to write a "catch everything" clause using a bare except: with no exception type named at all, hoping it will make the program bulletproof:
try:
marks = int(input("Enter marks: "))
avg = total_marks / marks
except:
print("Something went wrong.")
This looks safe, but it is actually dangerous, and here is precisely why. Suppose the variable was actually supposed to be named total, but was mistyped as total_marks, and total_marks was never defined anywhere. That typo would normally raise NameError — a genuine bug in your code, not a problem with the teacher's input. But because the bare except: catches every exception type without distinction, it silently swallows the NameError too and prints the same unhelpful "Something went wrong," hiding the fact that your program has a real bug. You would spend hours looking for the mistake in the wrong place — the input validation — when the actual problem was a misspelled variable name. The fix is always to name the specific exception types you genuinely expect, such as except ValueError: or except (ValueError, ZeroDivisionError):, so that anything else — a real bug — is allowed to crash loudly where you can see and fix it, rather than being hidden.
A second, related misconception is about the order of multiple except clauses. Python checks them top to bottom and stops at the first match, so putting a general type before a specific one silently makes the specific one unreachable:
try:
number = int("abc")
except Exception as error:
print("Something went wrong:", error)
except ValueError as error:
print("Please enter a valid number.")
Tracing this: int("abc") raises ValueError. Python checks the clauses in order — the first one is except Exception, and since ValueError is a more specific kind of Exception in Python's built-in hierarchy, it matches immediately. So Python runs that block, printing "Something went wrong: invalid literal for int() with base 10: 'abc'", and the second clause, except ValueError, is never even considered — it is dead code that will never run, no matter what happens. The rule to remember is always to list the most specific exception types first and only use broader ones, if at all, further down.
Putting It All Together
Here is a single function that uses every tool from this chapter together — try, several typed except clauses, a custom raise, an else, and a finally — modelled on the kind of validation an IRCTC-style ticket booking check would need:
def book_ticket(passenger_age, seats_available):
try:
if passenger_age <= 0:
raise ValueError("Passenger age must be positive.")
seat_number = 100 // seats_available
except ValueError as error:
print(f"Booking failed: {error}")
return None
except ZeroDivisionError:
print("Booking failed: No seats available.")
return None
else:
print(f"Ticket confirmed. Seat number: {seat_number}")
return seat_number
finally:
print("Booking attempt logged.")
book_ticket(22, 4)
book_ticket(-5, 4)
book_ticket(22, 0)
Tracing all three calls carefully. book_ticket(22, 4): passenger_age is positive, so no raise happens; seat_number = 100 // 4 = 25; the try block finished with no exception, so the else block runs, printing "Ticket confirmed. Seat number: 25" and preparing to return 25; before the function actually returns, finally runs, printing "Booking attempt logged." book_ticket(-5, 4): passenger_age <= 0 is true, so raise ValueError("Passenger age must be positive.") fires inside the try block; the first except clause matches, printing "Booking failed: Passenger age must be positive." and preparing to return None; finally still runs afterward, printing "Booking attempt logged." book_ticket(22, 0): passenger_age is fine, so Python reaches seat_number = 100 // 0, which raises ZeroDivisionError; this does not match the first except ValueError clause, so Python checks the next one, except ZeroDivisionError, which matches, printing "Booking failed: No seats available."; again, finally runs afterward regardless, printing "Booking attempt logged." Notice that in every one of the three calls, "Booking attempt logged." is the very last thing printed — that is finally doing exactly what it promises, every single time, whether the booking succeeded, failed with a validation error, or failed with a division error.
Check Your Understanding
- A program reads a student's roll number from a list of 30 students (valid indices 0 to 29) and the user enters 30. Which exception type is raised, and why does it happen even though 30 "looks like" a reasonable roll number?
- Rewrite this unsafe fragment so that it only catches the specific problem it is meant to catch, and explain in one sentence why a bare
except:would be worse here:try: price = float(input("Enter price: ")) except: print("Invalid price.") - In a function with
try, twoexceptclauses, anelse, and afinally, if thetryblock raises an exception that is caught by the secondexceptclause, list every block that runs, in the exact order they run. - A programmer writes
except Exception:as the first clause andexcept TypeError:as the second clause in the sametrystatement. Explain why the second clause will never execute, no matter what kind ofTypeErroroccurs. - Write a function
set_marks(marks)that usesraiseto produce aValueErrorwith the message "Marks cannot exceed 100." whenevermarks > 100, and trace what happens when it is called inside atry/exceptwith the value 150.
Summary
Python separates mistakes into three categories: syntax errors, caught before the program ever runs; logical errors, which produce wrong answers silently and cannot be caught by any code structure; and runtime exceptions, which happen while the program is executing and are exactly what error handling addresses. A try block surrounds code that might fail, and one or more typed except clauses — such as ValueError, ZeroDivisionError, TypeError, IndexError, KeyError, or FileNotFoundError — catch specific kinds of trouble and let the program keep running instead of crashing. Python checks except clauses from top to bottom and stops at the first match, so specific exception types must be listed before general ones, or the specific clause becomes unreachable. An else clause runs only when the try block raised nothing at all, and a finally clause always runs — success, failure, or even an uncaught exception on its way to crashing the program — which makes it the natural place for cleanup work. The raise keyword lets your own code produce exceptions to enforce rules Python has no built-in way to check, such as rejecting a negative age. And a bare except: with no type named is a genuine hazard rather than a safety net, because it silently hides real bugs — like a misspelled variable name raising NameError — behind the same generic message you intended only for bad user input. Writing robust code means anticipating exactly which things can go wrong, naming them precisely, and deciding, in advance, what should happen instead of a crash.
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.
Practice Exercises
Now it is time to practice! Complete these challenges to solidify your understanding:
- Exercise 1: Write a short program that demonstrates the core concept from this chapter. Test it with at least 3 different inputs.
- Exercise 2: Find a real-world example where python error handling: writing robust code is used in an Indian company (like TCS, Infosys, Flipkart, or ISRO). Write a paragraph explaining the connection.
- Exercise 3: Create a mind-map connecting python error handling: writing robust code to at least 3 other topics you have studied.