The module has been leaving warnings along the road: print("Warning: catalog.json does not exist..."), print("Row skipped..."). They worked in class, but picture the real scene: Ana closes the shop on a Saturday, shuts down the computer, and on Monday the till close doesn't balance. What happened on Saturday? The print() calls died with the terminal; not a trace remains. On-screen warnings serve whoever is watching; to diagnose what happened when nobody was watching you need a persistent record: the standard library's logging module. In this final lesson of the module you'll learn its levels, its basic configuration towards data/papyrus.log, the logger-per-module pattern and the crown jewel logger.exception(), which saves the full traceback to the file. We'll close with the ten rules that sum up all of module 7 and with a register.py that brings the three pieces together: try/except, custom exceptions and logging.
Contents
- Why
print()is no good for diagnosis - The five levels: from
DEBUGtoCRITICAL logging.basicConfig: format and output to a file- One logger per module:
getLogger(__name__) logger.exception(): the traceback, into the log- What to log and what not to
- The module 7 rulebook and the robust
register.py
Why print() is no good for diagnosis
print() and logging answer different questions: print() talks to the user standing in front of the screen; the log talks to the developer who will come along later. As soon as the program lives longer than one session, print() as a diagnostic tool fails on every front:
| Production need | print() |
logging |
|---|---|---|
| A trace remains after closing the terminal | No: it evaporates | Yes: a persistent file |
| Knowing when each thing happened | You'd have to concatenate the date by hand into every print | Automatic timestamp in the format |
| Telling routine from emergency | Everything prints the same | Levels: INFO vs ERROR |
| Raising/lowering the detail without touching code | Add/delete prints all over the file | Change level= in a single place |
| Saving the traceback of a handled exception | Copy it by hand (nobody does) | logger.exception() does it by itself |
| Knowing which module spoke | Anonymous | The logger's name goes on every line |
Mind the nuance: print() isn't banned. The till menu, the "Sale completed: 20.70 EUR", the messages to Julia and Omar — that's user interface and will remain print() (until module 10 turns it into a website). What migrates to the log is the telemetry: warnings, errors, breadcrumbs for diagnosis.
The five levels: from DEBUG to CRITICAL
Every log message carries a severity level. There are five, ordered, and the logger has a threshold: it only records what meets or exceeds its configured level.
| Level | When to use it (the Papyrus criterion) | Example |
|---|---|---|
DEBUG |
Fine detail for development; usually switched off in production | "normalized key: 'the odyssey'" |
INFO |
The shop's normal life: milestones confirming all is well | "Catalog loaded: 4 books" / "Sale: Hamlet x2, 20.70 EUR" |
WARNING |
Something odd, but the program carries on; someone should look at it | "Row 7 of sales.csv unreadable, skipped" |
ERROR |
An operation has failed; the program survives but that action didn't happen | "Could not save the catalog" |
CRITICAL |
The program cannot continue | "catalog.json corrupt and no backups in backups/" |
The litmus test for picking a level: what should the reader do about it? Nothing, just context → DEBUG/INFO. Take a look sometime this week → WARNING. Act today → ERROR. Drop everything and run → CRITICAL. With the threshold at INFO (typical in production), the DEBUG messages are discarded at no cost; you lower it to DEBUG when chasing a failure, and the same code becomes talkative without adding a single print.
logging.basicConfig: format and output to a file
A single call, at the start of the main program and only once, configures the whole system:
# register.py — at startup, before anything else
import logging
from pathlib import Path
BASE = Path(__file__).parent # the anchor from module 6
(BASE / "data").mkdir(exist_ok=True)
logging.basicConfig(
filename=BASE / "data" / "papyrus.log",
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
encoding="utf-8",
)Piece by piece:
filename: messages go todata/papyrus.loginstead of the screen. It opens in append mode (a, from module 6): each startup adds, never clobbers — the history is precisely the value.level=logging.INFO: the threshold.DEBUGis discarded;INFOand up gets written.format: the template for each line.%(asctime)sis the date and time,%(levelname)-8sthe level padded to 8 characters,%(name)sthe logger speaking,%(message)syour text. (It's the classic%syntax, older than f-strings;loggingkeeps it for history and performance.)encoding="utf-8": module 6's non-negotiable applies here too — the log will contain titles with accented characters.
The result, as seen inside papyrus.log:
2026-07-13 09:00:12,451 INFO warehouse: Catalog loaded: 4 books
2026-07-13 09:14:03,102 INFO warehouse: Sale: Hamlet x2, 20.70 EUR
2026-07-13 09:31:44,876 WARNING register: Row skipped in sales.csv: {'date': '2026-07-12', 'title': 'Faust', 'amount': 'free'}Date, severity, module, message with the guilty data: on Monday morning, Ana (or you) reconstructs Saturday line by line.
One logger per module: getLogger(__name__)
You don't call logging.info(...) raw all over the code. The professional pattern creates a named logger at the top of each module:
# warehouse.py
import logging
logger = logging.getLogger(__name__) # name: "warehouse"
def load_catalog():
...
logger.info("Catalog loaded: %d books", len(catalog))__name__is the variable module 3 introduced us to (the one fromif __name__ == "__main__":): it equals"warehouse"when the module is imported. So every log line declares its origin — the%(name)scolumn in the format — with no effort.- Separation of powers: the modules (
warehouse.py,errors.py...) emit with their logger; only the main program configures withbasicConfig. A library that configured logging would trample the configuration of whoever imports it. - The
%dstyle with separate arguments (logger.info("...: %d books", len(catalog))) letsloggingcompose the text only if the message passes the threshold. F-strings also work and are acceptable while you learn; the deferred style is the idiomatic one.
logger.exception(): the traceback, into the log
The crown jewel. Inside an except, logger.exception(...) records the message at ERROR level and appends the full traceback of the exception in flight — the forensic report from 07-01, filed automatically:
def save_catalog(catalog):
path = BASE / "data" / "catalog.json"
try:
with open(path, "w", encoding="utf-8") as f:
json.dump([asdict(b) for b in catalog.values()], f,
ensure_ascii=False, indent=2)
except OSError:
logger.exception("Could not save the catalog to %s", path)
raise # logging is NOT handling: let the caller decideIn papyrus.log the ERROR line appears followed by the entire Traceback (most recent call last): ..., with file, line and cause. Three usage rules:
- Only inside an
except— that's where the "exception in flight" to attach actually exists. - It pairs perfectly with 07-03's bare
raise: log at the boundary and re-raise. The log keeps the evidence; the exception continues its journey towards whoever can decide. Logging is not handling. - It replaces the ultimate antipattern:
except OSError: passwas guilty silence (07-02);except OSError: logger.exception(...)followed by a conscious decision (re-raise, default value, abort) is engineering.
What to log and what not to
A useful log tells the program's operational story. Log: startup and shutdown (with version and loaded configuration), business milestones at INFO (sales, registrations, restocks, till closes), every anomaly at WARNING+ with the guilty data ({row!r}, the path, the title), and the traceback via logger.exception() in every boundary except.
And what must not go in, because the log is a plain-text file that gets copied, shared and ends up in support emails:
- Personal and sensitive data: no customers' full names tied to their purchases, no addresses, phone numbers, bank or card details.
"Sale: Hamlet x1, member MARTA-002"is fine (an opaque internal code);"Marta García, card 4779..."never. Data-protection law (in Europe, the GDPR) makes this a legal obligation, not just courtesy — and a log has none of a database's access control. - Secrets: passwords, tokens, API keys. Not even "just a moment, to debug": logs get archived, and secrets written there are compromised.
- Noise: logging every loop iteration at
INFOburies the signal. That's whatDEBUGis for, and in production it's switched off.
The module 7 rulebook and the robust register.py
The ten rules that sum up the whole module, each with its lesson of origin:
- Read the traceback bottom-up: type and message first, call chain after (07-01).
- EAFP towards the outside world: try and handle;
Path.exists()was a band-aid with a race condition (07-01). - Catch specific types: never a bare
except:;Exceptiononly at the outermost boundary, and logging it (07-02). - Minimal
try: only the line that can fail;elsefor the happy path,finallyfor unconditional cleanup (07-02). - Don't silence: a caught error is counted, logged or re-raised; never a lone
pass(07-02, 07-05). - Fail fast: guard clauses at the door, state mutation only after all guards have passed (07-03).
- Actionable messages: what was expected, what arrived (
!r) and, where relevant, what to do (07-03). Nonefor legitimate absences, exceptions for broken contracts; if both coexist, let it be deliberate, likefind_book()/get_book()(07-03, 07-04).- Your own hierarchy with attributes: the
exceptdistinguishes your error from the rest and code reads data, not strings (07-04). - Log at the boundary: record with
logger.exception()where you handle, not at every intermediate level — one error, one log entry, not five (07-05).
And the practical closer: register.py bringing the module's three pieces together.
# register.py — the program Ana runs every morning
import logging
from pathlib import Path
from warehouse import load_catalog, save_catalog, sell
from errors import PapyrusError, BookNotFoundError, InsufficientStockError
BASE = Path(__file__).parent
(BASE / "data").mkdir(exist_ok=True)
logging.basicConfig(
filename=BASE / "data" / "papyrus.log",
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
encoding="utf-8",
)
logger = logging.getLogger(__name__)
def ask_int(prompt): # the M1 debt, settled in 07-02
while True:
text = input(prompt)
try:
return int(text)
except ValueError:
print(f"'{text}' is not a whole number. Try again.")
def main():
logger.info("Papyrus opens the till")
catalog = load_catalog() # EAFP on the inside (07-02)
while True:
title = input("\nTitle (or 'done'): ").strip()
if title.casefold() == "done":
break
units = ask_int("Units: ")
try:
amount = sell(catalog, title, units)
except BookNotFoundError as e: # leaf: specific reaction (07-04)
print(f"We don't have '{e.title}'. Shall we order it?")
logger.warning("Sale failed, unknown title: %r", e.title)
except InsufficientStockError as e: # leaf: readable attributes (07-04)
print(f"Only {e.available} of '{e.title}' left.")
logger.warning("Stock short: %s", e)
except PapyrusError as e: # base: the business safety net (07-04)
print(f"Operation not completed: {e}")
logger.error("Unspecific business error: %s", e)
else: # happy path outside the try (07-02)
print(f"Sale completed: {amount:.2f} EUR (VAT included)")
logger.info("Sale: %s x%d, %.2f EUR", title, units, amount)
save_catalog(catalog)
logger.info("Papyrus closes the till")
if __name__ == "__main__":
try:
main()
except Exception: # the ONLY Exception boundary (rule 3)
logger.exception("Unforeseen error: emergency till shutdown")
print("An unexpected error occurred. Check data/papyrus.log")
raise SystemExit(1)Notice the three-ring architecture: the hierarchy's leaves are handled right at the counter (specific reaction, WARNING), the base PapyrusError acts as the business safety net, and a single except Exception at the program's front door — with logger.exception() and an honest on-screen message — catches the unforeseen without silencing it. print() for Ana, logging for diagnosis: every message to its audience.
One honest mention remains: in systems that run for weeks, papyrus.log grows without limit. logging itself ships the solution — handlers such as RotatingFileHandler, which splits the log by size or by date, plus file-based configuration — but that's advanced material beyond the essentials; it's enough to know it exists and where to look (logging.handlers) when the shop needs it.
Common Mistakes and Tips
- Calling
basicConfig()in every module. Only the main program configures; modules create theirgetLogger(__name__)and emit. Besides,basicConfigonly takes effect the first time: repeated calls are silently ignored and cause confusion. logger.error(e)inside theexceptinstead oflogger.exception(...). It saves the message but throws away the traceback, which was the valuable half. Inside anexcept, almost alwaysexception().- Logging and believing it's handled. The log is memory, not decision: after logging, decide — re-raise, apply a default, or abort. Log-and-swallow is the
passantipattern in nicer clothes. - Logging the same error at every level of the stack. Five identical entries for a single failure muddy the diagnosis. Rule 10: log at the boundary where you handle.
- Sensitive data in the log. Review every
logger.*call as if a stranger were going to read the file — because sooner or later one will. Internal codes yes; identities, cards and secrets, never. - Tip: during development, switch the threshold to
level=logging.DEBUGand add generousDEBUGmessages in the delicate logic (key normalization, conversions). In production, threshold atINFO: theDEBUGcalls stay there, dormant and free. - Tip: open
papyrus.lognow and then even when nothing fails. A log that's only read in emergencies tends to turn out empty, badly formatted or mute exactly when it's needed most.
Exercises
-
close_till()with telemetry. Adapt the 07-02 version so it uses a module logger: each corrupt row atWARNINGwith the full row (%r), the day's total atINFO, and onFileNotFoundErroranINFO("no sales today") — whyINFOand notERROR? The summaryprint()calls for Ana stay: reason out which message belongs on which channel. -
The nightly backup that speaks up. Wrap module 6's
make_backup()(which copiescatalog.jsontobackups/catalog-<date>.jsonwithshutil.copy2) so that it: logs the destination atINFOif all goes well; and onOSErroruseslogger.exception()and re-raises. Explain why re-raise rather than swallow the error, given that it's "only" a backup. -
Rulebook audit. This snippet breaks at least four of the ten rules. Identify them by number and rewrite it:
def add_member(members, name, code): try: logging.basicConfig(level=logging.DEBUG) if code in members: return False members.append(code) logging.info("New member: " + name + " with card 4779-1234-5678-9010") return True except: pass
Solutions
-
logger = logging.getLogger(__name__) def close_till(): path = BASE / "data" / "sales.csv" total, sales_ok, corrupt = 0.0, 0, 0 try: with open(path, encoding="utf-8", newline="") as f: for row in csv.DictReader(f): try: total += float(row["amount"]) except ValueError: corrupt += 1 logger.warning("Unreadable row in sales.csv: %r", row) else: sales_ok += 1 except FileNotFoundError: logger.info("Till close with no sales: %s does not exist", path) print("No sales recorded today.") return 0.0 logger.info("Till close: %d sales, %d corrupt, total %.2f EUR", sales_ok, corrupt, total) print(f"Till close: {sales_ok} sales, total {total:.2f} EUR") return totalINFOand notERRORbecause a day with no sales is a legitimate business outcome (07-03's criterion applied to log levels): nobody has to "act today". Channels: theprintgives Ana the summary she needs in the moment; the log additionally keeps the exact corrupt rows and the breakdown, which is what Monday's diagnosis will require. -
def make_backup(): source = BASE / "data" / "catalog.json" target = BASE / "backups" / f"catalog-{date.today().isoformat()}.json" try: shutil.copy2(source, target) except OSError: logger.exception("Backup failed towards %s", target) raise logger.info("Backup created: %s", target)We re-raise because a silenced failed backup is a false sense of security: the day Ana needs to restore from
backups/, she'd discover none had been created for weeks. Rule 5 (don't silence) and the log-and-re-raise pattern from 07-03/07-05: the log keeps the traceback and the caller decides whether to abort the close or raise the alarm. -
Infractions: rule 3 (bare
except:), rule 5 (pass: the error vanishes, and on top of that the function returnsNoneinstead ofTrue/False, a third phantom state), rule 10 / the configuration pattern (basicConfiginside a library function, trampling — or being ignored by — the main program's configuration), and the sensitive data norm (a card number written into the log). As a bonus: an anonymous logger instead ofgetLogger(__name__)and atrysheltering code that can't fail in any expected way (rule 4). Rewrite:logger = logging.getLogger(__name__) def add_member(members, name, code): if not name.strip() or not code.strip(): raise ValueError("name and code must not be empty") # rule 6 if code in members: logger.info("Registration rejected, duplicate code: %r", code) return False members.append(code) logger.info("New member: %s", code) # internal code; no name, no card return True
Conclusion
Module 7 answers, one by one, the questions module 6's ending left open. What if the file isn't there? load_catalog() tries EAFP-style and turns the FileNotFoundError into an empty catalog with a warning — without the Path.exists() band-aid or its race condition (07-01, 07-02). What if the backup is corrupt? JSONDecodeError is distinguished from the missing file and translated with raise ... from e without losing the evidence (07-02, 07-03). What if float(row["price"]) receives "free"? A minimal try skips the row, counts it and leaves it written at WARNING in papyrus.log (07-02, 07-05). What if Julia types an impossible price? The guard clauses and M5's __post_init__ — which you now know how to read as contracts — reject it with an actionable message, and ask_int() settled along the way the debt int(input()) had been dragging since module 1 (07-03, 07-02). Along the road, Papyrus gained its own vocabulary of errors — PapyrusError and its leaves with attributes, in errors.py — and a three-ring till that catches the specific right at the counter, the generic at the boundary, and leaves everything written down with logger.exception() (07-04, 07-05). The shop no longer crashes: it explains itself. Module 8 changes gear: Papyrus's code is correct and robust, and now it's time to make it more expressive and more efficient — type hints that document the contracts this module defended by hand, decorators that wrap functions without repeating code, generators that process sales without loading them whole into memory, and the moment to honor the outstanding promise of with: understanding context managers from the inside and, at last, building your own.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
