The logging module records what a program is doing, with a level for each message (DEBUG, INFO, WARNING, ERROR or CRITICAL) and configurable output: the console, a file, or a log collection service. Unlike print(), log messages can be filtered by level, carry timestamps and module names, and be switched on or off without editing code.
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
logger.info("Import started")
logger.warning("Skipped shelf %s: missing title", "B7")This lesson covers choosing levels, naming loggers, the %s argument style, logging exceptions with their tracebacks, and what must never appear in logs. The examples import a library's book catalogue.
Log levels
Each level signals how important a message is:
| Level | Number | Use for |
|---|---|---|
DEBUG | 10 | detailed diagnostics while developing |
INFO | 20 | normal milestones: started, finished, counts |
WARNING | 30 | something unexpected, but the program continues |
ERROR | 40 | an operation failed |
CRITICAL | 50 | the program can't continue |
A logger drops messages below its configured level. The default is WARNING, which is why logging.info("...") prints nothing until you configure a lower level with logging.basicConfig(level=logging.INFO). In development you might run at DEBUG; in production, INFO or WARNING.
Choose levels honestly. If every progress message is logged as ERROR, real failures are buried and alerting systems fire constantly. A skipped record with a known reason is a WARNING; a database that can't be reached is an ERROR.
Logger names
Get a logger per module with logging.getLogger(__name__). The name follows the module path, such as catalog.importer, and loggers form a hierarchy by dots: catalog.importer is a child of catalog, which is a child of the root logger.
logger = logging.getLogger(__name__) # in catalog/importer.py: "catalog.importer"Messages travel up the hierarchy to the handlers configured on ancestors, usually the root. That lets the application configure output once, at start-up, while each module just logs. It also allows targeted tuning: logging.getLogger("catalog.importer").setLevel(logging.DEBUG) shows detailed messages from one module only, and logging.getLogger("urllib3").setLevel(logging.WARNING) quietens a chatty library.
Libraries should never call basicConfig() or add handlers to the root logger; that's the application's decision. A library module just creates its logger and logs.
Deferred formatting
Pass values as arguments rather than building the string yourself:
logger.debug("Processed %s records from %s", count, filename) # preferred
logger.debug(f"Processed {count} records from {filename}") # works, but always formatsWith the argument style, the message is only formatted if a handler will actually output it, so DEBUG messages cost almost nothing in production. It also keeps the message template constant, which lets log analysis tools group identical messages that differ only in their values.
The format of each line is set by a formatter, through basicConfig(format=...) or on a handler. Common fields include %(asctime)s, %(levelname)s, %(name)s, %(message)s, %(funcName)s and %(lineno)d. Handlers decide where messages go: StreamHandler for the console, FileHandler for a file, RotatingFileHandler to cap file size, and many more.
Exceptions and secrets
Inside an except block, logger.exception("message") logs at ERROR level and appends the full traceback, so the log shows exactly where the failure happened:
try:
year = int(record["year"])
except (KeyError, ValueError):
logger.exception("Could not parse year for record %s", record.get("id"))Outside an except block there's no active exception, so use logger.error() instead, or pass exc_info=True explicitly.
Logs are copied, stored and read by many people and systems. Never log passwords, API keys, tokens, full card numbers or unnecessary personal data. Log identifiers (record id 1042) rather than contents (borrower name and address), mask secrets (key ...9f2c) and check what your __repr__ methods include, since objects are often logged whole. Data protection rules can apply to log files just as they do to databases.
Independent practice and a worked check
Task: add useful import progress and error logs with appropriate levels and no private record contents.
import logging, sys
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("catalog")
borrowers = [{"id": 7, "email": "ana@example.com", "fines": "2.50"},
{"id": 8, "email": "ben@example.com", "fines": "unpaid"}]
total = 0.0
for b in borrowers:
try:
total += float(b["fines"])
except ValueError:
log.exception("Bad fines value for borrower %s", b["id"]) # id, not email
log.info("Fines total: %.2f", total)The ERROR line names borrower 8 and includes the traceback, without the email address. For the worked check, logger.exception() captures exception context inside an exception handler, where an exception is currently being handled.
Reference and next practice
The logging HOWTO and the logging cookbook cover configuration in depth. Continue with pytest in depth and revisit debugging. Practise with the Python exercises and Python quiz.
Logging Summary
- Log levels: DEBUG, INFO, WARNING, ERROR and CRITICAL distinguish kinds of operational messages.
- Logger names: Named loggers let application components be configured independently.
- Deferred formatting: Passing a format string and arguments defers message interpolation until needed.
- Exceptions and secrets: logger.exception records exception context inside an exception handler.
Logging Examples
Each example shows the code, the output it produces and what each part does.
Logging in a small program
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
logger = logging.getLogger("catalog")
logger.info("Imported %s shelves", 7)INFO:Imported 7 shelvesLine-by-line explanation
- Line 2
basicConfigsets the minimum level to INFO and a simple line format; logging writes to standard error by default. - Line 3A named logger for this part of the program.
- Line 4
%sis filled in with 7 only because the INFO message is going to be shown.
Levels, filtering and a logged exception
import logging, sys
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
format="%(levelname)-7s %(name)s: %(message)s")
logger = logging.getLogger("catalog.import")
records = [{"id": 1, "year": "1998"}, {"id": 2, "year": "n/a"}, {"id": 3}]
loaded = 0
logger.info("Import started with %d records", len(records))
for record in records:
logger.debug("Processing %s", record) # hidden at INFO level
try:
int(record["year"])
loaded += 1
except KeyError:
logger.warning("Record %s has no year; skipped", record["id"])
except ValueError:
logger.error("Record %s has an invalid year", record["id"])
logger.info("Import finished: %d loaded, %d skipped", loaded, len(records) - loaded)INFO catalog.import: Import started with 3 records
ERROR catalog.import: Record 2 has an invalid year
WARNING catalog.import: Record 3 has no year; skipped
INFO catalog.import: Import finished: 1 loaded, 2 skippedLine-by-line explanation
- Lines 3 to 4
stream=sys.stdoutsends logs to standard output;%(levelname)-7spads the level name for alignment. - Line 11DEBUG is below the configured INFO level, so these messages never appear.
- Lines 15 to 18Different problems get different levels, so a search for ERROR finds only the invalid data.
Common Logging Mistakes
Logging a secret
logger.info("Connecting with API key %s", api_key)logger.info("Connecting with API key ending %s", api_key[-4:])Anything in a log may be read by people and systems that should never see credentials.
Expecting INFO messages without configuring logging
import logging
logging.info("Import started") # nothing appears: default level is WARNINGimport logging
logging.basicConfig(level=logging.INFO)
logging.info("Import started")Logging in Real Projects
Logging in production systems
Servers and scheduled jobs run with nobody watching, so logs are how teams find out what happened. Web frameworks such as Django and Flask use the logging module, and their configuration usually lives in a dictionary passed to logging.config.dictConfig(). Containers typically log to standard output, where the platform collects messages; services often log JSON lines so tools like Elasticsearch, Datadog or CloudWatch can search fields such as level and request ID.
Good logs answer three questions after an incident: what was the program doing, what failed, and which record or request was involved, all without exposing private data.
Logging FAQ
Why use logging instead of print in Python?
Logging adds levels, timestamps and module names, can be filtered or redirected without code changes, and can write to files or monitoring services. print() is fine for quick checks and for a program's real output, but diagnostic messages belong in logs.
Why doesn't logging.info() print anything?
The default level is WARNING, so INFO and DEBUG messages are dropped. Call logging.basicConfig(level=logging.INFO) once at start-up, before any logging calls.
How do I log to a file in Python?
logging.basicConfig(filename="app.log", level=logging.INFO, encoding="utf-8") sends messages to a file. For long-running programs, add a RotatingFileHandler or TimedRotatingFileHandler so the file doesn't grow forever.
What is the difference between logger.error and logger.exception?
Both log at ERROR level. logger.exception() also includes the current exception's traceback, so it should be called inside an except block. logger.error() logs only the message unless you pass exc_info=True.
Should I use f-strings in log messages?
They work, but the %s argument style is recommended: the message is only formatted if it will be output, and the constant template makes logs easier to group and search. Linters such as Ruff and Pylint can flag f-strings in logging calls.
Logging Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Where does logger.exception normally capture exception context?
Which review concern is valid for logging?
Logging Quiz
2 questions to check what you learned.
Where does logger.exception normally capture exception context?
- Before any exception occurs
- Inside an exception handler
- Python ignores the distinction
Which statement correctly describes deferred formatting?
- Passing a format string and arguments defers message interpolation until needed.
- Only the example output matters, not the rule
- Every operation has the same behavior