Intercepting Standard Logging with Loguru

Adopting Loguru in application code takes an afternoon. Getting your dependencies' records into the same stream takes an InterceptHandler — about fifteen lines, most of which exist to solve one problem: keeping the reported source location accurate. This page covers that handler, the propagation setup around it, and the framework-specific cases. It builds on Loguru configuration and sinks, part of the modern Python logging libraries deep dive section.

Two front doors, one sink Two paths into the same output. Application code calls Loguru's logger directly and reaches the configured sinks with no intermediate step. Dependencies — urllib3, boto3, SQLAlchemy, a web framework — call the standard library, which builds LogRecords and passes them to whatever handler is on the root logger. That handler is the InterceptHandler: it translates the record's numeric level to a Loguru level name, computes the frame depth needed to skip logging's own frames, and re-emits the record through Loguru with its exception information intact. Both paths converge on the same sinks, so one format, one rotation policy and one retention policy cover the whole process. The diagram marks the two things the handler must get right: level translation, which fails loudly on custom levels, and depth, which fails silently by reporting every library record's location as logging slash dunder init dot py. your code and your dependencies, ending in the same sink your code from loguru import logger your dependencies urllib3 · boto3 · sqlalchemy logging.getLogger(__name__) InterceptHandler level: 40 → "ERROR" depth: skip logging's frames Loguru sinks one format, one rotation, one retention policy the two things the handler must get right level translation — fails loudly on a custom level · depth — fails silently, reporting every record as logging/__init__.py
Fifteen lines, and most of them are about depth. The level mapping fails loudly; the depth calculation fails silently, which makes it the one worth testing.

Prerequisites

pip install "loguru>=0.7.2,<1.0.0"

Implementation

Step 1 — Write the handler. Three responsibilities: translate the level, find the real caller, and forward with exc_info intact.

import logging
import sys
from loguru import logger

class InterceptHandler(logging.Handler):
    """Forward every standard-library record into Loguru."""

    def emit(self, record: logging.LogRecord) -> None:
        try:
            level = logger.level(record.levelname).name       # by name, not by number
        except ValueError:
            level = record.levelno                            # custom level: use the number

        # Walk out of the logging module so Loguru reports the real call site.
        frame, depth = sys._getframe(6), 6
        while frame and frame.f_code.co_filename == logging.__file__:
            frame = frame.f_back
            depth += 1

        logger.opt(depth=depth, exception=record.exc_info).log(
            level, record.getMessage()
        )

The try/except ValueError matters more than it looks. logger.level() raises for a name Loguru does not know, and a project that registered a custom level with logging.addLevelName(25, "AUDIT") will hit it on the first audit record. Falling back to the numeric level keeps that record rather than losing it inside handleError.

Step 2 — Install it on the root logger, once. force=True removes any handlers a library installed at import time, which is the usual source of duplicate output.

logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)

Then remove handlers from named loggers that ship with their own, leaving propagation on so their records reach the root:

for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "gunicorn.error", "gunicorn.access"):
    logging.getLogger(name).handlers = []
    logging.getLogger(name).propagate = True

This is the same handlers-plus-propagation rule that produces duplicates in any root-handler configuration — the version for Django is in logging configuration in Django settings.

Step 3 — Keep level control in the standard library. The handler only sees records that already passed the logger's level check, so noisy dependencies are still quietened the usual way.

logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
logging.getLogger("botocore").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

That split is worth stating plainly: levels stay in logging, formatting and delivery move to Loguru.

What the depth argument is counting The call stack between a library's log call and Loguru, drawn from the top down. At the top is the frame that matters: the application or library code that called logger.info, whose file and line the log record should report. Below it are several frames belonging to the logging module itself — the logger's info method, its internal log method, the handle and callHandlers routines, and finally the emit method of the intercept handler. Loguru, asked to record the caller, would stop at the first of those logging frames and report logging slash dunder init dot py as the source of every intercepted record. The depth argument tells it how many frames to skip, and because the number of logging frames varies with Python version and call path, the handler computes it by walking back while the frame's filename is logging's own rather than hard-coding a constant. the frames between the call site and Loguru urllib3/connectionpool.py:876 — the real call site report this logging/__init__.py — Logger.warning logging/__init__.py — Logger._log logging/__init__.py — Logger.handle · callHandlers InterceptHandler.emit — where you are now depth = frames to skip not a constant — it varies by version and by call path so the handler walks back while frame.f_code.co_filename == logging.__file__ rather than hard-coding a number get it wrong and every library record is attributed to logging/__init__.py — which no test asserts on, so it ships
The frame count is not a constant. Hard-coding it works on the Python version you wrote it for and silently misattributes every record on the next one.

Step 4 — Wire the sinks. With interception in place, Loguru owns the output for the whole process.

import sys
from loguru import logger

logger.remove()
logger.add(sys.stdout, serialize=True, level="INFO", enqueue=True, backtrace=False, diagnose=False)

serialize=True emits one JSON object per line, including for intercepted records, so a library warning and an application info line have the same shape. Rotation, retention and compression, if you write files rather than stdout, are covered in Loguru rotation, retention and compression.

Levels stay in logging; everything after them moves to Loguru A split of responsibilities after installing the intercept handler. On the standard library side sit the decisions made before a record is created or forwarded: which logger exists, what level each namespace is set to, whether a record propagates, and therefore whether the intercept handler ever sees it at all. On the Loguru side sit every decision made afterwards: the output format, whether records are serialised as JSON, which sinks receive them, rotation, retention, compression, and whether writes are enqueued. The boundary is the intercept handler itself. The practical consequence is that quietening a noisy dependency is still a logging.getLogger call, and changing how records look is a logger.add call — and confusing the two produces the recurring question of why setting a Loguru level did not quieten a library. who owns which decision after interception the standard library still owns which loggers exist the level on each namespace propagation so a record filtered here never reaches Loguru at all, whatever Loguru is set to Intercept Handler Loguru owns everything after format and serialisation which sinks receive the record rotation · retention · compression · enqueue so changing how records look is a logger.add call, never a getLogger one "I set the Loguru level to WARNING and urllib3 is still loud" — because the record was admitted by logging, long before Loguru saw it a sink level filters what Loguru writes; a logger level decides what is created in the first place
The two halves are not interchangeable. A sink level filters what gets written; a logger level decides whether a record is ever handed over.

Configuration options

Option Where Default Recommended
basicConfig(force=True) stdlib False True — removes library handlers
Handler placement root only root; clear named-logger handlers
propagate named loggers True True, with no handlers of their own
logger.opt(depth=…) handler 0 computed by walking frames
logger.opt(exception=…) handler None record.exc_info
Level control stdlib loggers stays in logging
logger.level(name) Loguru raises wrapped in try/except ValueError

Verification

import logging
from loguru import logger

configure()                                       # intercept + sinks from above
logging.getLogger("urllib3.connectionpool").warning("Retrying (Retry(total=2)) after error")
logger.info("order accepted", order_id=8812)

Expected Output:

{"text": "...", "record": {"level": {"name": "WARNING"}, "name": "urllib3.connectionpool", "function": "urlopen", "file": {"name": "connectionpool.py"}, "line": 876, "message": "Retrying (Retry(total=2)) after error"}}
{"text": "...", "record": {"level": {"name": "INFO"}, "name": "__main__", "function": "<module>", "file": {"name": "app.py"}, "line": 12, "message": "order accepted"}}

The first record is the test: file.name is connectionpool.py, not __init__.py, which means the depth calculation is working. That is the assertion worth keeping:

def test_intercepted_records_keep_their_call_site(capsys):
    logging.getLogger("urllib3").warning("probe")
    out = capsys.readouterr().out
    assert "__init__.py" not in out               # the depth walk did its job

Common mistakes

Every library record points at logging/__init__.py

Error signature: the source location is identical for every intercepted record. Root cause: depth was not computed, or was hard-coded for a different Python version. Remediation: walk back while frame.f_code.co_filename == logging.__file__, and assert on the call site in a test.

Records appear twice

Error signature: each library warning is printed once in Loguru's format and once in the library's own. Root cause: the named logger kept its own handler while also propagating to the root. Remediation: clear the handlers on those loggers and leave propagate=True.

A custom level raises inside the handler

Error signature: records at a project-specific level vanish, and stderr shows a ValueError from logger.level. Root cause: Loguru has no level registered under that name. Remediation: catch ValueError and pass the numeric level, or register the level with logger.level("AUDIT", no=25) at startup.

Carrying structured fields across the bridge

The intercept handler as written forwards the message and the exception, and drops everything else the record was carrying. That is fine for a library warning and a real loss for your own records, because extra fields are exactly the part worth keeping.

Loguru's equivalent of extra is logger.bind(), so the bridge has to translate one into the other. The reference-set difference used for a standard-library JSON formatter works here too: anything on the record that is not a standard attribute is a field somebody added deliberately.

import logging
from loguru import logger

_RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {
    "message", "asctime", "taskName",
}

class InterceptHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        try:
            level = logger.level(record.levelname).name
        except ValueError:
            level = record.levelno

        frame, depth = sys._getframe(6), 6
        while frame and frame.f_code.co_filename == logging.__file__:
            frame = frame.f_back
            depth += 1

        extras = {k: v for k, v in record.__dict__.items() if k not in _RESERVED}
        logger.bind(**extras).opt(depth=depth, exception=record.exc_info).log(
            level, record.getMessage()
        )

With serialize=True on the sink, those fields appear under record.extra in the emitted JSON, which is where a query expects them. Without this, a service that migrates to Loguru silently loses every structured field it was previously emitting — and the loss is invisible in development, because the console renderer never showed them prominently anyway.

Standard library Loguru Notes
record.getMessage() the message argument interpolation already done
record.levelname logger.level(name) falls back to the number for custom levels
record.exc_info .opt(exception=...) preserves the traceback
extra={...} fields logger.bind(**extras) needs the reference-set difference
record.name extra["name"] Loguru's own name is the calling module
Caller location .opt(depth=...) the frame walk above

The direction nobody expects

Interception moves standard-library records into Loguru. The opposite direction — Loguru records into standard-library handlers — comes up when part of the stack already depends on logging infrastructure: a QueueHandler for non-blocking delivery, an OpenTelemetry logs bridge, a vendor handler.

Loguru supports it because a sink is any callable, so a sink that constructs a LogRecord and hands it to a standard-library logger is a few lines. It is worth knowing that this exists and worth avoiding when possible: records crossing twice pay both translations and lose a little at each. If the standard library owns the delivery infrastructure, the simpler arrangement is usually to keep logging as the front end and use Loguru only where its ergonomics are wanted.

Deciding whether interception is the right move

Interception makes Loguru the whole process's output layer, which is a commitment. Three questions decide whether it is the right one.

Does the ecosystem you depend on assume logging? Django's LOGGING, Celery's setup_logging, gunicorn's logconfig_dict, pytest's caplog and the OpenTelemetry logs bridge are all built around the standard library. Each has a workaround under Loguru, and each workaround is one more thing to maintain.

Do you need dictConfig-style declarative configuration? Loguru is configured in code by design. That is an advantage for a small service and a limitation where operations expects to change a configuration file without a deploy.

Does anything in the stack need to reconfigure logging at runtime? A level change through logging.getLogger(name).setLevel(...) still works after interception, because the standard library is still deciding what to admit — which is convenient, and is also why the level control described elsewhere on this site applies unchanged. What does not work is a runtime change to the output format, since that lives in a Loguru sink and Loguru sinks are added and removed rather than reconfigured. Removing and re-adding a sink at runtime is supported and is a heavier operation than swapping a formatter.

Is the ergonomic gain worth it for your team? Loguru's logger.add() genuinely is simpler than a handler graph, and for a service with two sinks and no unusual requirements that simplicity is the whole argument. The comparison in full is in structlog vs Loguru vs standard library logging.

Frequently Asked Questions

Why does every intercepted record point at logging/__init__.py?

Because Loguru records the caller by walking the stack, and by default it stops inside the logging module, which is where the call to your handler came from. The fix is the depth argument: count the frames belonging to logging itself and pass that number, so Loguru skips them and reports the application frame that made the original call.

Do I still need to configure levels on standard-library loggers?

Yes. The intercept handler only receives records that already passed the logger's level check, so setting a library's logger to WARNING still suppresses its INFO records before Loguru ever sees them. Level configuration stays in the standard library; formatting and delivery move to Loguru.

Why do some records appear twice after installing the handler?

Because a named logger has both its own handler and propagation to root, where your intercept handler now sits — so the record is handled once by each. Clear the handlers on those named loggers and leave propagation on, which is the same rule that applies to any root-handler setup.

How do custom levels map across?

Loguru looks up the level by name, so a custom standard-library level registered with addLevelName resolves only if a Loguru level of the same name exists. Wrap the lookup in a try/except and fall back to the numeric level, otherwise a single record at a custom level raises inside the handler and is dropped by handleError.