Python has five logging levels — DEBUG (10), INFO (20), WARNING (30), ERROR (40), and CRITICAL (50) — and the root logger ignores everything below WARNING until you tell it otherwise.
That default is the reason most people’s first encounter with the logging module is a script that logs nothing. The levels themselves are simple. Choosing between them consistently across a codebase is the part teams get wrong, usually by treating them as a vague severity vibe rather than a filter with a job.
Table of contents
- The five levels and their numbers
- Why your logs are empty
- Where the level actually gets checked
- Choosing a level without agonising
- Exceptions get their own call
- Per-module loggers and why the root logger is not enough
- Levels only help if the logs go somewhere you can read
- How this fits the rest of the stack
- FAQ
The five levels and their numbers
Each level is an integer. The logger compares the level of a message against its own threshold and drops anything lower. That is the entire mechanism.
- DEBUG (10) — details that only matter when something is already wrong. Variable values, branch decisions, the payload you are about to send.
- INFO (20) — confirmation that expected things happened. Service started, job completed, request handled.
- WARNING (30) — something unexpected, but the program continues. A retry fired, a deprecated path was taken, a cache missed when it should not have.
- ERROR (40) — an operation failed. This request did not complete, this job did not finish. The process is still alive.
- CRITICAL (50) — the program cannot continue, or is about to stop being useful in a way that needs a person.
There is also NOTSET (0), which means “defer to the parent logger”, and it is a configuration value rather than something you log at.
Why your logs are empty
This is the first thing everyone hits.
import logging
logging.info("starting up") # prints nothing
logging.warning("heads up") # prints
The root logger defaults to WARNING. INFO is 20, WARNING is 30, so the INFO call is filtered out before it reaches any handler. Nothing is broken; the threshold is just above where you are logging.
The fix is one call, and it belongs in your entry point, not scattered through modules.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
basicConfig only does something the first time it is called, and only if the root logger has no handlers yet. Call it twice and the second call is silently ignored, which produces its own confusing afternoon.
Where the level actually gets checked
There are two thresholds, not one, and both have to pass. The logger has a level, and each handler attached to it has a level. A message must clear the logger’s threshold to be created, then clear each handler’s threshold to be emitted by that handler.
That sounds like bureaucracy until you want two destinations with different verbosity, which is a genuinely common need.
import logging
logger = logging.getLogger("payments")
logger.setLevel(logging.DEBUG) # let everything through the logger
console = logging.StreamHandler()
console.setLevel(logging.INFO) # humans see INFO and up
file_out = logging.FileHandler("debug.log")
file_out.setLevel(logging.DEBUG) # the file keeps everything
logger.addHandler(console)
logger.addHandler(file_out)
If the logger were left at WARNING, the DEBUG handler would never receive anything to write. The logger is the gate; handlers are the filters behind it. Set the logger permissively and let handlers narrow it.
Choosing a level without agonising
Most inconsistency comes from teams having no shared rule. Here is one that holds up.
Ask: who is meant to read this, and do they need to act?
- Nobody reads it unless they are debugging a specific problem — DEBUG.
- Someone reading logs to confirm the system is behaving — INFO.
- Someone should look at this eventually, but not tonight — WARNING.
- This request or job failed and someone needs to know — ERROR.
- Wake someone up — CRITICAL.
The most common misuse is logging at ERROR for things that were handled. A retry that succeeded on attempt two is not an error; the operation completed. Log it at WARNING and keep ERROR meaning something failed. Once ERROR stops being reliable, every alert built on it becomes noise, and then people mute the alert.
The second most common is INFO inside a loop that runs ten thousand times. That is DEBUG, and if you truly need it in production, sample it.
Exceptions get their own call
Inside an except block, do not format the exception yourself.
try:
charge_card(order)
except PaymentError:
logger.exception("charge failed for order %s", order.id)
logger.exception logs at ERROR and attaches the full traceback. It only works inside an exception handler. Outside one, use logger.error("...", exc_info=True) if you have the exception in hand.
Note the %s placeholder rather than an f-string. The logging module defers formatting until it knows the message will actually be emitted, so a DEBUG call that gets filtered costs almost nothing. An f-string is evaluated before the call, so the work happens whether the message survives or not. On a hot path that difference is measurable.
Per-module loggers and why the root logger is not enough
Use logging.getLogger(__name__) at the top of each module. You get a logger named after the module’s import path, and names are hierarchical — app.payments.stripe inherits from app.payments, which inherits from app.
That hierarchy is what lets you turn one noisy subsystem down without touching anything else.
# quiet one chatty dependency, keep everything else at INFO
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("app.payments").setLevel(logging.DEBUG)
Logging everything through the root logger takes this away. You end up choosing between drowning and going blind, when what you wanted was to see one thing clearly.
Levels only help if the logs go somewhere you can read
A carefully levelled log stream that writes to a file inside a container is a stream nobody reads. The container restarts, the file goes with it, and the WARNING that would have explained the outage is gone.
Write to stdout and let the platform handle collection. In a container, StreamHandler with no arguments is the right default — no log rotation to configure, no disk to fill, no file to lose on restart.
Services deployed on RunxBuild stream stdout and stderr into deploy and runtime logs you can read from the dashboard, so the level you chose is the level you can filter on afterwards. Set the threshold with an environment variable and you can raise verbosity on a misbehaving service without a code change or a rebuild.
import logging, os
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
How this fits the rest of the stack
Logging is free until the volume is not. DEBUG in production multiplies your log throughput, and throughput has a number attached to it alongside the compute, the database, and the storage. The RunxBuild hosting calculator lays those line items out together so the sum is visible before you commit to it.
Useful related references:
- Python Print to stderr: And Why Logging Is Usually Better
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python services on RunxBuild
FAQ
What are the Python logging levels in order?
DEBUG (10), INFO (20), WARNING (30), ERROR (40), CRITICAL (50). Higher numbers mean higher severity. A logger emits messages at or above its configured level and drops everything below it.
Why does logging.info print nothing?
The root logger defaults to WARNING (30), which is above INFO (20), so the message is filtered before reaching a handler. Call logging.basicConfig(level=logging.INFO) once in your entry point. Note that basicConfig does nothing if the root logger already has handlers.
What is the difference between the logger level and the handler level?
Both are checked. The logger level decides whether a record is created at all; each handler level decides whether that handler emits it. Set the logger permissively and use handler levels to send different verbosity to different destinations — INFO to the console, DEBUG to a file.
Should I use logger.error or logger.exception in an except block?
logger.exception, which logs at ERROR and attaches the traceback automatically. It only works inside an active exception handler. Outside one, logger.error with exc_info=True gives the same result if you hold a reference to the exception.
Why use percent-style placeholders instead of f-strings in log calls?
The logging module defers formatting until it knows the record will be emitted, so a filtered DEBUG call costs almost nothing. An f-string is evaluated at the call site regardless of whether the message survives filtering, so you pay for messages nobody sees.