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:
printhat 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.basicConfigwirkt 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ügelogging.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": Falseist in der Praxis zwingend. Der StandardTruewürde alle Logger deaktivieren, die vor demdictConfig-Aufruf erstellt wurden, einschließlich derloggeram Anfang deiner Module. Das Resultat: Log verschwindet ohne Fehlermeldung.- Handler wird nur bei
rootangebracht. Der Loggershopsetzt nur den Level; Records propagieren zum root und werden dort gedruckt. - Root auf
WARNINGsetzen, um lauteINFO-Logs von Drittanbieter-Bibliotheken zu unterdrücken. Dein Code imshop-Zweig loggt trotzdem aufINFO. - Level wird aus der Umgebungsvariable
LOG_LEVELgelesen. Du kannstDEBUGfü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:
_RESERVEDenthält Standard-Attribute einesLogRecord, wiepathname,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=strverhindert, dassjson.dumpsbei Typen wieDecimaloderUUIDfehlschlä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
extradürfen nicht mit vorhandenenLogRecord-Attributen kollidieren. Beispiel:extra={"name": "..."}oderextra={"message": "..."}führt zuKeyError: "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. QueueListenerläuft auf einem eigenen Thread, nimmt Records heraus und leitet sie antarget, z. B. einenSysLogHandler.atexit.register(listener.stop)stellt sicher, dass alle verbleibenden Logs geflush werden, bevor der Prozess beendet wird.
Hinweis: Filter wie
RequestIdFiltermüssen amQueueHandlerbefestigt werden, nicht am Ziel-Handler. Der Ziel-Handler läuft auf dem Listener-Thread, undContextVarauf 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:
caploghängt einen Handler an den root logger an. Falls deine Konfiguration"propagate": Falsefürshopsetzt, 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
shopund root, währendpropagateimmer nochTrueist. 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. RotatingFileHandlerin 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 einenQueueHandler, der zu einem einzigen Schreib-Prozess führt.- Zu viel auf
INFOin heißen Schleifen loggen. Speicher- und Rausch-Kosten explodieren. Solche Logs gehören aufDEBUG. - JSON-Formatter beim lokalen Entwickeln nutzen. JSON ist auf den Bildschirm schwer zu lesen. Wähle den Formatter je nach Umgebungsvariable:
plainfür Entwicklung,jsonfü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.