Chapter 12: Errors and Exceptions
Section 1
Errors are a normal part of programming, not a sign that you are bad at it. Every program, no matter how carefully written, will eventually meet data it did not expect, a file that does not exist, or a user who types something surprising. Learning to handle errors well is therefore not an optional extra; it is a core programming skill, as important as writing the happy path that works when everything goes right. A program that handles errors gracefully feels reliable, while one that crashes on the first surprise feels fragile, even if both implement the same core logic correctly.
Python distinguishes between syntax errors, which prevent a program from running at all, and exceptions, which occur while a program is running. A syntax error means the code itself is not valid Python, perhaps because of a missing colon or an unbalanced bracket, and Python reports it before execution begins. An exception, by contrast, means the code is valid, but something went wrong during execution, such as dividing by zero or opening a missing file. Understanding that distinction helps you respond appropriately: syntax errors are fixed by correcting the code, while exceptions may be handled, prevented, or allowed to stop the program, depending on the situation.
An exception, when it is not handled, stops the program and prints a traceback. A traceback is a report showing the chain of function calls that led to the error, ending with the type and message of the exception. Beginners often find tracebacks intimidating, because they are long and technical, but they are actually helpful: they name the error, show where it happened, and show how the program got there. Learning to read a traceback from the bottom up, starting with the error type and message, then looking at the last line of your own code in the chain, is a skill that will save you hours of guessing.
The goal of this chapter is to turn errors from something you fear into something you manage. You will learn how exceptions are raised, how to catch and handle them, how to write code that cleans up reliably, and how to define your own exception types for your own programs. You will also learn when not to catch an exception, because letting a program fail loudly is sometimes the honest and safest choice. Error handling is a judgement skill, and this chapter builds that judgement step by step.
print(10 / 0)
Errors also teach you humility about your own code, because a program that works on your examples may still fail on data you never considered. That humility is healthy: it leads you to test with unusual inputs, to handle edge cases, and to write messages that help users when something does go wrong. A programmer who expects errors, and plans for them, builds programs that survive contact with the real world far better than one who assumes everything will go as planned.
Check: What is the difference between a syntax error and an exception that occurs while a program is running?
Section 2
The try and except statements are Python's main tools for handling exceptions. Code that might fail goes inside the try block, and code that responds to a specific failure goes inside an except block. If no exception occurs, the except block is skipped, and execution continues after the whole statement. If an exception of the named type occurs, execution jumps to the matching except block, and the program continues from there, rather than stopping. That jump is the key idea: an exception interrupts the normal flow, and the handler provides an alternative flow.
You can write several except blocks, each for a different exception type, and Python will use the first one that matches. Order matters when exception types are related, because a broader type placed first will catch exceptions that a later, more specific block was meant to handle. The usual rule is to list specific types before general ones, so that each failure gets the most precise response available. A handler for FileNotFoundError should come before a handler for the broader OSError, for example, if both are present, so that a missing file gets its specific message.
An except block without a named type catches every exception, and it is usually a poor choice. It hides the difference between expected failures, such as a missing file, and unexpected failures, such as a bug in your own logic, and it can make debugging very difficult, because the program continues as if nothing went wrong when something clearly did. There are rare cases where catching everything is justified, such as at the top level of a long-running service that must keep running, but even there, the error should be logged, not silently ignored. For most code, name the exceptions you expect, and let the rest propagate.
When you catch an exception, you can bind it to a variable with as, as in except ValueError as err:, and then inspect or print its message. That message often contains useful detail, such as the value that failed to convert, or the filename that was not found. Including that detail in your own message helps the user understand what happened, without requiring them to read a traceback. A handler that says what failed, why, and what to do next is far more helpful than one that merely says something went wrong.
try:
age = int("not a number")
except ValueError as err:
print("Could not convert to a number:", err)
Handlers also need to consider what happens after the error: does the program retry, use a default, ask the user again, or stop? Each choice has consequences, and the handler should make the choice explicit, rather than letting execution drift onward with missing data. A handler that prints a message and then continues to use a variable that was never assigned, for example, will cause a second, more confusing error. Handling an error means deciding the next step, not just acknowledging that something happened.
Check: Why should except blocks name specific exception types instead of catching every exception?
Section 3
Some exceptions are common enough to learn by name, because you will meet them repeatedly. ValueError occurs when a function receives a value of the right type but an inappropriate value, such as trying to convert "abc" to an integer. TypeError occurs when an operation is applied to a value of the wrong type, such as adding a number to a string. KeyError occurs when a dictionary is accessed with a missing key, and IndexError occurs when a list is accessed with an out-of-range index. FileNotFoundError and PermissionError relate to files, as seen in the previous chapter, and ZeroDivisionError occurs when dividing by zero.
Learning these names is not about memorising a list for its own sake; it is about recognising patterns in your own mistakes. When you see TypeError, you know to check the types of the values involved, not their values. When you see KeyError, you know a dictionary lookup failed, and you can check whether the key should exist, or whether a default should be used. Each exception type is a clue, pointing to a category of problem, and experienced programmers read that clue first, before looking at the details. The exception type narrows the search, which is why precise types are valuable.
You should also learn which exceptions are related. Many file exceptions inherit from OSError, which means a handler for OSError will catch them all, while a handler for a specific type will catch only that type. That hierarchy is useful when you want a general response to any file problem, but it is a trap when you place a general handler before a specific one, as discussed earlier. Understanding the hierarchy, at least for the common types, helps you write handlers that are neither too broad nor too narrow for the situation.
Not every exception should be caught where it occurs. Often, the best place to handle an exception is further up the call chain, where more context is available about what the program was trying to achieve. A low-level function that reads a file may raise FileNotFoundError, while a higher-level function that knows the file was a user-supplied configuration can catch it and print a helpful message about that configuration. Letting exceptions propagate to a place with context is a normal and healthy pattern, not a failure to handle them.
data = {"name": "Ada"}
try:
print(data["age"])
except KeyError:
print("No age recorded.")
Common exceptions also appear in combinations that reveal a deeper problem, such as a TypeError followed, after a fix, by a ValueError in the same code. That sequence often means the original code was passing the wrong kind of data, and fixing the type exposed a value problem that was hidden behind it. Working through exceptions one at a time, fixing the first, rerunning, and reading the next, is a normal debugging rhythm, and it teaches patience as well as precision.
Check: How does knowing whether an error is a TypeError or a ValueError help you find the bug faster?
Section 4
The else and finally clauses complete the try statement, and each has a distinct role. Code in the else block runs only if the try block completed without an exception. It is used for work that should happen after a successful attempt, but that should not itself be protected by the except handlers. Keeping success-only code in else, rather than at the end of the try block, is a matter of precision: code in the try block is protected, so an exception there would be caught, while code in else is not, so an exception there propagates. That distinction matters when you want to be clear about which operations the handler is meant to cover.
Code in the finally block runs no matter how the try statement ends, whether by success, by a handled exception, by an unhandled exception, or even by a return statement. It is used for cleanup that must happen in all cases, such as closing a file, releasing a resource, or restoring a setting. In modern Python, the with statement usually handles file cleanup more elegantly, but finally remains important for other resources and for situations where a with statement is not available. A finally block that itself raises an exception can obscure the original error, so it should be kept simple and safe.
A common pattern combines all three: try an operation, handle expected failures in except, do success-only work in else, and clean up in finally. That structure reads like a story: attempt, recover, proceed, tidy up. When you see that structure in someone else's code, you can understand its error handling at a glance, because each part has a clear role. When you write it yourself, the structure forces you to think about each case separately, which leads to more careful handling than a single block that tries to do everything.
You should also be careful with return inside try and finally. A return in finally will override a return or an exception from the try block, which is almost never what you want, and it can hide errors in a confusing way. The rule is simple: do not return from finally; use it only for cleanup. Similarly, avoid complex logic in finally, because cleanup code should be reliable, and complex code is more likely to fail at exactly the moment when an error is already being handled.
try:
value = int("42")
except ValueError:
print("bad number")
else:
print("converted:", value)
finally:
print("attempt finished")
The else clause also helps when the try block contains several operations, and only some of them should be protected. By moving success-only work to else, you narrow the protected region to exactly the operation that might fail, which makes it clearer what the handler is for. Narrow protected regions are easier to reason about, because there are fewer ways for an exception to arise inside them, and therefore fewer surprises about which handler will catch a given failure.
Check: What is the difference between code in the else block and code at the end of the try block?
Section 5
Raising an exception is how a program signals that something has gone wrong, or that a precondition has not been met. The raise statement creates an exception object and starts the search for a handler, just as if the error had occurred naturally. You raise an exception when a function cannot do what it was asked to do, and continuing would produce a wrong result or hide a problem. For example, a function that expects a positive number might raise ValueError for a negative input, rather than returning a meaningless result that the caller might mistake for success.
Choosing the right exception type when raising matters, because the caller will catch by type. Raising a standard type, such as ValueError for a bad value or TypeError for a wrong type, lets callers use the handlers they already know, without learning a new type for every function. Raising a custom type, covered later in this chapter, is appropriate when the error is specific to your program's domain and a standard type would be misleading. In either case, the message should explain what was wrong and, when possible, what would have been acceptable, so the caller can fix the call or report the problem clearly.
Raising is also a way to enforce a function's contract: the documented conditions under which it works. If a caller violates the contract, raising is more honest than silently working around the violation, because a workaround may produce a result that looks correct but is not. A function that raises for invalid input teaches its callers to pass valid input, while a function that silently accepts anything teaches its callers that anything is acceptable, which leads to confusion later. Clear contracts, enforced by raising, make programs easier to reason about.
You should not raise exceptions for ordinary control flow, such as using an exception to exit a loop or to signal a normal outcome. Exceptions are for exceptional situations, and using them as a routine signalling mechanism makes code harder to follow, because the normal path and the error path become tangled. When a normal outcome needs to be communicated, a return value, or a special result, is clearer. Reserve raising for situations where the function genuinely cannot fulfil its promise, and where the caller needs to know that it did not.
def double_positive(n):
if n <= 0:
raise ValueError("n must be positive")
return n * 2
print(double_positive(3))
Raising exceptions also interacts with documentation: a function that raises for certain inputs should say so in its docstring or comments, naming the exception type and the condition that triggers it. That documentation lets callers write handlers in advance, rather than discovering the exception by trial and error. A function whose raising behaviour is undocumented is harder to use correctly, because callers must guess which failures are possible and how to respond to them.
Check: Why is raising ValueError for a negative input better than returning a meaningless result?
Section 6
When you catch an exception and then decide that the caller should also know about it, you can re-raise it. A bare raise inside an except block re-raises the current exception, preserving its original type, message, and traceback, so that a higher-level handler can respond, or the program can stop with the original error intact. Re-raising is useful when a function needs to do some cleanup or logging on failure, but does not itself know how to recover. It can log that the operation failed, clean up, and then pass the failure onward, rather than swallowing it.
You can also raise a different exception from inside a handler, which is called chaining. Python links the new exception to the original one, so that a traceback shows both: the original cause and the new exception that was raised while handling it. Chaining is useful when a low-level failure, such as a file error, should be reported as a higher-level failure, such as a configuration error, because the higher-level description is more meaningful to the caller. The chain preserves the low-level detail, so nothing is lost, while the new type gives the caller a more useful category to catch.
There is also a form of chaining where you explicitly suppress the original context, using raise ... from None. That is appropriate when the original exception would be confusing or distracting, and the new exception fully explains the problem on its own. Suppressing context should be a deliberate choice, not a default, because the original error often contains the clue needed to diagnose the problem. When in doubt, let Python chain automatically, so the full story remains available to anyone debugging the failure.
A common mistake is to catch an exception, print a message, and then continue as if the operation had succeeded, without re-raising or returning a clear failure signal. That pattern hides the failure from the caller, which may then proceed with missing or partial data, producing a wrong result far from the original error. If a function cannot recover, it should either re-raise, raise a new exception, or return a value that unambiguously signals failure, and the choice should be documented so callers know what to expect.
try:
int("oops")
except ValueError:
print("logging the failure")
raise
Re-raising also preserves information that a new exception alone might lose, such as the original line number and the chain of calls that led to the failure. When you re-raise, a debugger or a log can show the full history, whereas a handler that swallows the exception and raises a generic one erases that history. Preserving history is a courtesy to future debuggers, who will need every clue available to understand a failure that may be hard to reproduce.
Check: Why would a function catch an exception, log it, and then re-raise it, instead of handling it completely?
Section 7
Guarding against errors before they happen is often better than catching them afterwards. A guard is a check at the start of a function or a block that validates inputs, checks preconditions, and returns early or raises clearly if something is wrong. For example, checking that a list is not empty before accessing its first element prevents an IndexError, and checking that a key exists before accessing it prevents a KeyError. Guards make the normal path clearer, because the checks are separated from the main logic, and they make failures more predictable, because they happen at a known place with a known message.
There are two broad styles for dealing with potential failures: look before you leap, and ask for forgiveness. Looking before you leap means checking conditions first, as with guards. Asking for forgiveness means trying the operation and catching the exception if it fails. Each style has strengths: checking first can be clearer when the check is simple, while trying first can be more reliable when conditions can change between the check and the operation, as with files. Python's culture tends to favour asking for forgiveness in many cases, but the choice should depend on the situation, not on a slogan.
A useful rule is to guard when the check is cheap, clear, and stable, and to try and catch when the operation itself is the most reliable test, or when checking would duplicate the operation's own logic. For example, checking whether a string can be converted to an integer requires attempting the conversion, so trying and catching ValueError is natural. Checking whether a list is empty, by contrast, is a simple, stable check, so guarding is natural. Thinking about which style fits each case, rather than applying one style everywhere, leads to code that is both clear and robust.
Guards also help with readability, because they state a function's expectations up front. A reader who sees a guard at the top of a function knows immediately what inputs are acceptable, without having to infer them from the code that follows. That upfront statement is a form of documentation, and it makes the function easier to use correctly, because callers can see the contract before they call it. A function whose guards are clear is a function that is hard to misuse accidentally.
def first_item(items):
if not items:
return None
return items[0]
print(first_item([]), first_item([5, 6]))
Guards also interact with performance in a minor way: a guard that checks a simple condition is cheap, while a guard that repeats an expensive operation is wasteful. If checking would require doing the work twice, trying and catching may be more efficient, because the operation itself performs the check as part of its work. That efficiency consideration is secondary to clarity in most code, but it is worth keeping in mind when a guard would duplicate significant work.
Check: When is checking a condition first better than trying an operation and catching its exception?
Section 8
Assertions are a tool for checking assumptions during development, and they are distinct from exception handling. An assert statement checks a condition that you believe should always be true at that point in the program, and if it is false, it raises AssertionError. Assertions are meant to catch bugs in your own code, not to handle bad user input or external failures, because they can be disabled when Python is run with optimisation, and because a user should never see an AssertionError as the response to their input. Using assertions for input validation is therefore a mistake, even though it may appear to work in testing.
The value of assertions lies in making assumptions explicit. When you write assert total >= 0, you are stating that, at this point, the total should never be negative, and if it ever is, something in your logic is wrong. That statement helps a reader understand your reasoning, and it helps you find bugs, because a failing assertion points directly to a violated assumption, rather than to a symptom far downstream. Assertions are most useful for internal invariants, conditions that your code itself guarantees, such as a list being sorted after a sort, or a counter never going negative.
Assertions should have clear messages, explaining what was expected, so that a failure is informative. A bare assert x tells the reader only that x was false, while assert x > 0, "count must be positive" tells them what was expected and why it matters. That message is part of the documentation value of assertions, and it makes debugging faster, because the failing assumption is named rather than guessed. Even so, assertions are not a substitute for proper error handling; they are a development aid that complements it, by catching logic errors early, during testing, before they reach users.
In production code, you should not rely on assertions to enforce anything essential, because they may be turned off. Essential checks, such as validating user input or file contents, should use explicit guards and exceptions, which always run. Assertions are for checks that, if they fail, indicate a bug that should be fixed in the code, not a condition that the program should handle gracefully. Keeping that distinction clear prevents a whole class of subtle failures where a program works in development, with assertions on, but fails silently in production, with assertions off.
def area(width, height):
assert width >= 0 and height >= 0, "dimensions must not be negative"
return width * height
print(area(3, 4))
Assertions also have a role in tests, where they check that a function's output matches an expected value, and a failure indicates that the function, or the test's expectation, is wrong. In tests, assertions are the main mechanism, because a test is supposed to fail loudly when its expectation is violated. That context is different from production code, where an assertion failure in front of a user is unhelpful. The same tool, therefore, has different appropriate uses depending on whether the audience is a developer running tests or a user running a program.
Check: Why should assertions not be used to validate user input, even though they can check a condition?
Section 9
Some programs need to handle many different exceptions in a uniform way, and grouping handlers can help. You can catch several exception types in a single except block by listing them in a tuple, as in except (ValueError, TypeError):, when the response is the same for each. That grouping reduces duplication, because one handler covers several related failures, and it makes the code's intention clear: these failures are treated alike, because they mean the same thing in this context, such as invalid input. Grouping should be used when the failures genuinely share a response, not merely to save lines when the responses should differ.
You can also use a broader exception type to catch a family of related failures, such as catching OSError for any file-related problem, when the specific type does not change the response. That breadth is useful for a top-level handler that logs any file problem and reports it generically, but it is too broad for code that needs to distinguish a missing file from a permission problem, because those require different advice to the user. The choice between grouping, broadening, and handling separately depends on whether the distinction matters to the response, which is a question about the program's behaviour, not just its structure.
Another technique is to handle exceptions at different levels for different purposes. A low-level function might catch a specific exception to clean up, then re-raise, while a higher-level function catches it again to report it to the user. That layered handling is normal, and it reflects the different responsibilities at each level: the low level knows how to clean up, the high level knows how to communicate. Each layer should do only its own job, and should not try to handle aspects that belong to another layer, because that duplication leads to inconsistent messages and missed cleanup.
Finally, remember that exception handling is not free. It adds code, it adds paths through the program that must be tested, and it can obscure the main logic if overused. Handling only the exceptions you expect, and only where you can respond meaningfully, keeps the code focused. A program with a handler for every conceivable failure, most of which it cannot meaningfully recover from, is harder to read than one that handles the few failures it understands and lets the rest fail loudly, where they can be seen and fixed.
try:
value = int("12x")
except (ValueError, TypeError):
print("invalid number input")
Grouping handlers also requires care about the message: a grouped handler's message must make sense for every exception it catches, not just for the most common one. A message that says invalid number, for example, makes sense for both ValueError and TypeError when converting input, but it would be misleading if the group also included a file error. The message is part of the handler's contract, and it should be as accurate for the rarest member of the group as for the most frequent one.
Check: When is it appropriate to catch several exception types in one except block?
Section 10
Reading a traceback is a skill that deserves deliberate practice, because it is how you will spend much of your debugging time. A traceback lists the function calls, from the outermost to the innermost, that were active when the exception occurred, with the file, line number, and code for each call. The final line names the exception type and message, which is the most important part: it tells you what went wrong, in general terms. The line above it, in your own code, tells you where it went wrong, specifically. Starting from those two pieces, you can work backwards through the call chain to understand how the program reached that point.
A common beginner mistake is to read a traceback from the top, getting lost in library code that you did not write and cannot change. Library frames are often irrelevant to the fix, because the bug is usually in how your code called the library, not in the library itself. The useful frames are the ones in your own files, especially the last one, which shows the exact operation that failed. Focusing on your own code first, and only looking at library code if the library's documentation suggests you misunderstood its use, saves time and frustration.
Tracebacks also reveal the difference between the place where an exception is raised and the place where it is caught. If an exception is caught and re-raised, or chained, the traceback may show multiple sections, each with its own chain. Reading those sections in order tells the story of the failure: where it started, where it was handled, and where it was raised again. That story is more informative than any single line, because it shows the path the error took through your program, which is often the clue to why a handler did not catch it, or why it was caught in the wrong place.
Practising traceback reading means deliberately causing errors in small programs, reading the output carefully, and fixing the cause, rather than guessing. Each error you read carefully teaches you a pattern: a NameError means a misspelled or undefined name, an AttributeError means a missing attribute, a TypeError means a type mismatch. Over time, those patterns become instant recognition, and debugging becomes faster, not because you make fewer errors, but because you understand the ones you make more quickly.
def greet(name):
return "Hello, " + name
print(greet(42))
Traceback reading also improves when you reproduce an error in isolation, with the smallest program that still fails. A small reproduction removes unrelated code from the traceback, leaving only the frames that matter, and it makes the cause easier to see. That habit, shrinking a failing program until it is minimal, is one of the most effective debugging techniques, because it forces you to identify which part of the program is actually necessary for the failure to occur.
Check: When reading a traceback, why should you start with the final line and your own code, rather than the top?
Section 11
Defining your own exception types lets you signal errors that are specific to your program's domain, in a way that callers can catch precisely. A custom exception is usually a small class that inherits from Exception, or from a more specific standard type, and it may carry extra information, such as the value that caused the problem or the operation that failed. For example, a task-list program might define TaskNotFoundError, and a configuration program might define InvalidConfigError, each naming a failure that standard types describe only vaguely. That naming makes handlers clearer, because catching TaskNotFoundError says exactly what failure is being handled.
Custom exceptions should be used sparingly, and their names should be clear. Defining a new type for every possible failure creates a maze of types that callers must learn, while defining none forces callers to catch broad standard types and guess which failure occurred. A good rule is to define a custom type when the failure is distinct in meaning, when callers might want to handle it differently from similar failures, or when the standard message would be misleading. If none of those apply, a standard type with a clear message is usually sufficient, and simpler for everyone.
A custom exception class can be as simple as a name and a docstring, inheriting everything else from its parent. It can also define an __init__ method that stores extra attributes, such as the offending value, so that a handler can inspect them without parsing the message string. Storing structured information, rather than burying it in a message, is a good practice, because messages are for humans, while attributes are for programs. A handler that needs the bad value should be able to read it directly, not extract it from text.
You should also think about where custom exceptions live in your program. Defining them near the code that raises them, or in a small module dedicated to errors, makes them easy to find and import. A caller that wants to catch a custom exception must be able to import it, so its location matters. If the exception is defined inside a function, it cannot be caught elsewhere, which defeats its purpose. Exceptions are part of a program's interface, and like any interface, they should be visible, documented, and stable, not hidden inside implementation details.
class TaskNotFoundError(Exception):
pass
def find_task(tasks, title):
if title not in tasks:
raise TaskNotFoundError(f"no task named {title!r}")
return tasks[title]
Custom exceptions also make a program's error handling self-documenting, because the type name itself explains the failure in domain terms. A reader who sees except TaskNotFoundError understands the situation immediately, without needing to read a message string or guess which standard type was reused. That clarity is especially valuable in larger programs, where many different failures might otherwise be hidden behind the same generic type.
Check: When is defining a custom exception better than raising a standard exception type?
Section 12
Input validation is where exception handling meets user interaction, and it deserves a dedicated pattern. When a program asks a user for a number, a date, or a choice, it should expect invalid input, and it should respond by explaining the problem and asking again, rather than crashing or accepting a wrong value. The usual pattern is a loop that tries to convert and validate the input, catches the relevant exceptions, prints a helpful message, and repeats until valid input is given. That loop turns a potential crash into a conversation, where the program guides the user toward correct input.
Validation has two layers: format and meaning. Format validation checks whether the input can be parsed at all, such as whether a string can become an integer. Meaning validation checks whether the parsed value makes sense in context, such as whether an age is non-negative, or whether a choice is one of the allowed options. Both layers matter, and both should produce specific messages: a format error should say the input was not a number, while a meaning error should say the number was out of range. Vague messages, such as invalid input, force the user to guess what was wrong, which is frustrating and unhelpful.
A validation loop also needs an escape hatch, especially for programs that run unattended or that read from a file rather than a terminal. A loop that asks forever, with no way to quit, can hang a program if input is piped from a file that contains only invalid lines. Allowing a maximum number of attempts, or recognising a quit command, or treating end-of-input as a signal to stop, prevents that hang. The escape hatch should be documented in the prompt, so users know how to leave the loop intentionally, rather than discovering it by accident.
Finally, validation should happen as close as possible to where input enters the program, so that the rest of the program can assume valid data. That assumption simplifies the main logic, because it does not need to re-check every value at every step. A program with a clear validation boundary, where data is checked on entry and trusted thereafter, is easier to reason about than one where validation is scattered, and where every function must defend itself against bad data that should have been caught earlier.
while True:
text = input("Enter a positive number: ")
try:
value = int(text)
if value <= 0:
raise ValueError("must be positive")
break
except ValueError as err:
print("Try again:", err)
Validation loops also teach a user-interface lesson: the program should never blame the user for invalid input, but should instead explain what was expected in neutral, helpful language. A message such as Please enter a whole number is kinder and clearer than Invalid input, and it increases the chance that the next attempt will succeed. Tone in error messages matters, because users who feel blamed are less likely to try again carefully.
Check: Why should a program that asks for user input use a loop that retries on invalid input, rather than crashing?
Section 13
Files and exceptions are closely linked, because file operations are among the most failure-prone parts of a program. A file may be missing, a folder may not exist, permission may be denied, or the disk may be full, and each of those failures raises a distinct exception. Handling file exceptions well means distinguishing those cases, giving specific advice for each, and ensuring that resources are cleaned up even when an operation fails. The with statement, introduced in the previous chapter, is the primary cleanup tool, and it works seamlessly with exception handling, closing files even when an exception interrupts the block.
A common file-handling pattern is to try to open and read a file, catch FileNotFoundError to report a missing file, catch PermissionError to report a permission problem, and let other exceptions propagate, because they are unexpected. That pattern gives the user actionable information for the common cases, without hiding surprises. It also avoids the mistake of catching a broad exception and treating every file problem as a missing file, which would mislead a user who actually has a permission problem and needs a different fix.
Writing files has its own failure modes. A write may fail because the disk is full, because the folder is read-only, or because the file is locked by another program. Those failures often occur partway through writing, leaving a partial file, which is why the temporary-file-and-replace pattern from the previous chapter is valuable: a failed write leaves the original intact, and only a successful write replaces it. Combining that pattern with exception handling, where the temporary file is cleaned up on failure, gives a robust write operation that protects data even when things go wrong.
Another file pattern is to handle encoding errors explicitly, rather than letting them crash a program that is processing many files. When reading a batch of files, one file with a bad encoding should not stop the whole batch, if the program can report that file and continue with the rest. That decision, to continue or to stop, depends on the program's purpose: a converter that must process every file should stop, while a summariser that can tolerate a missing file might continue. Making that decision explicitly, and documenting it, is part of designing a file program's error behaviour.
try:
with open("notes.txt", "r", encoding="utf-8") as f:
text = f.read()
except FileNotFoundError:
print("notes.txt is missing.")
except PermissionError:
print("Cannot read notes.txt: permission denied.")
File exception handling also benefits from a consistent style across a program: if one part reports missing files in a certain way, other parts should do the same, so users learn what to expect. Inconsistent messages, where one missing file is reported as an error and another as a warning, confuse users about severity. Consistency in error reporting is a form of usability, and it is achieved by following the same patterns, and the same wording, wherever the same failure occurs.
Check: Why should a file program distinguish FileNotFoundError from PermissionError in its messages?
Section 14
When a program calls other functions, exceptions can travel through several layers before they are handled, and that propagation is a feature, not a flaw. A low-level function that encounters a problem raises an exception, and each caller up the chain either handles it, cleans up and re-raises it, or lets it pass through unchanged. That chain allows the handling to happen where the most context is available, such as at the user interface, where a message can be phrased in terms of what the user was trying to do, rather than in terms of a low-level detail they do not care about.
Propagation also means that a function's documentation should mention the exceptions it can raise, either directly or through functions it calls, when those exceptions are part of its contract. A caller cannot write a correct handler for an exception it does not know about, so undocumented exceptions lead to handlers that are either too broad, catching everything in desperation, or missing, allowing a program to crash on a failure it could have handled. Documenting exceptions is therefore part of designing a function's interface, not an optional extra for thoroughness.
A related concept is the idea of an exception boundary: a place in the program where exceptions are caught and converted into a different form, such as a user-facing message, a log entry, or a return value. Boundaries often sit at the edges of a program, where it meets the user, a file, or another system, because those are the places where a failure needs to be translated from a technical detail into an action or a message. Inside the program, exceptions can propagate freely, because the code there shares a common understanding; at the boundary, they must be translated, because the outside world does not share that understanding.
You should also be aware that propagation can be stopped accidentally by a handler that catches too broadly. A function that catches every exception, logs it, and returns a default, will prevent higher layers from knowing that a failure occurred, which may cause them to proceed with wrong data. That is why broad handlers should be used sparingly, and only at boundaries where a default or a message is genuinely the right response. Letting exceptions propagate is often the most honest choice, because it preserves information for the layer that can actually decide what to do.
def load(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
try:
print(load("missing.txt"))
except FileNotFoundError:
print("Could not load the requested file.")
Propagation also affects testing, because a test for a low-level function should check that it raises the expected exception, rather than checking that it handles it. Handling is the caller's job, and testing the raiser separately from the handler keeps each test focused. That separation makes tests easier to write and to understand, because each test checks one layer's responsibility, not the combined behaviour of several layers at once.
Check: Why is it often better to handle a file exception at a higher level, where the user's goal is known, than at the lowest level?
Section 15
Resource cleanup is a central reason for finally and with, and it deserves a closer look. A resource is anything a program acquires and must release, such as an open file, a network connection, or a lock. If a program acquires a resource and then fails before releasing it, the resource may remain held, causing later operations to fail, or the system to run out of resources. Cleanup code ensures release happens even on failure, and it should be written to be reliable, simple, and independent of the operation that failed, because it runs in an already exceptional situation.
The with statement is Python's preferred cleanup mechanism, because it ties acquisition and release together in a single, readable construct. When you write with open(...) as f:, you are saying that the file is acquired at the start of the block and released at the end, no matter how the block ends. That guarantee is stronger and clearer than manual closing, which requires the programmer to remember to close in every path, including error paths. For any resource that supports with, using it is almost always the right choice, and writing your own cleanup with try and finally is reserved for resources that do not support it.
Custom context managers, covered in a later chapter, extend this idea to your own resources, allowing you to define acquisition and release behaviour once, and then use with everywhere. That extension is powerful, because it turns a cleanup pattern into a reusable abstraction, and it reduces the chance of forgetting cleanup in a new place. For now, the lesson is to recognise the pattern: acquire, use, release, with release guaranteed, and to prefer constructs that guarantee it over manual code that merely hopes for it.
A final cleanup caution is to avoid doing complex work in cleanup code. Cleanup should release, not compute, because a failure during cleanup can obscure the original failure, making debugging harder. If cleanup itself can fail, it should handle its own failures gracefully, logging them rather than raising new exceptions that hide the original problem. Simple, defensive cleanup is a hallmark of robust code, because it acknowledges that cleanup runs at the worst possible moment, when something has already gone wrong.
f = open("notes.txt", "w", encoding="utf-8")
try:
f.write("hello\n")
finally:
f.close()
print("file closed even if writing failed")
Cleanup also applies to temporary files and folders created during an operation, which should be removed if the operation fails, so that a failed run does not leave clutter behind. A program that creates a temporary file, fails, and leaves it, will accumulate debris over repeated failures, which can confuse later runs. Cleaning up temporaries in a finally block, or using a context manager that does so automatically, keeps the filing system tidy even when operations fail.
Check: Why is the with statement usually preferable to manual closing in a finally block for files?
Section 16
Some errors are not exceptions in the usual sense, but warnings about potential problems. Python's warnings module lets a program or a library signal that something is deprecated, or that a usage is questionable, without stopping execution. Warnings are for situations where the program can continue, but the user or developer should know that a better approach exists, or that a future version may change behaviour. They are a gentler signal than an exception, appropriate when the current behaviour is still correct, but not ideal.
Warnings differ from exceptions in their default handling: they are printed, but they do not interrupt execution, and they may be suppressed or turned into errors by configuration. That flexibility makes them useful for library authors, who want to alert users to a change without breaking existing code immediately. For application programmers, warnings are less commonly used directly, but understanding them helps when you see a warning from a library you use: it is telling you that your code still works, but that you should update it before a future change removes the old behaviour.
You should not use warnings as a substitute for exceptions when an operation has actually failed. If a function cannot do what it was asked, it should raise, not warn and return a wrong result, because a warning can be missed, suppressed, or ignored, while an exception cannot be ignored without an explicit handler. Warnings are for advice, exceptions are for failures, and confusing the two leads to programs that appear to work while silently doing the wrong thing, which is worse than a clear failure.
When you encounter a warning in your own programs, treat it as a message to act on, not as noise to suppress. Read what it says, understand why it was issued, and update your code to the recommended approach, if one is given. Suppressing warnings without understanding them is a habit that hides real problems, and it can lead to a program that breaks suddenly when a deprecated feature is finally removed. Warnings are an early, friendly signal; heeding them early is far easier than fixing a breakage later.
import warnings
warnings.warn("This function is deprecated; use the new one instead.", UserWarning)
print("program continues")
Warnings also appear when a program uses a feature that is still supported but no longer recommended, such as an old function that has been superseded by a clearer one. In that case, the warning is an invitation to modernise, not a sign that the program is broken. Updating promptly, while the old feature still works well, is far less stressful and disruptive than updating under pressure after the feature has been removed and the program has stopped working. Keep this habit consistent in every project you build.
Check: What is the difference between a warning and an exception, in terms of whether a program continues?
Section 17
Debugging with exceptions is a practical skill that goes beyond reading tracebacks. One technique is to add temporary print statements or logging around a failing operation, to see the values involved just before the failure. That information often reveals the cause immediately: a variable that is None when it should be a string, a list that is empty when it should have items, or a filename that is misspelled. Temporary debugging output should be removed or converted to proper logging once the bug is fixed, so that it does not clutter the program's normal output, but while debugging, it is an invaluable tool.
Another technique is to isolate the failing code in a small, separate program that reproduces the error with minimal input. That isolation removes distractions, makes the traceback shorter, and often reveals that the bug is in a different place than you first thought. If the small program does not reproduce the error, then something in the larger program's context, such as a global state or a previous operation, is contributing, and that itself is a clue. Isolation is not just a debugging step; it is a way of thinking that separates the failing part from the rest of the system.
A third technique is to use the exception itself as a debugging aid, by catching it in a test or a debugging session and inspecting its attributes, its message, and the values that caused it. In an interactive session, you can reproduce the failure, catch the exception, and examine the state at that moment, which is often more informative than a static reading of the code. That interactive exploration is a skill that grows with practice, and it complements reading code, because it shows what the program actually did, not what you thought it would do.
Finally, remember that debugging is not about blame, but about understanding. An exception is not a judgement on your ability; it is information about a mismatch between expectation and reality. Approaching debugging with curiosity, rather than frustration, leads to faster fixes, because you are looking for the mismatch, not defending a assumption. Each bug you understand deeply teaches you something about Python, about your program, or about the problem domain, and that learning accumulates into expertise over time.
try:
result = 10 / 0
except ZeroDivisionError as err:
print("debugging:", type(err).__name__, err)
Debugging with exceptions also benefits from writing down what you tried, especially for intermittent failures that do not reproduce reliably. A short note recording the input, the error, and the outcome of each attempt helps you avoid repeating the same experiment, and it helps a colleague pick up where you left off. Debugging notes are a humble tool, but they turn a frustrating, repetitive process into a systematic search for the cause.
Check: Why is reproducing a failure in a small, isolated program often more effective than debugging the full program?
Section 18
Exception handling in loops requires care, because a failure in one iteration should not necessarily stop the whole loop, nor should it necessarily be ignored. The right behaviour depends on the loop's purpose. In a batch processing loop, where each item is independent, a failure on one item might be logged and the loop might continue with the next item, so that one bad item does not prevent the rest from being processed. In a loop where items depend on each other, a failure might mean the whole operation should stop, because later items cannot be processed correctly without the earlier ones. Deciding which behaviour is correct is part of designing the loop.
A common pattern for batch loops is to put the try inside the loop, so that each iteration has its own handler, and a failure in one iteration does not affect the next. Putting the try outside the loop, by contrast, means the first failure stops the loop entirely, because execution jumps out of the loop to the handler. Both patterns are valid, but they mean different things, and choosing between them should be deliberate, based on whether items are independent. A program that puts the try outside when it should be inside will stop early and lose work; one that puts it inside when it should be outside will continue with bad state and produce misleading results.
Another loop consideration is the state that a failed iteration leaves behind. If an iteration partially updates a shared structure before failing, the next iteration may see inconsistent state, unless the failed iteration cleans up or the loop resets state at the start of each iteration. Designing iterations to be self-contained, with their own local variables and cleanup, reduces that risk, because each iteration starts fresh. When iterations must share state, the handler should restore a consistent state before continuing, or the loop should stop, rather than proceeding with a corrupted state.
Finally, loops that retry an operation, such as retrying a failed file read or a failed conversion after asking again, need a limit, as discussed for input validation. A retry loop without a limit can run forever if the failure is persistent, such as a file that will never appear. A limit, combined with a clear message when the limit is reached, turns an infinite loop into a bounded attempt that fails gracefully, which is far better for both users and for programs that run unattended, where an infinite loop wastes resources indefinitely.
for text in ["10", "oops", "20"]:
try:
print(int(text))
except ValueError:
print(f"skipped invalid input: {text!r}")
Loop error handling also raises the question of reporting: if a batch loop skips several bad items, it should summarise how many were skipped and why, rather than silently discarding them. A summary at the end, such as Skipped 3 invalid lines, gives the user a chance to notice and fix the bad data, whereas silent skipping hides data loss. Transparency about skipped items is part of trustworthy batch processing, even when skipping is the correct behaviour.
Check: In a batch loop, why does placing try inside the loop give different behaviour from placing it outside the loop?
Section 19
When you write a library or a module for others to use, your exception choices become part of its public interface, and they deserve the same care as function names and return values. A library should raise exceptions that are meaningful to its users, document them, and avoid leaking internal implementation details through exception types that only make sense inside the library. For example, a library that reads a configuration file should not force its users to catch a low-level file exception if a higher-level configuration exception would be clearer; it should translate, using chaining, so that users deal with the library's concepts, not its internals.
A library should also avoid catching exceptions that it does not understand, because swallowing a user's error, or a bug in the user's code, makes the library harder to debug. If a library function calls user-supplied code, such as a callback, and that code raises an exception, the library should generally let it propagate, rather than catching it and continuing as if nothing happened, unless the library's contract explicitly says it will handle callback failures. Letting user errors propagate respects the user's ability to handle their own failures, and it preserves the traceback that they need to debug.
Stability of exception types is another library concern. Changing the type of exception a function raises, or adding a new exception that existing callers do not catch, can break code that depends on the library, even if the function's normal behaviour is unchanged. Library authors therefore treat exception types as part of backward compatibility, changing them only with a clear reason and, ideally, a deprecation path. For your own small libraries, the lesson is simpler: choose exception types thoughtfully at first, document them, and change them only when necessary, with an awareness of who might be catching them.
Finally, a library's exceptions should carry useful information, not just a type. A message that names the operation, the value, and the reason, and attributes that give structured access to those details, help users diagnose problems without reading the library's source code. That helpfulness is a quality that distinguishes a well-designed library from a merely functional one, because users judge a library as much by how it fails as by how it succeeds. A library that fails clearly is a library that users trust.
class ConfigError(Exception):
pass
def load_config(text):
if "name" not in text:
raise ConfigError("config must contain a name")
return text
Library exception design also includes the choice of a base exception for the library, from which all its specific exceptions inherit. That base type lets users catch any library error with a single handler, while still allowing specific handlers for specific failures. It is a pattern used by many real libraries, and it balances the need for precision with the need for a simple, catch-all option for users who do not need to distinguish every failure.
Check: Why should a library translate a low-level file exception into a higher-level exception that matches its own domain?
Section 20
This chapter's closing theme is judgement: knowing when to handle, when to raise, when to let propagate, and when to stop. There is no single rule that covers every case, because error handling is about trade-offs between robustness, clarity, and honesty. A program that handles everything, hiding every failure behind a default, may appear robust, but it is dishonest, because it pretends to succeed when it has not. A program that handles nothing, crashing on every surprise, is honest, but it is fragile, because it cannot cope with ordinary, expected failures. Good error handling lives between those extremes, handling the expected, propagating the unexpected, and failing clearly when recovery is not possible.
A practical way to develop judgement is to ask, for each operation that might fail, three questions: Is this failure expected in normal use? Can this code respond meaningfully, by retrying, using a default, or asking again? And will the caller or user understand the response? If the failure is expected and the code can respond meaningfully, handle it. If the failure is unexpected, or the code cannot respond meaningfully, let it propagate to a place that can, or let it stop the program with a clear traceback. If the response would confuse the user, reconsider whether handling is really the right choice, or whether a clearer failure is better.
Testing error handling is as important as testing normal behaviour. A program's handlers should be exercised with the failures they are meant to handle, by providing missing files, invalid input, and other edge cases, and checking that the program responds as intended. Untested handlers are a risk, because they may contain bugs of their own, such as referencing a variable that was never assigned, or printing a message that is misleading. Testing handlers turns them from hopeful code into verified behaviour, and it gives you confidence that the program will behave well when it meets real failures, not just in theory.
As you move on to classes and other topics, keep error handling in mind as a thread that runs through all of them. Classes will define their own exceptions, functions will raise for invalid arguments, and programs will handle failures at boundaries. The skills in this chapter, reading tracebacks, choosing handler scope, raising clearly, and cleaning up reliably, will recur in every later chapter, and they will become more natural with practice. Error handling is not a separate topic to be learned and forgotten; it is a habit to be carried forward.
try:
value = int("42")
except ValueError:
value = 0
print("value is", value)
Judgement in error handling also improves with review: reading other people's error handling, in libraries and in example programs, shows you patterns you might not have invented yourself, and it reveals habits to avoid, such as empty except blocks. Review is not about copying blindly, but about building a repertoire of approaches, so that when you face a new failure mode, you have several well-understood options to choose from, rather than a single habit applied everywhere.
Check: What three questions can help you decide whether to handle an exception where it occurs or let it propagate?
Section 21
Exceptions in data conversion are among the most frequent in beginner programs, because data from files, users, and other sources arrives as text and must be converted to numbers, dates, or other types. Each conversion is a potential failure point, and handling it well means distinguishing a format problem, where the text cannot be parsed at all, from a range problem, where the text parses but the value is out of bounds. A conversion function that reports both kinds clearly helps the caller decide whether to ask again, use a default, or stop, rather than guessing from a generic error.
A useful pattern for conversion is a small helper function that tries the conversion, catches the relevant exception, and returns either a value or a clear failure signal, such as None or a custom result. That helper concentrates conversion logic in one place, so that the main program does not repeat try blocks everywhere. It also makes the conversion's behaviour explicit: callers can see, from the helper's name and documentation, whether a failure returns None, raises, or uses a default, and they can code accordingly. Concentrating a repeated pattern into a helper is a general programming skill, and conversion is a good place to practise it.
Conversion helpers should not hide too much, however. A helper that returns None for every failure, without distinguishing format from range, or without logging, can make debugging harder, because the caller cannot tell why conversion failed. A helper that raises a custom exception with the original value and the reason preserves that information, while still concentrating the logic. The choice between returning a failure signal and raising depends on whether failure is ordinary, in which case a signal is natural, or exceptional, in which case raising is natural. That distinction, ordinary versus exceptional, recurs throughout error handling.
Batch conversion, where many values are converted at once, adds the question of partial success. If one value in a list fails, should the whole batch fail, or should the good values be kept and the bad ones reported? The answer depends on the program: a configuration loader might fail the whole load on one bad value, because a partial configuration is misleading, while a data summariser might keep the good values and report the bad ones, because a partial summary is still useful. Making that decision explicitly, and documenting it, is part of designing a conversion routine, not an afterthought to be decided by accident.
def to_int(text):
try:
return int(text)
except ValueError:
return None
print(to_int("42"), to_int("oops"))
Conversion also matters when data comes from a file with inconsistent formatting, such as numbers written with commas, spaces, or different decimal marks. A conversion helper that strips whitespace and handles a known variation, while rejecting unknown ones, is more useful than one that accepts only a perfect format. That tolerance, applied carefully and documented, makes a program work with real files, which are rarely as clean as textbook examples, without accepting nonsense.
Check: Why should a conversion helper distinguish between a format failure and a range failure in its reporting?
Section 22
When a program processes a file line by line, errors in individual lines should be handled in a way that matches the program's purpose. A program that loads a configuration file might stop on the first bad line, because a partial configuration could cause wrong behaviour. A program that summarises a large log file might skip bad lines, counting them and reporting them at the end, because the summary is still useful without them. The choice is not about which approach is universally better, but about which approach preserves the program's usefulness while not hiding data loss. Reporting skipped lines, with their numbers, is essential in the skipping case, so that the loss is visible.
Line-by-line error handling also benefits from collecting errors rather than stopping at the first one. A program that validates a file and reports all its problems at once is more helpful than one that reports one problem, waits for a fix, and then reports the next, because the user can fix everything in one pass. Collecting errors requires storing them in a list, continuing through the file, and then deciding, at the end, whether the collected errors mean the whole operation failed or merely produced warnings. That pattern, collect then decide, is a useful generalisation of handling, and it appears in validators, compilers, and data loaders.
Another consideration is the line number and the raw line content in error reports. A message that says Line 42: could not parse age is far more actionable than one that says could not parse age, because it lets the user jump directly to the problem. Including a snippet of the line, when it is not sensitive, helps further, because the user can see the offending value without reopening the file. Error messages are a user interface, and the more specific they are, the less work they leave for the user to do in diagnosing the problem.
Finally, line processing should be defensive about line endings, blank lines, and trailing whitespace, as discussed in the previous chapter, because those details are common sources of parse failures that are not really data errors. A program that treats a blank line as a parse failure, rather than skipping it, will report many false errors on files that humans consider perfectly normal. Distinguishing a truly malformed line from a harmless formatting variation is part of writing a tolerant, helpful parser, and it requires thinking about what the file's author intended, not just what the code expects.
for number, line in enumerate(["10", "oops", "20"], start=1):
try:
print(number, int(line))
except ValueError:
print(f"line {number}: could not parse {line!r}")
Line-by-line handling also teaches you to think about the file as a whole, not just as individual lines. A file with many bad lines may indicate a systematic problem, such as a wrong format or a corrupted export, rather than a few typos, and a program that reports the proportion of bad lines helps the user recognise that pattern. A single bad line is a typo; a third of the file being bad is a format mismatch, and the response should differ accordingly.
Check: Why is collecting all validation errors in a file, rather than stopping at the first, often more helpful to the user?
Section 23
Retrying an operation is a legitimate response to a transient failure, such as a file that is temporarily locked, or a resource that is briefly unavailable. A retry loop tries the operation, catches the transient exception, waits a short time, and tries again, up to a maximum number of attempts. That pattern turns a momentary problem into a success, without user intervention, which is valuable for programs that run unattended. Retrying is not appropriate for permanent failures, such as a missing file that will not appear, or invalid input that will not become valid on its own, because retrying those only wastes time and delays a clear failure message.
Distinguishing transient from permanent failures is therefore the key judgement in retry design. A transient failure is one where the same operation, tried again later, might succeed, because the underlying condition may change. A permanent failure is one where the condition will not change without external action, such as creating a file or fixing input. Catching only the exceptions that indicate transience, and letting permanent failures propagate immediately, keeps retry loops from becoming infinite waits for a condition that will never improve. That distinction should be documented, so that future readers understand why certain exceptions are retried and others are not.
Retry loops also need backoff: waiting longer between successive attempts, rather than retrying immediately in a tight loop. Immediate retry can overwhelm a struggling resource, or waste processor time, while a short pause gives the resource time to recover. Backoff can be a fixed pause, or an increasing pause, and for learning programs, a simple fixed pause is enough to illustrate the idea. The important point is that retrying is not just repeating; it is repeating with patience, giving the system a chance to change between attempts.
Finally, a retry loop must report its outcome clearly. If it succeeds after retries, a log message noting the retries is helpful for diagnosing a flaky resource. If it exhausts its attempts, it should raise the last exception, or a clear summary, rather than silently giving up, so that the caller knows the operation failed after genuine effort, not after a single attempt. That reporting turns retry from a hidden mechanism into a visible, trustworthy behaviour, which is important for programs that depend on it in production.
import time
attempts = 0
while attempts < 3:
try:
print("operation succeeded")
break
except OSError:
attempts += 1
time.sleep(0.1)
Retry design also applies to user-facing operations, where retrying too aggressively can annoy a user who is waiting. A program that retries silently for a long time, without showing progress, feels hung, even if it will eventually succeed. Showing a brief message, such as Retrying, attempt 2 of 3, keeps the user informed and gives them a sense of control, even if they cannot speed up the operation itself.
Check: Why should a retry loop distinguish transient failures from permanent failures?
Section 24
Error messages are a form of communication, and writing them well is a skill worth practising deliberately. A good error message answers three questions: what went wrong, what was involved, and what can be done about it. A message such as Could not open notes.txt: file not found. Check that the file is in the current folder, or create it. answers all three, while a message such as Error answers none. The extra words cost little, but they save the user from guessing, searching, or giving up, and they make the program feel professionally made, even when it is a learning exercise.
Error messages should be specific about values, but careful about sensitive information. Including a filename, a line number, or an invalid value helps the user locate and fix the problem. Including a password, a secret key, or personal data in an error message, by contrast, can leak that information into logs, screenshots, and support messages, where it is hard to remove. A good rule is to include the information needed to diagnose the problem, and nothing more, especially nothing that would be harmful if the message were shared. That restraint is part of defensive programming, applied to messages as well as to data.
Tone matters in error messages too. A message that blames the user, such as You typed it wrong, is less helpful than one that states the expectation neutrally, such as Expected a whole number, got 'abc'. Neutral, factual messages keep the focus on fixing the problem, rather than on fault, and they are more likely to be read carefully. Users who feel blamed may ignore messages, or may avoid reporting problems, which makes the program harder to improve. Writing messages as if you are helping a colleague, not scolding a stranger, is a small habit with a large effect on usability.
Finally, error messages should be consistent within a program. If one part says file not found and another says missing file, users must learn two phrases for the same problem. Consistent wording, consistent structure, and consistent placement of details, such as putting the filename first, make messages easier to scan and easier to search for in documentation. Consistency is not about pedantry; it is about reducing the cognitive load on someone who is already dealing with a failure, and who needs clarity most at that moment.
try:
open("missing.txt", "r", encoding="utf-8")
except FileNotFoundError:
print("Could not open missing.txt: file not found.")
Error messages also need to work when they are translated or read by people with different levels of technical knowledge. A message that uses jargon, such as a raw exception type name, may be clear to a developer but confusing to a casual user. Pairing a plain-language summary with the technical detail, for example in parentheses or in a log, serves both audiences, without forcing either to guess what the other half of the message means.
Check: What three questions should a good error message answer for the user?
Section 25
Some exceptions are raised by Python itself for programming mistakes, and learning to recognise them helps you fix bugs faster. NameError means a name was used that has not been defined, often because of a typo or because a variable was defined in a different scope. AttributeError means an object does not have the attribute you tried to access, often because the object is of a different type than you expected. IndexError and KeyError, already discussed, mean a missing position or key. TypeError means an operation was applied to the wrong type, and ValueError means the right type but a wrong value. Each of these points to a different kind of mistake, and recognising the kind narrows your search.
A useful debugging habit is to reproduce the error with a minimal example, then change one thing at a time until the error disappears, noting which change fixed it. That process, sometimes called bisection, is more reliable than guessing, because it isolates the cause. If changing a variable's type fixes a TypeError, then the type was the problem; if changing a value fixes a ValueError, then the value was the problem. That disciplined approach, changing one variable at a time, prevents the confusion that comes from changing several things at once and not knowing which one mattered.
Another habit is to check your assumptions about types and values by printing them, or by inspecting them in an interactive session, just before the failing line. Many bugs are assumptions that turned out to be false: a variable you thought was a list is actually a string, a function you thought returned a number returned None, a dictionary you thought contained a key does not. Printing the type and value, with type() and a plain print, often reveals the false assumption immediately, and it is a faster route to the fix than re-reading the whole program in search of a logic error that may not exist.
Finally, remember that these builtin exceptions are not punishments; they are Python's way of telling you, precisely, what it could not do and why. A program that raises TypeError is not broken in a mysterious way; it is telling you that an operation and a type did not match, and that information is the starting point for a fix. Treating exceptions as messages from Python, rather than as failures of your ability, makes debugging less stressful and more effective, because you are listening to the program, not fighting it.
values = [1, 2, 3]
try:
print(values[10])
except IndexError as err:
print("index problem:", err)
Builtin exceptions also become easier to recognise when you deliberately trigger each one in a small experiment and read its message. That experiment, done once in a safe setting, builds a mental catalogue that you will draw on for years, because the same types recur in every program you write. It is a small investment of time that pays off every time you debug, by turning an unfamiliar error into a familiar pattern.
Check: How can printing a variable's type just before a failing line help you diagnose a TypeError?
Section 26
When an exception occurs inside a function, the caller may need to know not just that it failed, but what the function was trying to do. Wrapping a low-level exception in a higher-level one, with chaining, provides that context, as discussed earlier, but it also requires care about what information to preserve. The new exception's message should describe the higher-level operation, such as loading a configuration, while the chained original preserves the low-level detail, such as the missing file. Together, they give a complete story: what the program was trying to achieve, and why it could not, which is exactly what a debugger or a user needs.
Chaining should not be used to hide a bug. If a function catches an exception caused by its own logic error, and re-raises it as a different type, it may obscure the original cause and make debugging harder. Chaining is for translation, where a low-level failure genuinely means something different at a higher level, not for disguising a mistake. A good test is whether the new exception's message, on its own, would help someone understand the problem, without needing to know the low-level detail. If it would not, then chaining may be adding confusion rather than clarity, and letting the original propagate might be better.
Another form of translation is to catch an exception and return a result that indicates failure, such as a status object or a None value, rather than raising at all. That translation is appropriate when failure is an ordinary outcome that the caller expects to handle as data, such as a search that finds nothing, or a lookup that misses. It is not appropriate when failure is exceptional and the caller might forget to check the return value, because an unchecked None can cause a later, more confusing error far from the original failure. The choice between raising and returning a failure signal depends on how likely the caller is to check, and on how serious an unchecked failure would be.
In practice, many programs use both forms: raising for exceptional failures, and returning a signal for ordinary, expected absences. A function that looks up a user by name might return None if the user does not exist, because absence is ordinary, while raising if the database file cannot be read, because that is exceptional. That distinction, ordinary absence versus exceptional failure, is a useful guide, and it helps callers know when they must handle an exception and when they can simply check a return value.
try:
with open("config.txt", "r", encoding="utf-8") as f:
config = f.read()
except FileNotFoundError as err:
raise RuntimeError("Could not load configuration") from err
Translation of exceptions also requires attention to the traceback that a user or developer will see, because a chained exception produces a longer traceback that can be harder to read if it is not understood. Explaining, in documentation or in a comment, why a translation exists, helps readers navigate that longer traceback, by telling them which part is the cause and which part is the higher-level description. Documentation of translation is part of making it helpful rather than confusing.
Check: When is translating a low-level exception into a higher-level one helpful, and when might it hide useful detail?
Section 27
Testing exception handling means writing tests that deliberately trigger failures and check the response. A test might call a function with invalid input and assert that it raises ValueError, or it might call a file function with a missing file and assert that it reports the problem gracefully. Those tests are as important as tests for successful behaviour, because they verify that the program's safety nets actually work, rather than merely existing in the code. An untested handler may look correct but fail in practice, for example by referencing a variable that is not defined in the handler's scope, which only becomes visible when the handler actually runs.
A testing framework, or even simple try blocks in a test script, can check that an exception is raised, by catching it and noting that it occurred, or by asserting that it did not occur when it should not. The key is to test both sides: that valid input does not raise, and that invalid input does raise the expected type, with a message that contains the relevant detail. Testing only the happy path leaves the error path unverified, which is risky, because error paths are often less exercised in normal use, and therefore more likely to contain bugs that go unnoticed until a real failure occurs.
Edge cases for exception tests include empty input, extreme values, missing files, and permission problems, where they can be simulated safely. Each edge case should have a test, because each represents a real situation that a user might encounter, and because testing them is cheap compared with the cost of a program failing in front of a user. A test suite that covers edges gives confidence not just that the program works, but that it fails well, which is a different and equally important kind of confidence.
Finally, tests for exceptions should be readable, stating clearly what failure is being tested and what response is expected. A test named test_missing_file_reports_clearly communicates its purpose better than one named test_1, and it helps future maintainers understand what behaviour is being protected. When a test fails after a change, its name should hint at what broke, so that the fix can be targeted. Readable tests are part of readable error handling, because they document the program's failure behaviour in executable form.
def parse_age(text):
age = int(text)
if age < 0:
raise ValueError("age must not be negative")
return age
try:
parse_age("-3")
except ValueError as err:
print("correctly raised:", err)
Exception tests also help when a program is refactored, because they protect the failure behaviour that users may depend on, even when the implementation changes. A refactoring that changes a success path is often caught by normal tests, but a refactoring that changes a failure path, such as raising a different type, may only be caught by exception tests. Those tests are therefore a safety net for the parts of a program that are least visible in normal use.
Check: Why is it important to test that a function raises the expected exception for invalid input, not just that it works for valid input?
Section 28
A program's overall error strategy should be designed, not improvised one handler at a time. At the top level, a program might have a handler that catches unexpected exceptions, logs them, prints a friendly message, and exits cleanly, rather than showing a raw traceback to a user who cannot act on it. At lower levels, handlers deal with expected failures, such as missing files or invalid input, with specific responses. That layered strategy, specific handlers below and a general safety net above, gives both precision for known failures and grace for unknown ones, without letting the general handler hide the specific ones.
The top-level safety net should log the full traceback, even if it shows the user only a friendly message, because the log is what a developer needs to fix the problem. A program that prints a friendly message and discards the traceback makes debugging impossible, because the details are lost. A program that prints only a traceback, by contrast, overwhelms a user who cannot fix the underlying bug. The combination, friendly message for the user and full log for the developer, serves both audiences, and it is a hallmark of a well-designed program's error strategy.
Designing an error strategy also means deciding what a program should do when it cannot recover. Should it exit, retry, ask the user, or continue with a degraded result? Each option has trade-offs, and the right one depends on the program's purpose and its users. A data-processing program that cannot read its input should probably exit, rather than producing a partial result that might be mistaken for complete. An interactive program that cannot read one file might ask the user for another filename, rather than exiting, because the user is present to help. Those decisions should be made deliberately, and consistently, across the program.
Finally, an error strategy should be revisited as a program grows. A strategy that works for a small script, where a traceback is acceptable because the user is the developer, may not work for a tool used by others, where a friendly message and a log are needed. As the audience changes, the strategy should change with it, and that evolution should be a conscious decision, not an accident of adding handlers wherever a crash happened to occur. Reviewing error handling periodically, like reviewing any other design, keeps it aligned with the program's real use.
import sys
try:
raise RuntimeError("unexpected failure")
except RuntimeError as err:
print(f"Something went wrong: {err}", file=sys.stderr)
print("Please check the log and try again.")
An error strategy also needs to consider programs that are run by other programs, where a friendly message alone is not enough, because the calling program needs a machine-readable signal, such as an exit code, to know that a failure occurred. A program that prints a friendly message but exits with a success code will mislead its caller into thinking it succeeded, which can cause downstream errors. Exit codes, like messages, are part of the interface, and they must reflect the outcome honestly.
Check: Why should a top-level error handler show a friendly message to the user but log the full traceback for the developer?
Section 29
Exceptions and logging work together, because a log is where the details of a failure can be preserved for later investigation, even when the user sees only a summary. A program that logs exceptions, with their type, message, and traceback, creates a record that can be examined after the fact, without requiring the failure to be reproduced. That record is especially valuable for programs that run unattended, where no one is present to see the traceback when it occurs, and where the log may be the only evidence of what went wrong. Logging exceptions is therefore not an optional extra for serious programs; it is how they remember their failures.
Logging should include context: what the program was doing, what input it was processing, and what the relevant values were, without including sensitive information. A log entry that says Failed to process file notes.txt at line 42 is far more useful than one that says Failed, because it narrows the investigation to a specific file and line. Context turns a log from a list of errors into a narrative of what happened, and that narrative is what makes debugging possible hours or days after the failure, when the original conditions are no longer visible.
A logging library, covered in a later chapter, provides levels such as INFO, WARNING, and ERROR, and lets a program control where logs go and how much detail they contain. For now, the principle is what matters: log failures at a level that reflects their severity, include enough context to diagnose them, and keep logs in a place where they can be found. A program that logs to a file that is overwritten on every run, for example, loses the history that makes logs useful, just as a log file that is never rotated grows without bound. Managing logs is part of managing errors, not a separate concern.
Finally, logging should not replace handling. A program that logs an exception and then continues as if nothing happened has not handled the failure; it has merely recorded it. Handling means deciding what to do next, whether to retry, to use a default, to ask again, or to stop, and logging is the record of that decision, not the decision itself. A good program both handles and logs: it responds appropriately in the moment, and it leaves a record so that the response, and the failure that prompted it, can be reviewed later.
import logging
logging.basicConfig(level=logging.ERROR)
try:
int("oops")
except ValueError:
logging.exception("Failed to convert input")
Logging exceptions also requires care about volume: a program that logs every minor failure in great detail can produce logs so large that the important failures are buried. Choosing a level for each log entry, and logging full tracebacks only for errors that need investigation, keeps logs readable. A log that is too noisy is almost as useless as a log that is too sparse, because in both cases the reader cannot find the signal among the noise.
Check: Why is logging an exception not a substitute for handling it, and what should handling add beyond the log?
Section 30
This section revisits the idea that not all failures are equal, and that a program's response should be proportional to the failure's seriousness. A missing optional configuration file might be handled by using defaults and logging a note, because the program can still run usefully without it. A missing required input file, by contrast, should stop the program with a clear message, because running without it would produce a misleading result. A corrupted file that might contain important data should be reported prominently, because silently ignoring it could lose information the user cares about. Proportional responses require judgement about what the program can still do meaningfully after a failure.
Proportionality also applies to the effort spent on handling. Spending a great deal of code on handling a failure that is extremely unlikely, and that would be harmless if it crashed, may not be worthwhile, while spending too little on a common failure, such as invalid user input, will frustrate users daily. The effort should match the likelihood and the impact: common, high-impact failures deserve careful handling and testing, while rare, low-impact failures may deserve only a clear traceback. That allocation of effort is a practical engineering decision, not a moral one, and it should be revisited as experience shows which failures actually occur.
A related idea is graceful degradation: a program that cannot do its full job, but can still do part of it usefully, should do that part and explain what is missing, rather than refusing to do anything. A report generator that cannot read one of several input files might generate a report from the files it could read, clearly noting the missing file, rather than producing nothing. That partial result, honestly labelled, is often more useful than no result, especially when the user can supply the missing file later and rerun. Graceful degradation is not about hiding failures; it is about being useful despite them, with transparency about the limitation.
Finally, proportional handling requires feedback from real use. A program's first error strategy is a guess, based on what failures seem likely, and real use will reveal which failures actually happen, which messages confuse users, and which handlers are never triggered. Listening to that feedback, through logs, user reports, and testing, and adjusting the strategy accordingly, is how error handling improves over time. A program that is never reviewed for its error behaviour will keep handling the failures its author imagined, rather than the ones its users actually meet.
from pathlib import Path
if Path("optional.txt").exists():
print("optional file found")
else:
print("using defaults; optional.txt not found")
Proportional responses also apply to the messages a program shows when it degrades: a partial result should not just be labelled, but should explain what is missing and how to obtain a complete result. A user who sees a partial report without that explanation may mistake it for a complete one, even if it is labelled partial in a footnote. Clear, prominent labelling, near the result itself, is what makes degradation honest rather than merely technically disclosed.
Check: What does graceful degradation mean, and why must a degraded result be clearly labelled as partial?
Section 31
Exceptions can be used to signal the end of an iteration or a search, but that use should be approached with caution, because it mixes control flow with error handling. For example, a search function might raise a custom NotFoundError when it cannot find an item, rather than returning None, forcing the caller to handle absence as an exception. That design makes absence unmissable, because a caller that forgets to handle it will see a crash, rather than a silent None that causes a later error. It also makes absence more expensive to handle, because exception handling is not designed as a routine return mechanism, and code that uses it for ordinary outcomes can be harder to follow.
The decision between raising and returning for absence depends on how ordinary absence is. If absence is expected and frequent, such as a user searching for a word that is often not present, returning None or an empty result is clearer and more efficient. If absence is rare and indicates a problem, such as a required configuration entry that should always exist, raising is clearer, because it signals that something is wrong, not merely that a search came up empty. That distinction, frequent and ordinary versus rare and problematic, is the same distinction that guides the choice between returning a failure signal and raising, discussed earlier, applied to the specific case of searching.
A related pattern is the use of a sentinel value, a special value that means absence, such as None or a dedicated object. Sentinels work well when the caller is expected to check for them, and when the type system or documentation makes the check obvious. They work poorly when the sentinel could be confused with a genuine value, such as using -1 to mean not found in a context where -1 could also be a valid result. Choosing a sentinel that cannot be mistaken for a real value, and documenting it clearly, prevents a class of bugs where absence is mistaken for a result.
In practice, Python's own libraries use both patterns: dictionary access with square brackets raises KeyError for a missing key, while the get method returns a default, often None, for the same situation. That dual approach lets callers choose: use brackets when absence is an error, use get when absence is ordinary. Providing both options, where appropriate, is a thoughtful design, because it acknowledges that the same operation can be an error in one context and an ordinary outcome in another, and it lets the caller decide which interpretation fits their situation.
def find(items, target):
for item in items:
if item == target:
return item
return None
print(find([1, 2, 3], 2), find([1, 2, 3], 9))
Search design also affects how a program composes with other code, because a function that raises for absence forces every caller to handle that case, while a function that returns None lets callers ignore absence, for better or worse. That forcing can be a benefit when absence must not be ignored, but it can also be a burden when absence is common and handling it everywhere becomes noisy. The choice therefore depends on the cost of ignoring absence, which varies by context.
Check: When should a search function raise an exception for a missing item, and when should it return None instead?
Section 32
Handling exceptions in a program that interacts with the outside world, such as reading files, requires thinking about partial state. If an operation fails halfway, what has already been changed, and what remains unchanged? A program that writes several files, and fails on the third, has left the first two changed and the rest unchanged, which may be an inconsistent state. Handling that situation might mean rolling back the changes, by restoring backups, or it might mean reporting the partial state clearly and leaving it to the user to resolve, depending on the program's purpose and the cost of rollback. Ignoring partial state, and pretending the operation either fully succeeded or fully failed, is the least helpful response.
One way to reduce partial-state problems is to validate everything before changing anything. A program that checks all its inputs, all its files, and all its preconditions before making any changes is less likely to fail halfway, because most failures will be caught in the validation phase, before any state has changed. That validate-then-act pattern, already mentioned for files, is a general strategy for operations that change multiple things: do all the checking first, then do all the changing, so that a failure during checking leaves state untouched, and a failure during changing is rarer, because the checks have already passed.
Another way is to make operations idempotent, meaning that running them twice has the same effect as running them once. An idempotent write, for example, writes the same final contents regardless of how many times it is run, so a failure and a retry do not create a duplicate or a corrupted result. Idempotence is a valuable property for operations that might be retried, because it makes retry safe. Designing for idempotence often means thinking in terms of final states, rather than increments: set a value, rather than adding to it, or write a complete file, rather than appending to a partial one, when the situation allows.
Finally, partial state should be reported honestly. If a batch operation processed ten items successfully and failed on the eleventh, the report should say so, naming the successful and failed items, rather than saying only that the operation failed. That honesty lets the user decide whether to retry the failed items, to accept the partial result, or to roll back, with full information. A program that hides partial success behind a generic failure message forces the user to investigate what actually happened, which is work the program could have saved them by reporting clearly in the first place.
processed = []
for item in [1, 2, 3]:
try:
processed.append(item * 2)
except Exception:
print("failed on", item)
break
print("processed so far:", processed)
Partial state also appears in simpler operations, such as updating a list while iterating over it, where a failure halfway through can leave the list half-updated. Designing updates to build a new list first, and then replace the old one only when the whole update succeeds, avoids that half-updated state, by making the change atomic at the level of the replacement. That build-then-replace pattern is a general way to turn a multi-step change into a single, safe step.
Check: Why is validating all inputs before changing any state a good way to reduce partial-state problems?
Section 33
The try statement can be nested, with one try inside another, and nesting is sometimes necessary when different operations need different handlers. For example, an outer try might handle file errors for a whole operation, while an inner try handles conversion errors for individual values, with different responses for each. Nesting makes the structure explicit: the inner handler deals with the inner operation, and the outer handler deals with the outer operation, and an exception that is not caught inside will propagate to the outer handler, if one matches. That layering mirrors the layering of the operations themselves, and it can make complex error handling clearer, when used sparingly.
Nesting can also become confusing, especially when several levels of try are combined with loops and conditions, making it hard to tell which handler will catch a given exception. A useful guideline is to keep nesting shallow, and to extract complex inner operations into functions with their own handlers, so that each function has at most one or two levels of handling. Extracting a function does not just reduce nesting; it gives the operation a name, which makes its error handling easier to understand and to test in isolation. A program with many shallow handlers is usually clearer than one with a few deeply nested ones.
Another consideration is the interaction between nested try blocks and finally. An inner finally runs before an outer handler, because cleanup for the inner operation happens as the exception propagates outward. That ordering is logical, but it can be surprising if you expect the outer handler to run first. Understanding that cleanup happens on the way out, at each level, helps you predict the order of messages and side effects when an exception propagates through nested blocks, and it helps you write cleanup code that does not depend on a handler having already run.
In practice, you will nest try blocks less often than you might expect, because many situations can be handled with a single try and several except blocks, or with a helper function that encapsulates one level of handling. Nesting is a tool for when the operations genuinely have different scopes and different responses, not a default structure for every program. Using it only when needed keeps error handling readable, which is important, because error handling code is often read under stress, when something has gone wrong, and clarity then matters more than ever.
try:
try:
value = int("oops")
except ValueError:
print("inner: bad number")
raise
except ValueError:
print("outer: conversion failed")
Nested handling also becomes clearer when each level is given a distinct responsibility in a comment, such as inner handles conversion, outer handles file access. A comment that names the responsibility helps a reader predict which handler will catch a given exception, without tracing through the nesting manually. That small documentation step is especially valuable in code that will be read under stress, when a failure has already occurred and clarity is at a premium.
Check: When is nesting try blocks appropriate, and when should you extract an inner operation into a function instead?
Section 34
Exceptions are objects, and like other objects, they can carry attributes beyond their message. A custom exception might store the filename that caused a failure, the value that was invalid, or the operation that was attempted, as attributes that a handler can inspect programmatically. That structured information is more useful than a message string alone, because a handler can make decisions based on it, such as retrying for one filename but not for another, or reporting the invalid value in a structured log. Designing exceptions with useful attributes is part of designing them as an interface, not just as a message carrier.
Standard exceptions also carry useful information, though it is less often accessed directly. A FileNotFoundError, for example, has attributes for the filename and the operating system error number, which a handler can use to give a more specific message, or to decide on a response. Most handlers will simply use the message, and that is fine, but knowing that attributes exist means you can write a more precise handler when the situation calls for it, without parsing the message string, which is fragile and locale-dependent. Attributes are the programmatic interface; messages are the human interface, and a good exception provides both.
When you design a custom exception, think about what a handler would need to know to respond sensibly, and store exactly that, no more and no less. Storing too little forces handlers to guess or to parse messages, while storing too much, especially sensitive data, risks leaking information through logs or error reports. A balanced design stores the operation, the offending value, and perhaps a suggestion, but not secrets, passwords, or unrelated state. That balance reflects the same restraint that applies to error messages: include what is needed to diagnose and respond, and nothing that could cause harm if shared.
Finally, exception attributes should be documented, just as a function's parameters are, so that handlers know what is available. A docstring for a custom exception that lists its attributes, their meanings, and an example of handling, turns the exception from a mysterious type into a usable tool. Documentation of exceptions is often overlooked, because exceptions feel like an afterthought, but for a library or a shared module, it is essential, because handlers are written by people who did not write the raising code, and they need to know what they can rely on.
class BadValueError(ValueError):
def __init__(self, value):
super().__init__(f"bad value: {value!r}")
self.value = value
try:
raise BadValueError(999)
except BadValueError as err:
print("caught value:", err.value)
Exception attributes also help when an exception crosses a boundary between systems, such as being logged in a structured format that can be searched and analysed. A log entry that includes the filename and value as separate fields can be filtered and counted, whereas a log entry that buries them in a sentence cannot be analysed without parsing text. Structured attributes therefore support not just handling, but also monitoring and analysis of failures over time.
Check: Why is storing structured information as exception attributes better than burying it only in the message string?
Section 35
A program that catches an exception should consider whether it has enough information to respond, or whether it should ask for more. Sometimes, the handler can recover by using a default, by asking the user again, or by trying an alternative approach, and those recoveries are the most satisfying forms of handling, because the program continues usefully despite the failure. Other times, the handler cannot recover, because the failure means an essential input is missing, or an essential operation cannot be performed, and the honest response is to stop with a clear message. Distinguishing recoverable from unrecoverable failures is a key judgement, and it depends on the program's purpose, not just on the exception type.
Recoverable failures are often those where a reasonable alternative exists. A missing optional file can be replaced by defaults, a single bad line in a large file can be skipped, a mistyped number can be re-entered. Unrecoverable failures are those where no alternative preserves the program's correctness: a missing required file, a corrupted essential database, a permission problem that prevents writing output. In those cases, attempting to continue would produce a misleading result, or would fail again later in a more confusing way, so stopping early, with a clear explanation, is the kinder and more honest choice. Early stopping, sometimes called failing fast, prevents wasted work and misleading output.
A handler that tries to recover should also verify that the recovery actually worked, rather than assuming it did. A program that falls back to defaults should check that the defaults are valid, and a program that retries should check that the retry succeeded, before proceeding as if the original operation had succeeded. That verification is part of handling, not an extra step, because a recovery that silently fails is worse than no recovery, as it creates a false sense of success. A handler that reports its recovery, such as Using defaults because the config file was missing, also helps the user understand why the program behaved as it did, rather than wondering why its settings were ignored.
Finally, recovery should be tested, just as failure should be tested. A recovery path that is never exercised in testing may contain its own bugs, which will only appear when a real failure triggers it, at the worst possible moment. Testing recovery means deliberately causing the failure in a test, and checking that the program recovers as intended, with the expected defaults, message, or retry behaviour. That testing turns recovery from a hope into a verified behaviour, and it gives confidence that the program will cope when it meets real failures, not just in theory.
from pathlib import Path
if Path("config.txt").exists():
print("config loaded")
else:
print("config missing; using defaults")
Recovery also includes the case where a program can continue with reduced functionality, such as a tool that cannot reach an optional service but can still work with local data. That reduced mode should be communicated clearly, so the user knows which features are unavailable and why, rather than discovering the limitation by trial and error. Transparency about reduced functionality preserves trust, because the user can decide whether the reduced result is sufficient for their purpose.
Check: What distinguishes a recoverable failure, where a program can continue usefully, from an unrecoverable one?
Section 36
Error handling in interactive programs has a special character, because the user is present and can help. An interactive program that encounters invalid input can explain the problem, suggest a correction, and ask again, turning a failure into a short dialogue. That dialogue should be patient and specific, not repetitive and vague: each retry should give new information, or at least remind the user of the expected format, rather than simply repeating Invalid input until the user guesses correctly. A program that helps the user succeed is more usable than one that merely rejects failure, even if both eventually obtain valid input.
Interactive programs also need to handle interruption gracefully. A user may press the interrupt key to stop a program, which raises KeyboardInterrupt in Python, and a program that catches that exception and exits cleanly, perhaps saving work first, is more polite than one that leaves files in an inconsistent state. Catching KeyboardInterrupt should be done carefully, and only where cleanup or saving is needed, because a program that ignores interruption entirely frustrates users who want to stop it. The goal is not to prevent stopping, but to stop cleanly, preserving as much useful work as possible.
Another interactive consideration is the difference between an error and a help request. A user who types something unexpected may be confused, not mistaken, and a program that responds to confusion with a helpful explanation, or with a suggestion to type help, is more useful than one that responds with a bare error. Designing error messages as teaching moments, where appropriate, turns failures into learning opportunities, and it reduces the number of times the same failure recurs. That teaching tone should be concise, however, because a user who already understands the format does not need a tutorial on every retry.
Finally, interactive programs should remember that the user is a person, with limited patience and attention. Error handling that requires many retries, or that asks the same question repeatedly without progress, will drive users away, even if it is technically correct. Keeping retries bounded, giving clear progress, and offering an escape, such as a quit command, respects the user's time and attention, and it makes the program feel like a helpful tool rather than an obstacle. Usability and error handling are not separate concerns in interactive programs; they are the same concern, viewed from the user's side.
try:
name = input("Enter your name (or 'quit'): ")
print("Hello,", name)
except KeyboardInterrupt:
print("\nGoodbye.")
Interactive error handling also benefits from remembering the user's previous valid input, so that a single mistake does not force them to re-enter everything. A form that preserves correct fields while asking again only for the incorrect one is far less frustrating than one that clears all fields on any error. That preservation is a small design detail, but it shows respect for the user's effort, and it reduces the chance that a retry will introduce a new mistake in a previously correct field.
Check: Why should an interactive program handle KeyboardInterrupt by cleaning up and exiting, rather than ignoring it?
Section 37
Error handling in batch programs, which run without a user present, has a different character from interactive handling. A batch program cannot ask for help, so it must decide on its own whether to continue, to retry, or to stop, and it must record its decisions in a log, so that a human can review them later. That log becomes the program's way of communicating with its operator, and it should be detailed enough to reconstruct what happened, which items succeeded, which failed, and why. A batch program that fails silently, or that logs only a generic message, leaves its operator guessing, which is a failure of communication, not just of code.
Batch programs also need to consider the consequences of partial success, as discussed earlier, and to report it clearly in their final summary. A batch program that processes a thousand files, succeeds on nine hundred and ninety, and fails on ten, should end with a summary that says so, naming the failures, rather than exiting with a success code that implies everything worked. That summary, and the exit code that accompanies it, are the program's report to the system that called it, and they should reflect the outcome honestly, so that downstream steps can decide whether to proceed. Honest exit codes are part of honest error handling in batch contexts.
Another batch consideration is idempotence and re-runnability. A batch program that fails halfway should be safe to run again, without duplicating work or corrupting results, because re-running is the most common response to a batch failure. Designing for re-runs means using final-state writes, checking for already-processed items, or recording progress, so that a second run picks up where the first left off, or safely repeats it. A program that cannot be safely re-run forces its operator to clean up manually before retrying, which is error-prone and time-consuming, especially under pressure after a failure.
Finally, batch programs should be tested with failures, just as interactive programs should, but with a focus on logs and exit codes rather than on user messages. A test might run the program on a set of inputs that includes bad files, and check that the log contains the expected failures, that the summary counts are correct, and that the exit code indicates partial failure. Those tests verify the program's communication with its operator, which is its primary interface in a batch context, and they ensure that a failure in production will be visible, understandable, and actionable, rather than hidden.
results = {"ok": 0, "failed": 0}
for text in ["10", "oops"]:
try:
int(text)
results["ok"] += 1
except ValueError:
results["failed"] += 1
print(results)
Batch error handling also benefits from a dry-run mode, where a program reports what it would do, and what failures it would encounter, without actually changing anything. A dry run lets an operator preview a batch operation, fix its inputs, and then run it for real with greater confidence. That preview is especially valuable for destructive batch operations, where a mistake could affect many files, and where a dry run is a cheap insurance against a costly error.
Check: Why is an honest exit code and a final summary especially important for a batch program that runs without a user?
Section 38
When you encounter an exception you do not understand, a systematic approach helps more than guessing. First, read the traceback carefully, noting the exception type, the message, and the line in your code where it occurred. Second, reproduce the error with the smallest possible input, to confirm that you understand the trigger. Third, form a hypothesis about the cause, such as a wrong type, a missing key, or a bad file, and test it by inspecting the relevant values or by changing one thing and rerunning. Fourth, fix the cause, not just the symptom, and verify the fix by rerunning the original failing case and by running related tests. That systematic cycle, read, reproduce, hypothesise, fix, verify, is more reliable than trial-and-error changes.
A common pitfall in that cycle is fixing the symptom by catching the exception and ignoring it, without addressing the underlying cause. That approach may make the immediate error disappear, but it leaves the bug in place, where it will cause a different, possibly worse, problem later. A handler that swallows an exception caused by a bug is not a fix; it is a hiding place for the bug. The distinction between handling an expected failure and hiding a bug is crucial, and it depends on whether the failure is a legitimate, external condition, such as a missing file, or a mistake in your own logic, such as a wrong index. The former should be handled; the latter should be fixed.
Another pitfall is changing code based on a guess, without verifying that the guess was correct. If you change several things at once and the error disappears, you may not know which change fixed it, and you may have introduced a new problem while fixing the old one. Changing one thing at a time, and rerunning after each change, is slower in the moment, but faster overall, because it builds a clear understanding of cause and effect. That understanding also helps you explain the fix to others, and to avoid making the same mistake again, which is part of learning from debugging rather than merely surviving it.
Finally, when you are stuck, explaining the problem to someone else, or even to yourself in writing, often reveals the cause. The act of describing what you expected, what actually happened, and what you have already tried, forces you to organise your thinking, and the gap between expectation and reality often becomes visible in the description itself. That technique, sometimes called rubber-duck debugging, is not a joke; it is a recognised way of using language to clarify thought, and it works because many bugs are misunderstandings, not mysteries, and misunderstandings become visible when stated plainly.
data = [1, 2, 3]
try:
print(data[5])
except IndexError:
print("index 5 is out of range for a 3-item list")
Systematic debugging also includes checking whether an error is reproducible on another machine or with a different version of the data, because a failure that depends on environment, such as a missing file or a different setting, has a different cause from one that depends only on code. That environmental check prevents a long search through code for a bug that is actually a configuration difference, and it directs the fix to the right place, whether code or setup.
Check: Why is catching and ignoring an exception caused by a bug in your own code not a valid fix?
Section 39
Exceptions in Python are part of a larger philosophy about errors: it is better to ask for forgiveness than permission, in many cases, but it is also better to fail loudly than to fail silently. Those two ideas, often quoted together, capture a balance: try the operation, because checking first may be unreliable or wasteful, but if it fails, make the failure visible, with a clear exception, rather than hiding it behind a default that pretends success. That balance is not a rigid rule, but a mindset that favours action and honesty over caution and concealment, while still respecting the need for careful handling where recovery is possible.
Failing loudly does not mean crashing unpleasantly in front of a user. It means that a failure should be visible to someone who can act on it, whether that is a developer reading a log, a user seeing a clear message, or a calling program receiving a non-zero exit code. A failure that is visible can be fixed; a failure that is hidden will persist, causing wrong results, lost data, or confusion, until it is discovered by accident, often much later and at greater cost. The visibility of a failure is therefore a design property, not an accident of whether a traceback happened to be printed.
That philosophy also explains why Python has so many specific exception types, rather than a single generic error. Specific types make failures visible in a structured way, allowing handlers to distinguish them, logs to record them precisely, and debuggers to understand them quickly. A generic error, by contrast, hides the nature of the failure behind a single label, forcing everyone to parse messages to understand what happened. Specificity is a form of honesty, because it names the problem rather than obscuring it, and it is a form of helpfulness, because it gives the next person a starting point for a fix.
As you write more Python, you will internalise this philosophy, not as a slogan, but as a habit: try, handle what you can, propagate what you cannot, and always leave a clear record of what happened. That habit will make your programs more reliable, not because they fail less often, but because they fail better, with clearer messages, cleaner cleanup, and more honest reporting. Failing better is a realistic goal for any program, because failures are inevitable, while failing well is a choice that you make in how you write your handlers, your messages, and your logs.
try:
result = [1, 2, 3][10]
except IndexError:
print("That index does not exist; failing clearly instead of guessing.")
The philosophy of failing loudly also applies to tests: a test that passes despite a bug, because it does not check the relevant behaviour, is a silent failure in the test suite itself. Writing tests that would fail if the code were wrong, by checking specific values and specific exceptions, ensures that failures in the code become visible in the tests, rather than hidden behind vague assertions that pass for the wrong reasons.
Check: What does it mean to fail loudly, and why is a visible failure better than a hidden one?
Section 40
This chapter has introduced a toolkit for errors, and it is worth reviewing how the tools fit together. try and except handle expected failures, else separates success-only work, finally and with guarantee cleanup, raise signals failures, custom exceptions name domain-specific failures, and logging and messages communicate failures to users and developers. No single tool solves every problem; they work together, each addressing a different aspect of failure, from detection to recovery to communication. Seeing them as a toolkit, rather than as isolated features, helps you choose the right combination for each situation, rather than reaching for the same tool every time.
A good exercise for consolidating this chapter is to take a small program you wrote earlier, perhaps a file program from the previous chapter, and improve its error handling deliberately. Add handlers for missing files, invalid input, and malformed lines, with specific messages and line numbers. Add a top-level handler that logs unexpected failures. Test it with bad inputs, and check that it fails gracefully, with clear messages, rather than crashing with a bare traceback for ordinary problems. That exercise, applied to a program you already understand, teaches error handling in context, which is more effective than learning it in abstract examples alone.
As you do that exercise, notice how error handling changes the program's structure. Handlers add code, but they also clarify the program's assumptions, by stating what can go wrong and what will be done about it. A program with explicit handlers is easier to review, because its failure behaviour is visible, whereas a program without handlers leaves its failure behaviour implicit, to be discovered by accident. That visibility is a benefit, even though it costs lines, because it makes the program's contract with its users and callers explicit, and explicit contracts are easier to keep.
Later chapters will return to exceptions in new contexts: classes will raise for invalid state, iterators will use a specific exception to signal completion, and libraries will define their own exception hierarchies. The foundations in this chapter, reading tracebacks, choosing handler scope, raising clearly, and cleaning up reliably, will support all of those later uses, and they will become second nature with practice. Error handling is not a chapter to be completed and left behind; it is a practice to be carried into every program you write from now on.
try:
print(int("7"))
except ValueError:
print("not a number")
else:
print("conversion succeeded")
Reviewing the toolkit also reminds you that error handling is not just about code, but about communication with future readers, users, and maintainers. Each handler, message, and log entry is a message to someone else, explaining what went wrong and what was done about it. Writing those messages with the reader in mind, rather than as an afterthought, is what turns a technically correct handler into a genuinely helpful one.
Check: How do try, except, else, and finally work together as a toolkit for handling failures?
Section 41
One of the most instructive errors for beginners is the off-by-one error, which often appears as an IndexError or as a loop that runs one time too many or too few. Off-by-one errors arise from confusion about whether a range includes its endpoints, whether an index starts at zero or one, or whether a count is a position or a length. Handling them well starts with prevention: using Python's iteration and slicing, which handle boundaries correctly, rather than manual index arithmetic, which is prone to mistakes. When an IndexError does occur, it is often a sign to replace manual indexing with iteration, or to check the boundary condition carefully, rather than to add a handler that hides the error.
Another instructive pattern is the error caused by mutating a collection while iterating over it, which can skip items or cause unexpected behaviour without raising an exception at all. That silent wrongness is more dangerous than a clear exception, because it produces a result that looks plausible but is incorrect. The prevention is to iterate over a copy, or to build a new collection, rather than modifying the original during iteration, a habit that also makes the code's intention clearer. When a program produces a wrong result without an exception, the debugging process is harder, because there is no traceback to point to the problem, and the cause must be inferred from the output alone.
A third pattern is the error caused by a variable that is None when a value was expected, leading to an AttributeError or TypeError when the program tries to use it. That pattern often indicates a function that returned None to signal absence, and a caller that forgot to check for it, or a variable that was never assigned on some path. Handling it well means checking for None explicitly where absence is possible, or designing functions to raise rather than return None when absence is an error, as discussed earlier. The choice should be consistent, so that callers know whether to check for None or to catch an exception, rather than having to guess for each function.
These patterns share a lesson: many exceptions are symptoms of a design choice that could be improved, rather than isolated accidents to be handled in place. Replacing manual indexing with iteration, avoiding mutation during iteration, and making absence explicit are design improvements that prevent whole classes of errors, rather than handling them one by one. Error handling is important, but error prevention, through better design, is even more powerful, because a prevented error needs no handler, no message, and no recovery, and it cannot surprise a user.
items = [10, 20, 30]
for item in items:
print(item)
Off-by-one errors also appear in file and loop boundaries, such as reading one line too many, or stopping one iteration too early, and they are often revealed by testing with the smallest possible input. A list with one item, a file with one line, and a loop that should run once are excellent test cases, because they expose boundary confusion that larger inputs may hide. Testing boundaries deliberately is a habit that prevents many off-by-one errors from reaching users.
Check: Why is replacing manual index arithmetic with iteration often a better fix for an IndexError than adding a handler?
Section 42
When a program reads configuration or data from a file, it should validate the whole file before acting on it, where possible, rather than acting on each part as it is read and failing halfway. Whole-file validation means reading all records, checking each one, collecting any errors, and only then proceeding to use the data, if no errors were found, or reporting all errors, if any were found. That approach prevents partial action, where some records are used and others are not, and it gives the user a complete picture of what is wrong, so they can fix everything at once, rather than fixing one error at a time through repeated runs.
Whole-file validation is not always possible, especially for very large files that cannot be held in memory, or for streaming programs that must act as data arrives. In those cases, per-record handling, with skipping and reporting, is the practical alternative, as discussed for batch loops. The choice between whole-file and per-record validation depends on the file's size, the program's memory, and the cost of partial action. For small configuration files, whole-file validation is almost always the right choice, because the file is small, the cost of partial action is high, and the benefit of a complete error report is large. For large data files, per-record handling may be necessary, and its limitations should be acknowledged.
A validation function that returns a list of errors, rather than raising on the first one, is a useful building block for whole-file validation. That function can be tested independently, with files containing known errors, and its error list can be formatted into a clear report, with line numbers and messages for each problem. The main program can then decide, based on whether the list is empty, whether to proceed or to report and stop. That separation, validation as a function that returns errors, and action as a separate step that depends on validation, makes the program's logic clearer and its error behaviour easier to test.
Validation also interacts with defaults and with optional fields. A file that omits an optional field should not be reported as erroneous, if the program has a sensible default for that field, but a file that omits a required field should be reported, with a message naming the missing field. Distinguishing required from optional, and documenting that distinction, is part of validation design, and it prevents a validator that is too strict, rejecting valid files, or too lenient, accepting files that will cause problems later. A validator that matches the program's real requirements, no more and no less, is a validator that users trust.
errors = []
for line in ["Ada,34", "Bola"]:
if line.count(",") != 1:
errors.append(f"bad line: {line!r}")
print("errors:", errors)
Whole-file validation also makes it easier to give a user a single, complete report that they can work through systematically, rather than a series of separate reports that interrupt their workflow. That single report can be saved, shared, or checked off item by item, which is more practical than a process that stops, reports, and restarts repeatedly. The workflow benefit, as much as the technical correctness, is why whole-file validation is preferred for small files.
Check: Why is validating a whole small configuration file before acting on it better than acting on each record as it is read?
Section 43
Error handling should also consider the program's state after a handler runs. A handler that catches an exception and continues should leave the program in a consistent state, with variables assigned, structures intact, and resources released, so that subsequent code can proceed safely. A handler that continues with a variable unassigned, a file left open, or a list half-updated creates a second problem, often more confusing than the first, because the subsequent failure will not obviously be related to the original one. Ensuring consistency after handling is therefore part of handling itself, not a separate cleanup step to be remembered later.
One way to ensure consistency is to initialise variables before the try block, to values that are safe to use if the try fails, such as an empty list or a default value. That way, code after the handler can use the variable without checking whether the try succeeded, because it always has a defined, sensible value. Another way is to structure the code so that code after the handler only runs on success, by placing it in the else block, or by returning early from the handler, so that it does not run after a failure at all. Both patterns prevent the use of uninitialised or partially updated state, and both are clearer than a handler that leaves state ambiguous.
A related pattern is to use a handler to restore state explicitly, such as resetting a counter, clearing a partial result, or closing a file that was opened before the failure. That restoration should be part of the handler's job, and it should be tested, because a handler that restores incorrectly can be worse than one that does not restore at all, if it restores to a wrong state that looks correct. Testing handlers, as discussed earlier, should therefore check not just that the handler runs, but that the program's state after handling is the state that subsequent code expects, which is a stronger and more useful test.
Finally, consider the state of the user interface after handling. An interactive program that catches an error and continues should show the user where they are, what happened, and what they can do next, rather than silently returning to a prompt with no explanation. A batch program that catches an error and continues should log what happened and update its summary counts, so that the final report reflects the failure. In both cases, the program's external state, what the user or operator sees, should be consistent with its internal state, so that no one is misled about what actually happened.
result = []
try:
result = [int("oops")]
except ValueError:
result = []
print("result after handling:", result)
Consistent state after handling also matters for long-running programs, where a handler that leaves state inconsistent may not cause an immediate failure, but may cause a failure much later, when the inconsistent state is finally used. That delayed failure is hard to trace back to the original handler, because the connection between the two events is not obvious in a traceback. Ensuring consistency immediately, in the handler itself, prevents that delayed, confusing failure.
Check: Why should a handler ensure that variables have safe values before execution continues after a failure?
Section 44
A useful way to think about exceptions is as a form of communication between parts of a program that do not know each other directly. A low-level function does not know who called it, or what the caller was trying to achieve, but by raising an exception with a clear type and message, it communicates the nature of the failure to whoever is listening. A high-level function does not know the details of the low-level operation, but by catching the exception, it learns that something went wrong and can respond in terms of its own goal. That communication, through exceptions, allows parts of a program to be developed independently, because each part only needs to know the exception contract, not the other's implementation.
That communication breaks down when exceptions are vague, inconsistent, or undocumented. A low-level function that raises a generic exception for every failure gives its caller no information to act on, forcing the caller to guess or to catch everything and hope. A function that raises different types for the same failure on different paths confuses callers, who cannot write a reliable handler. A function that raises an undocumented exception surprises callers, who discover the failure only when their program crashes in production. Clear, consistent, documented exceptions are therefore a form of good communication, and they are as important to a program's design as clear function names and return values.
Communication through exceptions also has a direction: exceptions travel upward, from low-level code to high-level code, while normal return values travel in the opposite direction, from a function back to its immediate caller. That upward travel means that a handler at a high level can respond to failures from many different low-level operations with a single, coherent response, such as reporting that a task could not be completed, without needing to know which low-level step failed. That coherence is valuable for user-facing messages, which should be about the user's goal, not about internal steps, and it is a reason to let exceptions propagate to a level where the user's goal is known, rather than handling them at the lowest level with a technical message.
Finally, exceptions communicate not just failures, but also expectations. A function that raises for invalid input communicates that valid input is expected, and that the caller is responsible for providing it. A function that returns a default for invalid input communicates that invalid input is acceptable and will be handled internally. Those communications shape how callers use the function, and they should be deliberate, because callers will adapt to whichever signal they receive, and changing the signal later will break their code. Choosing an exception strategy is therefore choosing a communication strategy, and it should be done with the caller in mind.
def get_name(user):
if "name" not in user:
raise KeyError("user has no name")
return user["name"]
try:
print(get_name({}))
except KeyError as err:
print("problem:", err)
Communication through exceptions also fails when a program raises an exception in one part and catches it in another part that was not designed to handle it, because the handler may misinterpret the failure. Designing exception contracts carefully, and documenting which exceptions a function may raise, prevents handlers from misinterpreting failures they were not meant to handle.
Check: How do exceptions act as communication between a low-level function and a high-level caller that does not know its details?
Section 45
When you write code that will be read by others, your error handling will be judged by its clarity as much as by its correctness. A handler that is easy to understand, with a clear comment explaining why it exists and what failure it handles, will be maintained correctly, while a handler that is cryptic, with no explanation, may be removed by a future maintainer who does not understand its purpose, reintroducing a bug that had been fixed. Comments on handlers should explain the why, not just the what: why this failure is expected, why this response is appropriate, and why other responses were not chosen. That why is the knowledge that is hardest to recover from code alone.
Clarity in error handling also means avoiding clever tricks, such as using exceptions for control flow in a way that is not obvious, or catching a broad exception to handle a narrow case, with a comment that the broad catch is really for one specific failure. Clever code may work, but it is hard to maintain, because the next reader must understand the trick before they can change the code safely. Straightforward code, with specific handlers, clear messages, and simple recovery, is easier to maintain, even if it is slightly longer, because its behaviour is visible and its intentions are clear. In error handling, as in much of programming, boring and clear beats clever and obscure.
Another aspect of clarity is naming. A custom exception with a clear name, such as ConfigNotFoundError, communicates its meaning instantly, while a name such as Error1 or a reused generic type communicates nothing. Variable names in handlers should also be clear: except FileNotFoundError as err: is clearer than a single-letter name, because it signals that the variable holds an error, not a value. Clear naming in error handling is not a cosmetic concern; it is what allows a reader to understand the failure behaviour quickly, especially when they are reading under pressure because a failure has occurred.
Finally, clarity means consistency in style across a codebase. If one part of a program logs errors in a certain format, other parts should follow the same format, so that logs can be read and searched uniformly. If one part reports missing files with a certain message, other parts should use the same message for the same failure, so that users learn to recognise it. Consistency reduces the learning burden for readers and users, and it makes automated tools, such as log analysers, more effective, because they can rely on a stable format. Achieving consistency requires attention, but it pays off in maintainability.
try:
int("abc")
except ValueError as err:
# Expected: user typed a non-number; ask again in the caller.
print("Please enter a whole number.")
Clarity in error handling also helps when a program is translated or localised, because clear, specific messages are easier to translate accurately than vague ones. A message that names the operation and the value can be translated with its meaning preserved, whereas a message that relies on idiom or on a single word may lose meaning in translation. Writing messages with translation in mind, even if you never translate them, tends to produce clearer messages in the original language as well.
Check: Why should a comment on an exception handler explain why the failure is expected, not just what the handler does?
Section 46
Error handling intersects with security in ways that are easy to overlook. An error message that reveals internal details, such as a file path, a database structure, or a stack trace, can give an attacker information that helps them find vulnerabilities, even if the message itself is not a vulnerability. A program that shows a full traceback to an external user, for example, reveals its file layout, its library versions, and its internal logic, all of which are useful to someone probing for weaknesses. For that reason, programs that face untrusted users should show friendly, generic messages externally, while logging detailed information internally, where only trusted developers can see it. That separation, friendly outside and detailed inside, is a security practice as well as a usability one.
Another security intersection is the handling of sensitive data in exceptions. If an exception's message includes a password, a token, or personal data, because that data was part of the operation that failed, then logging or displaying the exception can leak that data. A program should avoid including sensitive values in exception messages, and it should sanitise logs that might contain them, by replacing sensitive values with placeholders before logging. That sanitisation is a defensive habit, similar to not logging passwords, and it should be applied consistently, because a single leaked message in a log file can persist long after the program has finished, and can be copied, shared, or backed up without the user's knowledge.
A third intersection is the handling of resource exhaustion, such as a program that catches an exception and retries indefinitely, or that creates files on every failure without cleaning them up, potentially filling a disk. An attacker who can trigger failures repeatedly could use such a program to exhaust resources, causing a denial of service for legitimate users. Designing handlers to be bounded, with limits on retries, on log size, and on temporary files, prevents that abuse, and it is part of writing robust code that behaves well even when faced with malicious or simply repeated failures. Boundedness is a security property, not just a performance one, because unbounded behaviour can be exploited.
These security considerations do not mean that error handling should be secretive or unhelpful; they mean that helpfulness should be directed to the right audience. Users get clear, actionable messages without internal details; developers get full details in logs that are protected; and the program's behaviour remains bounded and predictable, even under attack. Balancing helpfulness and security is a judgement that depends on who will see each message, and it should be made deliberately for each kind of error, rather than by defaulting to the same message for everyone.
try:
open("/private/notes.txt", "r", encoding="utf-8")
except OSError:
print("Could not open the requested file.")
Security in error handling also includes the timing and volume of error responses, because a program that responds differently to valid and invalid inputs, in timing or in message detail, can leak information about which inputs are valid. That side-channel leakage is a subtle risk, but it is worth knowing about, because it explains why some programs give the same generic message for several different failures, deliberately sacrificing specificity for security. The balance between helpfulness and security depends on the threat model, and it should be chosen deliberately.
Check: Why should a program show a generic message to an external user but log detailed traceback information internally?
Section 47
As programs grow, error handling becomes a cross-cutting concern, affecting many parts of the code, and it benefits from shared helpers and conventions. A helper that formats error messages consistently, a helper that logs exceptions with context, or a helper that validates a common kind of input, can be reused across a program, ensuring consistency and reducing duplication. Those helpers should be small, well-tested, and documented, because they will be used in many places, and a bug in a helper will affect every handler that uses it. Investing in shared error-handling helpers is therefore worthwhile, even for a medium-sized program, because it raises the quality of handling everywhere at once.
Conventions are equally important. A team or a project should agree on conventions such as which exceptions are raised for which failures, how messages are phrased, where logs go, and what exit codes mean, and then follow those conventions consistently. Conventions reduce the cognitive load on readers, who can predict how a new part of the program will handle errors by knowing the project's conventions, rather than having to learn a new style for each module. They also make code review easier, because a reviewer can check a handler against the convention, rather than judging it in isolation. Agreeing on conventions early, and documenting them, is a small investment with a large payoff in maintainability.
Shared helpers and conventions also help with testing, because they can be tested once, thoroughly, and then trusted wherever they are used. A message-formatting helper, for example, can be tested with many different inputs, ensuring that it never leaks sensitive data and always includes the required details, and then every handler that uses it inherits that correctness. That reuse of testing effort is efficient, and it means that new handlers can be written quickly, by combining trusted helpers, rather than by reinventing formatting, logging, and validation for each new failure. Reuse is a force multiplier for quality, in error handling as in other areas.
Finally, as a program grows, its error handling should be reviewed periodically, to check that conventions are still being followed, that helpers are still adequate, and that new kinds of failures, revealed by real use, are being handled appropriately. That review is part of maintenance, not a one-time design task, because programs and their environments change, and error handling that was adequate at first may become inadequate as the program's audience, scale, or dependencies change. A program whose error handling evolves with it is a program that remains trustworthy as it grows, rather than one whose early decisions become a liability.
def report_error(operation, err):
print(f"Could not {operation}: {err}")
try:
int("oops")
except ValueError as err:
report_error("convert input", err)
Shared helpers also need to be versioned and documented like any other shared code, because a change to a helper's message format or logging behaviour can affect every handler that uses it. That shared impact means helpers should change cautiously, with tests that verify their behaviour, and with communication to the people who use them. Treating helpers as a small library, with its own stability expectations, prevents a well-intentioned improvement from breaking many handlers at once.
Check: How do shared error-handling helpers and conventions improve consistency across a larger program?
Section 48
A final practical topic is how to handle errors when using external libraries or tools, whose exception types and behaviours you do not control. A library may raise exceptions that are specific to its implementation, with messages that are technical and hard for your users to understand, or it may raise a generic exception for many different failures, making it hard to distinguish them. In such cases, your program should translate library exceptions into its own, clearer exceptions, as discussed for libraries, or handle them at a boundary where the translation can be done once, rather than in many places. That translation layer isolates your program from the library's details, so that changing libraries later does not require changing every handler.
External tools also introduce failures that are not exceptions in your program's language, such as a tool that exits with a non-zero code, or that writes an error to its output without raising. Handling those failures requires checking exit codes, parsing output, or checking for expected output files, rather than catching exceptions, and it requires the same care about messages and logging as exception handling. A program that calls an external tool and ignores its exit code is like a program that catches an exception and ignores it: it pretends success when failure may have occurred, and it will produce misleading results. Checking external results explicitly is therefore part of error handling, even when no exception is involved.
Another external consideration is version differences. A library or tool may change its exception types, its messages, or its behaviour between versions, and a program that depends on a specific message string, or on a specific exception type that is not part of the library's documented interface, may break when the library is updated. Depending on documented behaviour, rather than on incidental details, makes a program more resilient to updates, and it is a reason to prefer catching documented exception types, and to avoid parsing messages for decisions, where possible. When a library's documentation is unclear, testing with the actual version you use, and noting that version in your program's documentation, helps future maintainers understand the dependency.
Finally, external failures should be reported in terms of your program's goal, not the library's internals, just as internal low-level failures should be translated for users. A message such as Could not save your report because the storage service failed is more helpful to a user than LibraryError: code 42, because it connects the failure to what the user was trying to do, and it suggests that the problem is with saving, not with their input. That user-centred reporting is the same principle that applies to all error messages, extended to the boundary between your program and the tools it uses.
try:
import json
json.loads("{bad json")
except ValueError as err:
print("Could not parse the data:", err)
External library handling also benefits from a fallback plan: if a library fails in a way that your program cannot translate, what should happen? A fallback, such as using a simpler method, reporting a generic failure, or exiting cleanly, should be decided in advance, rather than improvised when the failure occurs. Planning for unknown failures, with a generic handler and a clear message, ensures that even an unanticipated library error results in a comprehensible outcome.
Check: Why should your program translate a library's technical exception into a message about your program's goal for the user?
Section 49
This section offers a checklist you can use when reviewing error handling in your own programs, to catch common mistakes before they reach users. First, check that every except block names a specific exception, or a deliberate group, rather than catching everything, and that the handler's response is meaningful for every type it catches. Second, check that handlers do not swallow exceptions caused by bugs, but instead let bugs propagate to be fixed, while handling only expected, external failures. Third, check that resources are cleaned up, through with or finally, even when an exception occurs, and that temporary files are removed on failure. Fourth, check that error messages answer what, what was involved, and what to do next, without leaking sensitive details.
Fifth, check that handlers leave the program in a consistent state, with variables assigned and structures intact, so that code after the handler can proceed safely, or that code after the handler only runs on success, through else or early return. Sixth, check that retry loops are bounded, with a maximum number of attempts and a pause between them, and that they retry only transient failures, not permanent ones. Seventh, check that top-level handling logs full details for developers while showing a friendly message to users, and that exit codes reflect the outcome honestly. Eighth, check that exception tests exist, covering both valid and invalid inputs, and that they check the exception type and, where relevant, the message.
Ninth, check that custom exceptions, where they exist, have clear names, useful attributes, and documentation, and that they are defined where callers can import them. Tenth, check that error handling is consistent across the program, in wording, in logging, and in exit codes, so that users and developers can learn to recognise each failure type. Working through that checklist for a program, even a small one, often reveals handlers that are too broad, messages that are vague, or cleanup that is missing, and fixing those issues before release is far easier than fixing them after a user has encountered a confusing failure in production.
Using a checklist does not make error handling mechanical; it makes it systematic, ensuring that the judgement you have developed in this chapter is applied consistently, rather than only to the failures you happen to think of. A checklist is a tool for attention, not a substitute for judgement, and it should be adapted to your program's needs, adding items for its specific risks, such as file handling, network use, or sensitive data, and removing items that do not apply. The goal is not to tick boxes, but to prompt careful thought about each aspect of failure, so that none is overlooked by accident.
try:
value = int("42")
except ValueError:
print("bad input")
else:
print("ok:", value)
A checklist also helps when reviewing code written by someone else, because it gives the reviewer a structure for their comments, rather than relying on whatever they happen to notice. A reviewer who works through the checklist will check handler scope, message clarity, cleanup, state consistency, and tests, even if the code looks correct at first glance. That structured review catches issues that a casual read might miss, and it makes review feedback more consistent and less personal.
Check: Which checklist item is most likely to catch a handler that catches every exception and silently continues?
Section 50
Error handling is a skill that grows with experience, and this final section encourages you to keep practising it deliberately. Each time you write a program, ask what can go wrong, not just what should go right, and write handlers, messages, and tests for the failures you identify. Each time you debug a failure, note what the exception told you, how you found the cause, and what would have made the failure easier to diagnose, and apply that lesson to your next program, by improving a message, adding a check, or restructuring a handler. That cycle of anticipation, handling, debugging, and improvement is how expertise in error handling develops, over many programs, not in a single chapter.
As you move to the next chapter, on classes and objects, you will see error handling in a new light: classes will define their own invariants, methods will raise for invalid state, and objects will need to maintain consistency even when an operation fails partway. The principles from this chapter, validate early, fail clearly, clean up reliably, and communicate honestly, will apply directly to those class designs, because an object that can be left in an inconsistent state by a failed method is an object that is hard to trust. Keeping those principles in mind as you learn classes will help you design objects that are robust, not just functional, and that distinction will matter more as your programs grow larger and more complex.
The chapter's closing advice is to treat errors as information, not as embarrassment. A program that fails clearly is giving you information about a mismatch between expectation and reality, and that information is valuable, whether it reveals a bug in your code, a problem in your data, or a misunderstanding about the problem you are solving. Programs that hide errors deprive you of that information, while programs that report errors clearly give you the chance to learn and to improve. Embracing errors as information, rather than fearing them as failures, is a mindset that will serve you throughout your programming life, in every language and every domain, because errors are not the opposite of good programming; handling them well is part of what good programming means.
print("Errors are information.")
print("Handle them clearly, and learn from them.")
Practising error handling also means learning from other people's failures, by reading post-mortems, bug reports, and incident reviews, where they are available, and asking what handling would have prevented or mitigated the problem. That learning from real incidents, rather than only from textbook examples, builds a sense of which failures matter most in practice, and which handling patterns are most valuable. It also builds humility, because real incidents often involve failures that no one anticipated, which is a reminder to design for the unexpected as well as the expected.
Check: How will the error-handling principles from this chapter apply when you design classes in the next chapter?
Practical Task
Build a small, robust file-processing program that reads a text file of records, one per line in the format name, age, validates each line, and writes a summary report. Your program should: handle a missing input file with a clear message and a non-zero exit (do not crash with a bare traceback for this expected case); collect and report all malformed lines with their line numbers, rather than stopping at the first; skip blank lines without counting them as errors; convert ages to integers, rejecting negative ages with a specific message; and write the report to a new file using a with statement, preserving the original input unchanged. Test it with a missing file, an empty file, a file with one malformed line, a file with several malformed lines, and a file containing a negative age, and record what your program does in each case.
Chapter Questions
- What is the difference between handling an expected external failure, such as a missing file, and hiding a bug in your own code by catching its exception?
- Explain how
try,except,else, andfinallyeach contribute to a robust operation, using a file-reading example. - When should you define a custom exception type, and what information should it carry beyond its message?
- Why should a batch program that skips bad lines still report how many lines it skipped and which ones they were?
- Describe how you would test a function's error handling, including what you would check for both valid and invalid inputs.