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.
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.
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()
- You are writing a library that other people install. There is one global logger shared with the whole process, so your package inherits or fights whatever the application configured. The documented workaround, calling logger.disable(__name__) and asking users to enable it, works but adds a support burden your users did not ask for
- You need config-file-driven logging. loguru deliberately has no equivalent of logging.config.dictConfig or fileConfig, so environments differ by Python code, not by YAML. The third-party loguru-config package exists precisely because this hurts
- You log anything sensitive. diagnose=True is the default, and it prints the repr of local variables inside tracebacks, so passwords, tokens and full request bodies can land in your log files. You have to remember to pass diagnose=False on production sinks
- You need release cadence. The last PyPI release, 0.7.3, is from December 2024; the repo is still active but the project has been on 0.x for years, so features you read about on master may not be installable
- You want structured logs in your own schema. serialize=True emits loguru's nested shape, {"text": ..., "record": {...}}, not a flat object, so most log pipelines need a custom sink to reshape it. structlog starts from the structured event instead
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 linebind() 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
| Package | Registry | Pick it when |
|---|---|---|
| structlog | PyPI | Your 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-logger | PyPI | You want to keep standard logging and dictConfig exactly as they are and only need the output to become JSON. |
| rich | PyPI | You mainly want beautiful console output and tracebacks for a CLI tool, and are happy to keep standard logging underneath via RichHandler. |