Loguru in Python: JSON logs, context, and exception handling

Configure Loguru sinks, compact JSON, request context, safe exception behavior, standard logging interception, and queue completion before collecting in Guance.

Best practices
Illustration of logs flowing through a collection pipeline

Loguru provides a preconfigured Python logger and a single add() API for configuring sinks, levels, formats, rotation, and retention. It can reduce setup code, while bind() and contextualize() attach the fields needed to follow application work.

Production configuration still needs explicit decisions about exception handling, sensitive data, queue ownership, and shutdown. This guide covers those decisions, a compact JSON format, integration with standard-library logging, and collection in Guance.

Start with the default logger

pip install loguru
from loguru import logger

logger.debug("Debug details")
logger.info("Service started")
logger.success("Job completed")
logger.warning("Configuration missing; using default")
logger.error("Database connection failed")

In its default configuration, Loguru writes to stderr with a DEBUG threshold and includes time, level, and source location. Color depends on the destination and configuration. Environment variables can override defaults. In addition to familiar severity names, Loguru includes TRACE at 5 and SUCCESS at 25; define their downstream mapping rather than assuming every backend recognizes them.

Replace the default sink before adding your own

Calling add() supplements existing handlers. Remove the default first when you intend to replace it:

import sys
from loguru import logger

logger.remove()
logger.add(
    sys.stdout, level="INFO", serialize=True,
    backtrace=False, diagnose=False,
)
logger.add(
    "logs/app_{time}.log",
    level="DEBUG",
    rotation="100 MB",
    retention="7 days",
    compression="gz",
    serialize=True,
    backtrace=False,
    diagnose=False,
)
logger.info("Logging configured")

Here stdout and the file are intentional separate copies, with different thresholds. Do not collect both into the same destination unless duplicate ingestion is intended. The file sink handles rotation, retention, and compression; those options do not apply to every sink type.

For time-based rotation, rotation also accepts intervals such as "1 day" or a time such as "12:00". Choose a policy based on file size, operational boundaries, and the timestamps used by the application.

Retention cleanup runs as part of the file sink's lifecycle, such as rotation, rather than acting as a continuously running deletion scheduler. Test the generated files and cleanup timing for the chosen policy. Disk capacity, file permissions, and collector behavior still require attention.

diagnose=False prevents Loguru's enhanced traceback formatter from displaying variable values. This is a useful production default, but exception messages and application fields can still contain secrets. backtrace=False limits the extended traceback presentation. The logger API reference describes these controls.

Choose a JSON schema deliberately

serialize=True emits a JSON object with a rendered text value and a structured record object. Level and timestamp information are nested. A parser expecting top-level level and time will need a different mapping.

For a smaller schema, replace the previous sink setup with this complete alternative. It uses a patch to prepare one JSON record and a callable format to avoid appending a separate, unstructured exception block:

import json
import sys
import traceback
from loguru import logger


def prepare_json(record):
    event = {
        "time": record["time"].isoformat(),
        "level": record["level"].name,
        "message": record["message"],
        "extra": {
            key: value for key, value in record["extra"].items()
            if key != "serialized"
        },
    }
    if record["exception"] is not None:
        exc = record["exception"]
        event["exception"] = "".join(
            traceback.format_exception(exc.type, exc.value, exc.traceback)
        )
    record["extra"]["serialized"] = json.dumps(event, ensure_ascii=False)


logger = logger.patch(prepare_json)
logger.remove()
logger.add(
    sys.stdout,
    level="INFO",
    format=lambda record: "{extra[serialized]}\n",
    backtrace=False,
    diagnose=False,
)
logger.bind(request_id="req-abc-123").info("Order created")

Keeping application values under extra prevents a bound field named time from overwriting the event timestamp. Bind JSON-compatible values; json.dumps will reject unsupported objects unless you define an explicit serialization policy. Test that failure path rather than silently converting arbitrary objects to text.

The exception string contains escaped newlines inside the JSON value, so the serialized event remains one physical line. This custom traceback uses Python's formatter and does not include local variable values. Review the resulting error text for sensitive content anyway. Loguru's serialization recipes explain this pattern.

Attach context with bind and contextualize

bind() returns a logger carrying additional fields; it does not permanently mutate every use of the shared logger:

req_logger = logger.bind(request_id="req-abc-123", user_id="USR-9")
req_logger.info("Order created")

contextualize() attaches values within a context-local scope. For a synchronous middleware factory:

from uuid import uuid4


def logging_middleware(get_response):
    def middleware(request):
        with logger.contextualize(request_id=uuid4().hex):
            return get_response(request)
    return middleware

The values are restored when the scope exits, including on an exception. Loguru uses context-local storage to isolate threads and asynchronous tasks, but work still needs to execute in the intended scope. Async middleware must await the downstream operation inside an appropriate async wrapper; the synchronous example should not be pasted unchanged into every framework.

Use only the identifiers the investigation needs. A user ID remains potentially sensitive and should follow the application's access and retention policy.

Decide whether catch should re-raise

A decorator can log a failure while leaving the caller responsible for handling it:

@logger.catch(reraise=True)
def risky_operation():
    return 1 / 0

The default @logger.catch uses reraise=False: it logs a matching exception and suppresses it, returning the configured default value. That can make a failed operation appear successful to its caller if applied without care. Choose suppression only when it is the intended recovery behavior. For existing try/except blocks, logger.exception() records the current exception; then explicitly return a failure or re-raise as appropriate.

Avoid logging the same exception at every layer. Choose the boundary that has the useful context and owns the outcome.

Bridge standard-library logging

Many libraries use Python's logging module. Their records do not automatically enter Loguru. A bridge can preserve standard exception information and locate the original caller:

import inspect
import logging


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

        frame, depth = inspect.currentframe(), 0
        while frame and (
            depth == 0 or 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()
        )


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

This avoids a fixed stack depth that can point at the wrong caller, and falls back to a numeric level for custom logging levels. Configure it once at application startup. force=True replaces root handlers, but named loggers with their own handlers or propagate=False may need separate configuration. Frameworks can also install logging configuration later.

This minimal bridge forwards the message and exception; it does not automatically preserve every custom LogRecord extra. Add an explicit allowlist if you need those fields. Avoid a loop where Loguru forwards back into the same intercepted standard logger. See the Loguru integration example.

Treat queueing as a lifecycle decision

Adding enqueue=True sends records through a multiprocessing-compatible queue. A consumer thread in the process that owns the handler writes them; it does not automatically create a dedicated logging subprocess. Records and bound values must be compatible with the queue's serialization requirements.

For a synchronous program, call logger.complete() at the appropriate shutdown point to wait for queued messages. If sinks are coroutines, await the returned object from the relevant event loop to complete their tasks. Neither approach recovers records lost in an abrupt process or machine failure.

With multiple processes, configure ownership and the multiprocessing start context deliberately. Do not have unrelated worker processes independently rotate the same file, and do not remove the parent's shared handler while children still use it. Follow the multiprocessing recipes for the deployment's start method.

Verify the fields in Guance

Configure the actual file or container output using the DataKit log collector. Set source and service, then inspect a representative record before writing Pipeline field extraction.

The default serialized form stores context under record.extra; the compact example above uses extra. Its timestamp, level, and message are top-level fields. They cannot share an unchanged parser. Use the Pipeline quick start to map the chosen format and convert severity into status.

Emit a known request ID, an ordinary error, and an exception. Check timestamp parsing, SUCCESS and TRACE mapping, request grouping, and the exception field. Test rotation, queue draining, and collection interruption before relying on an alert built from these records.

Common questions

Can Loguru support a large application?

It can, when configuration, standard-logging integration, context propagation, output ownership, and failure handling are consistent. Project size alone does not settle those choices, and enqueue=True does not replace that design work.

How do I reduce the default JSON size?

Define a smaller schema as shown above and measure retained bytes. Keep the fields required for investigation and exception analysis. A smaller record is useful only if queries and correlations still work.

Is enqueue=True enough for multiprocessing and asyncio?

No. It supplies a queueing mechanism; process context, handler ownership, serializable values, coroutine sinks, and orderly completion still matter. Exercise the actual worker model before deployment.

How should we migrate from logging?

Bridge a limited portion of the application first, check for missing fields and duplicate output, then migrate business calls if useful. Standard-library logging can remain a stable interface for libraries; replacing every call is not a prerequisite for a unified output path.

Related reading

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting