# Exception Handling in Python: Catch Narrowly, Log Everything

> Exception handling in Python done properly: try, except, else and finally, custom exceptions, chaining and logging, and why a bare except can break Ctrl-C.

- **Author:** Althaf Ashraf — AI Systems Engineer, Tata Consultancy Services (https://www.1stepgrow.com/authors/althaf-ashraf/)
- **Published:** Jul 21, 2026 · **Updated:** Sep 17, 2026
- **Topic:** Python & Programming · **Format:** Guide · **Read time:** 9 min
- **Canonical URL:** https://www.1stepgrow.com/articles/python-exception-handling/

## Key takeaways

- Catch the narrowest exception that could actually occur, because a bare except swallows bugs and even Ctrl-C.
- Keep the try block small so it is obvious which line you are guarding.
- Use else for the success path and finally for cleanup that must always run.
- raise ... from err preserves the original cause, while a bare raise re-raises with the traceback intact.
- Since Python 3.11, ExceptionGroup and except* let you report and handle several failures at once.

Exception handling in Python means using `try`, `except`, `else` and `finally` to deal with errors deliberately instead of letting them crash your program. Done well, it catches only the failures you can handle and records everything else. Done badly, it catches too much and does nothing with what was caught.

The bad version is easy to write. A bare `except: pass` also catches `KeyboardInterrupt`, so Ctrl-C can stop working, and it silently discards the `AttributeError` from a simple typo. This guide is for Python programmers writing scripts, pipelines or services. It covers each clause, which exceptions to catch, custom exceptions, chaining, exception groups and logging, with example output from Python 3.14.

## How does try, except, else and finally work?

```python
for user_input in ["42", "abc"]:
    try:
        value = int(user_input)
    except ValueError as err:
        print(f"Not a number: {err}")
    else:
        print(f"Parsed {value}")        # runs only if no exception
    finally:
        print("Always runs")            # cleanup, always
```

Output:

```text
Parsed 42
Always runs
Not a number: invalid literal for int() with base 10: 'abc'
Always runs
```

Four clauses, each with a job:

- **try**: the code that might fail. Keep it small.
- **except**: how to handle a specific failure.
- **else**: the success path. It keeps non-risky code out of `try`.
- **finally**: cleanup that must happen either way.

The [Errors and Exceptions chapter of the Python tutorial](https://docs.python.org/3/tutorial/errors.html) covers each clause, and the [try statement reference](https://docs.python.org/3/reference/compound_stmts.html#the-try-statement) has the exact rules.

## Why should you catch exceptions narrowly?

It is the single most important habit in exception handling in Python:

```python
import logging

logger = logging.getLogger(__name__)

# terrible
try:
    result = compute(data)
except:
    pass

# bad
try:
    result = compute(data)
except Exception:
    result = None

# good
try:
    result = compute(data)
except (ValueError, KeyError) as err:
    logger.warning("compute failed for %s: %s", data.id, err)
    result = None
```

A bare `except:` matches every exception. That includes `KeyboardInterrupt` and `SystemExit`, which the [built-in exceptions documentation](https://docs.python.org/3/builtins/exceptions.html#exception-hierarchy) deliberately places under `BaseException` rather than `Exception` so that ordinary handlers do not catch them. As a result, Ctrl-C can stop working. A bare except also swallows typos: misspell an attribute name inside the `try` and the resulting `AttributeError` is silently discarded, leaving you with wrong behaviour and no clue why.

Catch what you can actually handle. Let everything else propagate to something that logs it.

Python 3.14 also accepts `except ValueError, KeyError:` without parentheses, but only when there is no `as` clause. The parenthesised form works on every supported version, so it remains the safer choice for shared code.

## How much code should go inside a try block?

As little as possible: only the line or lines that can raise the exception you plan to handle. Otherwise you catch errors you never anticipated:

```python
# too broad - which line raised?
try:
    config = load_config(path)
    conn = connect(config.db_url)
    rows = conn.query(sql)
    return transform(rows)
except Exception as err:
    logger.error("something failed: %s", err)

# better - the guard is specific
try:
    config = load_config(path)
except FileNotFoundError:
    logger.error("config missing at %s", path)
    raise

conn = connect(config.db_url)
rows = conn.query(sql)
return transform(rows)
```

In the first version, a bug in `transform` produces the same log line as a missing file. In the second, only the thing you anticipated is caught.

## Which built-in exceptions will you meet most?

These are the exceptions you will see most often, with the message Python 3.14 prints for each trigger:

| Exception | Typical trigger | Message |
|---|---|---|
| `ValueError` | `int("abc")` | invalid literal for int() with base 10: 'abc' |
| `TypeError` | `len(5)` | object of type 'int' has no len() |
| `KeyError` | `{}["k"]` | 'k' |
| `IndexError` | `[][1]` | list index out of range |
| `AttributeError` | `None.foo` | 'NoneType' object has no attribute 'foo' |
| `FileNotFoundError` | `open("nope.txt")` | [Errno 2] No such file or directory: 'nope.txt' |
| `ZeroDivisionError` | `1 / 0` | division by zero |

Frequently the cleaner answer is to avoid the exception entirely:

```python
# instead of catching KeyError
value = config.get("timeout", 30)

# instead of catching FileNotFoundError for an optional file
from pathlib import Path
if Path(path).exists():
    ...
```

However, note the race condition in that last pattern. For files, catching `FileNotFoundError` is often genuinely safer than checking first, because the file can disappear between the check and the open.

## When should you create a custom exception?

Define your own when callers need to distinguish your failures from library ones. Start with one base class for the module, then subclass it:

```python
class DataPipelineError(Exception):
    """Base for everything this module raises."""

class SchemaMismatchError(DataPipelineError):
    def __init__(self, expected, actual):
        self.expected = expected
        self.actual = actual
        super().__init__(f"Expected columns {expected}, got {actual}")

class SourceUnavailableError(DataPipelineError):
    pass
```

The base class matters: callers can catch `DataPipelineError` to handle anything from your module, or a specific subclass for targeted handling.

```python
try:
    run_pipeline()
except SchemaMismatchError as err:
    alert_data_team(err.expected, err.actual)
except DataPipelineError:
    logger.exception("pipeline failed")
    raise
```

Attaching structured data to the exception, such as `expected` and `actual` above, is far more useful than embedding it only in the message string. Custom exceptions are ordinary classes, so the same rules about [inheritance and composition in Python](https://www.1stepgrow.com/articles/python-inheritance-composition) apply.

## How do you re-raise an exception properly?

A bare `raise` inside an except block re-raises the current exception with its traceback intact:

```python
try:
    process(record)
except ValidationError:
    metrics.increment("validation_failures")
    raise                      # traceback preserved
```

When wrapping in your own exception type, use `from` to keep the cause:

```python
try:
    conn = psycopg.connect(url)
except psycopg.OperationalError as err:
    raise SourceUnavailableError("Cannot reach warehouse") from err
```

The traceback then shows both, separated by the line "The above exception was the direct cause of the following exception:". The tutorial's section on [exception chaining](https://docs.python.org/3/tutorial/errors.html#exception-chaining) explains the difference between explicit and implicit chaining.

Use `from None` deliberately when the original is genuinely noise:

```python
raise ConfigError("Invalid timeout") from None
```

To add context without changing the exception type, attach a note (Python 3.11+):

```python
def parse_row(n, raw):
    try:
        return int(raw)
    except ValueError as err:
        err.add_note(f"while parsing row {n}")
        raise

parse_row(17, "x")
```

The traceback ends with the original message followed by the note:

```text
ValueError: invalid literal for int() with base 10: 'x'
while parsing row 17
```

## How do you handle several errors at once?

Raise an `ExceptionGroup` and handle it with `except*`. Since Python 3.11, `ExceptionGroup` bundles unrelated exceptions, and `except*` handles each type in the group separately. This is useful for validation passes and concurrent tasks, where you want every failure rather than only the first:

```python
errors = []
for raw in ["10", "x", None]:
    try:
        int(raw)
    except (ValueError, TypeError) as err:
        errors.append(err)

try:
    if errors:
        raise ExceptionGroup("batch failed", errors)
except* ValueError as eg:
    print("bad values:", len(eg.exceptions))     # bad values: 1
except* TypeError as eg:
    print("wrong types:", len(eg.exceptions))    # wrong types: 1
```

Unlike ordinary `except` clauses, more than one `except*` clause can run for the same group. Plain `except` still works for everyday code, so reach for groups only when you genuinely have several failures to report.

## When should you use finally for cleanup?

Use `finally` for cleanup that must run whether or not an exception occurred, including on `return`, but prefer a context manager when one exists:

```python
def read_rows(path):
    f = open(path, encoding="utf-8")
    try:
        return list(f)
    finally:
        f.close()          # runs even though we returned
```

Never put `return`, `break` or `continue` inside `finally`. It silently discards any exception in flight, and from Python 3.14 the compiler emits a `SyntaxWarning` for it.

In practice a context manager is better:

```python
def read_rows(path):
    with open(path, encoding="utf-8") as f:
        return list(f)
```

Write your own for resources that need cleanup:

```python
import time
from contextlib import contextmanager

@contextmanager
def timed(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        logger.info("%s took %.2fs", label, time.perf_counter() - start)

with timed("model training"):
    train(model, data)
```

The timing is logged even if training raises. `@contextmanager` is itself built on generators, which [iterators and generators in Python](https://www.1stepgrow.com/articles/python-iterators-generators) covers in depth.

## How do you log an exception with its traceback?

Call `logger.exception` inside the except block. According to the [logging documentation](https://docs.python.org/3/library/logging.html#logging.Logger.exception), it logs at `ERROR` level and adds the exception information automatically:

```python
try:
    risky()
except Exception:
    logger.exception("risky() failed")     # includes full traceback
    raise
```

By contrast, `logger.error(str(err))` throws away the traceback, which is the part you need when debugging a failure later.

## A worked example: collecting failures in a data pipeline

A pattern that comes up constantly is to process everything, collect failures, and report at the end:

```python
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)

records = [
    {"id": 1, "amount": "250"},
    {"id": 2, "amount": "abc"},
    {"id": 3},
    {"id": 4, "amount": "75.5"},
]

def transform(record):
    return {"id": record["id"], "amount": float(record["amount"])}

results, failures = [], []

for record in records:
    try:
        results.append(transform(record))
    except (ValueError, KeyError) as err:
        failures.append({"id": record.get("id"), "error": repr(err)})

if failures:
    logger.warning("%d of %d records failed", len(failures), len(records))

print(results)
print(failures)
```

Output:

```text
WARNING 2 of 4 records failed
[{'id': 1, 'amount': 250.0}, {'id': 4, 'amount': 75.5}]
[{'id': 2, 'error': 'ValueError("could not convert string to float: \'abc\'")'}, {'id': 3, 'error': "KeyError('amount')"}]
```

One bad row does not kill the job, and you get a record of exactly what went wrong rather than a silent gap in the output. Note `repr(err)` rather than `str(err)`: a `KeyError` message on its own is just `'amount'`, which tells a reader nothing about the type.

## Common mistakes with exception handling in Python

- **Bare `except:` or `except Exception: pass`.** Both hide bugs. Catch specific types and log what you catch.
- **A huge `try` block.** You cannot tell which line failed, and you catch errors you never anticipated.
- **Logging `str(err)` without the traceback.** Use `logger.exception` inside the handler.
- **Raising a new exception without `from`.** The original cause becomes harder to trace. Use `raise New(...) from err`.
- **`return` inside `finally`.** It overrides the `try` block's return value and swallows exceptions.
- **Using exceptions for normal control flow.** A missing optional key is not exceptional; `dict.get` is clearer.



## Related reading

[The role of programming in data science](https://www.1stepgrow.com/articles/programming-for-data-science) covers code quality more broadly. [User-defined functions](https://www.1stepgrow.com/articles/python-functions) covers structuring the code you are protecting. For errors around file input and output, see [reading and writing files in Python](https://www.1stepgrow.com/articles/python-reading-writing-files).

## Frequently asked questions

### Why is `except: pass` bad?

It catches everything, including KeyboardInterrupt and SystemExit, so Ctrl-C and sys.exit() can be silently ignored. It also hides genuine bugs such as typos and AttributeErrors, and leaves no record that anything went wrong. Debugging a system built this way means guessing, because the evidence was thrown away.

### What is the difference between except Exception and a bare except?

A bare except matches every exception, including KeyboardInterrupt and SystemExit, which inherit from BaseException rather than Exception. except Exception catches ordinary errors while leaving those control-flow exceptions alone, so Ctrl-C still stops the program. If you really must catch broadly, catch Exception, log it with the traceback, and usually re-raise.

### When should I use else in a try block?

Use else for code that should run only if no exception occurred, and that you do not want inside the try. It keeps the protected region minimal, so you do not accidentally catch an error raised by the success path. The Python tutorial recommends it for exactly that reason.

### Should I use exceptions or return None for errors?

Raise exceptions for genuinely exceptional conditions the caller must handle. Use return values for expected outcomes: dict.get returning None for a missing key is right, because absence is normal. A silent None for a real failure just moves the crash somewhere less informative, usually far from the line that caused it.

---
_Source: 1stepGrow (https://www.1stepgrow.com/articles/python-exception-handling/). Cite with the title, "1stepGrow" and a link._
