Log Levels and Severity Mapping in Python

A log level is only useful if every system that reads it agrees on what it means, and that agreement breaks the moment a Python CRITICAL meets an OpenTelemetry collector that has never heard of it. This guide is part of the Python Logging Fundamentals and Structured Data guide and resolves the cross-framework severity inconsistencies that fragment alerting. It covers the native numeric tiers, custom levels and their costs, effective-level resolution and propagation, per-logger routing, the translation to OpenTelemetry SeverityNumber, the syslog mapping needed for legacy infrastructure, and the runtime controls that let you change verbosity during an incident. For the wire-format details that consume these values, see formatter configuration, and for the syslog bridge specifically, see mapping Python log levels to syslog.

Three severity scales aligned Python levels DEBUG through CRITICAL map to OpenTelemetry SeverityNumbers and to syslog numeric severities. Python DEBUG 10 INFO 20 WARNING 30 ERROR 40 CRITICAL 50 OTel DEBUG 5 INFO 9 WARN 13 ERROR 17 FATAL 21 syslog DEBUG 7 INFO 6 WARNING 4 ERR 3 CRIT 2
One source of truth, three target scales: Python, OpenTelemetry, and syslog severities aligned.

The guiding principles for this guide:

  • Treat Python's native integer tiers as the single source of truth.
  • Translate to other scales at the application boundary, never deep in business logic.
  • Always emit both a numeric severity and its text label.
  • Guard expensive log construction with a level check in hot paths.

Prerequisites

Severity mapping needs only the standard library. Pin the OpenTelemetry SDK only if you intend to bridge Python records into the OTel logs pipeline rather than serialize them yourself.

# Standard library is sufficient for the mapping itself.
python --version

# Optional: only when emitting through the OpenTelemetry logs SDK.
pip install "opentelemetry-sdk>=1.30.0,<2.0.0"

Concept and architecture

Python's logging module defines five core tiers, each backed by an integer: DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50. The integers are what matter at runtime. A logger compares the record's levelno against its effective level with a single integer comparison, which is far cheaper than matching on names and is the reason you should reason in numbers, not strings, on high-frequency paths. The spacing of ten between tiers is deliberate: it leaves room to slot a value between two standard levels if you ever truly need one, and it explains why NOTSET=0 sits below DEBUG as the sentinel that means "no explicit level set here."

The names themselves are just a lookup table. logging.getLevelName(20) returns "INFO", and the same function called with a string returns the integer, because the internal _nameToLevel and _levelToName maps are bidirectional. When a downstream formatter reads record.levelname it is reading a string that was resolved from levelno at record-creation time, so the integer is always the authoritative field and the name is a convenience derived from it.

Custom levels and why to avoid them

Custom levels are registered with logging.addLevelName(value, name), which binds an integer to a label so downstream parsers do not encounter an unknown name. Adding the level name does not by itself add a convenience method; if you want logger.notice(...) to exist you must also attach a method that calls self.log(NOTICE, ...). In practice you rarely need any of this: the standard five tiers map cleanly onto every downstream system, and inventing a NOTICE=25 only creates a value that OpenTelemetry, syslog, and your alerting rules have to be taught about separately. Record business events at INFO with a structured event_type field instead, which keeps the severity channel reserved for operational urgency.

import logging

# Registering a custom level is two steps: name it, then attach a method.
NOTICE = 25
logging.addLevelName(NOTICE, "NOTICE")


def notice(self: logging.Logger, message: str, *args, **kwargs) -> None:
    if self.isEnabledFor(NOTICE):
        self._log(NOTICE, message, args, **kwargs)


logging.Logger.notice = notice  # only do this if you genuinely need a sixth tier

The reason cross-system mapping is non-trivial is that the three scales disagree on direction and granularity. Python and OpenTelemetry both increase with severity, while syslog decreases — emerg is 0 and debug is 7. OpenTelemetry has no CRITICAL; its highest band is FATAL, so a faithful mapping renders Python CRITICAL as FATAL. OpenTelemetry's range is also wider, with 24 numbers grouped into six bands of four, which means a custom Python level can be placed precisely (a NOTICE between INFO and WARN becomes SeverityNumber 11) instead of collapsing. Establishing one translation table at the application boundary, consumed by the formatter configuration layer, keeps every service consistent.

Effective level, propagation, and per-logger routing

A logger's effective level is the threshold actually applied when deciding whether to create a record. A logger left at the default NOTSET does not reject everything; instead it walks up its ancestor chain until it finds a logger with an explicit level, ultimately falling back to the root's WARNING. Reading logger.getEffectiveLevel() at runtime tells you the real threshold rather than the locally configured one, which is the single most useful diagnostic when a record you expected never appears. The level check runs before any handler or filter, so it is the cheapest possible place to suppress volume.

Once a record is admitted, it propagates up the tree to every ancestor's handlers unless a logger on the path sets propagate = False. This is why per-logger routing is two independent decisions: the level controls admission at each named logger, and propagation controls how far the record travels. To route one noisy subsystem to its own sink while keeping everything else on the shared stdout handler, attach a handler directly to requests.packages.urllib3, lower its level, and set propagate = False so its records do not also flow to the root. The deployment-grade version of this wiring lives in how to configure Python logging for production.

A second, easily missed gate sits below the per-logger level: the module-wide manager level, exposed as logging.disable(level). It rejects every record at or below the given level across all loggers in the process regardless of their own thresholds, which is occasionally useful for a global emergency mute but a frequent source of "my DEBUG logs vanished everywhere at once" confusion. Treat it as a process-level kill switch, not a routing tool, and reset it with logging.disable(logging.NOTSET) once the incident passes. Per-logger levels and the manager level compose: a record must clear both before any handler is consulted, so a forgotten logging.disable(logging.INFO) left in a test fixture will silently suppress production-relevant records even when every logger is correctly set to DEBUG.

Step-by-step implementation

Step 1 — Define the canonical translation table. Keep a single dictionary keyed by Python levelno and reuse it everywhere.

import logging

# Python levelno -> OpenTelemetry SeverityNumber. CRITICAL maps to FATAL (21).
OTEL_SEVERITY = {
    logging.DEBUG: 5,
    logging.INFO: 9,
    logging.WARNING: 13,
    logging.ERROR: 17,
    logging.CRITICAL: 21,
}

Step 2 — Emit numeric and text severity together. A formatter reads record.levelno for the number and record.levelname for the human label, so both routing and reading are satisfied from one record.

import json


class OTelSeverityFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        log_obj = {
            "severity_number": OTEL_SEVERITY.get(record.levelno, 0),
            "severity_text": record.levelname,
            "message": record.getMessage(),
            "logger": record.name,
            "timestamp": self.formatTime(record, self.datefmt),
        }
        return json.dumps(log_obj, default=str)

Step 3 — Guard expensive log construction. Wrap any payload that costs real CPU in logger.isEnabledFor so a disabled level never pays the serialization tax.

logger = logging.getLogger("perf.app")

def handle(payload: dict) -> None:
    if logger.isEnabledFor(logging.DEBUG):
        logger.debug("payload=%s", json.dumps(payload, indent=2))
    logger.info("request handled", extra={"status": 200})

Step 4 — Route by level with a filter. When two sinks must diverge by severity rather than by logger name, a logging.Filter placed on a handler gives you a precise band. The example below keeps an alert sink to ERROR and above while letting the bounded filter reject anything quieter.

class MinLevelFilter(logging.Filter):
    """Admit only records at or above a threshold on this handler."""
    def __init__(self, level: int) -> None:
        super().__init__()
        self.level = level

    def filter(self, record: logging.LogRecord) -> bool:
        return record.levelno >= self.level


alert_handler = logging.StreamHandler()
alert_handler.addFilter(MinLevelFilter(logging.ERROR))  # alerts only see ERROR+

Step 5 — Change levels at runtime without a restart. During an incident, raise verbosity with setLevel or a fresh dictConfig, and restore it on a timer so debug logging never lingers.

logging.getLogger("perf.app").setLevel(logging.DEBUG)  # widen during triage

Configuration reference

Python Level Python Int OTel SeverityNumber OTel Text syslog Severity syslog Int
(custom) DEBUG2 5 2 TRACE2 debug 7
DEBUG 10 5 DEBUG debug 7
(custom) NOTICE 25 11 INFO3 notice 5
INFO 20 9 INFO info 6
WARNING 30 13 WARN warning 4
ERROR 40 17 ERROR err 3
CRITICAL 50 21 FATAL crit 2

The syslog column follows RFC 5424 and is expanded in mapping Python log levels to syslog. The two custom rows show how a non-standard Python integer lands precisely inside the wider OpenTelemetry banding instead of collapsing onto the nearest tier — the only legitimate reason to deviate from the five defaults. Note that cloud ingestion pipelines such as CloudWatch and Cloud Logging often re-normalize severity on receipt; disable that behavior when you require strict OpenTelemetry compliance, and keep the table above as the single source of truth.

Async and concurrency considerations

Level filtering is the first line of defense for async services because synchronous serialization on the event loop thread starves every coroutine waiting to run. The isEnabledFor guard matters more here than in threaded code: a heavy json.dumps inside a hot coroutine blocks the loop until it completes. Route WARNING and ERROR streams through buffered, non-blocking sinks rather than synchronous file writes; the patterns live in handler architecture and the queue mechanics in non-blocking logging with QueueHandler.

The logging module's internal locks are thread-safe, so calling setLevel from a background configuration-polling thread is safe with respect to record emission. Effective-level resolution is also safe to read concurrently, which is what makes a hot-reload of verbosity practical: a watcher thread can lower a logger's level mid-incident and emitting threads pick it up on their next call without coordination. Verify that any custom handler you wrote respects the same lock discipline before mutating its own thresholds, and apply probabilistic or token-bucket sampling to DEBUG so a traffic spike cannot flood the pipeline. Preserve error traces unconditionally to keep SLO visibility intact.

One subtlety worth pinning down for hot-path code is the cost asymmetry between the two ways to suppress a record. A disabled logger short-circuits inside Logger.debug itself, but the lazy %-style formatting (logger.debug("x=%s", value)) only avoids the interpolation, not the construction of value. If value is the result of an expensive call, that call still runs before debug is even invoked. The isEnabledFor guard is therefore not redundant with lazy formatting: it is the only thing that prevents the argument expressions from being evaluated at all. Reserve the guard for genuinely expensive payloads, because the guard plus the internal level check is two comparisons where one would do; for cheap arguments, lazy % formatting alone is the idiomatic and faster choice.

Production code examples

The module below maps Python integers onto the OpenTelemetry scale, emits both severity fields, and merges request context, which is exactly the shape a collector expects.

import logging
import json
import sys

OTEL_SEVERITY = {
    logging.DEBUG: 5,
    logging.INFO: 9,
    logging.WARNING: 13,
    logging.ERROR: 17,
    logging.CRITICAL: 21,
}


class OTelSeverityFormatter(logging.Formatter):
    _SKIP = {
        "msg", "args", "levelname", "levelno", "pathname", "filename",
        "module", "exc_info", "exc_text", "stack_info", "lineno",
        "funcName", "created", "msecs", "relativeCreated", "thread",
        "threadName", "processName", "process", "taskName", "name",
        "message", "asctime",
    }

    def format(self, record: logging.LogRecord) -> str:
        log_obj = {
            "severity_number": OTEL_SEVERITY.get(record.levelno, 0),
            "severity_text": record.levelname,
            "message": record.getMessage(),
            "logger": record.name,
            "timestamp": self.formatTime(record, self.datefmt),
        }
        # Merge caller-supplied extras while skipping reserved attributes.
        for key, value in record.__dict__.items():
            if key not in self._SKIP and not key.startswith("_"):
                log_obj[key] = value
        return json.dumps(log_obj, default=str)


logger = logging.getLogger("otel.app")
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(OTelSeverityFormatter())
logger.addHandler(handler)

if __name__ == "__main__":
    logger.info("Service initialized", extra={"service_version": "1.4.2"})
    logger.warning("High latency detected", extra={"p99_ms": 450})
    logger.critical("Database connection pool exhausted")

Expected Output (three newline-delimited JSON records):

{"severity_number": 9, "severity_text": "INFO", "message": "Service initialized", "logger": "otel.app", "timestamp": "2026-06-19 10:15:30,123", "service_version": "1.4.2"}
{"severity_number": 13, "severity_text": "WARNING", "message": "High latency detected", "logger": "otel.app", "timestamp": "2026-06-19 10:15:30,124", "p99_ms": 450}
{"severity_number": 21, "severity_text": "CRITICAL", "message": "Database connection pool exhausted", "logger": "otel.app", "timestamp": "2026-06-19 10:15:30,124"}

The second example shows lazy evaluation keeping a disabled DEBUG payload off the CPU entirely.

import logging
import json

logger = logging.getLogger("perf.app")
logger.setLevel(logging.INFO)  # DEBUG disabled in production


def process_request(payload: dict) -> None:
    # The guard prevents the json.dumps call from ever running when DEBUG is off.
    if logger.isEnabledFor(logging.DEBUG):
        logger.debug("Processing payload: %s", json.dumps(payload, indent=2))
    logger.info("Request processed successfully", extra={"status": 200})


if __name__ == "__main__":
    process_request({"user_id": "u_992", "action": "checkout", "items": 12})

Expected Output:

# The DEBUG line is never serialized or emitted; only INFO fires.
Request processed successfully

The third example routes one chatty dependency to its own threshold and proves effective-level resolution by reading it back. The urllib3 logger keeps its own WARNING floor and stops propagating, so its INFO chatter never reaches the application's DEBUG stdout handler.

import logging
import sys

root = logging.getLogger("app")
root.setLevel(logging.DEBUG)
stdout = logging.StreamHandler(sys.stdout)
stdout.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
root.addHandler(stdout)

# Quiet a noisy dependency without affecting the rest of the tree.
noisy = logging.getLogger("urllib3")
noisy.setLevel(logging.WARNING)   # admit WARNING and up only
noisy.propagate = True            # still flows to root handlers, but pre-filtered

if __name__ == "__main__":
    print("app effective:", logging.getLevelName(root.getEffectiveLevel()))
    print("urllib3 effective:", logging.getLevelName(noisy.getEffectiveLevel()))
    root.debug("app debug visible")
    noisy.info("connection pool detail")   # suppressed by urllib3's WARNING floor
    noisy.warning("retrying request")      # admitted

Expected Output:

app effective: DEBUG
urllib3 effective: WARNING
app DEBUG app debug visible
urllib3 WARNING retrying request

Common mistakes

DEBUG left enabled in production without sampling. Root cause: a blanket setLevel(logging.DEBUG) floods sinks with verbose records, inflating I/O, tail latency, and storage cost. Remediation: keep production at INFO, gate DEBUG behind authenticated SRE toggles, and apply rate-limited sampling to any verbose category.

Hardcoding severity strings instead of numeric standards. Root cause: emitting only a text label forces every downstream consumer to string-match, which breaks the moment a label differs across services. Remediation: emit severity_number alongside severity_text from the canonical table so routing keys off a stable integer.

Mismatched severity mapping across microservices. Root cause: each service ships its own translation dictionary and they drift, so the same condition alerts in one service and not another. Remediation: publish the translation table in a shared internal package and import it everywhere rather than copying it.

Inventing custom levels for business events. Root cause: an AUDIT=35 value has no meaning to OpenTelemetry or syslog, so it falls into a default bucket downstream. Remediation: log at a standard tier and carry the distinction in a structured event_type field that stays queryable.

Setting a child logger's level but forgetting that NOTSET inherits. Root cause: a logger left at the default NOTSET does not block records; it defers to its ancestors, so a record you meant to suppress slips through the root's WARNING floor or a parent's DEBUG. Remediation: read getEffectiveLevel() while debugging missing or surprise records, and set an explicit level on any logger whose threshold must not depend on its parents.

Frequently Asked Questions

How do I map Python logging levels to OpenTelemetry severity numbers?

Map Python's 10, 20, 30, 40, 50 to OpenTelemetry's 5, 9, 13, 17, 21 respectively using a translation dictionary applied during formatting. Emit the resulting SeverityNumber alongside the text label so collectors route on the number.

Should I use custom log levels for business events?

No. Custom numeric levels break downstream routing and alerting that assume the standard tiers. Record business events at INFO with a structured field such as event_type, which keeps severity semantics intact and stays queryable.

What is the performance impact of checking log levels before formatting?

The check itself is a single integer comparison and is effectively free. Guarding an expensive payload with logger.isEnabledFor avoids the string interpolation or JSON serialization entirely when the level is disabled, which removes the bulk of the cost in hot paths.

Why do my CRITICAL logs show up as FATAL in the collector?

OpenTelemetry has no CRITICAL tier; its closest band is FATAL. A correct mapping renders Python CRITICAL as SeverityNumber 21 with text FATAL, so the relabeling is expected and consistent rather than a bug.

What is a logger's effective level and how is it resolved?

The effective level is the threshold actually used to admit records. A logger left at NOTSET inherits by walking up its ancestors until one has an explicit level, falling back to the root's WARNING. You can read it at runtime with getEffectiveLevel.