Q1With logging.basicConfig(level=logging.INFO), which level is suppressed?
logging — Record instead of print
Learn the 5 log levels and basicConfig formatting, format codes like %(levelname)s, and writing to a file with FileHandler to move past print debugging.
logging is the standard module for recording your app's behavior in stages. Writing debug output with print runs into problems: you forget to remove it in production, you can't tell what's important, and switching to file output later means rewriting everything. With logging, you get importance levels (DEBUG / INFO / WARNING / ERROR / CRITICAL) for filtering output, Formatter for unified formatting, and Handler for switching destinations (stdout / file / remote).
logger.info(...) from your code, the flow is Logger filters by level → Handler decides the destination → Formatter assembles the format. Because the three responsibilities are separated, changing the format or destination in a single place changes the whole behavior.Log levels — 5 importance tiers
logging has 5 levels, and only messages at or above the level set on the Logger are emitted. The standard practice is DEBUG during development and INFO or WARNING in production — and being able to change the output volume from a single setting without touching the code is the biggest difference from print.
Choosing among WARNING / ERROR / DEBUG
With the logger configured, call levels other than INFO and observe the output. Since the level is set to INFO, DEBUG produces no output at all — confirm that too. This is the foundation of logging's "change output volume from one setting" behavior.
Emit logs to a file
Real projects almost always need logs persisted to a file, not just on screen. You'll later want to grep for postmortem investigation or pull them into a monitoring tool. Pass filename="app.log" to basicConfig and the root logger's destination switches to a file — doing the same thing with print would mean rewriting every print call, but with logging you change one config line to redirect.
logging, you flip between them in one config line.filemode is either "a" (default, append) or "w" (overwrite). Append is the production choice — keep history. Overwrite is often more convenient during development — start fresh each time. To emit to both screen and file, you skip basicConfig and combine StreamHandler + FileHandler explicitly (covered later).
StreamHandler and FileHandler separately and addHandler each.import logging
import os
# Create the parent folder first (FileHandler doesn't auto-create it)
os.makedirs("logs", exist_ok=True)
# Pass filename to basicConfig to switch the destination to a file
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s",
filename="logs/app.log", # File output
filemode="w", # "a" (append) or "w" (overwrite)
force=True,
)
logger = logging.getLogger("app")
logger.info("Started up")
logger.warning("Config outdated")
logger.error("DB connection failed")
# Read the file back to confirm the contents
with open("logs/app.log") as f:
print(f.read(), end="")
Define handlers in a separate file
As your app grows, carve the logger config (format, level, destination) out into a dedicated module and have each module import from it. Logger configs no longer duplicate, and you change format/destination in one place. The pattern: build a StreamHandler directly, attach a Formatter, register with logger.addHandler(...) — all of that lives in a separate file.
main.py / orders.py / users.py) just calls setup_logger(__name__) to get a configured logger. To change format or destination, edit log_setup.py only.# log_setup.py — dedicated module for logger config
import logging
def setup_logger(name):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
if not logger.handlers: # Prevent duplicate registration
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("[%(name)s] [%(levelname)s] %(message)s")
)
logger.addHandler(handler)
logger.propagate = False # Don't propagate to root
return logger
# main.py — imports log_setup and uses it
from log_setup import setup_logger
logger = setup_logger("orders")
logger.info("Order received")
More detailed format — timestamp / module / line
The Formatter format string accepts %(...)s-style replacement codes, letting you pack the operationally relevant info onto a single line. Here are the six most-used codes with examples. Logger names can be hierarchical via dot-separated form like parent.child.grandchild — calling getLogger(__name__) from each module automatically records "which module emitted this log".
| Code | What it outputs | Example |
|---|---|---|
| %(asctime)s | Timestamp | 2024-12-01 10:30:45 |
| %(name)s | Logger name (dot-separated for hierarchy) | app.orders |
| %(levelname)s | Log level | INFO / WARNING / ERROR |
| %(funcName)s | Calling function name | process_order |
| %(lineno)d | Calling line number | 42 |
| %(message)s | Message body | Processing order |
import logging
# Detailed format for production
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] [%(levelname)s] %(funcName)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
logger = logging.getLogger("app.orders")
def process_order(order_id):
logger.info(f"Processing order {order_id}")
process_order(1234)
# Sample output:
# 2024-12-01 10:30:45 [app.orders] [INFO] process_order:13 - Processing order 1234
Carve config into a yaml file
Once logger configuration gets complex, instead of hard-coding format / handler / level in Python, carve them out into a yaml config file. logging.config.dictConfig(...) accepts a dict config, so just parse the yaml and pass it in to configure the whole logger setup. Your codebase suddenly handles "different yaml files for production / staging / development" without changes.
# logging.yml — single source for logger config
version: 1
disable_existing_loggers: false
formatters:
default:
format: "[%(name)s] [%(levelname)s] %(message)s"
handlers:
console:
class: logging.StreamHandler
formatter: default
level: INFO
loggers:
app:
level: INFO
handlers: [console]
propagate: false
| Key | Role | Notes |
|---|---|---|
| version | dictConfig schema version | Currently only 1. Required. |
| disable_existing_loggers | Whether to disable existing loggers | false recommended (true silences loggers like app.orders from earlier) |
| formatters | Named list of Formatters | Define under any name (e.g., default), referenced from handlers |
| formatters.<name>.format | Format string (%(...)s codes) | Same as the Formatter argument in code |
| handlers | Named list of Handlers (destinations) | Define console / file / mail and so on |
| handlers.<name>.class | Fully-qualified Handler class name | logging.StreamHandler / logging.FileHandler / logging.handlers.RotatingFileHandler etc. |
| handlers.<name>.formatter | Name of the Formatter to apply | Use a key defined under formatters |
| handlers.<name>.level | Per-Handler level | Filter output more finely than at the Logger level |
| loggers | Named list of Loggers | loggers.app is retrieved via logging.getLogger("app") |
| loggers.<name>.handlers | List of Handler names attached to this logger | Multiple OK, like [console, file] |
| loggers.<name>.propagate | Whether to propagate to parent loggers | false stops propagation to the root logger (avoids double output) |
Log rotation
If logs keep growing, the file balloons indefinitely, so production needs rotation — rename old files into a separate name and delete eventually. logging.handlers ships with two flavors — size-based and time-based — and you pick by use case.
RotatingFileHandler — size-based
RotatingFileHandler(filename, maxBytes, backupCount) is a Handler that switches to a new file when maxBytes would be exceeded. Old files get renamed with a numeric suffix (app.log.1 / app.log.2), and once backupCount is exceeded the oldest is deleted. With maxBytes=10_000_000 (10 MB) + backupCount=5, your logs cycle within a 60 MB ceiling.
maxBytes, it gets renamed app.log → app.log.1, and a fresh app.log is created to keep writing. If app.log.N already exists, it bumps to app.log.N → app.log.N+1, and the oldest file beyond backupCount is deleted.import logging
from logging.handlers import RotatingFileHandler
# Size-based: rotate when maxBytes is exceeded
size_handler = RotatingFileHandler(
"app.log",
maxBytes=10_000_000, # Rotates when this would be exceeded (10 MB)
backupCount=5, # Retains app.log.1 through app.log.5 (max 60 MB total)
)
size_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
TimedRotatingFileHandler — time-based
TimedRotatingFileHandler(filename, when, interval, backupCount) rotates at the time boundary specified by when. Use when="midnight" (every day at 0:00), "H" (hourly), "M" (minute), "S" (second), "D" (daily), and so on. Backups carry timestamp suffixes like app.log.2024-12-01_00-00-00, so the filename tells you "when this log was written" directly.
app.log opens. Older timestamped files past backupCount are auto-deleted.import logging
from logging.handlers import TimedRotatingFileHandler
# Time-based: rotate every day at midnight
day_handler = TimedRotatingFileHandler(
"app.log",
when="midnight", # "S" sec / "M" min / "H" hour / "D" day / "midnight" etc.
interval=1, # Every N units (when="H", interval=6 means every 6 hours)
backupCount=30, # Retain 30 days of history
)
day_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
Knowledge Check
Answer each question one by one.
Q2When you pass filename="app.log" to logging.basicConfig, where do the logs go?
Q3The main benefit of carving logger config (format / level / handler) into a separate module is what?