Learning Goals
3 minBy the end of this lesson you can:
- Write logs to a file with
FileHandler. - Log to the console and a file at once with multiple handlers.
- Cap log size with
RotatingFileHandler(by bytes) orTimedRotatingFileHandler(by day). - Give each handler its own level — verbose file, quiet console.
Warm-Up · The Log That Ate the Disk
5 minA naive file log appends forever:
app.log → 12 KB (day 1) app.log → 400 MB (month 3) app.log → 14 GB (month 9 — disk full, server down) 💥
A production log must rotate: when the current file hits a size or age limit, it's renamed (app.log.1) and a fresh app.log begins. Keep only the last N — old logs are pruned automatically. Python's logging.handlers does the whole dance for you, so logs persist and the disk stays safe.
New Concept · Handlers & Rotation
14 minLogging to a file
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", filename="app.log", # ← this sends output to a file instead of console filemode="a", # "a" append (default), "w" overwrite each run ) logging.info("this lands in app.log, not the screen")
One keyword (filename=) redirects everything to a file. But the file still grows forever — and you lose the console. We fix both by building handlers ourselves.
Console AND file at once
import logging log = logging.getLogger("app") log.setLevel(logging.DEBUG) fmt = logging.Formatter("%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") console = logging.StreamHandler() # → terminal console.setLevel(logging.INFO) # quiet: INFO and up console.setFormatter(fmt) file = logging.FileHandler("app.log", encoding="utf-8") # → file file.setLevel(logging.DEBUG) # verbose: everything file.setFormatter(fmt) log.addHandler(console) log.addHandler(file) log.debug("only in the file") log.info("in both console and file")
Each handler has its own level. A common setup: console shows INFO+ (a tidy live view), the file records DEBUG+ (the full forensic trail). Same log calls, two destinations.
Rotation by size
from logging.handlers import RotatingFileHandler handler = RotatingFileHandler( "app.log", maxBytes=1_000_000, # roll over at ~1 MB backupCount=5, # keep app.log.1 … app.log.5, prune older encoding="utf-8", )
When app.log reaches maxBytes, it's renamed app.log.1 (and existing .1 → .2, etc.), a new app.log starts, and anything beyond backupCount is deleted. Maximum disk used ≈ maxBytes × (backupCount + 1) — bounded forever.
Rotation by time
from logging.handlers import TimedRotatingFileHandler handler = TimedRotatingFileHandler( "app.log", when="midnight", # roll over each day at 00:00 backupCount=14, # keep 14 days of logs encoding="utf-8", )
Now you get one file per day (app.log.2026-05-27 …), keeping a fortnight. when can be "midnight", "H" (hourly), "W0" (weekly on Monday), and more. Use this when "show me yesterday's log" matters; use size-based when bursts of volume are the worry.
A rotating handler is just a smarter FileHandler — you still call setFormatter and setLevel on it and addHandler it to your logger. The rotation is invisible to your log calls; you never change log.info(...).
Worked Example · A Production-Ready Logging Setup
12 minGoal: one reusable setup_logging() you can drop into any automation — console for live INFO, a rotating file for full DEBUG history.
import logging from logging.handlers import RotatingFileHandler from pathlib import Path def setup_logging(name: str = "app", log_dir: str = "logs") -> logging.Logger: Path(log_dir).mkdir(parents=True, exist_ok=True) log = logging.getLogger(name) log.setLevel(logging.DEBUG) if log.handlers: # already configured — don't double up return log fmt = logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") console = logging.StreamHandler() console.setLevel(logging.INFO) console.setFormatter(fmt) fileh = RotatingFileHandler( Path(log_dir) / f"{name}.log", maxBytes=1_000_000, backupCount=5, encoding="utf-8") fileh.setLevel(logging.DEBUG) fileh.setFormatter(fmt) log.addHandler(console) log.addHandler(fileh) return log # usage in any script: log = setup_logging("backup") log.info("backup started") log.debug("detail recorded only in logs/backup.log") log.warning("disk at 85%%")
# console (INFO and up): 2026-05-28 14:30:01 INFO backup backup started 2026-05-28 14:30:01 WARNING backup disk at 85% # logs/backup.log (DEBUG and up) ALSO contains: 2026-05-28 14:30:01 DEBUG backup detail recorded only in logs/backup.log
Read the code
This is a setup you'll genuinely reuse. The if log.handlers: return guard makes it idempotent — safe to call from multiple modules. The console stays readable (INFO+) while the rotating file captures everything (DEBUG+) and can never exceed ~6 MB on disk. Drop setup_logging() at the top of any Level 7 project and you instantly have professional observability.
Try It Yourself
13 minConfigure logging to write INFO+ to run.log with timestamps. Run your script twice and confirm the file appends (doesn't overwrite).
Set up a RotatingFileHandler with a tiny maxBytes (say 500) and backupCount=3. Log 100 messages in a loop and inspect the resulting app.log, app.log.1, etc.
Hint
import logging from logging.handlers import RotatingFileHandler log = logging.getLogger("rot"); log.setLevel(logging.INFO) h = RotatingFileHandler("app.log", maxBytes=500, backupCount=3) log.addHandler(h) for i in range(100): log.info("message number %d with some padding text", i)
Set up two handlers: console at WARNING (so only problems show on screen) and a daily TimedRotatingFileHandler at DEBUG. Emit messages at several levels and confirm each destination gets the right subset.
Hint
import logging from logging.handlers import TimedRotatingFileHandler log = logging.getLogger("split"); log.setLevel(logging.DEBUG) c = logging.StreamHandler(); c.setLevel(logging.WARNING) f = TimedRotatingFileHandler("daily.log", when="midnight", backupCount=7); f.setLevel(logging.DEBUG) for h in (c, f): h.setFormatter(logging.Formatter("%(levelname)s %(message)s")) log.addHandler(h)
Mini-Challenge · A Log Analyser
8 minNow read logs back: write analyse(path) that scans a log file and reports how many lines of each level (INFO/WARNING/ERROR…) it contains, plus the timestamp of the first and last entry. This is the start of log monitoring — turning a wall of text into a summary.
Show a sample solution
from pathlib import Path from collections import Counter LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") def analyse(path: str) -> None: lines = Path(path).read_text(encoding="utf-8").splitlines() counts = Counter() for line in lines: for lvl in LEVELS: if f" {lvl} " in line or line.split()[2:3] == [lvl]: counts[lvl] += 1 break print(f"{len(lines)} lines") for lvl in LEVELS: if counts[lvl]: print(f" {lvl:9} {counts[lvl]}") if lines: print("first:", lines[0][:19]) print("last: ", lines[-1][:19]) analyse("logs/app.log")
Non-negotiables: per-level counts, first/last timestamps, handles a real rotated log file.
Recap
3 minPersist logs by adding a FileHandler — or attach several handlers to log to console and file at once, each with its own level (quiet console, verbose file). To stop logs eating the disk, use RotatingFileHandler (rolls over at maxBytes, keeps backupCount files) or TimedRotatingFileHandler (rolls over by time, e.g. daily). The rotation is invisible to your log.info(...) calls. A reusable setup_logging() with a rotating file gives every automation durable, bounded, professional logs.
Vocabulary Card
- FileHandler
- A handler that writes log records to a file.
- RotatingFileHandler
- Rolls the log file over at a size limit, keeping N backups.
- TimedRotatingFileHandler
- Rolls over on a schedule (daily, hourly…), keeping N periods.
- backupCount
- How many old log files to keep before deleting the oldest.
Homework
4 minCreate logging_setup.py with your own polished setup_logging(name, level, log_dir) helper that wires a console handler and a rotating file handler. Then write a tiny demo script that imports it, logs a realistic mix of messages (start, progress, a warning, an error with a traceback, finish), and proves the file contains more detail than the console. Keep it as a reusable module for the rest of the level.
Sample · logging_setup.py
import logging from logging.handlers import RotatingFileHandler from pathlib import Path def setup_logging(name="app", level=logging.INFO, log_dir="logs"): Path(log_dir).mkdir(parents=True, exist_ok=True) log = logging.getLogger(name) log.setLevel(logging.DEBUG) if log.handlers: return log fmt = logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") con = logging.StreamHandler(); con.setLevel(level); con.setFormatter(fmt) fh = RotatingFileHandler(Path(log_dir) / f"{name}.log", maxBytes=1_000_000, backupCount=5, encoding="utf-8") fh.setLevel(logging.DEBUG); fh.setFormatter(fmt) log.addHandler(con); log.addHandler(fh) return log if __name__ == "__main__": log = setup_logging("demo") log.info("started") log.debug("loaded 42 items") # file only log.warning("retrying connection") try: 1 / 0 except ZeroDivisionError: log.exception("math failed") # full traceback to both log.info("finished")
Non-negotiables: reusable helper, console+rotating file, idempotent, demo with traceback, file richer than console.