18 min read

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.

PythonError HandlingDebugging
Every Python Error Type, Explained

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 Exception when you mean "something went wrong." Catch BaseException almost 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 types
  • len(5) — object of type int has no len()
  • 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 a cp1252 console.
  • UnicodeTranslateError — raised during str.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:

  • FileNotFoundErrorENOENT. The path doesn't exist.
  • FileExistsErrorEEXIST. Creating something already there; what os.makedirs(..., exist_ok=True) suppresses.
  • PermissionErrorEACCES/EPERM. Also what you get trying to open a directory as a file on some platforms.
  • IsADirectoryError / NotADirectoryError — path type mismatch.
  • InterruptedErrorEINTR, 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_written attribute.
  • ChildProcessError — an operation on a child process that doesn't exist.
  • ProcessLookupErrorESRCH, signalling a dead PID.
  • TimeoutError — a system-level timeout expired. As of 3.10 socket.timeout is an alias for it, and as of 3.11 so is asyncio.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 port
    • ConnectionResetError — the peer forcibly dropped the connection
    • ConnectionAbortedError — 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 for warnings.warn()
  • DeprecationWarning — for library authors' own users; hidden by default except in __main__ and under test runners
  • PendingDeprecationWarning — deprecation slated for a later release
  • SyntaxWarning — dubious but legal syntax, like is on a literal
  • RuntimeWarning — dubious runtime behavior; NumPy's invalid value encountered lives here
  • FutureWarning — like DeprecationWarning, but aimed at end users rather than developers, so it is shown by default
  • ImportWarning — probable mistakes in import machinery; hidden by default
  • UnicodeWarning and BytesWarning — encoding-related; BytesWarning only appears under the -b flag
  • EncodingWarning — added in 3.10, flags I/O relying on the platform default encoding, surfaced with -X warn_default_encoding
  • ResourceWarning — 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.