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

  1. Why print() is no good for diagnosis
  2. The five levels: from DEBUG to CRITICAL
  3. logging.basicConfig: format and output to a file
  4. One logger per module: getLogger(__name__)
  5. logger.exception(): the traceback, into the log
  6. What to log and what not to
  7. 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 to data/papyrus.log instead 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. DEBUG is discarded; INFO and up gets written.
  • format: the template for each line. %(asctime)s is the date and time, %(levelname)-8s the level padded to 8 characters, %(name)s the logger speaking, %(message)s your text. (It's the classic % syntax, older than f-strings; logging keeps 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 from if __name__ == "__main__":): it equals "warehouse" when the module is imported. So every log line declares its origin — the %(name)s column in the format — with no effort.
  • Separation of powers: the modules (warehouse.py, errors.py...) emit with their logger; only the main program configures with basicConfig. A library that configured logging would trample the configuration of whoever imports it.
  • The %d style with separate arguments (logger.info("...: %d books", len(catalog))) lets logging compose 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 decide

In 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: pass was 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 INFO buries the signal. That's what DEBUG is 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:

  1. Read the traceback bottom-up: type and message first, call chain after (07-01).
  2. EAFP towards the outside world: try and handle; Path.exists() was a band-aid with a race condition (07-01).
  3. Catch specific types: never a bare except:; Exception only at the outermost boundary, and logging it (07-02).
  4. Minimal try: only the line that can fail; else for the happy path, finally for unconditional cleanup (07-02).
  5. Don't silence: a caught error is counted, logged or re-raised; never a lone pass (07-02, 07-05).
  6. Fail fast: guard clauses at the door, state mutation only after all guards have passed (07-03).
  7. Actionable messages: what was expected, what arrived (!r) and, where relevant, what to do (07-03).
  8. None for legitimate absences, exceptions for broken contracts; if both coexist, let it be deliberate, like find_book()/get_book() (07-03, 07-04).
  9. Your own hierarchy with attributes: the except distinguishes your error from the rest and code reads data, not strings (07-04).
  10. 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 their getLogger(__name__) and emit. Besides, basicConfig only takes effect the first time: repeated calls are silently ignored and cause confusion.
  • logger.error(e) inside the except instead of logger.exception(...). It saves the message but throws away the traceback, which was the valuable half. Inside an except, almost always exception().
  • 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 pass antipattern 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.DEBUG and add generous DEBUG messages in the delicate logic (key normalization, conversions). In production, threshold at INFO: the DEBUG calls stay there, dormant and free.
  • Tip: open papyrus.log now 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

  1. close_till() with telemetry. Adapt the 07-02 version so it uses a module logger: each corrupt row at WARNING with the full row (%r), the day's total at INFO, and on FileNotFoundError an INFO ("no sales today") — why INFO and not ERROR? The summary print() calls for Ana stay: reason out which message belongs on which channel.

  2. The nightly backup that speaks up. Wrap module 6's make_backup() (which copies catalog.json to backups/catalog-<date>.json with shutil.copy2) so that it: logs the destination at INFO if all goes well; and on OSError uses logger.exception() and re-raises. Explain why re-raise rather than swallow the error, given that it's "only" a backup.

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

  1. 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 total
    

    INFO and not ERROR because a day with no sales is a legitimate business outcome (07-03's criterion applied to log levels): nobody has to "act today". Channels: the print gives 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.

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

  3. Infractions: rule 3 (bare except:), rule 5 (pass: the error vanishes, and on top of that the function returns None instead of True/False, a third phantom state), rule 10 / the configuration pattern (basicConfig inside 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 of getLogger(__name__) and a try sheltering 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved