---
title: "Set up Python logging with the right log levels and exception capture"
description: "Python logging prints only WARNING and above until you configure it. Here is the 3-line setup, the 5 log levels, and how to log an exception with its traceback, from runs on Python 3.13."
canonical: "https://logbrew.co/en/guides/python-logging"
last_updated: "2026-11-10"
---

# Set up Python logging with the right log levels and exception capture

Python `logging` prints only WARNING and above until you configure it. Here is the 3-line setup, the 5 log levels, and how to log an exception with its traceback, from runs on Python 3.13.

Tested on November 10, 2026 with Python 3.13.5 and logbrew-sdk 0.1.29

By [Furkan Erday](https://github.com/furkanerday). Furkan Erday is the founder of LogBrew. He builds LogBrew with Claude Code and Codex every day.

Canonical: https://logbrew.co/en/guides/python-logging

## What each Python log level does with no setup, and in LogBrew

| Logging call | Printed with no setup | Own level in LogBrew |
| --- | --- | --- |
| `log.debug()` | Dropped | Merged into `info` |
| `log.info()` | Dropped | `info` |
| `log.warning()` | `WARNING:root:` | `warning` |
| `log.error()` | `ERROR:root:` | `error` |
| `log.critical()` | `CRITICAL:root:` | `critical` |

5 calls on the root logger with no configuration, then the same 5 through the LogBrew handler. A full mug means printed, or kept as its own level.

## Set up logging in 3 lines

### Call basicConfig() once, before the first log call

`basicConfig()` sets the level and the line format of the root logger. With no setup, Python prints only WARNING and above, as `WARNING:root:keg below 10%`, and drops DEBUG and INFO.

```bash
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("taproom")

log.debug("keg pressure read")
log.info("tap opened")
log.warning("keg below 10%")
log.error("keg empty")
log.critical("cooler offline")
```

Output

```text
2026-11-10 08:34:04,828 INFO taproom tap opened
2026-11-10 08:34:04,828 WARNING taproom keg below 10%
2026-11-10 08:34:04,828 ERROR taproom keg empty
2026-11-10 08:34:04,828 CRITICAL taproom cooler offline
```

### Name a logger after each module

`logging.getLogger(__name__)` gives each module its own logger, so every line names the module it came from. Keep the root logger for `basicConfig()` only.

### Read the level from the environment

`basicConfig()` accepts a level name as text. Run this with `LOG_LEVEL=DEBUG` and the debug line appears, with no code change.

```bash
import logging
import os

logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("taproom")
log.debug("keg pressure read")
log.info("tap opened")
```

Output

```text
DEBUG:taproom:keg pressure read
INFO:taproom:tap opened
```

### Pass force=True to configure again

Python ignores a second `basicConfig()` call when the root logger already has a handler. Pass `force=True` to replace the handlers, for example in tests.

```bash
import logging

logging.basicConfig(level=logging.WARNING)
logging.basicConfig(level=logging.DEBUG)
logging.debug("second basicConfig: not printed")
logging.basicConfig(level=logging.DEBUG, force=True)
logging.debug("basicConfig with force=True: printed")
```

Output

```text
DEBUG:root:basicConfig with force=True: printed
```

## Log every exception with its traceback

### Call log.exception() inside except

`log.exception()` logs at ERROR and appends the traceback. Pass `exc_info=True` to a call at any other level to do the same there.

```bash
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
log = logging.getLogger("taproom")
taps = {"ipa": 12}

def pour(beer):
    return taps[beer]

try:
    pour("stout")
except KeyError:
    log.exception("pour failed")
```

Output

```text
ERROR taproom pour failed
Traceback (most recent call last):
  File "app.py", line 11, in <module>
    pour("stout")
    ~~~~^^^^^^^^^
  File "app.py", line 8, in pour
    return taps[beer]
           ~~~~^^^^^^
KeyError: 'stout'
```

### Send each line to LogBrew with the logging handler

Add `LogBrewLoggingHandler` to your logger. Each record becomes a LogBrew log with its level, logger name, file, and line. The exception name and message come along, and so do the values in `extra={}`.

```bash
import logging
import os

from logbrew_sdk import HttpTransport, LogBrewClient, LogBrewLoggingHandler

client = LogBrewClient.create(api_key=os.environ["LOGBREW_SERVER_API_KEY"])
transport = HttpTransport()
log = logging.getLogger("taproom")
log.addHandler(LogBrewLoggingHandler(client, transport, metadata={"service": "taproom"}))
log.setLevel(logging.INFO)

taps = {"ipa": 12}

def pour(beer):
    return taps[beer]

try:
    pour("stout")
except KeyError:
    log.exception("pour failed", extra={"order_id": "ord_42"})

response = client.flush(transport)
print("HTTP", response.status_code, "accepted", response.accepted_events, "logs")
```

Output

```text
HTTP 200 accepted 1 logs
```

### Read the log back with its exception fields

`logbrew read logs --severity error --json` returns the line with `exceptionName`, `exceptionMessage`, the file, the function, the line number, and your `order_id`.

```bash
logbrew read logs --project <project_id> --severity error --since 1h --json | jq '.[0] | {level, message, logger: .attributes.logger, metadata: .attributes.metadata}'
```

Output

```text
{
  "level": "error",
  "message": "pour failed",
  "logger": "taproom",
  "metadata": {
    "exceptionMessage": "'stout'",
    "exceptionName": "KeyError",
    "fileName": "send.py",
    "functionName": "<module>",
    "levelName": "ERROR",
    "levelNumber": 40,
    "lineNumber": 20,
    "module": "send",
    "order_id": "ord_42",
    "processName": "MainProcess",
    "service": "taproom",
    "threadName": "MainThread"
  }
}
```

### Catch the crash that ends the process

`install_process_error_capture()` hooks `sys.excepthook`. An exception nobody catches becomes an issue at level `critical` with `handled: false`, captured before the process exits with code 1.

```bash
import atexit
import json
import os

from logbrew_sdk import LogBrewClient, RecordingTransport, install_process_error_capture

transport = RecordingTransport.always_accept()
client = LogBrewClient.create(api_key=os.environ["LOGBREW_SERVER_API_KEY"], transport=transport)
install_process_error_capture(client, on_capture_error=print)


@atexit.register
def show_captured_issue() -> None:
    issue = json.loads(transport.last_body())["events"][0]["attributes"]
    print(json.dumps({k: issue[k] for k in ("title", "level")}), json.dumps(issue["exception"]))


kegs = {"ipa": 3}
kegs["stout"] -= 1
```

Output

```text
Traceback (most recent call last):
  File "<stdin>", line 19, in <module>
KeyError: 'stout'
{"title": "KeyError", "level": "critical"} {"type": "KeyError", "mechanism": {"type": "python.excepthook", "handled": false}}
```

## What we saw when we tested it

With no configuration, 5 calls on the root logger printed 3 lines to stderr and dropped the DEBUG and INFO calls. The default line is the level, the logger name, and the message, as `WARNING:root:keg below 10%`.

A second `basicConfig()` call changed nothing until we passed `force=True`. Through the LogBrew handler, DEBUG and INFO records arrived as `info`, WARNING as `warning`, ERROR as `error`, and CRITICAL as `critical`.

`log.exception()` added `exceptionName` and `exceptionMessage` to the LogBrew log. With `include_exception_text=True` on the handler, the full traceback arrived as `exceptionText`.

```bash
import logging

logging.debug("keg pressure read")
logging.info("tap opened")
logging.warning("keg below 10%")
logging.error("keg empty")
logging.critical("cooler offline")
```

Output

```text
WARNING:root:keg below 10%
ERROR:root:keg empty
CRITICAL:root:cooler offline
```

## The 5 log levels and when to use each

DEBUG is for the values you need while you fix a bug, like the keg pressure, the SQL text, or the retry count. Leave it off in production.

INFO records what the program did, like a tap opened or an order paid. WARNING records what went wrong but was handled, like a keg below 10%. ERROR records a request or a job that failed, like a keg empty. CRITICAL records what stops the program, like a cooler offline.

Log each exception once, where you handle it. A line at every level of the call stack sends 3 alerts for 1 failure.

```bash
import logging

for name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
    print(name, logging.getLevelName(name))
print("root logger level:", logging.getLevelName(logging.getLogger().level))
```

Output

```text
DEBUG 10
INFO 20
WARNING 30
ERROR 40
CRITICAL 50
root logger level: WARNING
```

## What LogBrew records

Each record becomes 1 log with its level, logger name, file name, function, line number, and module. Primitive values from `extra={}` become fields you can filter by, and `metadata={}` on the handler adds a field to every line, as `service` above.

On an exception, the log gets `exceptionName` and `exceptionMessage`. Pass `include_exception_text=True` to the handler to store the traceback text as well.

`install_process_error_capture()` turns an uncaught exception into an issue at level `critical`, with the exception type, the stack frames, and the mechanism `python.excepthook`.

## Questions people ask

### What is the default logging level in Python?

WARNING. With no `basicConfig()` call, the root logger prints WARNING, ERROR, and CRITICAL to stderr as `WARNING:root:message`, and drops DEBUG and INFO.

### Why does logging.basicConfig do nothing?

The root logger already has a handler, from an earlier `basicConfig()` call or from a library, so Python ignores the call. Pass `force=True` to replace the handlers.

### What is the difference between logging.error and logging.exception?

`log.exception()` is `log.error()` with `exc_info=True`. It logs at ERROR and appends the traceback. Call it inside an `except` block only.

### What are the Python logging levels in order?

DEBUG 10, INFO 20, WARNING 30, ERROR 40, CRITICAL 50. A logger passes a record when its number is at or above the level you set.

### Should I use print() or logging in Python?

Use `logging`. `print()` has no level, no timestamp, no logger name, and no handler. With `logging` you turn lines on per module and send them to a file or to LogBrew.

## See every Python exception with its traceback

The LogBrew Python SDK sends each log line with its level, logger, file, and line, and turns an uncaught exception into an issue with its stack trace. Start on the generous free tier, no card needed.

[Start Free](https://logbrew.co/en)

## Sources

- [Python docs, Logging HOWTO](https://docs.python.org/3/howto/logging.html)
- [Python docs, logging module](https://docs.python.org/3/library/logging.html)
- [python/cpython#78078](https://github.com/python/cpython/issues/78078)

## More guides for agents and Python

- [Add an MCP server to Claude Code and Codex](https://logbrew.co/en/guides/mcp-server-claude-code-codex)
- [Use AGENTS.md in Claude Code and Codex](https://logbrew.co/en/guides/agents-md)
- [Fix the Claude overloaded error 529 and rate limit error 429](https://logbrew.co/en/guides/claude-overloaded-rate-limit-error)
- [Structured logging with a log format and log levels you can search by field](https://logbrew.co/en/guides/structured-logging)
- [How to fix errors in Claude Code, Codex, JavaScript, and Python](https://logbrew.co/en/guides)
