Logging Best Practices
Why print() is not logging, the logging module in depth, log levels, handlers and formatters, and what you should never log.
Four Things print() Genuinely Cannot Do
It is tempting to treat logging as "print, but fancier." That framing undersells the problem print() actually has in a production system. print() writes text to standard output and does nothing else — no concept of severity, no timestamp, no way to redirect output without changing code, and no way to turn it off. A production service that only knows how to print() has, in practice, no visibility into its own behavior once it is running unattended on a server nobody is watching a terminal for.
1. LEVELS — print() can't distinguish "this is routine info" from
"this is a critical failure." Every print looks identical.
2. TIMESTAMPS — print("Order processed") tells you nothing about
WHEN it happened, unless you manually format one into every call.
3. ROUTING — print() always goes to stdout. You can't send warnings
to one file, errors to another, and info messages nowhere at all,
without rewriting every call site.
4. VERBOSITY CONTROL — you can't turn print() calls "off" selectively.
Either the print() line runs, or you delete/comment it out by hand.Each of these gaps matters in a real incident. Imagine a service has been running for three days when a customer reports a bug that happened "sometime yesterday afternoon." With only print() output — assuming it was even captured anywhere — you have an undifferentiated wall of text with no way to filter to yesterday afternoon, no way to isolate just the errors, and no way to tell which messages mattered without reading every line. This is precisely the gap the logging module exists to close, and precisely why Module 39's debugging techniques and this module are taught back to back — one is for catching a bug while you can see it happen; the other is for understanding what happened after the fact, on a system you were not watching live.
getLogger() and the Five Standard Levels
Python's built-in logging module is the standard tool for everything print() cannot do. The basic building block is a logger object, obtained with logging.getLogger(name), which you call methods on for each severity level instead of always calling the same print().
import logging
logger = logging.getLogger(__name__)
logger.debug("Cache lookup for key=user:4821 — miss") # DEBUG — fine-grained, dev-only detail
logger.info("Order #8842 processed successfully") # INFO — routine, expected events
logger.warning("Retrying API call — attempt 2 of 3") # WARNING — unexpected, but recovered
logger.error("Failed to charge card for order #8842") # ERROR — a real failure, needs attention
logger.critical("Database connection pool exhausted") # CRITICAL — the system is in serious troubleThe level you choose is not a stylistic detail — it determines who sees the message and how urgently. DEBUG is for details useful only while actively developing or diagnosing something, and is usually silenced in production. INFO records normal operation — things that happened as expected and are worth a record of, like "user logged in" or "job completed." WARNING flags something unexpected that the program recovered from on its own, like a retried network call. ERROR means an operation genuinely failed — the specific thing the program was trying to do did not happen. CRITICAL is reserved for failures serious enough that the whole application, or a major part of it, may be unable to continue.
DEBUG — "here's exactly what the code is doing, step by step"
INFO — "this expected thing happened"
WARNING — "something odd happened, but I recovered"
ERROR — "this specific operation failed"
CRITICAL — "the whole system may be about to go down"logging.getLogger(__name__) — the standard idiom, and why it matters
Nearly every real Python codebase uses logging.getLogger(__name__) at the top of each module, rather than one single global logger shared everywhere. __name__ is the module's own dotted path (e.g. "myapp.billing.charges"), so each module gets a logger named after itself automatically, with zero manual naming required. This matters because it lets you configure logging per module later — for example, silencing noisy debug output from a third-party library while keeping your own application's logs at full verbosity — something a single shared logger cannot do.
getLogger() at all and just call logging.warning(...) directly, you are using the implicit root logger — fine for a tiny script, but it gives up the per-module control described above. Every real module in a real project should create its own named logger with getLogger(__name__) as the very first logging-related line.Handlers and Formatters — Where Logs Go, and What They Look Like
A logger by itself decides whether a message is worth recording (based on its level). It does not decide where that message ends up, or what it looks like on the page — that is the job of two separate objects: handlers and formatters.
LOGGER — decides IF a message is important enough to process at all
HANDLER — decides WHERE a message goes (console, a file, both, a remote server...)
FORMATTER — decides WHAT a message looks like (timestamp, level, message text...)import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# A handler that writes to the console
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO) # only INFO and above reach the console
# A formatter controlling the text layout of every message
formatter = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.debug("This won't appear on console — below the handler's INFO level")
logger.info("Order #8842 processed successfully")
# Output: 2026-08-14 09:12:03,881 | INFO | myapp.orders | Order #8842 processed successfullyNotice there are two level checks happening: the logger's own level (here, DEBUG, meaning it will consider processing everything) and the handler's level (here, INFO, meaning even though the logger considered the debug message, this particular handler drops anything below INFO). This two-level design is exactly what lets one logger feed multiple handlers with different verbosity — for example, everything at DEBUG and above written to a file for later investigation, while only WARNING and above appears on the console during normal operation.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
)
logging.info("Service started")
# basicConfig() sets up the root logger in one call — fine for a small script,
# but real applications configure named loggers explicitly, as shown above.FileHandler and Log Rotation
Console output disappears the moment a terminal closes or a process restarts. Anything you genuinely want to keep — for debugging an incident days later, or for an audit trail — needs to go to a file, using logging.FileHandler.
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING) # console stays quiet — only warnings and above
console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(file_handler)
logger.addHandler(console_handler)
logger.info("Order #8842 processed") # goes to app.log only
logger.warning("Retry attempt 2 of 3") # goes to both app.log AND the consoleA file that grows forever is its own problem — eventually it fills the disk. The standard solution is a rotating file handler, which automatically starts a new file once the current one hits a size limit (or after a time period, like daily), and deletes or archives the oldest ones.
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log", maxBytes=10 * 1024 * 1024, backupCount=5
)
# app.log fills up -> renamed to app.log.1, a fresh app.log starts.
# Once app.log.5 would be created, the oldest file is deleted instead.Why Plain-Text Logs Get Hard to Search at Scale
Everything so far has produced plain, human-readable text lines — genuinely fine to read directly in a small file. It breaks down at scale. Once a service produces millions of log lines a day across dozens of running instances, a human is never reading most of that text directly — a machine is searching, filtering, and aggregating it. Plain text is awkward for a machine to parse reliably, because the "shape" of the useful data is embedded inside a sentence rather than existing as clearly labeled fields.
2026-08-14 09:12:03 | INFO | Order #8842 processed for user 4821, total $129.50
# To find "every failed order for user 4821 over $100", a search tool has to
# parse this sentence with a fragile regex, hoping the wording never changes.{"timestamp": "2026-08-14T09:12:03Z", "level": "INFO", "event": "order_processed",
"order_id": 8842, "user_id": 4821, "total": 129.50}
# Now "every order over $100 for user 4821" is a direct field query —
# total > 100 AND user_id = 4821 — not a text-parsing guess.Python does not have a JSON-formatting logger built directly into the standard library, but producing one is a small step: write a custom Formatter subclass that serializes the log record's fields as JSON instead of an f-string sentence, or reach for a small, widely used third-party package like python-json-logger that does exactly this. The principle to take away, even without memorizing the exact code: once a service is running at real production scale, logs are typically treated as structured data to be queried, not prose to be read top to bottom.
Logging Secrets Is a Security Incident, Not a Style Mistake
It is genuinely common, and genuinely dangerous, to accidentally log sensitive data while debugging — printing an entire request object "just to see what's in it," which happens to include a password field, an API key, or a full credit card number. Once that line ships, the sensitive value is sitting in a log file — often one that gets copied to backups, shipped to a third-party logging platform, or retained for months, with far weaker access controls than your actual database has.
logger.info(f"Login attempt: {request.json}")
# If request.json includes {"username": "...", "password": "hunter2"},
# that plaintext password is now permanently sitting in a log file.
logger.debug(f"Processing payment: {payment_details}")
# If payment_details includes a full card number, this is very likely
# a compliance violation (PCI-DSS) on top of being a security risk.logger.info(f"Login attempt for username={request.json.get('username')!r}")
# No password in the log at all — just enough to investigate the attempt.
logger.debug(f"Processing payment for order_id={order_id}, "
f"card_last4={card_number[-4:]}")
# Only the last 4 digits — enough to identify the transaction to a support
# agent, useless to an attacker who reads the log file.A short, practical checklist worth internalizing: never log passwords, API keys, tokens, or secrets — full stop, not even at DEBUG level, since DEBUG logs are still logs. Never log full credit card numbers, social security numbers, or other regulated personal data — log an identifier (an order ID, a masked last-4) instead. Be careful with logging entire request or response objects "for convenience," since it is very easy for a sensitive field to be buried inside one without you noticing at the time you write the line.
Configuring Logging for a Small Python Service, End to End
Bringing every piece together, here is a realistic logging setup for a small service — an order processor — the kind of configuration you would genuinely write once near the top of a real project and reuse everywhere.
import logging
from logging.handlers import RotatingFileHandler
def configure_logging():
root = logging.getLogger()
root.setLevel(logging.DEBUG)
file_handler = RotatingFileHandler(
"orders.log", maxBytes=5 * 1024 * 1024, backupCount=3
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
"%(levelname)-8s | %(message)s"
))
root.addHandler(file_handler)
root.addHandler(console_handler)import logging
logger = logging.getLogger(__name__)
def process_order(order):
logger.info(f"Processing order_id={order.id}")
try:
charge_card(order)
logger.info(f"Payment succeeded for order_id={order.id}")
except PaymentError as e:
logger.error(f"Payment failed for order_id={order.id}: {e}")
raise
logger.debug(f"Order {order.id} full payload: {order.items}")from logging_config import configure_logging
from orders import process_order
if __name__ == "__main__":
configure_logging()
process_order(load_next_order())Everything from Parts 01 through 06 shows up in this small example: a named logger per module (Part 02), a file handler retaining full detail and a console handler kept quieter (Parts 03–04), and deliberately no sensitive fields ever passed directly into a log call (Part 06). This is the realistic shape of logging in a small, real Python service — not a single global print() replaced one-for-one, but a small amount of one-time setup that every module in the project benefits from afterward.
A Silent Failure at an Austin Subscription-Box Company
A subscription-box company runs a nightly script that charges every customer due for renewal that day. It has always used print() statements, redirected to a text file by the server's cron job (python billing.py >> billing_output.txt), and nobody has ever needed to look closely at that file — until a Monday when finance notices Friday night's revenue is roughly 15% below every other Friday that month.
What the investigation runs into immediately
The on-call engineer opens billing_output.txt expecting to find the answer quickly. Instead they find exactly what Part 01 of this module warned about: thousands of identical-looking lines of plain text, no timestamps (so there is no way to isolate "just Friday night's run" — the file is one continuous append across every night the script has ever run), and no severity level distinguishing a customer whose card was declined normally from a customer the script actually failed to process due to a bug. Every line looks the same.
What proper logging would have shown immediately
With the file re-run under a real logger — using getLogger(__name__), level-aware output, and a timestamp on every line, exactly as shown in Part 07 — the engineer would have been able to grep directly for ERROR lines within Friday's timestamp range and found the actual cause in seconds: a third-party payment API had briefly returned malformed responses for about twenty minutes, and the billing script's exception handling silently skipped those customers instead of raising or clearly recording the failure. Roughly 200 renewals were quietly never charged, and nothing distinguished those failures from the thousands of normal successful lines around them.
The team spent the rest of that week replacing every print() in the billing system with structured, leveled logging (Parts 02–05), specifically so that a future incident like this one would be a two-minute log search instead of a two-day manual investigation through an undifferentiated text file. This is precisely the gap between print() and real logging that this module opened with — and it is a genuinely common story across companies that treat logging as an afterthought until a missed-revenue incident forces the issue.
Four Misconceptions About Logging
5 Interview Questions — With Complete Answers
Logging Mistakes Beginners Make Constantly
Logging Problems You Will Hit — And Exactly Why
🎯 Key Takeaways
- ✓print() has no levels, no automatic timestamps, no configurable routing, and no way to control verbosity without editing code — logging solves all four.
- ✓The five standard levels, in order: DEBUG, INFO, WARNING, ERROR, CRITICAL. Choose deliberately — logging everything at one level defeats the purpose of having levels at all.
- ✓logging.getLogger(__name__) is the standard idiom — a named logger per module enables per-module verbosity control, unlike a single shared root logger.
- ✓A logger decides IF a message is processed. A handler decides WHERE it goes. A formatter decides WHAT it looks like. All three are configured separately.
- ✓RotatingFileHandler prevents log files from growing forever by capping size and pruning old backups automatically.
- ✓Structured (JSON) logging becomes worth the setup once logs are searched by a machine at real production scale, rather than read by a human top to bottom.
- ✓Never log passwords, API keys, tokens, full card numbers, or other sensitive data — even at DEBUG level. Log an identifier or masked value instead.
- ✓A logger's default level is WARNING if never explicitly set — the most common reason a first attempt at logging.debug() or logging.info() silently produces nothing.
What comes next
Module 41 turns a working script into a real, installable Python package — project structure, pyproject.toml, building wheels, and publishing to PyPI.
Module 41 → Packaging and Distributing Python ProjectsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.