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:
| Level | Value | When to use |
|---|---|---|
| DEBUG | 10 | Detailed info for diagnosing problems |
| INFO | 20 | Confirmation that things work as expected |
| WARNING | 30 | Something unexpected, but the app still works |
| ERROR | 40 | A serious problem — the app could not do something |
| CRITICAL | 50 | A 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:
| Command | What it does |
|---|---|
n (next) | Execute the next line |
s (step) | Step into a function call |
c (continue) | Continue until next breakpoint |
p variable | Print a variable’s value |
pp variable | Pretty-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:
- Click to the left of a line number to add a breakpoint (red dot)
- Press
F5to start debugging - Use the debug toolbar to step through code
- The Variables panel shows all values
- 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.
Related Articles
- Python Tutorial #15: Context Managers — the TimedContext pattern
- Python Tutorial #11: Error Handling — try/except for exception logging
- Python Tutorial #17: Testing with pytest — test your logging output