Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Debugging

Heinrich-Heine-Universität Düsseldorf

Es gibt sehr unterschiedliche Arten von Fehlverhalten, die ein Programm zeigen kann, aber man kann allen davon systematisch begegnen.

Arten von Fehlern in der Softwareentwicklung

  1. Syntaktische Fehler

    • Der Interpreter weist uns auf diese Fehler hin, wenn wir nicht versehentlich gültigen Code geschrieben haben oder wenn der fehlerhafte Code nicht vom Interpreter erreicht wird (in der Ausführungskonfiguration). Dagegen helfen weitere statische Analysewerkzeuge wie Linter (die den Code lesen, ohne ihn auszuführen, und mögliche Fehlerquellen aufzählen), die oft auch in IDEs integriert sind.

  2. Laufzeitfehler

    • Wie die Division durch 0, Zugriff auf einen Nullpointer, Voller Speicher etc. Dabei hilft uns Exception Handling mit raise und try..except. In diesen Fällen gibt es eine ‘Stack Trace’ aus der sich ablesen lässt, welcher Code genau akut zu diesem Fehler führte. Das ist noch nicht unbedingt die Stelle Code, die man ändern möchte um den Fehler zu beheben, aber immerhin die erste Anlaufstelle, um zu verstehen, was genau passiert ist. Wir modellieren, was passiert ist, und vergleichen das mit unserem mentalen Modell, was passieren sollte, und finden so die Stelle, die wir wirklich ändern wollen (wo unser mentales Modell nicht mit der Wirklichkeit übereinstimmt; entweder wir ändern unser mentales Modell oder den Code).

  3. Semantische Fehler (Logikfehler)

    • Beispiel: Wenn man z.B. x = y + z geschrieben hat, aber x = y * z die richtige Rechnung wäre (um einen bestimmten Algorithmus zu implementieren). Das Programm läuft ohne Fehler, aber die Ausgabe entspricht nicht den Erwartungen. Hier kann man ebenfalls systematisch die Stelle im Code finden, ab der die Erwartungen (unser mentales Modell) nicht mehr der Realität entsprechen. Häufig lassen sich kleinere Methoden isoliert testen, um solche Fehler gering zu halten.

  4. Umgebungsfehler (‘also bei mir klappt alles’)

    • Abhängig von installierten Paket(version)en, Betriebssystem, Zustand der Internetverbindung, Rechte im Dateisystem, API Limits, kann sich ein Programm recht unterschiedlich verhalten. Anwendungen, die in sehr heterogenen Umgebungen eingesetzt werden, müssen daher aufwändig in diesen Umgebungen getestet werden. Dazu sind Continuous Integration und Containerization nützlich.

  5. Nicht-deterministische Fehler (‘Heisenbugs’)

    • Fehler, die sehr fragil von bestimmten Laufzeitparametern abhängen, oft race conditions in Code mit mehreren Threads oder Netzwerkverbindungen. Da print-Statements oft das Timing stark verändern, sind diese Bugs schwer zu debuggen. Häufig ist eine Entkopplung und Vereinfachung von Systemen der einzige Weg, um systematisch race conditions zu vermeiden. Auch intensives Logging mit präzisen Timestamps kann hilfreich sein, um solchen Fehlern auf den Grund zu gehen.

  6. Requirements-Fehler

    • Das Programm tut, was es soll, laut Entwickler; der Nutzer/Auftraggeber hatte sich aber etwas anderes vorgestellt - die Anforderungen sind nicht korrekt umgesetzt. Während es wenig formale Werkzeuge gibt, diese Sorte Fehler zu vermeiden, ist es eine wichtige Fehlerkategorie in der Praxis. In der Regel hat man jede Menge implizite Annahmen, was ‘offensichtlich’ ist. Es kann hilfreich sein, das offensichtliche explizit zu machen.

  7. Bedienfehler

    • Das Programm tut, was vorgesehen ist laut Entwickler; der Nutzer hat das Interface / die Eingabedaten aber nicht so verwendet, wie vorgesehen und dadurch treten Fehler oder fehlerhafte Ausgaben auf. Wenn bei einem Bedienfehler kryptische Fehlermeldungen auftreten, so ist dies eine Version von Requirements-Fehler, denn fast immer (außer bei kurzen Skripten) möchte man Nutzereingaben bzw. Nutzerverhalten so verarbeiten, dass alle möglichen (auch die sinnlosen) Eingaben/Eingabedaten zu gültigen Programmzuständen führen. Dieser Fehlerklasse kann man frühzeitig entgegen wirken, indem man Assertions über Nutzereingaben in den Code schreibt (die dann ggf. aufgefangen werden, um dem Nutzer entsprechendes Feedback zu geben, wenn der ‘Vertrag’ nicht eingehalten wurde). Traditionell ist der Bedienfehler auch als Layer-8-Fehler (PEBKAC) bekannt.

Man könnte hier noch mehr aufzählen, z.B. Varianten von schlecht organisiertem Code, was wiederum eigene Fehlerquellen/-arten mit sich bringt, aber das bringt uns hier nicht so viel.

Debugger helfen im Wesentlichen bei Fehlerklasse 2, dem Laufzeitfehler. Häufig präsentieren sich auch die anderen Fehler auf die ein oder andere Weise als Laufzeitfehler, sodass wir sehr davon profitieren, wenn wir diese systematisch beheben können. Vereinfacht gesagt beruht Debugging auf der wissenschaftlichen Methode: Wir stellen Hypothesen auf und versuchen, diese zu falsifizieren. Dabei werden unsere Hypothesen immer besser/enger. Ein Debugger ist nur ein technisches Hilfsmittel, um schneller Hypothesen zu falsifizieren.

Debugging mit assert und print

Eine typische Hypothese beim Debugging hat z.B. die Form

``Der Inhalt der Variable x in Zeile 12 ist der leere String’’

das können wir leicht testen, indem wir in Zeile 12 die Zeile

assert x == ""

einfügen, und betrachten ob es einen AssertionError gibt. Wenn wir in einer komplexen Umgebung arbeiten, kann es leider sein, dass so ein AssertionError aufgefangen wird, und vielleicht wollen wir auch noch mehr über die Natur des Problems wissen; daher könnten wir auch den Wert von x ausgeben in Zeile 12:

print("Hoping x is the empty string, it is", x)

Noch schöner wäre es, diese Information direkt an eine Assertion zu knüpfen - der gegebene String wird in den AssertionError verpackt.

assert x == "", "We were all expecting x to be empty by now..."

Debugging mit print ist für kurze Skripte genau das richtige, aber für größere Anwendungen ist es deutlich komfortabler mit logging zu arbeiten (das ist wie Java’s Log4j). Es sollten dann eigentlich keine prints mehr zum Debugging verwendet werden.

Breakpoints und pdb

Mit print-Debugging kommt man leicht dazu, dass man das Programm laufen lässt, bis zu der Stelle an der man gerade etwas mit print ausgibt um zu sehen, ob die Erwartungen erfüllt werden. Dann studiert man die Ausgabe und möchte evtl. noch mehr Variablen betrachten. Dazu ändert man den Code und lässt das Programm erneut laufen - sehr ineffizient.

Schöner ist es, an der Stelle im Programm anzuhalten, damit man den Code vor dem Fehler nicht mehrfach wieder ausführen muss. Dazu schreiben wir nur einmalig breakpoint() in den Code. Sobald der Interpreter dahin kommt, springt pdb an, der (interaktive) Python Debugger. Hier kann man die Werte aller Variablen inspizieren, und z.B. Zeile für Zeile durch den Code gehen.

In pdb gibt help eine Übersicht über die verfügbaren Befehle. So liefert z.B. bt eine Backtrace/Stack trace.

Exceptions im Debugger

Traceback

Während in Java die stack traces mit dem Fehler anfangen und dann der Stack weiter (nach unten) wächst, ist es in Python umgekehrt, und der Fehler steht als letztes im Traceback (chronologisch, most recent call last).

Häufig ist der konkrete Fehler, der ganz unten steht, innerhalb einer Bibliothek, deren Code man gar nicht unbedingt so gut kennt. Anstatt nun den Code der Bibliothek zu lesen, sucht man zunächst nach der Schnittstelle, also dem Code, der die Bibliothek aufruft -- den hier ist mutmaßlich eine Annahme verletzt (zumindest wenn wir erstmal davon ausgehen, dass der Fehler bei uns liegt und nicht bei der externen Bibliothek).

Beispiel: Indexfehler

Wir debuggen die folgende Methode:

def find_peaks(values):
    peaks = []
    for i in range(len(values)):
        current_val = values[i]
        next_val = values[i+1]  # <--- Oh!
        if current_val > next_val:
            peaks.append(current_val)
    return peaks

Diesen Code können wir ausführen, es wird ohne Probleme eine Methode find_peaks deklariert. Soweit, so gut. Wir rufen den Code auf:

data = [10, 50, 30, 20, 40]
print(find_peaks(data))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in find_peaks
IndexError: list index out of range

Zeile 5 ist die mit # <--- Oh! markierte Zeile. Der IndexError kann sich nur auf den Ausdruck values[i+1] beziehen, d.h. es gibt innerhalb der for-Schleife irgendwann ein i sodass i+1 außerhalb des halboffenen Intervalls [0,len(values))[0, \texttt{len(values)}) liegt. Das ist äquivalent dazu, dass i+1 >= len(values), also i >= len(values) - 1. Da die Schleife durch range(len(values)) iteriert, nimmt i die ganzzahligen Werte von 0 bis len(values)-1 einschließlich an. Für genau den letzten Wert gilt i = len(values) - 1, dabei tritt der Fehler auf.

Anstatt das selbst logisch zu analysieren, können wir natürlich an der fraglichen Stelle einfach print(i) aufrufen und somit sehr schnell empirisch den Fehler finden. Wenn wir uns nun vorstellen, dass wir nicht so leicht auf die Idee kämen, dass i das Problem ist, würden wir vielleicht mit breakpoint() als erste Zeile im Schleifenkörper arbeiten und dann in Ruhe alle lokalen Variablen inspizieren. In Ruhe von Hand mehrfach durch die Schleife gehen ist nicht so effizient, daher wäre vielleicht ein conditional breakpoint nützlich, etwa mit der Bedingung i >= len(values)-2, wenn man schon kapiert hat, dass das Problem gegen Ende der Schleife auftritt. Entweder bietet unsere IDE conditional breakpoints an, oder wir schreiben if(condition): breakpoint().

Der beste Fix für den hier vorgestellten Fehler ist eigentlich nicht, den Index zu korrigieren, bis zu dem iteriert wird - es ist, die Iteration semantisch sorgfältiger zu beschreiben; hier wäre es vorteilhafter, mit pairwise (oder einfacher zip) zu arbeiten und auf explizite Indices vollständig zu verzichten.

Fehlerbehandlung für den Kontrollfluss

Seit Python 3.11 sind sogenannte Zero-Cost Exceptions umgesetzt: Die interne Maschinerie wurde so optimiert, dass das Eintreten in einen try-Block praktisch keine Rechenzeit mehr kostet. Ein Performance-Overhead entsteht erst dann, wenn tatsächlich eine Exception geworfen wird. Dies macht das try..except-Muster als legitimes Kontrollfluss-Werkzeug attraktiv.

Fehlermeldungen in Python 3.14

In Python 3.9 wurde ein neuer Parser eingeführt, mit dessen Hilfe in Python 3.10, 3.11 und 3.12 kleinere Verbesserungen bei den Fehlermeldungen erreicht werden konnten. In 3.13 sind die Formatierungen (auf mehreren Zeilen) verbessert worden. In Python 3.14 wurden besondere Vorkehrungen getroffen für SyntaxError, ValueError und TypeError, sodass es gerade für Anfänger leichter ist, die Fehler zu beheben.

Daher ist es möglicherweise eine gute Idee, direkt mit Python 3.14 zu arbeiten, wenn man sich noch mit der Syntax schwer tut.

Logging

Logging (das Protokollieren von Programmabläufen) hilft enorm beim Debugging, weil man nicht erst mühsam Introspektion in den Programmablauf durch zusätzlichen Debug-Code benötigt, wenn die Logs schon aussagekräftig sind. Zudem lassen sich in Logfiles teilweise Muster erkennen, die man durch gezieltes Debuggen nicht notwendig gesehen hätte (wenn etwa eine Produktiv- von einer Testumgebung abweicht).

Im Lebenszyklus eines jeden Projekts kommt irgendwann der Moment, an dem nackte print-Statements größtenteils durch spezifischere Logging-Direktiven ersetzt werden. Das ist eine Art von Refinement, bei der nun zusätzlich spezifiziert werden muss, auf welcher Ebene geloggt werden soll (Error, Warning, Info, Debug). Außerdem lässt sich Logging nicht-blockierend konfigurieren, während print ein Programm erheblich verlangsamen kann.

import logging
logger = logging.getLogger(__name__)
logging.basicConfig(filename='myapp.log', level=logging.DEBUG)

logger.info('Started')
x = 2*3
logger.warning(f"x is {x}, which is {'larger than' if x > 6 else 'smaller or equal to'} 6")
logger.info('Finished')

with open('myapp.log') as logfile:
    for i, line in enumerate(logfile):
            print(i, line.strip())
        
# Output:
# 0 INFO:__main__:Started
# 1 WARNING:__main__:x is 6, which is smaller or equal to 6
# 2 INFO:__main__:Finished

Wenn man in eine Datei loggt, kann diese irgendwann zu groß werden, weshalb Logfiles rotiert werden, z.B. mit einem RotatingFileHandler.

Es lässt sich auch nah am print-Modus bleiben, indem man keine Datei verwendet, sondern den Standard-Output. Das ist für Loglevel INFO der Default, wenn man keine Datei angibt.

Generell lässt sich der Output mit Templates formatieren:

import logging

FORMAT = '%(asctime)s %(clientip)-15s %(user)-8s %(message)s'
logging.basicConfig(format=FORMAT, level=logging.INFO)

logger = logging.getLogger(__name__)
extra_data = {'clientip': '192.168.0.1', 'user': 'someuser'}
logger.info('Something happens', extra = extra_data)
# Output: 2026-05-08 17:25:02,537 192.168.0.1     someuser Something happens

In der Fehlerbehandlung wollen wir die Stack Trace mitloggen, was exception automatisch macht:

import logging
logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

try:
    1 / 0
except ZeroDivisionError:
    logger.exception("Damn!")
finally:
    print("Weiter geht's")

# Output:
# 2026-05-08 17:27:37,853 - Damn!
# Traceback (most recent call last):
#  File "<stdin>", line 2, in <module>
# ZeroDivisionError: division by zero
# Weiter geht's

Das Paket logging aus der Standardbibliothek verhält sich ähnlich zu Log4J aus der Java-Welt, aber es gibt natürlich noch andere Logging-Bibliotheken für Python wie z.B. Loguru oder Structlog, was besonders dann nützlich ist, wenn man strukturierte Daten loggen möchte. Wenn man logging in einer Einzel-Prozess-Anwendung einsetzt, ist es thread-safe, d.h. mehrere Threads können auf dem selben Logfile schreiben. Mehrere Prozesse sind hingegen nicht möglich!

Dynamische Code-Ausführung

Gelegentlich ist es beim Debugging oder in hochdynamischen Architekturen nötig, Quelltext erst zur Laufzeit zu evaluieren. Python bietet dafür drei Built-ins: eval, exec und compile.

x = 5
print(eval("x + 10")) # Output: 15
code_str = "print('Schöner Code')"
exec(code_str) # Output: Schöner Code

Man kann mit der vollen Methodensignatur exec(object, globals=None, locals=None) auch Variablen (also den Scope) mitgeben.

compile(source, filename, mode) übersetzt Quellcode in ein Code-Objekt. Der Parameter mode entscheidet über die Art der Übersetzung: 'eval' (für einzelne Ausdrücke), 'exec' (für Anweisungsblöcke) oder 'single' (für eine einzelne interaktive Anweisung). Das resultierende Objekt kann an eval oder exec übergeben werden. Dies ist performanter, wenn derselbe dynamische Code mehrfach ausgeführt werden soll, da der Parse- und Kompilierungsschritt nur einmalig anfällt.

Bytecode-Analyse und Introspektion

Um tiefer in die Funktionsweise des CPython-Interpreters zu blicken, etwa um unerklärlichen Performance-Flaschenhälsen auf den Grund zu gehen oder um zu lernen, was wirklich passiert, lässt sich der erzeugte Bytecode mit dem Disassembler dis analysieren.

import dis

def add(a, b):
    return a + b

dis.dis(add)
# Output:
#  4           RESUME                   0
#
#  5           LOAD_FAST_LOAD_FAST      1 (a, b)
#              BINARY_OP                0 (+)
#              RETURN_VALUE

Für den direkten programmatischen Zugriff auf diesen Low-Level-Zustand besitzt jedes Funktionsobjekt das Attribut __code__, welches das Metadaten- und Code-Objekt speichert. Die rohen Bytecode-Instruktionen (als Byte-String) lassen sich über __code__.co_code abrufen:

code = add.__code__
print(code.co_code)     # Output: b'\x95\x00X\x01-\x00\x00\x00$\x00'
print(code.co_varnames) # Output: ('a', 'b')