🎯 Learning Objectives
- Explain why
loggingis superior toprint()for production code. - Use the five standard log levels appropriately.
- Configure logging with
basicConfigfor 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:
- Severity levels — categorise messages as DEBUG, INFO, WARNING, ERROR, or CRITICAL.
- Multiple destinations — send output to the console, a file, a remote server, or all three simultaneously.
- Toggle without code changes — raise or lower verbosity via config; no need to delete or comment out lines.
- Structured output — include timestamps, module names, line numbers automatically.
- 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.pyprint() 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:
| Level | Numeric Value | Typical Use |
|---|---|---|
| NOTSET | 0 | Inherit from parent logger |
| DEBUG | 10 | Detailed diagnostic info for developers |
| INFO | 20 | Confirmation things are working as expected |
| WARNING | 30 | Something unexpected, but the app still works |
| ERROR | 40 | A function failed; the app may partially work |
| CRITICAL | 50 | A 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 50levels_demo.pyEffective 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.
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 startedbasic_console.pyExample 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 consolebasic_file.pyExample 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.pybasicConfig() 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.pyThe 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 = Falsepropagation.pylogging.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.
| Handler | Destination | Key Parameters |
|---|---|---|
StreamHandler | Console (stderr/stdout) | stream |
FileHandler | Single file | filename, mode |
RotatingFileHandler | File with size-based rotation | maxBytes, backupCount |
TimedRotatingFileHandler | File with time-based rotation | when, 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.py6 · 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.pyCommon format tokens:
| Token | Output |
|---|---|
%(levelname)s | Level name (DEBUG, INFO, …) |
%(name)s | Logger name |
%(asctime)s | Timestamp string |
%(message)s | The logged message |
%(filename)s | Source filename |
%(lineno)d | Source line number |
%(funcName)s | Function name |
Example output with the format above:
# 2024-06-15 09:12:45 | ERROR | myapp.db:connect:42 — Connection refusedoutput%(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.pyFor 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.py8 · 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" configdictconfig_demo.py"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.pySample 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- 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.
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
| Command | Short | Action |
|---|---|---|
help | h | Show all commands |
next | n | Step over — execute current line, move to next |
step | s | Step into — enter called function |
continue | c | Resume execution until next breakpoint |
return | r | Run until current function returns |
list | l | Show source around current line |
where | w | Show call stack (traceback) |
print <expr> | p | Evaluate and print an expression |
pp <expr> | — | Pretty-print an expression |
quit | q | Abort the program |
break <line> | b | Set a breakpoint at line number |
clear | cl | Remove 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=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
| Tool | Install | Highlights |
|---|---|---|
| ipdb | pip install ipdb | pdb + IPython shell (tab-completion, syntax highlighting) |
| pudb | pip install pudb | Full-screen TUI debugger in the terminal |
| VS Code debugger | built-in | GUI breakpoints, watch expressions, call stack pane |
| PyCharm debugger | built-in | Similar 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 anexceptblock instead oflogger.error. - Use lazy formatting —
logger.debug("val=%s", val)notlogger.debug(f"val={val}")— f-strings evaluate even when the level is disabled. - Remove all breakpoints before committing — use
git grep -n breakpointas a pre-commit hook. - Use
dictConfigin 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
RotatingFileHandleror delegate to systemd/Docker log drivers.
Exercises
Exercise 1 — Application Logger Setup
Build a small CLI tool that downloads a URL and saves it to disk. Requirements:
- Configure logging via
dictConfigwith two handlers:StreamHandler(INFO+) andRotatingFileHandlerwriting toapp.log(DEBUG+, 1 MB, 3 backups). - Use a named logger
downloaderthroughout. - 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
RequestIdFilterthat attaches arequest_idfrom a thread-local to every log record (usethreading.local()). - Create a
JsonFormatterthat emits each record as a single JSON line containingtime,level,logger,message, andrequest_id. - Attach both to a handler and emit a few test records, showing how
request_idchanges 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.debugcalls showinglo,hi,mid,arr[mid]each iteration. - Run with
PYTHONBREAKPOINT=0to 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