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 and Structured Data reference 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 layer that consumes these values, see formatter configuration; for the syslog bridge specifically, see mapping Python log levels to syslog; and for the deployment-grade level layout of a running service, see how to configure Python logging for production.

One Python level number, three target scales Three aligned columns. Python levelno rises with severity: DEBUG 10, INFO 20, WARNING 30, ERROR 40, CRITICAL 50. OpenTelemetry SeverityNumber also rises: DEBUG 5, INFO 9, WARN 13, ERROR 17, FATAL 21. Syslog RFC 5424 falls instead: debug 7, info 6, warning 4, err 3, crit 2. Each Python row connects straight across to its counterpart in the other two scales. The final row is highlighted because OpenTelemetry has no CRITICAL tier, so Python CRITICAL 50 is rendered as FATAL 21. one Python levelno, translated once at the application boundary Python logging levelno — rises with severity DEBUG 10 INFO 20 WARNING 30 ERROR 40 CRITICAL 50 OpenTelemetry SeverityNumber 1–24 — rises DEBUG 5 INFO 9 WARN 13 ERROR 17 FATAL 21 syslog RFC 5424 — falls with severity debug 7 info 6 warning 4 err 3 crit 2 Python and OpenTelemetry numbers rise with severity; syslog numbers fall — 7 is debug, 2 is crit. OpenTelemetry has no CRITICAL tier, so Python CRITICAL 50 renders as FATAL 21 — expected, not a bug.
One source of truth, three target scales: Python, OpenTelemetry, and syslog severities aligned row by row.

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.
  • Make the threshold an operational input, not a constant compiled into the code.

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. Python 3.11 or newer is worth having for logging.getLevelNamesMapping(), which turns environment-driven level parsing into a dictionary lookup instead of a getattr against the logging module.

# Standard library is sufficient for the mapping itself.
python --version          # 3.11+ recommended for getLevelNamesMapping()

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

One environment variable carries the whole contract for a service. Everything else in this guide reads from it rather than hardcoding a tier:

# The single operational knob; every logger threshold derives from it.
export LOG_LEVEL=INFO

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. That string-to-integer direction is legacy behaviour retained for compatibility; on Python 3.11 and later, logging.getLevelNamesMapping() gives you the name-to-number dictionary explicitly and is the honest way to validate operator input. 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.

The gates a record passes, in order

Where a record is admitted or dropped, in order Phase one runs before any LogRecord exists. A call to logger.info reaches the process-wide logging.disable floor and then the logger's effective level, which is resolved by walking ancestors up to the root's WARNING. If either gate rejects the call, nothing is built and the cost ends there. If both pass, the LogRecord is constructed: the record factory runs and pathname and lineno are resolved. Phase two operates on a record that already exists: the emitting logger's filters, propagation up the ancestor chain where logger levels are never re-checked, each handler's own level as a floor, and each handler's filters as a ceiling before the formatter runs. Only phase one prevents the work. phase 1 — nothing is built yet (one integer compare, cache-backed) logger.info(…) the call site argument expressions already ran logging.disable floor process-wide, checked first manager.disable ≥ levelno → drop effective level walks ancestors until one is set NOTSET → parent → root WARNING dropped here no LogRecord is built — the cost ends LogRecord constructed record factory runs; pathname and lineno resolved phase 2 — the record already exists (per logger, per sink) logger filters Filter.filter(record) may mutate the record propagate up each ancestor's handlers levels are not re-checked handler.level a floor, per sink delivery only handler filters the ceiling lives here then the formatter runs Only phase 1 prevents work; every phase-2 gate discards a record you have already paid to build.
The logger level is the only gate that stops a record from existing — handler levels and filters merely decide which sink sees the record you already built.

Level filtering is not one decision but a chain, and knowing the order explains almost every "why is this record missing" question. A call to logger.info(...) first consults isEnabledFor, which checks the process-wide logging.disable floor and then the logger's effective level. Only if that passes is a LogRecord constructed at all — meaning arguments are formatted, pathname and lineno are resolved, and any record factory runs. The record then passes the emitting logger's own filters, propagates up the tree, and at each handler on the way is checked again against that handler's level and its filters before the formatter is ever called.

Two consequences follow. First, the logger level is the only gate that prevents record creation, so it is the cheapest place to control volume; handler levels only prevent delivery to one sink after the record already exists. Second, isEnabledFor results are memoized in a per-logger _cache dictionary keyed by level, and that cache is invalidated whenever any level in the tree changes or logging.disable is called. This is what makes a hot verbosity change safe and makes a steady-state disabled logger.debug(...) call cost little more than a dictionary hit and a comparison.

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 and keeps the event queryable as data.

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:
    # Mirror the stdlib guard so a disabled NOTICE costs one comparison.
    if self.isEnabledFor(NOTICE):
        self._log(NOTICE, message, args, **kwargs)


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

Third-party libraries make this decision for you and then hand you the mismatch. Loguru ships TRACE=5 and SUCCESS=25 out of the box, and structlog's filtering bound logger enforces its threshold before any processor runs rather than at a handler — differences worked through in structlog vs Loguru vs standard library logging. If any of those levels reach your pipeline, they need a row in the translation table below or they will be bucketed as unspecified.

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.

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 that logger, raise its level, and set propagate = False so its records do not also flow to the root. Note the asymmetry that trips people up: raising a child's level suppresses records for the whole subtree below it, but lowering a child's level to DEBUG while the root stays at WARNING still emits — because the root's level is never re-checked during propagation, only its handlers' levels are.

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

Build order for the severity layer Six steps arranged left to right in two rows. One, the canonical translation table: OTEL_SEVERITY keyed by levelno with a bisect fallback that rounds down, shipped as one shared package rather than per service. Two, a formatter that emits severity_number from the table beside severity_text from record.levelname. Three, an isEnabledFor guard that wraps only costly payloads so their argument expressions never run. Four, routing by band, where a handler level gives the floor and a Filter gives the ceiling. Five, LOG_LEVEL resolved at boot and validated against getLevelNamesMapping so a typo fails loudly. Six, a timed setLevel override whose daemon Timer restores the Step 5 baseline automatically. build order — each step consumes the one before it 1 translation table OTEL_SEVERITY keyed by levelno bisect fallback rounds down one shared package, not per service 2 emit both fields severity_number ← the table severity_text ← levelname machines route, humans read 3 isEnabledFor guard wraps costly payloads only argument expressions never run two compares beat one json.dumps 4 route by band handler.level gives the floor a Filter gives the ceiling stdout INFO–WARNING, stderr ERROR+ 5 LOG_LEVEL at boot checked against getLevelNamesMapping() a typo fails loudly, not silently the one knob operators touch 6 timed override setLevel now, Timer restores daemon timer, no follow-up verbosity cannot outlive the incident the Timer restores the Step 5 baseline automatically
Steps 1 through 6 in build order; Step 6's timer hands the threshold back to the Step 5 baseline without anyone remembering to.

Step 1 — Define the canonical translation table. Keep a single dictionary keyed by Python levelno and reuse it everywhere. A non-standard Python level should never fall through to zero, so resolve anything unrecognized to the highest table entry at or below it: a stray AUDIT=35 then lands on WARN rather than becoming unspecified. That fallback is deliberately conservative — it rounds down to a standard tier. Any custom level you intend to keep deserves an explicit row with its precise in-band number instead, which is how a NOTICE=25 becomes SeverityNumber 11 rather than collapsing onto INFO at 9.

import bisect
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,
}
_LEVELS = sorted(OTEL_SEVERITY)


def to_otel_severity(levelno: int) -> int:
    """Exact match when possible, else the nearest standard tier below."""
    if levelno in OTEL_SEVERITY:
        return OTEL_SEVERITY[levelno]
    index = bisect.bisect_right(_LEVELS, levelno) - 1
    # Below DEBUG (e.g. Loguru TRACE=5) has no lower tier: report TRACE (1).
    return OTEL_SEVERITY[_LEVELS[index]] if index >= 0 else 1

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. Never emit record.levelno under the severity_number key: a Python INFO of 20 reads as FATAL on the OTel scale, which is exactly the failure the table exists to prevent.

import json


class OTelSeverityFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        log_obj = {
            "severity_number": to_otel_severity(record.levelno),
            "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:
    # Without the guard, json.dumps runs even though DEBUG is disabled.
    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. A handler's own level gives you a floor; a filter is what gives you a ceiling, which is how you keep ordinary traffic on stdout and errors on stderr without printing errors twice.

import sys


class LevelBandFilter(logging.Filter):
    """Admit only records inside [low, high) on this handler."""

    def __init__(self, low: int, high: int = logging.CRITICAL + 10) -> None:
        super().__init__()
        self.low, self.high = low, high

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


stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.addFilter(LevelBandFilter(logging.INFO, logging.ERROR))  # INFO, WARNING
alert_handler = logging.StreamHandler(sys.stderr)
alert_handler.setLevel(logging.ERROR)  # a floor needs no filter

Step 5 — Resolve the startup threshold from the environment. Parse LOG_LEVEL once at bootstrap, validate it against the real name mapping, and fail loudly on a typo rather than silently defaulting — a service that starts at WARNING because someone wrote INF0 is a service whose incident timeline has a hole in it. This is the value dictConfig should interpolate; see logging configuration and dictConfig for the declarative form.

import os


def resolve_level(default: int = logging.INFO) -> int:
    raw = os.getenv("LOG_LEVEL", "").strip().upper()
    if not raw:
        return default
    names = logging.getLevelNamesMapping()  # Python 3.11+
    if raw not in names:
        raise ValueError(f"LOG_LEVEL={raw!r} is not a known level: {sorted(names)}")
    return names[raw]


logging.getLogger().setLevel(resolve_level())

Step 6 — Change levels at runtime without a restart, and revert automatically. During an incident you want DEBUG on one subsystem for ten minutes, not until the next deploy. setLevel takes effect on the next call because the level cache is cleared for the whole tree, so a timer that restores the previous value is all the safety you need.

import threading


def temporary_level(name: str, level: int, seconds: float = 600.0) -> None:
    """Widen one logger during triage; restore it without human follow-up."""
    target = logging.getLogger(name)
    previous = target.level                     # the explicit level, not the effective one
    target.setLevel(level)                      # clears the level cache tree-wide
    timer = threading.Timer(seconds, target.setLevel, args=(previous,))
    timer.daemon = True                         # never hold up interpreter shutdown
    timer.start()


temporary_level("perf.app", logging.DEBUG, seconds=600)

Configuration reference

Which severity control to reach for Five controls compared across three columns. logging.disable acts in phase one on record creation, scoped to every logger in the process, and nothing is built when it drops a record. Logger.setLevel also acts in phase one on creation, scoped to that logger and its subtree, and nothing is built. Logger.propagate acts in phase two on the dispatch walk, scoped to the ancestors of that logger; the record exists and the walk simply stops. Handler.setLevel acts in phase two as a per-sink floor on one sink, by which point the record is already built. A Filter on a handler acts in phase two as the ceiling for one sink or logger, again after the record is already built. the same five knobs, scored by when they act rather than by what they are called control what it gates scope cost of a record it drops logging.disable() phase 1 · creation every logger in the process nothing is built Logger.setLevel() phase 1 · creation this logger and its subtree nothing is built Logger.propagate phase 2 · the walk ancestors of this logger record exists; the walk stops Handler.setLevel() phase 2 · delivery floor one sink record already built Filter on a handler phase 2 · delivery ceiling one sink or one logger record already built stops the record existing only redirects one already built
Two of these knobs control volume; the other three control destination. Reaching for the wrong pair is how a service ends up paying for records nobody ever reads.

The knobs that decide which records exist and where they land:

Control Type Default Production recommendation
LOG_LEVEL environment variable string unset INFO; the only threshold operators touch
Logger.setLevel() on the root int or string WARNING set explicitly at bootstrap from LOG_LEVEL
Logger.setLevel() on a child int or string NOTSET (inherit) leave inheriting unless the subsystem needs its own floor
Handler.setLevel() int NOTSET (admit all) ERROR on alert sinks, unset on the primary sink
Logger.propagate bool True False only where the logger owns a dedicated handler
logging.disable() int NOTSET (0) leave at NOTSET; incident kill switch only
Logger.isEnabledFor() guard call wrap any payload costing more than a few microseconds
logging.raiseExceptions bool True False in production so a broken sink cannot spam stderr
logging.basicConfig(force=...) bool False True only in tests, to replace handlers a library installed

And the severity translation itself, which belongs in a shared internal package rather than being retyped per service:

Python Level Python Int OTel SeverityNumber OTel Text syslog Severity syslog Int
(custom) TRACE 5 1 TRACE debug 7
DEBUG 10 5 DEBUG debug 7
INFO 20 9 INFO info 6
(custom) NOTICE 25 11 INFO3 notice 5
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 can land precisely inside the wider OpenTelemetry banding instead of collapsing onto the nearest tier — the only legitimate reason to deviate from the five defaults, and the reason such a level needs its own row rather than relying on the round-down fallback. Two operational cautions apply to the table as a whole. If you emit through the OpenTelemetry logs SDK rather than serializing yourself, its LoggingHandler carries its own standard-to-OTel translation, so confirm it agrees with this one before running both paths side by side. And cloud ingestion pipelines such as CloudWatch and Cloud Logging often re-normalize severity on receipt; disable that behaviour when you require strict OpenTelemetry compliance, and keep this table as the single source of truth.

Async and concurrency considerations

What a debug call costs the event loop Four tracks over the same twenty milliseconds. In the first, an unguarded logger.debug serializes its payload inline: after coroutine A runs, json.dumps and the write occupy eight milliseconds during which the loop cannot switch tasks, so coroutine B starts late. In the second, isEnabledFor rejects the call in about a tenth of a microsecond — the amber tick — and coroutines B and C run back to back. In the third and fourth tracks the guarded call is paired with a QueueHandler: put_nowait costs the loop a sliver, the loop continues with coroutines B and C, and the same eight milliseconds of formatting and writing happen on the listener thread instead. Record creation is still paid on the loop; only formatting and I/O move off it. 20 ms of one event-loop thread, same workload three ways unguarded debug json.dumps inline coro A json.dumps + write · 8 ms coro B (late) the loop cannot switch tasks — every pending coroutine waits out the serialization guarded debug isEnabledFor first coro A coro B coro C the amber sliver is the guard: two comparisons, and the payload is never built guarded + queue loop thread coro A coro B coro C put_nowait, then straight back to the loop listener thread off the loop format + write · 8 ms, off the loop record creation is still paid on the loop; only formatting and I/O move to the listener 0 ms 5 10 15 20 ms
The same disabled DEBUG call, three ways: inline serialization stalls every coroutine, the guard costs two comparisons, and the queue moves only the formatting off the loop.

Level filtering is the first line of defence 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, and unlike a thread there is no pre-emption to bail you out. 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, because setLevel clears the level cache for the whole tree under the module lock. 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.

When a QueueHandler sits in the path, remember that the level decision has already happened by the time the record is enqueued — the listener's handlers apply their own levels on the consuming thread, but the cost of record creation was paid on the request path. Keep the emitting logger's level tight rather than relying on a strict handler level behind the queue, otherwise you pay full construction cost for records the listener will discard. The same reasoning applies to context enrichment: identifiers pulled from context variables must be captured at record creation, because the queue boundary means the listener thread no longer has the emitting task's context.

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

One record, one line: which field is translated On the left, a LogRecord in memory with name otel.app, levelno 30, levelname WARNING, msg High latency detected, and an extra field p99_ms of 450. On the right, the single JSON line the formatter writes to stdout, with the same fields in the same order: logger, severity_number 13, severity_text WARNING, message, and p99_ms. Only levelno passes through to_otel_severity, which turns 30 into 13 and rounds unknown levels down; every other field is copied across unchanged. Emitting record.levelno directly under severity_number would publish 30, which reads as FATAL on the OpenTelemetry scale. the same record, before and after the formatter LogRecord in memory name "otel.app" levelno 30 levelname "WARNING" msg "High latency detected" p99_ms (extra) 450 to_otel_severity() 30 → 13, unknown rounds down one JSON line on stdout "logger": "otel.app", "severity_number": 13, "severity_text": "WARNING", "message": "High latency detected", "p99_ms": 450 Only the number is translated; the label ships verbatim, so no consumer has to guess which scale it reads. Emitting record.levelno under severity_number would publish 30 — FATAL on the OpenTelemetry scale.
The solid path is the only translated field; the dashed paths copy across untouched, which is why severity_text stays readable while severity_number stays routable.

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. It is the same field projection used by structured logging with the Python standard library, narrowed to the severity concern.

import bisect
import json
import logging
import sys

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


def to_otel_severity(levelno: int) -> int:
    if levelno in OTEL_SEVERITY:
        return OTEL_SEVERITY[levelno]
    index = bisect.bisect_right(_LEVELS, levelno) - 1
    return OTEL_SEVERITY[_LEVELS[index]] if index >= 0 else 1


class OTelSeverityFormatter(logging.Formatter):
    # Reserved LogRecord attributes that must not be copied into the JSON body.
    _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": to_otel_severity(record.levelno),
            "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 json
import logging

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__":
    logging.basicConfig(format="%(message)s")
    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 sink 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, while app.checkout — left at NOTSET — inherits DEBUG from its parent.

import logging
import sys

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

# Quiet a noisy dependency and give it a sink of its own.
noisy = logging.getLogger("urllib3")
noisy.setLevel(logging.WARNING)          # admit WARNING and above only
dep_sink = logging.StreamHandler(sys.stdout)
dep_sink.setFormatter(logging.Formatter("[dep] %(name)s %(levelname)s %(message)s"))
noisy.addHandler(dep_sink)
noisy.propagate = False                  # do not also flow to ancestor handlers

child = logging.getLogger("app.checkout")  # NOTSET: inherits app's DEBUG

if __name__ == "__main__":
    print("app.checkout effective:", logging.getLevelName(child.getEffectiveLevel()))
    print("urllib3 effective:", logging.getLevelName(noisy.getEffectiveLevel()))
    child.debug("cart recalculated")
    noisy.info("connection pool detail")   # dropped by urllib3's WARNING floor
    noisy.warning("retrying request")      # admitted, to the dependency sink only

Expected Output:

app.checkout effective: DEBUG
urllib3 effective: WARNING
app.checkout DEBUG cart recalculated
[dep] urllib3 WARNING retrying request

Common mistakes

Triage tree for a record that never appeared Start from a record you expected but never saw, and work down the gates in the order they run. First, is logging.disable set? It is checked before every logger level; reset it with logging.disable(logging.NOTSET) and never use it as a routing tool. Second, is getEffectiveLevel too high? NOTSET inherits and the root is WARNING, so set an explicit level on the emitting logger, resolved from LOG_LEVEL at bootstrap. Third, does an ancestor set propagate to False? The walk stops before the shared sink, so leave propagate True unless that logger owns the handler its records need. Fourth, is a handler level above the record? The sink drops what the logger admitted, so lower that handler's level or add a second sink for the band. Fifth, is a filter returning False? Band filters add a ceiling as well as a floor, so print handler.filters. If it is still missing, the record was never created at all. a record you expected never appeared logging.disable set? checked before every logger level logging.disable(logging.NOTSET) and never use it as a routing tool yes no effective level too high? NOTSET inherits; the root is WARNING set an explicit level on the emitting logger, resolved from LOG_LEVEL at bootstrap yes no propagate = False? the walk stops before the shared sink leave propagate True unless that logger owns the handler its records need yes no handler.level above it? the sink drops what the logger admitted lower that handler's level, or add a second sink for the band you actually need yes no a filter returning False? a band filter is a ceiling, not a floor print handler.filters — a LevelBandFilter silently excludes everything above it yes still missing? then the record was never created read getEffectiveLevel() at the call site and logging.root.manager.disable, in that order
Work the gates in the order they run: process floor, effective level, propagation, handler level, handler filter — the first one that answers "yes" is your bug.
  • Error signature: log volume and storage cost jump after a deploy, and tail latency rises with no code change to the request path. Root cause: a blanket setLevel(logging.DEBUG) shipped to production, so every verbose record is created, formatted, and written. Remediation: keep production at INFO, drive the threshold from LOG_LEVEL, gate DEBUG behind an authenticated toggle with the timed revert from Step 6, and sample verbose categories.

  • Error signature: an alert rule matching severity >= 17 fires for routine INFO traffic from one service. Root cause: the service emits record.levelno under the severity_number key, so a Python INFO of 20 reads as FATAL on the OpenTelemetry scale. Remediation: translate through the canonical table and emit severity_text beside it, so no consumer has to guess which scale a number belongs to.

  • Error signature: the same condition pages on-call from one service and stays silent in another. Root cause: each service ships its own copy of the translation dictionary and they have drifted, usually at the custom-level rows. Remediation: publish the table in a shared internal package and import it; treat a change to it as a fleet-wide change, not a local edit.

  • Error signature: records at a custom level such as AUDIT=35 arrive with severity 0 or unspecified in the backend. Root cause: the translation is a plain dictionary lookup with a zero default, so any integer that is not exactly 10/20/30/40/50 falls through. Remediation: resolve unknown levels to the nearest standard tier below, as to_otel_severity does — or better, log at a standard tier and carry the distinction in a structured event_type field.

  • Error signature: a record you expected to suppress still appears, or a child logger set to DEBUG produces nothing. Root cause: a logger left at NOTSET defers to its ancestors rather than blocking, and a lowered child level cannot defeat a handler level set higher upstream. Remediation: read getEffectiveLevel() on the emitting logger and inspect each handler's level while debugging; set an explicit level on any logger whose threshold must not depend on its parents.

  • Error signature: every DEBUG record in the process vanishes at once, across unrelated loggers, with no configuration change. Root cause: logging.disable was called — typically by a test fixture or a leftover mute — and its process-wide floor is checked before any per-logger level. Remediation: inspect logging.root.manager.disable, reset it with logging.disable(logging.NOTSET), and never use it as a routing mechanism.

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 against a per-logger cache 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.

Why did all my DEBUG records disappear across every logger at once?

Something called logging.disable, which sets a process-wide floor that is checked before any per-logger level. It is usually a leftover from a test fixture or a temporary mute. Reset it with logging.disable(logging.NOTSET) and confirm with logging.root.manager.disable.