Learn by reading through in order

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).

How logging processes a call
Codelogger.info(...)LoggerFilter by levelHandlerPick destinationFormatterAssemble format
When you call logger.info(...) from your code, the flow is Logger filters by levelHandler decides the destinationFormatter 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.

The 5 logging levels
DEBUGDetailINFOProgressWARNINGCautionERRORFailureCRITICALFatal
DEBUG (detailed) / INFO (normal progress) / WARNING (caution) / ERROR (failure) / CRITICAL (catastrophic). Only messages at or above the Logger's level are emitted — DEBUG in development, INFO/WARNING in production is the standard pattern.

First, configure formatting and level with basicConfig and emit one INFO log.

① Import logging and configure basicConfig with level=logging.INFO, format="[%(levelname)s] %(message)s", force=True

② Get the logger via logger = logging.getLogger("app")

③ Emit one log via logger.info("App started")

(If your code runs correctly, the explanation will appear.)

Python Editor

Run code to see output

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.

Reuse the logger config from Practice 1 and emit at three levels. Note: at INFO, DEBUG does not appear.

① Emit WARNING via logger.warning("Config file is outdated")

② Emit ERROR via logger.error("DB connection failed")

③ Call logger.debug("Detailed trace") — at INFO level, nothing should print

Python Editor

Run code to see output

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.

Screen output vs. file output
logger.info(...)(screen only)Terminal closes→ Logs lostlogger.info(...)filename=app.logLater grep / pull intomonitoring tools
Screen output disappears when the terminal closes, but file output survives for postmortem grep and monitoring tool ingestion. With 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).

filemode and Handler combinations
filemode="a" append→ Keep history (prod)filemode="w" overwrite→ Clean slate (dev)StreamHandler→ Screen outputFileHandler→ File output
filemode="a" appends and keeps history (production), while filemode="w" overwrites for a clean slate each run (development). To send to both screen and file, build 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="")

Pass filename to basicConfig to switch logs to a file, then read the contents back from disk.

① Use os.makedirs("logs", exist_ok=True) to create the parent folder first (FileHandler doesn't auto-create it)

② Import logging and call basicConfig with level=logging.INFO, format="[%(levelname)s] %(message)s", filename="logs/app.log", filemode="w", force=True

③ Get the logger with logger = logging.getLogger("app")

④ Emit 3 logs at INFO / WARNING / ERROR with messages "Started up", "Config outdated", "DB connection failed"

⑤ Open logs/app.log for reading, then under the heading --- contents of logs/app.log --- print the contents without a trailing newline (print(content, end=""))

Python Editor

Run code to see output

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.

Centralize log_setup; modules import from it
log_setup.py(format, destination, level)main.pyorders.pyusers.pyimportimportimport
log_setup.py is the single source for logger config (format, destination, level). Each module (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")

log_setup.py is provided (open the 📂 file panel on the left of the console to inspect it). From main.py, import setup_logger and emit 3 logs with the logger named "orders".

① Import the function via from log_setup import setup_logger

② Get a logger with logger = setup_logger("orders")

③ Emit 3 logs at INFO / WARNING / ERROR: "Order received", "Low stock", "Payment API returned an error"

Python Editor

Run code to see output

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".

CodeWhat it outputsExample
%(asctime)sTimestamp2024-12-01 10:30:45
%(name)sLogger name (dot-separated for hierarchy)app.orders
%(levelname)sLog levelINFO / WARNING / ERROR
%(funcName)sCalling function nameprocess_order
%(lineno)dCalling line number42
%(message)sMessage bodyProcessing 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

Use 3 hierarchical logger names — app / app.orders / app.orders.payment — and show which module each log came from with %(name)s. In production you'd add %(asctime)s for the timestamp, but here we stick to hierarchy alone so the output stays deterministic.

① Import logging and configure basicConfig with level=logging.INFO, format="[%(name)s] [%(levelname)s] %(message)s", force=True

② Get 3 loggers: logging.getLogger("app"), logging.getLogger("app.orders"), logging.getLogger("app.orders.payment")

③ Emit one log from each: app calls info("App started"), app.orders calls info("Order received"), app.orders.payment calls error("Payment API error")

Python Editor

Run code to see output

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.

yaml config file → dictConfig flow
logging.ymlformat / handler / levelyaml.safe_load()+ dictConfig()Configured loggerlogger.info(...)
Write format / handler / level into logging.yml, then convert it to a dict via yaml.safe_load() and pass to logging.config.dictConfig(...). The Python side just loads the yaml and calls dictConfig, and the logger is set up.
# 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
KeyRoleNotes
versiondictConfig schema versionCurrently only 1. Required.
disable_existing_loggersWhether to disable existing loggersfalse recommended (true silences loggers like app.orders from earlier)
formattersNamed list of FormattersDefine under any name (e.g., default), referenced from handlers
formatters.<name>.formatFormat string (%(...)s codes)Same as the Formatter argument in code
handlersNamed list of Handlers (destinations)Define console / file / mail and so on
handlers.<name>.classFully-qualified Handler class namelogging.StreamHandler / logging.FileHandler / logging.handlers.RotatingFileHandler etc.
handlers.<name>.formatterName of the Formatter to applyUse a key defined under formatters
handlers.<name>.levelPer-Handler levelFilter output more finely than at the Logger level
loggersNamed list of Loggersloggers.app is retrieved via logging.getLogger("app")
loggers.<name>.handlersList of Handler names attached to this loggerMultiple OK, like [console, file]
loggers.<name>.propagateWhether to propagate to parent loggersfalse stops propagation to the root logger (avoids double output)

logging.yml is provided (open the 📂 file panel to inspect it). Parse to a dict via yaml.safe_load, then pass to logging.config.dictConfig to configure the logger.

① Import logging, logging.config, and yaml

② Open with with open("logging.yml") as f: and convert to a dict via yaml.safe_load(f)

③ Pass the dict to logging.config.dictConfig(...) to apply

④ Get the logger via logger = logging.getLogger("app") and emit 1 INFO and 1 WARNING: "Started with yaml config" and "WARNING via yaml"

Python Editor

Run code to see output

Log rotation

If logs keep growing, the file balloons indefinitely, so production needs rotationrename 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.

Two kinds: size-based and time-based
RotatingFileHandlerSize-based(rotate when maxBytes exceeded)TimedRotatingFileHandlerTime-based(rotate at when boundaries)
Size-based fits debug logs where you want a hard cap on disk use. Time-based fits access logs you want to slice by date for analysis or monitoring tools.

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.

How RotatingFileHandler works
app.logWritingmaxBytes exceeded→ rotateapp.log.1app.log.2 ...(backups)Past backupCount→ Oldest deleted
When the active file app.log approaches 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"))

rotate_logging.yml is provided (open the 📂 file panel to inspect). The RotatingFileHandler config (maxBytes=60 / backupCount=2) lives in yaml, and the code just loads and uses it.

① Import os / shutil, delete and recreate the rotate/ folder. Also clear all handlers on logger rotate_demo with close + removeHandler (avoids interference on rerun)

② Open the yaml with with open("rotate_logging.yml"), convert via yaml.safe_load(f), and pass to logging.config.dictConfig(...) to configure the handler

③ Get logger = logging.getLogger("rotate_demo") and emit 15 logs in a loop: for i in range(15): logger.info(f"event {i:02d}")

④ From os.listdir("rotate"), take only files starting with app.log, sort them, and under the heading Files after rotation: ◯ print each filename as - filename on its own line

Python Editor

Run code to see output

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.

How TimedRotatingFileHandler works
app.logWritingNext when (time)→ rotateapp.log.2024-12-01app.log.2024-12-02(timestamp suffix)Past backupCount→ Oldest deleted
At the time specified by when (every day at 0:00, etc.), the active app.log is renamed with a timestamp-suffixed name (e.g., app.log.2024-12-01_00-00-00) and a new 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")
)

time_logging.yml is provided. The TimedRotatingFileHandler config (when: S (per second) / interval: 1 / backupCount: 3) lives in yaml, and the Python code uses time.sleep to simulate the passage of time to fire rotations.

① Import logging / os / shutil / time. Clear all handlers on logger trotate_demo with close + removeHandler, then delete and recreate the trotate/ folder

② Open time_logging.yml, convert to a dict via yaml.safe_load(f), and pass to logging.config.dictConfig(...) to configure

③ Get the logger and emit 3 logs with sleep between them: logger.info(...)time.sleep(1.2)logger.info(...)time.sleep(1.2)logger.info(...), triggering 2 rotations

④ From os.listdir("trotate"), separate app.log (current log) from everything else (timestamped backups), then print Current log present: True/False and Backup count: ◯

Python Editor

Run code to see output
QUIZ

Knowledge Check

Answer each question one by one.

Q1With logging.basicConfig(level=logging.INFO), which level is suppressed?

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?