In the previous tutorial, we learned about databases with SQLite and SQLAlchemy. Now let’s learn about logging and debugging — the professional way to understand what your program is doing.

If you still use print() to debug your code, this tutorial is for you. By the end, you will know how to use Python’s logging module, debug with breakpoint(), and profile your code.

Why print() is Not Enough

Every beginner uses print() for debugging:

def process_order(order_id: int, amount: float) -> str:
    print(f"Processing order {order_id}")  # Debug
    print(f"Amount: {amount}")             # Debug
    if amount > 10000:
        print("WARNING: Large order!")      # Debug
    print("Order processed")               # Debug
    return "success"

This works for small scripts. But in real applications, print() has serious problems:

  • No log levels — you cannot filter debug messages from errors
  • No timestamps — you do not know when something happened
  • No file output — messages go to the console only
  • Cannot be disabled — in production, you see every print
  • No source info — you do not know which file or line produced the message

The logging module solves all of these problems.

The logging Module — Quick Start

Here is the fastest way to start logging:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

logging.info("Application started")
logging.debug("Loading configuration...")
logging.warning("Disk usage is at 85%")
logging.error("Failed to connect to database")

Output:

2026-07-01 10:00:00 | INFO | Application started
2026-07-01 10:00:00 | DEBUG | Loading configuration...
2026-07-01 10:00:00 | WARNING | Disk usage is at 85%
2026-07-01 10:00:00 | ERROR | Failed to connect to database

Every message has a timestamp, a level, and the text. You can filter, search, and redirect these messages.

Log Levels

Python has five log levels, from least to most serious:

LevelValueWhen to use
DEBUG10Detailed info for diagnosing problems
INFO20Confirmation that things work as expected
WARNING30Something unexpected, but the app still works
ERROR40A serious problem — the app could not do something
CRITICAL50A very serious error — the app might crash

When you set the logging level to WARNING, only WARNING, ERROR, and CRITICAL messages appear. DEBUG and INFO are ignored.

import logging

# Only show WARNING and above
logging.basicConfig(level=logging.WARNING)

logging.debug("This will NOT appear")
logging.info("This will NOT appear")
logging.warning("This WILL appear")
logging.error("This WILL appear")

In development: use DEBUG to see everything. In production: use WARNING or INFO to reduce noise.

Creating Named Loggers

For real projects, create named loggers instead of using the root logger:

import logging

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

# Add a handler (where to send messages)
handler = logging.StreamHandler()  # Print to console
handler.setLevel(logging.DEBUG)

# Add a formatter (how to format messages)
formatter = logging.Formatter(
    "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)

logger.info("Application started")
# 2026-07-01 10:00:00 | myapp | INFO | Application started

Named loggers let you control logging per module. For example, you might want DEBUG for your code but WARNING for a noisy library.

Formatters

Formatters control how log messages look. Here are some useful patterns:

Simple (Development)

formatter = logging.Formatter("%(levelname)s: %(message)s")
# ERROR: Connection failed

Detailed (Production)

formatter = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s"
)
# 2026-07-01 10:00:00 | ERROR    | myapp | server.py:42 | Connection failed

JSON (Structured Logging)

formatter = logging.Formatter(
    '{"time": "%(asctime)s", "level": "%(levelname)s", "message": "%(message)s"}'
)
# {"time": "2026-07-01 10:00:00", "level": "ERROR", "message": "Connection failed"}

JSON logging is useful when you send logs to monitoring systems like Elasticsearch or Datadog.

Handlers — Where Logs Go

A handler decides where log messages go:

  • StreamHandler — print to console (stdout/stderr)
  • FileHandler — write to a file
  • RotatingFileHandler — write to a file, rotate when it gets too big

You can have multiple handlers — for example, show INFO on the console and write DEBUG to a file:

import logging

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

# Console: show INFO and above
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(
    logging.Formatter("%(levelname)s: %(message)s")
)

# File: capture everything
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
    logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
)

logger.addHandler(console_handler)
logger.addHandler(file_handler)

logger.debug("This goes to file only")
logger.info("This goes to both console and file")
logger.error("This goes to both console and file")

Logging in Classes

Use logging in your classes to track what they do:

import logging
from dataclasses import dataclass

@dataclass
class OrderProcessor:
    logger: logging.Logger

    def process_order(self, order_id: int, amount: float) -> dict:
        self.logger.info("Processing order %d (amount: %.2f)", order_id, amount)

        if amount <= 0:
            self.logger.error("Invalid amount for order %d: %.2f", order_id, amount)
            return {"status": "error", "message": "Invalid amount"}

        if amount > 10000:
            self.logger.warning("Large order %d requires review", order_id)

        self.logger.info("Order %d processed successfully", order_id)
        return {"status": "success", "amount": amount}

Notice: We use %-style formatting in log messages ("Processing order %d" not f-strings). This is intentional — the logging module only formats the string if the message will actually be displayed. With f-strings, Python formats the string even if the log level is too low to show it.

Logging Exceptions

Use logger.exception() inside except blocks. It automatically includes the full traceback:

import logging

logger = logging.getLogger("myapp")

def divide(a: float, b: float) -> float | None:
    try:
        return a / b
    except ZeroDivisionError:
        logger.exception("Division by zero: %.2f / %.2f", a, b)
        return None

logger.exception() logs at ERROR level and adds the traceback. This is much better than just logging the error message.

Timing with Context Managers

Track how long operations take:

import logging
import time

class TimedContext:
    """Context manager that logs execution time."""

    def __init__(self, name: str, logger: logging.Logger) -> None:
        self.name = name
        self.logger = logger
        self.elapsed = 0.0

    def __enter__(self):
        self._start = time.perf_counter()
        self.logger.debug("Starting: %s", self.name)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self._start
        if exc_type:
            self.logger.error("%s failed after %.3fs", self.name, self.elapsed)
        else:
            self.logger.info("%s completed in %.3fs", self.name, self.elapsed)
        return False

# Usage
with TimedContext("database query", logger) as timer:
    time.sleep(0.1)  # Simulate work
print(f"Took {timer.elapsed:.3f}s")

Debugging with breakpoint()

Python 3.7+ has a built-in breakpoint() function that pauses your program and opens the debugger:

def calculate_total(items: list[dict]) -> float:
    total = 0
    for item in items:
        breakpoint()  # Program pauses here — inspect variables
        total += item["price"] * item["quantity"]
    return total

items = [
    {"name": "Book", "price": 19.99, "quantity": 2},
    {"name": "Pen", "price": 1.50, "quantity": 10},
]
calculate_total(items)

When the program pauses, you are in the pdb debugger. Here are the most useful commands:

CommandWhat it does
n (next)Execute the next line
s (step)Step into a function call
c (continue)Continue until next breakpoint
p variablePrint a variable’s value
pp variablePretty-print a variable
l (list)Show surrounding code
w (where)Show the call stack
q (quit)Quit the debugger

Example Debug Session

> calculate_total() -> total += item["price"] * item["quantity"]
(Pdb) p item
{'name': 'Book', 'price': 19.99, 'quantity': 2}
(Pdb) p total
0
(Pdb) n
> calculate_total() -> for item in items:
(Pdb) p total
39.98
(Pdb) c

Tip: To disable breakpoint() without removing it from code, set the environment variable PYTHONBREAKPOINT=0.

VS Code Debugging

VS Code has a built-in Python debugger that is easier than pdb:

  1. Click to the left of a line number to add a breakpoint (red dot)
  2. Press F5 to start debugging
  3. Use the debug toolbar to step through code
  4. The Variables panel shows all values
  5. The Watch panel lets you track specific expressions

VS Code debugging is visual and beginner-friendly. Use it for complex debugging. Use breakpoint() for quick checks.

Profiling with time.perf_counter()

To find slow parts of your code, measure execution time:

import time

def profile_function(func, *args, **kwargs):
    """Run a function and return (result, elapsed_seconds)."""
    start = time.perf_counter()
    result = func(*args, **kwargs)
    elapsed = time.perf_counter() - start
    return result, elapsed

def slow_sum(n: int) -> int:
    return sum(range(n))

result, elapsed = profile_function(slow_sum, 1_000_000)
print(f"Result: {result}, Time: {elapsed:.4f}s")

For more detailed profiling, use cProfile:

import cProfile

def main():
    total = 0
    for i in range(1000):
        total += sum(range(i))
    return total

cProfile.run("main()")

This shows how many times each function was called and how long it took.

The Copy-Paste Recipe

Here is a ready-to-use logging setup for any project:

import logging

def setup_logging(level: str = "INFO") -> logging.Logger:
    """Set up logging for your project. Call once at startup."""
    logger = logging.getLogger("myapp")
    logger.setLevel(getattr(logging, level))

    if not logger.handlers:
        handler = logging.StreamHandler()
        handler.setFormatter(
            logging.Formatter(
                "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
                datefmt="%Y-%m-%d %H:%M:%S",
            )
        )
        logger.addHandler(handler)

    return logger

# In your main file:
logger = setup_logging("DEBUG")
logger.info("Application started")

Copy this into your project, change "myapp" to your project name, and you have proper logging.

Common Mistakes

1. Using print() in Production

# BAD
print(f"Error: {error}")  # Goes to stdout, no level, no timestamp

# GOOD
logger.error("Processing failed: %s", error)

2. Wrong Log Level

# BAD — this is an error, not info
logger.info("Database connection failed!")

# GOOD — use the right level
logger.error("Database connection failed!")

3. Logging Sensitive Data

# BAD — password in the logs!
logger.info("User login: %s, password: %s", username, password)

# GOOD — never log passwords, tokens, or API keys
logger.info("User login: %s", username)

Source Code

You can find all the code from this tutorial on GitHub:

GitHub: python-tutorial/tutorial-21-logging

What’s Next?

In the next tutorial, we will build our first real project — a CLI tool using Click and Rich. We will apply everything we have learned: classes, error handling, testing, and logging.