mrkeyoor.com_
Thu 06 Aug 07:40 UTC
PyPIInfraupdated 06 Aug 2026

loguru

loguru replaces Python's standard logging module with a single pre-configured object: from loguru import logger, then logger.info("...") and you already have colored, timestamped output on stderr. There are no Logger, Handler, Formatter and Filter classes to wire together. One method, logger.add(), registers every kind of destination, and the same call sets the format, the level, the filter, file rotation, retention, compression, JSON serialization and whether writes go through a background queue. Exceptions get tracebacks that print the value of every variable in each frame. The trade is that there is exactly one logger for the whole process, and configuring it is an imperative call rather than a config file.

Verdict

For an application or a service, loguru is the fastest path from zero to logging that people will actually read, and the variable-level tracebacks pay for themselves the first time production breaks. Do not put it in a library, and do not ship it with diagnose left on.

API stability4/5Still 0.x after nine years, but in practice logger.add() and its keyword arguments have been stable across the 0.5 to 0.7 line and the changelog documents each removal. The version number understates how little churn there has been.
Docs5/5readthedocs covers the full API plus a recipes page that answers the questions people actually hit: intercepting standard logging, multiprocessing, pytest capture, security considerations. The README is a working tour rather than a feature list.
Maintenance3/5One maintainer, Delgan, pushing as recently as July 2026, with 244 open issues (265 counting PRs). The gap is releases: 0.7.3 shipped in December 2024, so fixes on master have sat unpublished for a long stretch.
Ecosystem4/5Around 19.1 million weekly downloads and near-universal name recognition in Python, with companion packages such as loguru-config and logprise. Because it bypasses standard logging, most observability vendors' handlers still target logging and need an adapter.

Use it if

  • You are writing an application or a service and want usable logging in one import instead of twenty lines of dictConfig that nobody on the team remembers how to edit
  • You want file rotation, retention and compression as arguments rather than as a handler class: logger.add("app.log", rotation="100 MB", retention="30 days", compression="zip")
  • You want tracebacks that show the values of local variables at each frame, which turns most one-off production bugs into a single log line you can read
  • You want per-request or per-task context without threading a logger object through every function, via logger.bind() and the contextvar-based logger.contextualize()
Skip it if

Setup reality

pip install loguru pulls nothing on Linux and macOS (colorama and win32-setctime only on Windows), and the first logger.info() works with no configuration at all. The real setup is three deliberate calls. First logger.remove() to drop the built-in stderr sink, otherwise your careful new sink duplicates every line. Then logger.add() per destination with an explicit level, format and diagnose=False for production. Then, if any third-party package uses standard logging (uvicorn, requests, SQLAlchemy, boto3 all do), an InterceptHandler class routed through logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True), which the README supplies and which you will copy into every project. Two more surprises: sinks are thread-safe but not multiprocess-safe unless you pass enqueue=True, and with the spawn start method that requires the sink itself to be picklable. Colors come from markup tags such as <green> in the format string, so a literal angle bracket in a message needs logger.opt(colors=False) or escaping.

Patterns

Log something with zero configurationfirst-log-line

from loguru import logger

logger.debug("That's it, beautiful and simple logging!")
logger.info("We discovered {} is the answer to {question}", 42, question="everything")
logger.success("Job finished")
logger.warning("Disk at {pct}%", pct=91)

Arguments use str.format braces, not %-style, and they are only formatted if a sink will actually emit the record. loguru adds two levels the standard library lacks: TRACE (5) and SUCCESS (25).

Take control of the console sinkreplace-default-sink

import sys
from loguru import logger

logger.remove()  # drop the built-in stderr handler
logger.add(
    sys.stdout,
    level="INFO",
    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
           "{level: <8} | {name}:{function}:{line} - {message}",
)

Skipping logger.remove() is the single most common mistake: add() appends, so you get every line twice. remove() with no argument clears all sinks, or pass the integer id that add() returned.

Write to files with rotation, retention and compressionfile-rotation-retention

from loguru import logger

logger.add("logs/app_{time}.log",
           rotation="100 MB",      # or "12:00", or "1 week"
           retention="30 days",    # or a count like 10
           compression="zip",
           level="DEBUG",
           enqueue=True)

rotation, retention and compression accept human strings, ints or callables. enqueue=True moves writes to a background process-safe queue, which is what you want when more than one process writes the same file.

Stop leaking variable values into production logsproduction-safe-tracebacks

import sys
from loguru import logger

logger.remove()
# dev: full variable dump; prod: frames only
logger.add(sys.stderr, backtrace=True, diagnose=True, level="DEBUG")
logger.add("logs/prod.log", backtrace=False, diagnose=False, level="INFO")

diagnose=True is the default and prints the repr of every local in the traceback, which is how API keys end up on disk. The loguru docs call this out under "Security considerations"; treat diagnose=False as mandatory for any sink that persists.

Catch and log exceptions without try/except noisecatch-exceptions

from loguru import logger

@logger.catch
def parse(payload):
    return payload["id"] / 0

with logger.catch(message="batch failed", reraise=True):
    parse({"id": 1})

As a decorator it swallows the exception by default; pass reraise=True when the caller still needs to see it. It also works on threads, where an unhandled exception otherwise disappears silently.

Attach context to every line in a request or taskbind-request-context

from loguru import logger

logger.add("api.log", format="{extra[request_id]} | {message}")

req_logger = logger.bind(request_id="abc123")
req_logger.info("handling")

with logger.contextualize(task="nightly-sync"):
    logger.info("started")  # extra['task'] present on this line

bind() returns a new bound logger you must pass around; contextualize() sets a contextvar so plain logger calls deeper in the stack pick it up, including across await points. A format string referencing {extra[request_id]} raises KeyError on any line that was not bound.

Emit JSON logsjson-output

import json, sys
from loguru import logger

def flat_sink(message):
    r = message.record
    sys.stdout.write(json.dumps({
        "ts": r["time"].isoformat(),
        "level": r["level"].name,
        "msg": r["message"],
        **r["extra"],
    }) + "\n")

logger.add(flat_sink, level="INFO")
# built-in alternative, nested shape:
logger.add(sys.stdout, serialize=True)

serialize=True produces {"text": "...", "record": {...}} with time, level, file, process and extra nested inside record. Most log shippers want flat keys, so a small custom sink like the one above is usually less work than reshaping downstream.

Route standard logging (uvicorn, requests, SQLAlchemy) into loguruintercept-stdlib-logging

import inspect, logging
from loguru import logger

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)

Without this, every third-party library keeps logging through the standard module and your loguru sinks never see it. force=True is what clears handlers a framework already installed; some frameworks (uvicorn with its own log config) need their loggers reset individually too.

Skip the cost of a debug line that will not be emittedlazy-expensive-logs

from loguru import logger

logger.opt(lazy=True).debug("state dump: {s}", s=lambda: expensive_snapshot())
logger.opt(exception=True).error("failed")
logger.opt(raw=True).info("no formatting, no newline")
logger.opt(depth=1).info("report the caller's file and line, not this wrapper's")

With lazy=True the callables run only if a sink accepts the level, which matters when the argument is a database query or a large repr. opt(depth=N) is the fix for helper functions that would otherwise stamp every log with their own line number.

Add your own severity levelcustom-level

from loguru import logger

logger.level("AUDIT", no=25, color="<yellow>", icon="@")
logger.log("AUDIT", "user {u} changed billing plan", u="kim")

logger.add("audit.log", level="AUDIT",
           filter=lambda r: r["level"].name == "AUDIT")

Levels are severity numbers, so AUDIT at 25 also passes any sink set to INFO (20). If you want a level routed to one file only, add the filter as shown rather than relying on the number.

Log safely from multiple processesmultiprocessing-safe

from multiprocessing import Process
from loguru import logger

logger.remove()
logger.add("logs/app.log", enqueue=True)  # required across processes

def worker(n):
    logger.info("worker {n} running", n=n)

if __name__ == "__main__":
    procs = [Process(target=worker, args=(i,)) for i in range(4)]
    [p.start() for p in procs]
    [p.join() for p in procs]
    logger.complete()

Sinks are thread-safe by default but not process-safe; enqueue=True routes records through a queue owned by the parent. Under the spawn start method (Windows and macOS defaults) the sink must be picklable, so a lambda or a closure will fail at fork time.

Use loguru inside a package you publishuse-inside-a-library

# mylib/__init__.py
from loguru import logger

logger.disable("mylib")  # no output unless the application opts in

# in the application that installs mylib:
# from loguru import logger
# logger.enable("mylib")

Never call logger.add() or logger.remove() from library code: both mutate the single process-wide logger and will surprise whoever installs you. Even with disable(), you are still forcing loguru as a dependency on every user.

Alternatives

PackageRegistryPick it when
structlogPyPIYour logs feed a search index or SIEM and you want structured events with your own key names as the primary format, not a pretty console default.
python-json-loggerPyPIYou want to keep standard logging and dictConfig exactly as they are and only need the output to become JSON.
richPyPIYou mainly want beautiful console output and tracebacks for a CLI tool, and are happy to keep standard logging underneath via RichHandler.