Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

Python Logging Levels: What DEBUG, INFO, WARNING, ERROR and CRITICAL Are Actually For

Sean

Platform Writer

Aug 07, 2026
9 min read

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.

Python Logging Levels: What DEBUG, INFO, WARNING, ERROR and CRITICAL Are Actually For

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

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:

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.

#python logging levels#python logging#logging module#debug logging#application observability