Logging in Python: Konfiguration für Production

1. Übersicht

print() reicht aus, wenn du kleine Skripte schreibst. In Production ist das anders: du brauchst Log-Filterung nach Level, Aggregation in ein zentralisiertes System, das Verfolgen eines Requests über mehrere Log-Zeilen, und die Logging-Performance muss im Griff sein.

Dieser Artikel zeigt dir, wie du Pythons Standard-logging-Modul für Production konfigurierst. Danach wirst du eine Konfiguration mit Loggern pro Modul, dictConfig, JSON-Logs, automatisch angehängter request_id, asynchronem Logging mit QueueHandler und Tests mit pytest aufbauen können. Alle Beispiele verwenden Python 3.12 und benötigen nur die Standardbibliothek. Nur der Test-Teil benötigt zusätzlich pytest.

2. Warum print und basicConfig nicht ausreichen

Schau dir einen Order-Processing-Service an, der “quick and dirty” geschrieben ist:

import logging

logging.basicConfig(level=logging.INFO)

def place_order(order_id: str, amount: float) -> None:
    print(f"Bestellung {order_id} wird aufgegeben")
    logging.info(f"Bestellung {order_id} amount={amount}")

place_order("ORD-1001", 250_000)
Bestellung ORD-1001 wird aufgegeben
INFO:root:Bestellung ORD-1001 amount=250000

Dieser Code hat drei Probleme:

  • print hat keinen Level, keinen Timestamp und lässt sich nicht über Konfiguration steuern.
  • logging.info(...) schreibt zum root logger. Du weißt nicht, aus welchem Modul die Log-Zeile kommt, und kannst Level nicht pro Bereich justieren.
  • basicConfig wirkt nur beim ersten Aufruf, wenn der root logger noch keine Handler hat. Falls eine Bibliothek es vorher aufruft, wird deine Konfiguration stillschweigend überschrieben.

3. Logger pro Modul mit getLogger(__name__)

Erste Regel: Jedes Modul erzeugt seinen eigenen Logger, benannt nach __name__.

# shop/orders.py
import logging

logger = logging.getLogger(__name__)  # Logger-Name: "shop.orders"

class OrderService:
    def place_order(self, order_id: str, quantity: int) -> None:
        logger.info("Bestellung %s mit Menge %d wird aufgegeben", order_id, quantity)
        if quantity > 50:
            logger.warning("Große Bestellung %s: Menge=%d", order_id, quantity)

Logger-Namen bilden eine Hierarchie mit Punkten als Trennzeichen. shop.orders ist Kind von shop, und shop ist Kind von root. Ein Record wandert vom Kind-Logger zum Parent-Logger (genannt propagation). Daher musst du Handler nur bei root oder bei shop anbringen und kannst Level pro Zweig justieren. Du kannst zum Beispiel shop.payments auf DEBUG setzen und den Rest auf INFO.

Beachte auch die Art der Parametrisierung: verwende %s mit Argumenten statt f-Strings. Wenn der Level abgeschaltet ist, wird der String nicht formatiert, sodass du keine Kosten für Logs hast, die niemals gedruckt werden. Log-Aggregatoren können außerdem Zeilen nach demselben Message-Template gruppieren.

Hinweis: In Bibliotheken (Packages, die du anderen zur Verfügung stellst), konfiguriere keine Handler. Rufe nur getLogger(__name__) auf und lass die Anwendung entscheiden. Falls nötig, füge logging.getLogger("your_lib").addHandler(logging.NullHandler()) hinzu.

4. Zentralisierte Konfiguration mit dictConfig

In Production sollte die gesamte Konfiguration an einer Stelle zusammengefasst und beim Anwendungsstart genau einmal aufgerufen werden. logging.config.dictConfig eignet sich dafür.

# shop/logging_config.py
import logging.config
import os

def setup_logging() -> None:
    level = os.getenv("LOG_LEVEL", "INFO")
    logging.config.dictConfig({
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "plain": {
                "format": "%(asctime)s %(levelname)-8s %(name)s - %(message)s",
            },
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "stream": "ext://sys.stdout",
                "formatter": "plain",
            },
        },
        "loggers": {
            "shop": {"level": level},
            "urllib3": {"level": "WARNING"},
        },
        "root": {"level": "WARNING", "handlers": ["console"]},
    })

Wichtige Punkte der Konfiguration:

  • "disable_existing_loggers": False ist in der Praxis zwingend. Der Standard True würde alle Logger deaktivieren, die vor dem dictConfig-Aufruf erstellt wurden, einschließlich der logger am Anfang deiner Module. Das Resultat: Log verschwindet ohne Fehlermeldung.
  • Handler wird nur bei root angebracht. Der Logger shop setzt nur den Level; Records propagieren zum root und werden dort gedruckt.
  • Root auf WARNING setzen, um laute INFO-Logs von Drittanbieter-Bibliotheken zu unterdrücken. Dein Code im shop-Zweig loggt trotzdem auf INFO.
  • Level wird aus der Umgebungsvariable LOG_LEVEL gelesen. Du kannst DEBUG für Fehlerbehebung aktivieren, ohne den Code zu ändern.

Die Funktion im Entry Point aufrufen:

# main.py
from shop.logging_config import setup_logging
from shop.orders import OrderService

setup_logging()
OrderService().place_order("ORD-1001", quantity=80)
2026-10-18 09:15:02,114 INFO     shop.orders - Bestellung ORD-1001 mit Menge 80 wird aufgegeben
2026-10-18 09:15:02,114 WARNING  shop.orders - Große Bestellung ORD-1001: Menge=80

Auf stdout zu schreiben ist die richtige Wahl bei Container-Umgebungen. Docker oder Kubernetes werden diese Stream-Ausgabe sammeln, sodass du nicht selbst Dateien verwalten musst.

5. Strukturierte Logs im JSON-Format

Log-Aggregatoren wie Loki, Elasticsearch oder CloudWatch durchsuchen JSON deutlich besser als freien Text. Du kannst einen eigenen Formatter schreiben, ohne externe Bibliotheken:

# shop/json_formatter.py
import json
import logging
from datetime import datetime, timezone

_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {"message", "asctime"}

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for key, value in record.__dict__.items():
            if key not in _RESERVED and not key.startswith("_"):
                payload[key] = value
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False, default=str)

So funktioniert es:

  • _RESERVED enthält Standard-Attribute eines LogRecord, wie pathname, lineno, args.
  • Alle anderen Attribute werden als benutzerdefinierte Felder betrachtet und ins JSON aufgenommen. Diese Felder kommen typischerweise vom extra=-Parameter oder von einem Filter (siehe Abschnitt 6).
  • default=str verhindert, dass json.dumps bei Typen wie Decimal oder UUID fehlschlägt.

Formatter in dictConfig mit dem speziellen Key "()" deklarieren:

"formatters": {
    "json": {"()": "shop.json_formatter.JsonFormatter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
    },
},

Geschäftsfelder mit extra hinzufügen:

logger.info("Zahlung erfasst", extra={"order_id": "ORD-1001", "amount": 250000})
{"ts": "2026-10-18T02:15:02.114+00:00", "level": "INFO", "logger": "shop.payments", "message": "Zahlung erfasst", "order_id": "ORD-1001", "amount": 250000}

Hinweis: Keys in extra dürfen nicht mit vorhandenen LogRecord-Attributen kollidieren. Beispiel: extra={"name": "..."} oder extra={"message": "..."} führt zu KeyError: "Attempt to overwrite 'name' in LogRecord".

6. Automatisches Anhängen von request_id mit contextvars

extra={"request_id": ...} in jeden Log-Aufruf zu schreiben ist mühsam und fehleranfällig. Besser: speichere die request_id in einer ContextVar und verwende einen Filter, um sie jedem Record hinzuzufügen.

# shop/request_context.py
import logging
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id_var.get()
        return True  # keine Records blockieren, nur Daten hinzufügen

ContextVar ist sicher für Threads und asyncio. Jeder Task oder Request behält seinen eigenen Wert, sodass ein Request nicht die ID eines anderen “ausleiht”.

Binde den Filter an den Handler, nicht am Logger. Ein Filter am Logger läuft nur für Records, die direkt vom Logger erzeugt werden, nicht für Records, die von Kind-Loggern propagieren.

"filters": {
    "request_id": {"()": "shop.request_context.RequestIdFilter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
        "filters": ["request_id"],
    },
},

Setze den Wert am Anfang jedes Requests, zum Beispiel in Middleware:

import uuid
from shop.request_context import request_id_var

def handle_request(order_id: str) -> None:
    token = request_id_var.set(uuid.uuid4().hex[:12])
    try:
        OrderService().place_order(order_id, quantity=3)
    finally:
        request_id_var.reset(token)
{"ts": "...", "level": "INFO", "logger": "shop.orders", "message": "Bestellung ORD-1002 mit Menge 3 wird aufgegeben", "request_id": "9f2c1ab07d3e"}

Jetzt brauchst du nur nach request_id zu filtern, um die gesamte Request-Historie zu sehen.

7. Exceptions richtig loggen

logger = logging.getLogger("shop.payments")

class PaymentService:
    def charge(self, order_id: str, amount: int) -> bool:
        try:
            raise ConnectionError("Gateway-Timeout")
        except ConnectionError:
            logger.exception("Zahlung für Bestellung %s fehlgeschlagen", order_id)
            return False

logger.exception(...) schreibt auf Level ERROR und hängt den Stack Trace automatisch an. Rufe es nur innerhalb eines except-Blocks auf. Falls du auf einem anderen Level loggen möchtest und trotzdem den Stack Trace brauchst, nutze logger.warning("...", exc_info=True).

Zwei häufige Fehler:

  • logger.error(f"Zahlung fehlgeschlagen: {e}") verliert den Stack Trace. Bei der Fehlersuche siehst du nur eine Zeile “Gateway-Timeout”, ohne zu wissen, wo es passiert ist.
  • In jeder Schicht zu loggen und dann erneut zu werfen. Eine Exception taucht fünfmal im Log auf. Logge nur an der Stelle, wo du die Exception behandelst – also dort, wo du entscheidest, sie nicht erneut zu werfen.

8. Logging nicht den Hauptthread blockieren lassen: QueueHandler

StreamHandler zu stdout ist normalerweise schnell genug. Aber Handler, die übers Netzwerk senden (syslog, HTTP, SMTP) oder auf die Festplatte schreiben, werden langsam und blockieren den Request-Verarbeitungs-Thread. Um das zu vermeiden, sammelst du Records in einer Queue und lässt einen Hintergrund-Thread zum eigentlichen Ziel schreiben:

import atexit
import logging
import logging.handlers
import queue

def setup_async_logging(target: logging.Handler) -> None:
    log_queue: queue.Queue = queue.Queue(-1)
    queue_handler = logging.handlers.QueueHandler(log_queue)

    listener = logging.handlers.QueueListener(
        log_queue, target, respect_handler_level=True
    )
    listener.start()
    atexit.register(listener.stop)  # restliche Logs vor Shutdown flushen

    root = logging.getLogger()
    root.handlers = [queue_handler]

Der Ablauf:

  • Der Anwendungs-Thread legt Records nur kurz in die queue. Das ist quasi augenblicklich.
  • QueueListener läuft auf einem eigenen Thread, nimmt Records heraus und leitet sie an target, z. B. einen SysLogHandler.
  • atexit.register(listener.stop) stellt sicher, dass alle verbleibenden Logs geflush werden, bevor der Prozess beendet wird.

Hinweis: Filter wie RequestIdFilter müssen am QueueHandler befestigt werden, nicht am Ziel-Handler. Der Ziel-Handler läuft auf dem Listener-Thread, und ContextVar auf diesem Thread hat nicht den Wert des ursprünglichen Requests.

9. Logs mit pytest caplog testen

Geschäfts-Warnungen – etwa bei großen Bestellmengen – sollten getestet werden. pytest zur Dev-Dependency hinzufügen:

pip install pytest
# tests/test_orders.py
import logging
from shop.orders import OrderService

def test_large_order_logs_warning(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2001", quantity=80)

    warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
    assert len(warnings) == 1
    assert warnings[0].name == "shop.orders"
    assert warnings[0].getMessage() == "Große Bestellung ORD-2001: Menge=80"

def test_small_order_does_not_warn(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2002", quantity=2)

    assert all(r.levelno < logging.WARNING for r in caplog.records)
tests/test_orders.py ..                                  [100%]
2 passed in 0.03s

caplog.at_level senkt temporär den Level des Loggers shop.orders im Bereich des with-Blocks. caplog.records enthält die ursprünglichen LogRecord-Objekte, sodass du Level, Logger-Name und sogar extra-Felder prüfen kannst.

Hinweis: caplog hängt einen Handler an den root logger an. Falls deine Konfiguration "propagate": False für shop setzt, wird der Test keine Records erfassen.

10. Häufige Fehler und was du nicht machen solltest

  • Logging auf Modul-Ebene konfigurieren (basicConfig oder dictConfig beim Import aufrufen). Konfiguriere nur im Entry Point, genau einmal.
  • Handler zweimal anhängen, etwa an shop und root, während propagate immer noch True ist. Jede Log-Zeile wird doppelt gedruckt. Hänge Handler nur an eine Ebene an.
  • Sensible Daten loggen: Passwörter, Token, Kartennummern. Logge nicht das gesamte Request-Objekt. Wenn nötig, schreibe einen Filter, um diese Felder zu maskieren.
  • RotatingFileHandler in Multi-Process-Apps (Gunicorn mit mehreren Workern) nutzen. Mehrere Prozesse, die dieselbe Datei rotieren, führen zu verloren gegangenen Logs. Schreibe zu stdout oder nutze einen QueueHandler, der zu einem einzigen Schreib-Prozess führt.
  • Zu viel auf INFO in heißen Schleifen loggen. Speicher- und Rausch-Kosten explodieren. Solche Logs gehören auf DEBUG.
  • JSON-Formatter beim lokalen Entwickeln nutzen. JSON ist auf den Bildschirm schwer zu lesen. Wähle den Formatter je nach Umgebungsvariable: plain für Entwicklung, json für Production.

11. Fazit

Eine gute Logging-Konfiguration für Production ist nicht komplex – sie erfordert nur Konsistenz. Verwende getLogger(__name__) in jedem Modul. Zentralisiere die Konfiguration mit dictConfig und disable_existing_loggers: False. Schreibe JSON zu stdout, hänge request_id über ContextVar und Filter an, und nutze logger.exception zur Stack-Trace-Erhaltung. Wenn Handler-Ziele langsam sind, wechsle zu QueueHandler und QueueListener.

Zum Schluss: Behandle Logging als Verhalten deines Systems und teste es mit caplog. Wenn es um 2 Uhr nachts ein Problem gibt, werden strukturierte Log-Zeilen wie diese dir helfen, die Ursache viel schneller zu finden.

pythonloggingproductionobservability