🟣 Advanced

Logging & Debugging

📖 Lesson 35 ⏱ 45 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Explain why logging is superior to print() for production code.
  • Use the five standard log levels appropriately.
  • Configure logging with basicConfig for quick scripts.
  • Build a logger hierarchy using getLogger(__name__) and understand propagation.
  • Attach Handlers (Stream, File, Rotating) and Formatters to loggers.
  • Set up centralized configuration via dictConfig.
  • Emit structured JSON logs suitable for cloud observability platforms.

1 · Why Logging?

Every beginner reaches for print() to inspect values. That works during a debugging session, but it falls apart the moment code ships to production. The logging module in the standard library solves five problems that print() cannot:

  1. Severity levels — categorise messages as DEBUG, INFO, WARNING, ERROR, or CRITICAL.
  2. Multiple destinations — send output to the console, a file, a remote server, or all three simultaneously.
  3. Toggle without code changes — raise or lower verbosity via config; no need to delete or comment out lines.
  4. Structured output — include timestamps, module names, line numbers automatically.
  5. Thread-safe — safe to call from multiple threads without garbled output.

Compare the two approaches:

# The print() approach — quick, but no metadata, no off-switch
print("Something went wrong with user", user_id)

# The logging approach — severity, timestamp, and toggleable
import logging
logging.warning("Something went wrong with user %s", user_id)
comparison.py
Key idea: print() is for the developer's terminal during development. logging is for every environment — dev, staging, and production.

2 · Log Levels

The standard library defines six levels, each with a numeric value:

LevelNumeric ValueTypical Use
NOTSET0Inherit from parent logger
DEBUG10Detailed diagnostic info for developers
INFO20Confirmation things are working as expected
WARNING30Something unexpected, but the app still works
ERROR40A function failed; the app may partially work
CRITICAL50A serious failure; the app may crash
import logging

logging.debug("Variable x = %d", x)        # Level 10
logging.info("Server started on port %d", port)  # Level 20
logging.warning("Disk usage above 80%%")    # Level 30
logging.error("Failed to connect to DB: %s", err)  # Level 40
logging.critical("Out of memory — shutting down")   # Level 50
levels_demo.py

Effective level inheritance: If a logger's level is NOTSET (0), it walks up the hierarchy until it finds an ancestor with a level set, and uses that. The root logger defaults to WARNING (30), so by default only WARNING and above are emitted.

Tip: Set the root level to DEBUG during development, and WARNING or ERROR in production — one-line config change, zero code edits.

3 · Basic Setup with basicConfig

logging.basicConfig() is the fastest way to get logging running. It configures the root logger with a handler and formatter in a single call.

Key parameters:

  • level — minimum severity to emit (e.g. logging.DEBUG).
  • format — format string for each log record.
  • filename — if given, logs go to this file instead of stderr.
  • filemode'a' (append, default) or 'w' (overwrite).

Example 1 — Console only

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(message)s",
)

logging.info("App started")
# 2024-06-15 09:01:32,451 [INFO] App started
basic_console.py

Example 2 — File only

import logging

logging.basicConfig(
    level=logging.WARNING,
    filename="app.log",
    filemode="a",
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

logging.error("Database timeout")
# Written to app.log, not the console
basic_file.py

Example 3 — Both console and file

import logging
import sys

# basicConfig sets up the file handler
logging.basicConfig(
    level=logging.DEBUG,
    filename="debug.log",
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)

# Add a second handler for the console
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
logging.getLogger().addHandler(console)

logging.debug("Only in file")
logging.info("In file AND console")
basic_both.py
Warning: basicConfig() is a one-shot function. If any handler is already attached to the root logger (including from an earlier basicConfig call or a library import), subsequent calls are silently ignored. Call it as early as possible — before importing other modules that log.

4 · Logger Hierarchy

Loggers form a tree rooted at the root logger (unnamed, accessed via logging.getLogger()). Child loggers are created by dot-separated names:

import logging

# Best practice: one logger per module
logger = logging.getLogger(__name__)  # e.g. "myapp.services.auth"
hierarchy.py

The hierarchy follows the dots in the name:

root ("")
├── myapp
│   ├── myapp.services
│   │   ├── myapp.services.auth
│   │   └── myapp.services.payment
│   └── myapp.models
└── urllib3

Propagation: When a child logger emits a record, it passes the record up to each ancestor's handlers. This means you only need to configure a handler on the root (or a high-level parent) to capture everything.

import logging

# Suppose root has a StreamHandler
logging.basicConfig(level=logging.DEBUG)

child = logging.getLogger("myapp.db")
child.setLevel(logging.WARNING)

child.info("Ignored — below child's effective level")
child.warning("Handled by child, then propagated to root's handler")

# Stop propagation when a child has its own handler:
child.propagate = False
propagation.py
Tip: Always use logging.getLogger(__name__) in libraries. Let the application owner decide where logs go by configuring the root or a top-level parent logger.

5 · Handlers

A Handler sends log records to a destination. You can attach multiple handlers to a single logger, each with its own level and formatter.

HandlerDestinationKey Parameters
StreamHandlerConsole (stderr/stdout)stream
FileHandlerSingle filefilename, mode
RotatingFileHandlerFile with size-based rotationmaxBytes, backupCount
TimedRotatingFileHandlerFile with time-based rotationwhen, interval, backupCount
import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)

# 1) Console handler — INFO and above
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)

# 2) Rotating file handler — DEBUG and above, 5 MB per file, keep 3 backups
rfh = RotatingFileHandler(
    "app.log", maxBytes=5_000_000, backupCount=3
)
rfh.setLevel(logging.DEBUG)

# 3) Timed rotation — rotate at midnight, keep 7 days
tfh = TimedRotatingFileHandler(
    "daily.log", when="midnight", backupCount=7
)
tfh.setLevel(logging.WARNING)

# Attach all handlers
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
for h in (ch, rfh, tfh):
    h.setFormatter(formatter)
    logger.addHandler(h)

logger.debug("Goes to rotating file only")
logger.info("Goes to console + rotating file")
logger.error("Goes to all three destinations")
handlers_demo.py

6 · Formatters

A Formatter converts a LogRecord into a string. Create one with a format string and an optional date format:

import logging

fmt = logging.Formatter(
    fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d — %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
formatter.py

Common format tokens:

TokenOutput
%(levelname)sLevel name (DEBUG, INFO, …)
%(name)sLogger name
%(asctime)sTimestamp string
%(message)sThe logged message
%(filename)sSource filename
%(lineno)dSource line number
%(funcName)sFunction name

Example output with the format above:

# 2024-06-15 09:12:45 | ERROR    | myapp.db:connect:42 — Connection refused
output
Tip: Use %(levelname)-8s (left-aligned, padded to 8 chars) so your log columns line up neatly in the terminal.

7 · Filters

Filters provide fine-grained control beyond level-based filtering. A filter can be attached to a handler (affects only that destination) or directly to a logger (affects all its handlers).

The simplest built-in filter passes only records from a named logger subtree:

import logging

# Only allow records from "myapp.services" and its children
f = logging.Filter("myapp.services")
handler.addFilter(f)
builtin_filter.py

For custom logic, subclass logging.Filter and override filter():

import logging

class DropNoisyModule(logging.Filter):
    """Suppress all records from the 'urllib3' logger."""

    def filter(self, record: logging.LogRecord) -> bool:
        # Return True to keep the record, False to drop it
        return not record.name.startswith("urllib3")


# Attach to a handler — only this destination is affected
console_handler.addFilter(DropNoisyModule())

# Or attach to a logger — all of its handlers are affected
logger = logging.getLogger("myapp")
logger.addFilter(DropNoisyModule())
custom_filter.py
Handler-level vs Logger-level filtering: Attach the filter to a handler when you want to silence noise in one destination only (e.g., the console) while still writing everything to a file. Attach it to the logger when you want to suppress records globally before they reach any handler.

8 · Configuration with dictConfig

For larger applications, define your entire logging setup declaratively in a dictionary and load it with logging.config.dictConfig(). This is the recommended approach because:

  • All config lives in one place — easy to review and version-control.
  • Swapping between environments (dev / staging / prod) means swapping one dict.
  • It can be loaded from YAML, JSON, or a settings module.
import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "standard": {
            "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
            "datefmt": "%Y-%m-%d %H:%M:%S",
        },
        "brief": {
            "format": "[%(levelname)s] %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "brief",
            "stream": "ext://sys.stdout",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "standard",
            "filename": "app.log",
            "maxBytes": 10_000_000,
            "backupCount": 5,
        },
    },
    "loggers": {
        "myapp": {
            "level": "DEBUG",
            "handlers": ["console", "file"],
            "propagate": False,
        },
        "urllib3": {
            "level": "WARNING",
        },
    },
    "root": {
        "level": "WARNING",
        "handlers": ["console"],
    },
}

logging.config.dictConfig(LOGGING_CONFIG)

logger = logging.getLogger("myapp.api")
logger.info("Server ready")  # goes to console + file via "myapp" config
dictconfig_demo.py
Warning: Set "disable_existing_loggers": False unless you intentionally want to silence loggers created before dictConfig runs (e.g., those from imported libraries).

Brief mention — fileConfig: The older logging.config.fileConfig() reads an INI-style file. It still works but lacks the flexibility of dictConfig (no custom objects, no incremental config). Prefer dictConfig for new projects.

9 · Structured / JSON Logging

Plain-text logs are human-readable, but hard for machines to parse at scale. Modern cloud platforms (AWS CloudWatch, GCP Logging, Datadog, ELK) work best with structured JSON logs — each record is a JSON object with consistent fields.

The python-json-logger library adds a JSON formatter that plugs directly into the standard logging framework:

# pip install python-json-logger
import logging
from pythonjsonlogger import jsonlogger

logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
    fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
    rename_fields={"asctime": "timestamp", "levelname": "level"},
)
handler.setFormatter(formatter)
logger.addHandler(handler)

logger.info("User signed in", extra={"user_id": 42, "ip": "10.0.0.1"})
json_logging.py

Sample JSON output (one line, pretty-printed here for clarity):

{
  "timestamp": "2024-06-15 09:30:12,003",
  "level": "INFO",
  "name": "myapp",
  "message": "User signed in",
  "user_id": 42,
  "ip": "10.0.0.1"
}
output.json
Why structured logs matter:
  • Filter by field (e.g., all ERROR logs where user_id == 42).
  • Aggregate and alert on numeric fields automatically.
  • Correlate across services using a shared request_id.
Alternative — structlog: The third-party structlog library offers a pipeline-based approach with bound loggers, processors, and pretty console output during development. It can wrap the standard library or work standalone. Consider it for greenfield projects that want maximum flexibility.

Debugging with pdb & breakpoint()

Python ships with a built-in interactive debugger — pdb (Python Debugger). Since Python 3.7 you trigger it with a single call to breakpoint().

def calculate_discount(price, pct):
    breakpoint()          # execution pauses here; opens (Pdb) prompt
    discount = price * pct / 100
    return price - discount

calculate_discount(100, 20)
debug_demo.py

Essential pdb Commands

CommandShortAction
helphShow all commands
nextnStep over — execute current line, move to next
stepsStep into — enter called function
continuecResume execution until next breakpoint
returnrRun until current function returns
listlShow source around current line
wherewShow call stack (traceback)
print <expr>pEvaluate and print an expression
pp <expr>Pretty-print an expression
quitqAbort the program
break <line>bSet a breakpoint at line number
clearclRemove a breakpoint
display <expr>Auto-print expression each step

Post-Mortem Debugging

Inspect the state at the point a program crashed without rerunning it:

import pdb, traceback

def risky():
    data = [1, 2, 3]
    return data[10]          # IndexError

try:
    risky()
except Exception:
    traceback.print_exc()
    pdb.post_mortem()        # opens (Pdb) at the crash site
post_mortem.py

Running from the Command Line

# Drop into pdb at the first line of the script
python -m pdb my_script.py

# Post-mortem: run normally; if it crashes, open pdb at the exception
python -m pdb -c continue my_script.py
terminal
PYTHONBREAKPOINT env var — set PYTHONBREAKPOINT=0 to silently skip all breakpoint() calls in production. Set it to pudb.set_trace or ipdb.set_trace to swap the debugger.

Useful Third-Party Debuggers

ToolInstallHighlights
ipdbpip install ipdbpdb + IPython shell (tab-completion, syntax highlighting)
pudbpip install pudbFull-screen TUI debugger in the terminal
VS Code debuggerbuilt-inGUI breakpoints, watch expressions, call stack pane
PyCharm debuggerbuilt-inSimilar GUI; excellent for Django/Flask projects

Best Practices

Library vs Application Logging

# ── In a LIBRARY ── (mylib/utils.py)
import logging

# Use __name__ — gives 'mylib.utils'; let the application configure the handler
logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())   # library standard — silent by default


# ── In an APPLICATION ── (main.py)
import logging
import logging.config
from mylib.utils import do_work

LOGGING_CONFIG = {
    "version": 1,
    "formatters": {
        "default": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "default",
        }
    },
    "root": {"level": "INFO", "handlers": ["console"]},
}

logging.config.dictConfig(LOGGING_CONFIG)

do_work()   # mylib.utils log records now flow through the app's handler
library_vs_app.py

Golden Rules

  • Never use the root logger in library code — always getLogger(__name__).
  • Never add handlers in library code — add NullHandler() only; let the application decide output.
  • Log exceptions with traceback — use logger.exception(msg) inside an except block instead of logger.error.
  • Use lazy formattinglogger.debug("val=%s", val) not logger.debug(f"val={val}") — f-strings evaluate even when the level is disabled.
  • Remove all breakpoints before committing — use git grep -n breakpoint as a pre-commit hook.
  • Use dictConfig in applications — it is declarative, easy to swap per environment, and the standard for frameworks like Django/Flask.
  • Rotate logs in production — unbounded log files fill disks; use RotatingFileHandler or delegate to systemd/Docker log drivers.
Log at the right level: DEBUG for development noise, INFO for significant events (startup, config loaded, request received), WARNING for recoverable issues, ERROR for failures that need attention, CRITICAL for failures that crash the process. Avoid logging at WARNING+ in normal happy-path code.

Exercises

Exercise 1 — Application Logger Setup

Build a small CLI tool that downloads a URL and saves it to disk. Requirements:

  • Configure logging via dictConfig with two handlers: StreamHandler (INFO+) and RotatingFileHandler writing to app.log (DEBUG+, 1 MB, 3 backups).
  • Use a named logger downloader throughout.
  • Log: start/end of download (INFO), byte count received (DEBUG), HTTP errors (ERROR with exc_info=True), file write success (INFO).
  • Wrap the request in try/except and use logger.exception() to capture the full traceback on failure.
💡 Hint
import logging, logging.config, urllib.request, pathlib

LOGGING_CONFIG = {
    "version": 1,
    "formatters": {
        "verbose": {
            "format": "%(asctime)s [%(levelname)-8s] %(name)s %(funcName)s:%(lineno)d — %(message)s",
            "datefmt": "%Y-%m-%d %H:%M:%S",
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "verbose",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "verbose",
            "filename": "app.log",
            "maxBytes": 1_048_576,
            "backupCount": 3,
        },
    },
    "loggers": {
        "downloader": {"level": "DEBUG", "handlers": ["console", "file"], "propagate": False}
    },
}

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("downloader")

def download(url: str, dest: str) -> None:
    logger.info("Starting download: %s", url)
    try:
        with urllib.request.urlopen(url) as resp:
            data = resp.read()
        logger.debug("Received %d bytes", len(data))
        pathlib.Path(dest).write_bytes(data)
        logger.info("Saved to %s", dest)
    except Exception:
        logger.exception("Download failed for %s", url)

download("https://httpbin.org/get", "response.json")

Exercise 2 — Custom Filter & JSON Formatter

Write a production-ready logging setup for a web API:

  • Create a RequestIdFilter that attaches a request_id from a thread-local to every log record (use threading.local()).
  • Create a JsonFormatter that emits each record as a single JSON line containing time, level, logger, message, and request_id.
  • Attach both to a handler and emit a few test records, showing how request_id changes per "request".
💡 Hint
import json, logging, threading
from datetime import datetime, timezone

_ctx = threading.local()

class RequestIdFilter(logging.Filter):
    def filter(self, record):
        record.request_id = getattr(_ctx, "request_id", "-")
        return True

class JsonFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({
            "time":       datetime.now(timezone.utc).isoformat(),
            "level":      record.levelname,
            "logger":     record.name,
            "message":    record.getMessage(),
            "request_id": getattr(record, "request_id", "-"),
        })

handler = logging.StreamHandler()
handler.addFilter(RequestIdFilter())
handler.setFormatter(JsonFormatter())

log = logging.getLogger("api")
log.addHandler(handler)
log.setLevel(logging.DEBUG)
log.propagate = False

def handle_request(rid: str, msg: str):
    _ctx.request_id = rid
    log.info(msg)

handle_request("req-001", "GET /users")
handle_request("req-002", "POST /orders")
handle_request("req-003", "DELETE /item/42")

Exercise 3 — Debug a Broken Binary Search

The function below has a subtle bug. Use breakpoint() and pdb to find it, then fix it and add logging statements to trace execution:

def binary_search(arr, target):
    lo, hi = 0, len(arr)          # bug is here
    while lo < hi:
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid
        else:
            hi = mid
    return -1

data = list(range(0, 100, 2))     # [0, 2, 4, ... 98]
print(binary_search(data, 42))    # should return 21; hangs instead

Requirements:

  • Insert breakpoint(), step through with pdb, identify both bugs.
  • Fix the bugs (two lines need changing).
  • Add logger.debug calls showing lo, hi, mid, arr[mid] each iteration.
  • Run with PYTHONBREAKPOINT=0 to confirm logging works without the debugger.
💡 Solution
import logging, os

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

def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1      # fix 1: hi = len-1, not len
    while lo <= hi:               # fix 2: <= not <
        mid = (lo + hi) // 2
        logger.debug("lo=%d hi=%d mid=%d arr[mid]=%d", lo, hi, mid, arr[mid])
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1           # fix 3: mid+1 to avoid infinite loop
        else:
            hi = mid - 1           # fix 4: mid-1
    return -1

data = list(range(0, 100, 2))
print(binary_search(data, 42))    # 21