The crash from last week. The enrichment service. You fixed it in forty minutes using the traceback. What would you have needed to diagnose it without the traceback?
I would have needed to know which request triggered it, what the input payload looked like, how many requests had already processed before it failed. The traceback told me where Python gave up. It did not tell me what the code was doing in the sixty seconds before that.
That context — what happened before the crash — is what logging provides. And right now your service provides it through 49 print statements. They are in production, writing to stdout, mixed into the server output, helping no one. The print that says print("WHY IS THIS NONE") has been there for three months.
I counted again this morning. Fifty-one now. I added two more during the cache investigation and forgot they were there when the PR merged.
Here is what replaces all of them — permanently:
import logging
logger = logging.getLogger(__name__)
logger.debug("Fetching customer record: %s", customer_id) # Level 10 — silent in prod
logger.info("Order processed: %s", order_id) # Level 20 — normal heartbeat
logger.warning("Retry %d of 3 for payment API", attempt) # Level 30 — recovered unexpectedly
logger.error("Payment failed for order %s", order_id) # Level 40 — user felt this
logger.critical("DB connection pool exhausted") # Level 50 — system at riskEvery message has a severity. In production you set the threshold to INFO — every debug call vanishes, zero cost. During an incident you drop to DEBUG in one place without touching code, without deploying. No prints to add. No prints to remove.
getLogger(__name__) — that gives the logger the module's name. So every log line carries the full module path: myapp.orders.processor. You can filter by module.
Correct. And the configuration lives in one place, at the application entry point:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)That single call configures every logger in the application. Every module that called getLogger(__name__) now writes to the same handler in the same format filtered to INFO. You did not touch any module. You changed one line in main.py.
The 3% empty-dict incident last month — I wanted to see everything from the enrichment step and nothing from the cache layer. I commented out cache print statements and forgot to uncomment three of them.
Three commented-out print statements that silently stopped reporting cache hits. With named loggers, that incident is two lines of config:
logging.getLogger("myapp.cache").setLevel(logging.WARNING)
logging.getLogger("myapp.enrichment").setLevel(logging.DEBUG)No code changes in either module. Revert when the incident is resolved.
The logger hierarchy — myapp.enrichment is a child of myapp, which is a child of the root. Silencing a parent silences all its children unless you explicitly set a level on them.
That is the hierarchy. And there is one performance trap before you start the migration. When you write logger.info(f"Order processed: {order_id}"), Python evaluates the f-string first, always — even when INFO is below the current threshold. When you write logger.info("Order processed: %s", order_id), the interpolation only happens if the message will actually be written.
F-strings are eager. The percent-style is lazy. Every debug call I write as an f-string is doing string work in production where DEBUG is filtered. On a hot path that is measurable.
Build the habit now: always pass arguments separately. logger.debug("Processing record %d of %d", i, total). The string is never built unless the message fires.
What about structured logging? I have seen JSON log output in some of the services we integrate with. Is that standard library or third-party?
Standard library gets you there with a custom Formatter:
import logging, json
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return json.dumps({
"time": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
})
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.getLogger().addHandler(handler)Every log line is a JSON object. Your log aggregator indexes each field. You query level:ERROR AND logger:myapp.payments instead of running grep on a wall of text.
Half our services are writing plain text and the aggregator dumps them into a catch-all message field. That is why the incident dashboard is useless — we have the data but it is not structured.
"We have logging" is not the same as "logging is useful." The standard library is the correct tool. JSON output is the format that makes logs queryable. The combination is what a well-instrumented service looks like.
The migration is mechanical. Print statements map to levels: diagnostic prints become DEBUG, state inspection becomes DEBUG, warning conditions become WARNING, confirmed failures become ERROR. And the format switches from f-string to percent-style. Each module gets one getLogger(__name__) at the top.
Correct. And print("WHY IS THIS NONE") becomes logger.error("Expected dict, got None — order_id: %s", order_id). Filtered, labeled, timestamped, grep-able. Drop to DEBUG at the next incident — no deployment, no orphaned prints.
Three months of prints replaced by one config call, one logger per module, and the right level on each call.
Tomorrow is inspect and dis — the tools that let Python explain itself to you. Today's challenge asks you to build a log analysis function: given a list of structured log entries and a minimum level, filter, count by level and by logger, find the most active logger, and flag whether any errors are present. You are writing the code that reads the output your service will produce.
So today I write the analysis tool. Tomorrow I see how the logging module itself is implemented — the handler dispatch, the formatter protocol.
Tomorrow you see your own code through Python's eyes. inspect and dis are the tools that resolve the gap between what you meant and what Python did — at the bytecode level.
The logger hierarchy. Python's logging module maintains a tree of Logger objects rooted at the root logger (logging.getLogger() with no argument). Named loggers are addressed by dotted paths: logging.getLogger("myapp.orders") creates a logger that is a child of myapp, which is a child of the root. When a log record is created, it propagates up the tree by default — a record written to myapp.orders.processor travels up through myapp.orders, myapp, and finally the root logger. Each level in the tree can have its own handlers and its own level threshold. A record is only processed at a level if the level's effective threshold allows it.
Effective level resolution. A logger's effective level is its own level if set, or the first non-NOTSET level found while walking up the parent chain. This means setting setLevel(DEBUG) on myapp.enrichment while the root is at INFO makes myapp.enrichment verbose without affecting any other logger. Setting setLevel(WARNING) on myapp.cache silences it below WARNING even if the root would pass INFO records. The hierarchy gives you surgical control without code changes.
Handler dispatch. Each logger can have zero or more Handler objects attached (StreamHandler, FileHandler, RotatingFileHandler, etc.). When a record passes the logger's level check and propagation reaches a logger with handlers, each handler's own level check runs, then the handler's Formatter converts the LogRecord to a string, then the handler writes it. basicConfig adds a single StreamHandler to the root logger. Custom formatters like JSONFormatter implement format(record: LogRecord) -> str and are attached with handler.setFormatter().
The lazy interpolation contract. Logger.info("msg: %s", arg) stores the format string and arguments separately in a LogRecord. The actual % interpolation happens inside Formatter.format() — only when a handler is about to write the record. If the record is filtered out before reaching a handler, getMessage() is never called and no string is built. This is the performance contract that makes %s-style safer than f-strings on hot paths. The contract breaks if you call str(arg) or build the string yourself before passing it to the logger — always let the logger own the interpolation.
Sign up to write and run code in this lesson.
The crash from last week. The enrichment service. You fixed it in forty minutes using the traceback. What would you have needed to diagnose it without the traceback?
I would have needed to know which request triggered it, what the input payload looked like, how many requests had already processed before it failed. The traceback told me where Python gave up. It did not tell me what the code was doing in the sixty seconds before that.
That context — what happened before the crash — is what logging provides. And right now your service provides it through 49 print statements. They are in production, writing to stdout, mixed into the server output, helping no one. The print that says print("WHY IS THIS NONE") has been there for three months.
I counted again this morning. Fifty-one now. I added two more during the cache investigation and forgot they were there when the PR merged.
Here is what replaces all of them — permanently:
import logging
logger = logging.getLogger(__name__)
logger.debug("Fetching customer record: %s", customer_id) # Level 10 — silent in prod
logger.info("Order processed: %s", order_id) # Level 20 — normal heartbeat
logger.warning("Retry %d of 3 for payment API", attempt) # Level 30 — recovered unexpectedly
logger.error("Payment failed for order %s", order_id) # Level 40 — user felt this
logger.critical("DB connection pool exhausted") # Level 50 — system at riskEvery message has a severity. In production you set the threshold to INFO — every debug call vanishes, zero cost. During an incident you drop to DEBUG in one place without touching code, without deploying. No prints to add. No prints to remove.
getLogger(__name__) — that gives the logger the module's name. So every log line carries the full module path: myapp.orders.processor. You can filter by module.
Correct. And the configuration lives in one place, at the application entry point:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)That single call configures every logger in the application. Every module that called getLogger(__name__) now writes to the same handler in the same format filtered to INFO. You did not touch any module. You changed one line in main.py.
The 3% empty-dict incident last month — I wanted to see everything from the enrichment step and nothing from the cache layer. I commented out cache print statements and forgot to uncomment three of them.
Three commented-out print statements that silently stopped reporting cache hits. With named loggers, that incident is two lines of config:
logging.getLogger("myapp.cache").setLevel(logging.WARNING)
logging.getLogger("myapp.enrichment").setLevel(logging.DEBUG)No code changes in either module. Revert when the incident is resolved.
The logger hierarchy — myapp.enrichment is a child of myapp, which is a child of the root. Silencing a parent silences all its children unless you explicitly set a level on them.
That is the hierarchy. And there is one performance trap before you start the migration. When you write logger.info(f"Order processed: {order_id}"), Python evaluates the f-string first, always — even when INFO is below the current threshold. When you write logger.info("Order processed: %s", order_id), the interpolation only happens if the message will actually be written.
F-strings are eager. The percent-style is lazy. Every debug call I write as an f-string is doing string work in production where DEBUG is filtered. On a hot path that is measurable.
Build the habit now: always pass arguments separately. logger.debug("Processing record %d of %d", i, total). The string is never built unless the message fires.
What about structured logging? I have seen JSON log output in some of the services we integrate with. Is that standard library or third-party?
Standard library gets you there with a custom Formatter:
import logging, json
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return json.dumps({
"time": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
})
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.getLogger().addHandler(handler)Every log line is a JSON object. Your log aggregator indexes each field. You query level:ERROR AND logger:myapp.payments instead of running grep on a wall of text.
Half our services are writing plain text and the aggregator dumps them into a catch-all message field. That is why the incident dashboard is useless — we have the data but it is not structured.
"We have logging" is not the same as "logging is useful." The standard library is the correct tool. JSON output is the format that makes logs queryable. The combination is what a well-instrumented service looks like.
The migration is mechanical. Print statements map to levels: diagnostic prints become DEBUG, state inspection becomes DEBUG, warning conditions become WARNING, confirmed failures become ERROR. And the format switches from f-string to percent-style. Each module gets one getLogger(__name__) at the top.
Correct. And print("WHY IS THIS NONE") becomes logger.error("Expected dict, got None — order_id: %s", order_id). Filtered, labeled, timestamped, grep-able. Drop to DEBUG at the next incident — no deployment, no orphaned prints.
Three months of prints replaced by one config call, one logger per module, and the right level on each call.
Tomorrow is inspect and dis — the tools that let Python explain itself to you. Today's challenge asks you to build a log analysis function: given a list of structured log entries and a minimum level, filter, count by level and by logger, find the most active logger, and flag whether any errors are present. You are writing the code that reads the output your service will produce.
So today I write the analysis tool. Tomorrow I see how the logging module itself is implemented — the handler dispatch, the formatter protocol.
Tomorrow you see your own code through Python's eyes. inspect and dis are the tools that resolve the gap between what you meant and what Python did — at the bytecode level.
The logger hierarchy. Python's logging module maintains a tree of Logger objects rooted at the root logger (logging.getLogger() with no argument). Named loggers are addressed by dotted paths: logging.getLogger("myapp.orders") creates a logger that is a child of myapp, which is a child of the root. When a log record is created, it propagates up the tree by default — a record written to myapp.orders.processor travels up through myapp.orders, myapp, and finally the root logger. Each level in the tree can have its own handlers and its own level threshold. A record is only processed at a level if the level's effective threshold allows it.
Effective level resolution. A logger's effective level is its own level if set, or the first non-NOTSET level found while walking up the parent chain. This means setting setLevel(DEBUG) on myapp.enrichment while the root is at INFO makes myapp.enrichment verbose without affecting any other logger. Setting setLevel(WARNING) on myapp.cache silences it below WARNING even if the root would pass INFO records. The hierarchy gives you surgical control without code changes.
Handler dispatch. Each logger can have zero or more Handler objects attached (StreamHandler, FileHandler, RotatingFileHandler, etc.). When a record passes the logger's level check and propagation reaches a logger with handlers, each handler's own level check runs, then the handler's Formatter converts the LogRecord to a string, then the handler writes it. basicConfig adds a single StreamHandler to the root logger. Custom formatters like JSONFormatter implement format(record: LogRecord) -> str and are attached with handler.setFormatter().
The lazy interpolation contract. Logger.info("msg: %s", arg) stores the format string and arguments separately in a LogRecord. The actual % interpolation happens inside Formatter.format() — only when a handler is about to write the record. If the record is filtered out before reaching a handler, getMessage() is never called and no string is built. This is the performance contract that makes %s-style safer than f-strings on hot paths. The contract breaks if you call str(arg) or build the string yourself before passing it to the logger — always let the logger own the interpolation.