Every Python Error Type, Explained
A complete tour of Python's exception hierarchy — what each built-in error actually means, what triggers it, the attributes it carries, and how to handle it without swallowing the bugs you needed to see.

Python has roughly seventy built-in exception types. Most developers know eight
of them and catch the rest with a bare except. That habit costs you: the
difference between KeyError and IndexError, or between ImportError and
ModuleNotFoundError, is usually the difference between a two-minute fix and an
afternoon of guessing.
This is the whole hierarchy — what each type means, what raises it, and what it carries.
The hierarchy in one picture
Everything inherits from BaseException. That class has exactly five direct
children, and the split between them is the single most important thing to
understand about error handling in Python.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
├── BaseExceptionGroup
└── Exception
├── ArithmeticError
├── AssertionError
├── AttributeError
├── BufferError
├── EOFError
├── ImportError
├── LookupError
├── MemoryError
├── NameError
├── OSError
├── ReferenceError
├── RuntimeError
├── StopIteration
├── StopAsyncIteration
├── SyntaxError
├── SystemError
├── TypeError
├── ValueError
└── Warning
except Exception catches everything in that bottom block and nothing in the
top four. That is deliberate. SystemExit, KeyboardInterrupt, and
GeneratorExit are control flow, not failures — if a broad handler caught them,
Ctrl-C would stop working inside your retry loop.
Rule of thumb: catch
Exceptionwhen you mean "something went wrong." CatchBaseExceptionalmost never — and if you do, re-raise.
The control-flow tier
SystemExit
Raised by sys.exit(). Carries the exit code in .code — an int, a string
(printed to stderr, exit status 1), or None (status 0). The interpreter catches
it at top level and shuts down cleanly, so finally blocks and context managers
still run.
try:
sys.exit(2)
except Exception:
print("never printed") # SystemExit is not an Exception
finally:
print("this does run")
Calling os._exit() instead skips all of that — no exception, no cleanup, no
buffer flush. Reserve it for the child side of a fork().
KeyboardInterrupt
Raised when the user hits Ctrl-C (SIGINT). It can land on any bytecode
boundary, which is why cleanup belongs in finally or a context manager rather
than after the try block. A broad except Exception inside a long loop will not
catch it — that is the behavior you want.
GeneratorExit
Raised inside a generator when .close() is called on it, including implicitly
when the generator is garbage-collected mid-iteration. You may catch it to clean
up, but you must not yield again afterwards — that turns into a
RuntimeError.
def worker():
try:
while True:
yield
except GeneratorExit:
release_resources() # fine
# yield here would raise RuntimeError
BaseExceptionGroup and ExceptionGroup
New in Python 3.11. A group wraps several exceptions raised concurrently —
the natural result of asyncio.TaskGroup or a thread pool where four workers
failed for four different reasons.
ExceptionGroup is the subclass that only holds Exception instances (and so
is itself catchable by except Exception); BaseExceptionGroup is the general
form. You unpack them with except*, which runs every matching branch rather
than just the first:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch(a))
tg.create_task(fetch(b))
except* ConnectionError as eg:
for exc in eg.exceptions:
log.warning("network: %s", exc)
except* ValueError as eg:
log.error("bad payload in %d tasks", len(eg.exceptions))
Both branches can fire from a single group. That is the point.
Syntax and structure
SyntaxError
The code could not be parsed. Unique among exceptions in that it is usually
raised at compile time, before a single line runs — so you cannot guard a
syntax error in the same file with try. You only catch it around code compiled
at runtime: eval(), exec(), compile(), ast.parse(), or an import of a
broken module.
Attributes: filename, lineno, offset, text, and since 3.10 also
end_lineno and end_offset — which is how modern tracebacks underline the
exact span rather than pointing at a whole line.
IndentationError
Subclass of SyntaxError. Indentation is wrong but the tokens are fine —
unexpected indent, unindent not matching any outer level, or an expected an indented block after a colon.
TabError
Subclass of IndentationError. Tabs and spaces mixed inconsistently, such that
indentation depends on tab width. Configure your editor to expand tabs and this
one disappears forever.
Names and attributes
NameError
A bare name was looked up and found in no scope — not local, not enclosing, not
global, not builtins. Typos, use-before-definition, and forgotten imports all
land here. Since 3.10 the message includes a suggestion (did you mean: 'length'?), and the attempted name is available programmatically as .name.
UnboundLocalError
Subclass of NameError, and the one that confuses people most. The name is
local — Python decided so at compile time because you assign to it somewhere in
the function — but you read it before that assignment ran.
count = 0
def bump():
print(count) # UnboundLocalError, not NameError
count += 1 # this line makes `count` local for the whole function
The fix is global count or nonlocal count, or not shadowing the name at all.
AttributeError
Attribute lookup or assignment failed on an object that does exist. Carries
.name (the attribute) and .obj (the object) since 3.10, which is what powers
the "did you mean" hint.
The classic production instance is calling a method on None — a function that
returned nothing where you expected an object. 'NoneType' object has no attribute 'x' is almost never a bug about x; it is a bug about whatever
produced the None several lines earlier.
Note that __getattr__ raising AttributeError is a normal protocol, not a
failure: it is how hasattr() works and how proxies signal "not mine."
Types and values
These two carry most of the traffic in real code, and the boundary between them is worth internalizing.
TypeError
The kind of thing is wrong. An operation is undefined for that type, or a call got arguments it cannot accept.
'str' + int— unsupported operand typeslen(5)— object of typeinthas nolen()f(1, 2)on a one-parameter function — argument count{[1,2]: 'x'}— unhashable type used as a dict key- calling something that is not callable
ValueError
The type is right, the content is not. The function accepts a str; this
particular str just isn't convertible.
int("banana") # ValueError: invalid literal for int() with base 10
[1, 2, 3].remove(9) # ValueError: list.remove(x): x not in list
a, b = [1, 2, 3] # ValueError: too many values to unpack
math.sqrt(-1) # ValueError: math domain error
When you write your own validators, follow the same split: wrong type →
TypeError, wrong value → ValueError. Callers depend on it.
The Unicode family
UnicodeError subclasses ValueError, and has three concrete children:
UnicodeDecodeError— bytes → str failed. The bytes are not valid in the claimed encoding. Reading a UTF-16 or Latin-1 file as UTF-8 produces this.UnicodeEncodeError— str → bytes failed. The character has no representation in the target encoding; writing"café"to an ASCII stream, or an emoji to acp1252console.UnicodeTranslateError— raised duringstr.translate(). Rare.
All three carry .encoding, .object, .start, .end, and .reason, so you
can report the exact offending byte range instead of "encoding error." Pass
errors='replace' or errors='ignore' to sidestep them when the data really is
dirty and you have decided to accept loss.
Lookups
LookupError
The base class for "you asked for a key or index that isn't there." Catch this
when you genuinely don't care which. Also raised directly by codecs.lookup()
for an unknown encoding name.
IndexError
A sequence index is out of range. Note that slicing never raises it —
[1,2,3][10:20] quietly returns []. Only direct indexing does. That asymmetry
hides plenty of off-by-one bugs.
KeyError
A mapping key is missing. The argument is the key itself, so e.args[0] gives
you the key back — and the message prints its repr, which is why a missing
string key shows with quotes.
Prefer d.get(k), d.get(k, default), collections.defaultdict, or
try/except KeyError over if k in d: d[k] — the last form does two lookups
and races under concurrency.
Arithmetic
ArithmeticError
Base class for the numeric failures below.
ZeroDivisionError
Division or modulo by zero, for int, float, Fraction, and Decimal alike.
Note that floats do not get IEEE infinity here — 1/0 raises, it does not
return inf. Use math.inf explicitly, or NumPy, which returns inf with a
RuntimeWarning instead.
OverflowError
A result is too large to represent. You will not see this from integer
arithmetic, since Python ints are arbitrary precision. It comes from float
conversions and C-backed math: math.exp(1000), or converting a huge int to a
float.
FloatingPointError
Defined but never raised by CPython under default settings. It exists for
numeric libraries that opt into trapping; numpy.seterr(all='raise') will
produce it.
The operating system
OSError
The big one. Everything the OS can refuse — files, sockets, processes,
permissions — funnels through OSError. Since 3.3, IOError,
EnvironmentError, WindowsError, socket.error, and select.error are all
plain aliases for it, so treat older code accordingly.
Attributes: .errno, .strerror, .filename, .filename2 (for two-path
operations like rename), and .winerror on Windows.
Python maps common errno values onto specific subclasses so you rarely need to
compare error numbers by hand:
FileNotFoundError—ENOENT. The path doesn't exist.FileExistsError—EEXIST. Creating something already there; whatos.makedirs(..., exist_ok=True)suppresses.PermissionError—EACCES/EPERM. Also what you get trying to open a directory as a file on some platforms.IsADirectoryError/NotADirectoryError— path type mismatch.InterruptedError—EINTR, a syscall interrupted by a signal. Since Python 3.5 (PEP 475) the interpreter retries automatically, so you should almost never see it.BlockingIOError— a non-blocking operation would block. Carries the extra.characters_writtenattribute.ChildProcessError— an operation on a child process that doesn't exist.ProcessLookupError—ESRCH, signalling a dead PID.TimeoutError— a system-level timeout expired. As of 3.10socket.timeoutis an alias for it, and as of 3.11 so isasyncio.TimeoutError. One name now covers all three.ConnectionError— base class for the network four:BrokenPipeError— writing to a socket or pipe the peer closed (EPIPE/ESHUTDOWN)ConnectionRefusedError— nothing listening on that portConnectionResetError— the peer forcibly dropped the connectionConnectionAbortedError— aborted locally
Catching the specific subclass is nearly always better than except OSError plus
an errno check.
Imports
ImportError
An import failed for any reason — the module was found but blew up on execution,
or from x import y found x but not y. Carries .name and .path.
ModuleNotFoundError
Subclass of ImportError, added in 3.6, raised specifically when the module
could not be located at all. The distinction matters when you write optional
dependencies:
try:
import orjson as json_impl
except ModuleNotFoundError:
import json as json_impl # not installed — fine, fall back
Catching the broader ImportError there would silently swallow a real crash
inside orjson's own import and route you to the fallback with no warning.
Runtime
RuntimeError
The catch-all for errors that fit no other category: mutating a dict while iterating it, using an uninitialized object, threading and asyncio misuse, generator protocol violations. Broad by nature, so read the message.
NotImplementedError
Subclass of RuntimeError. Raised by abstract methods and stubs to say "a
subclass owes an implementation here."
Do not confuse it with NotImplemented, which is a singleton value, not an
exception. Binary dunder methods (__eq__, __add__, …) should return NotImplemented when they don't know how to handle the other operand — that tells
Python to try the reflected operation. raise NotImplementedError there breaks
the protocol.
RecursionError
Subclass of RuntimeError since 3.5. The interpreter's recursion limit (default
1000 frames, see sys.setrecursionlimit) was exceeded. Usually genuine infinite
recursion; occasionally a legitimately deep structure, in which case rewrite the
algorithm iteratively rather than raising the limit — the limit exists to stop
you from smashing the C stack and segfaulting.
Also fires from unintended recursion in __getattr__ or __repr__, which
produces spectacularly long tracebacks.
PythonFinalizationError
New in 3.13, subclass of RuntimeError. You tried to do something — start a
thread, spawn a process — while the interpreter was shutting down. Typically
means a __del__ or atexit handler is doing too much.
Iteration
StopIteration
Raised by next() when an iterator is exhausted. for loops catch it for you,
so it is invisible in normal code. It carries .value, which is how a
generator's return value reaches yield from.
The trap: since PEP 479 (default in 3.7), a StopIteration that escapes a
generator body is converted into a RuntimeError rather than silently ending
the loop. Old code that called next() inside a generator without a guard
breaks on modern Python — and that is a good thing, because the silent version
truncated data.
StopAsyncIteration
The async for equivalent, raised by __anext__.
Memory, references, and the interpreter itself
MemoryError
Allocation failed. Python can sometimes recover — the exception is raised rather than the process dying — but the interpreter may already be in a fragile state. The realistic response is to free a large structure and abort the operation, not to continue as normal.
BufferError
A buffer-protocol operation could not be performed, most commonly resizing a
bytearray while a memoryview of it is still exported.
ReferenceError
A weakref.proxy was used after its referent had been garbage-collected.
SystemError
The interpreter found an internal inconsistency but judged the situation recoverable. Not your fault, structurally — it usually indicates a bug in CPython or, far more often, in a C extension. Report it with the extension modules you have loaded.
The remaining singles
EOFError
input() hit end-of-file without reading any data — piping an empty stdin, or
Ctrl-D at a prompt. Worth noting that file objects do not raise this; .read()
at EOF simply returns ''. Only the input()-style builtins do.
AssertionError
An assert statement failed. Two things to remember: the optional second operand
becomes the message (assert x > 0, f"got {x}"), and the entire statement is
stripped when Python runs with -O. Never use assert for validating untrusted
input or enforcing security invariants — use an explicit if and raise.
Warnings
Warning is an Exception subclass, but warnings are normally reported
through the warnings module rather than raised. The main types:
UserWarning— the default forwarnings.warn()DeprecationWarning— for library authors' own users; hidden by default except in__main__and under test runnersPendingDeprecationWarning— deprecation slated for a later releaseSyntaxWarning— dubious but legal syntax, likeison a literalRuntimeWarning— dubious runtime behavior; NumPy'sinvalid value encounteredlives hereFutureWarning— likeDeprecationWarning, but aimed at end users rather than developers, so it is shown by defaultImportWarning— probable mistakes in import machinery; hidden by defaultUnicodeWarningandBytesWarning— encoding-related;BytesWarningonly appears under the-bflagEncodingWarning— added in 3.10, flags I/O relying on the platform default encoding, surfaced with-X warn_default_encodingResourceWarning— unclosed files and sockets; hidden by default, and one of the most useful things to turn on in your test suite
# turn latent resource leaks into visible failures during tests
python -W error::ResourceWarning -m pytest
Writing your own
Subclass Exception, never BaseException. Give your package one root
exception so callers can catch the whole surface with a single name, then
specialize beneath it:
class StorageError(Exception):
"""Base for everything this package raises."""
class ObjectNotFound(StorageError, KeyError):
"""Missing object — also a KeyError so mapping-style callers work."""
class QuotaExceeded(StorageError):
def __init__(self, used: int, limit: int):
super().__init__(f"{used} bytes used of {limit} allowed")
self.used = used
self.limit = limit
Multiple inheritance from a matching builtin is a genuinely useful trick: code
that expects a KeyError keeps working, while code that knows your library can
catch StorageError.
Put the structured data on attributes, not only in the message string. A handler that has to regex your error text is a handler that will break.
Chaining, context, and notes
When you raise inside an except block, Python records the original on
__context__ automatically and prints both ("During handling of the above
exception, another exception occurred"). Make the relationship explicit with
raise ... from:
try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConfigError(f"{path} is not valid JSON") from exc
That sets __cause__ and prints "The above exception was the direct cause." Use
from None to suppress the chain when the inner exception is pure noise and
would only mislead.
Since 3.11 you can also attach information to an exception already in flight, without wrapping it:
except ValidationError as exc:
exc.add_note(f"while processing record {i} of {path}")
raise
The notes print with the traceback. This is the cleanest way to add context in a loop, since it preserves the original type and traceback entirely.
Handling patterns worth keeping
Catch narrowly, at the layer that can actually respond. A handler that cannot do anything useful should not exist; let the exception travel to one that can.
Never write a bare except:. It catches KeyboardInterrupt and SystemExit
too. If you truly need everything, write except BaseException: so the intent is
explicit, and re-raise.
Never write except Exception: pass. If you have decided to ignore a failure,
log it at debug level with exc_info=True and say why in a comment. Silent
pass is how a broken cache write becomes a six-hour incident.
Use else and finally for what they mean. Code in try should be only the
operation that can fail; the success path belongs in else; cleanup belongs in
finally or a context manager.
try:
conn = connect(dsn)
except ConnectionRefusedError:
return fallback()
else:
return conn.query(sql) # not guarded — its errors are real
finally:
metrics.timing("db.attempt", clock.elapsed())
Never return from finally. It discards any in-flight exception silently —
the single most effective way to make a bug invisible.
Log with logger.exception() inside a handler. It attaches the traceback
automatically; logger.error(str(exc)) throws away everything you will want at
3 a.m.
Reading a modern traceback
Python 3.11 added fine-grained locations, so the caret span points at the exact subexpression that failed rather than the whole line:
Traceback (most recent call last):
File "pipeline.py", line 42, in run
total = base.rate * item.qty
~~~~~~~~~~^~~~~~~~~~
AttributeError: 'NoneType' object has no attribute 'rate'
That underline tells you base is None, not item. Before 3.11 you would have
had to work that out yourself. Combined with 3.12's improved suggestions, most
AttributeError and NameError reports now name their own fix.
Read tracebacks bottom-up: the last line is what happened, the frame directly above it is where, and the frames above that are how you got there.