Logging in Python with Loguru

Author

Andres Monge

Published

December 18, 2024

Logging is an essential part of any application, providing insights into its runtime behavior. Python’s built-in logging module is powerful but can be cumbersome to configure. Enter Loguru, a third-party logging library that simplifies logging with a more intuitive API and rich features.

Why Loguru?

Loguru offers several advantages over the standard logging module:

  • Simpler API: No need for handlers, formatters, or loggers. Just import and log.
  • Rich Formatting: Easily customize log formats with colors, timestamps, and more.
  • Exception Handling: Automatically captures and logs exceptions with tracebacks.
  • Asynchronous Logging: Supports asynchronous logging out of the box.

Customizing Loguru

Loguru allows you to customize the log format, level, and output destinations. Here’s how to configure it:

Custom Log Format

You can define a custom format for your logs using Loguru’s formatting syntax. For example, to include the log level, timestamp, and message:

Code
LINE_BREAK = ''

from loguru import logger
import sys
import contextlib

logger.remove()  # Remove default handler
logger.add(sys.stdout, format="{time} | {level} | {message}" + LINE_BREAK)
logger.info("Custom format logging!")
2025-08-05T15:17:56.125554+0200 | INFO | Custom format logging!

Colorful Logs: Easier to Read

Colorful logs are not just visually appealing; they make logs easier to read and debug. Loguru supports colorized logs out of the box, allowing you to differentiate log levels at a glance. For example:

Code
logger.remove()  # Remove default handler
logger.add(sys.stdout, colorize=True, format="<green>{time}</green> | <level>{level}</level> | <cyan>{message}</cyan>")
logger.info("This is a colorful info log!")
2025-08-05T15:17:56.132757+0200 | INFO | This is a colorful info log!

Log Levels

Loguru supports the standard log levels: TRACE, DEBUG, INFO, WARNING, ERROR, and CRITICAL. You can set the log level globally or per handler:

Code
logger.remove()  # Remove default handler
logger.add(sys.stdout, level="DEBUG")
logger.warning("This is a warning message.")
2025-08-05 15:17:56.139 | WARNING  | __main__:<module>:3 - This is a warning message.

Exception Handling in Loguru

It’s important to understand that ExceptionFormatter is a private internal class used by Loguru to format exceptions, but it’s not an exception itself.

If you want to capture exceptions raised within your logging handlers, you need to use catch=False when adding a sink:

Code
from loguru import logger

def buggy_sink(message):
    raise ValueError("This is a buggy sink!")

# Will print the error.
logger.remove()
logger.add(buggy_sink, catch=True)
logger.info("Message")

# Will raise on error.
logger.remove()
logger.add(buggy_sink, catch=False)

try:
    logger.info("Message")
except ValueError as e:
    print(f"Caught exception: {e}")
    print(f"Exception type: {type(e)}")
Caught exception: This is a buggy sink!
Exception type: <class 'ValueError'>
--- Logging error in Loguru Handler #52 ---
Record was: {'elapsed': datetime.timedelta(seconds=849, microseconds=772788), 'exception': None, 'extra': {}, 'file': (name='3110224809.py', path='/tmp/ipykernel_1196518/3110224809.py'), 'function': '<module>', 'level': (name='INFO', no=20, icon='ℹ️'), 'line': 9, 'message': 'Message', 'module': '3110224809', 'name': '__main__', 'process': (id=1196518, name='MainProcess'), 'thread': (id=140358453032768, name='MainThread'), 'time': datetime(2025, 8, 5, 15, 17, 56, 147511, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'CEST'))}
Traceback (most recent call last):
  File "/home/aemonge/usr/docs/.venv/lib/python3.12/site-packages/loguru/_handler.py", line 206, in emit
    self._sink.write(str_record)
  File "/home/aemonge/usr/docs/.venv/lib/python3.12/site-packages/loguru/_simple_sinks.py", line 123, in write
    self._function(message)
  File "/tmp/ipykernel_1196518/3110224809.py", line 4, in buggy_sink
    raise ValueError("This is a buggy sink!")
ValueError: This is a buggy sink!
--- End of logging error ---

When capturing **kwargs as contextual data (rather than for formatting), prefer using logger.opt(depth=1).bind(**kwargs).trace(msg) to avoid common pitfalls.

Structured Logging with JSON

Loguru can handle JSON messages elegantly. Here’s how to implement structured logging with special handling for messages that contain “code” and “message” fields:

Code
import json
import sys
from loguru import logger as loguru_logger

def format_record(record: dict) -> str:
    """
    Format a log record with special handling for JSON messages.
    """
    msg = f"<level>{record['level'].name:<9}</level> "

    # Time formatting for specific levels
    if record["level"].name in ["TRACE", "INFO", "ERROR"]:
        msg += f"<italic>{record['time']:DD/MMM/YY HH:mm:ss.SSS}</italic> - "

    # Name/line formatting for specific levels
    if record["level"].name in ["TRACE", "DEBUG", "WARNING", "ERROR", "CRITICAL"]:
        msg += f"<underline>{record['name']}:{record['line']}</underline> - "

    # Color mapping
    color_tags = {
        "TRACE": "cyan",
        "DEBUG": "blue",
        "INFO": "green",
        "WARNING": "magenta",
        "ERROR": "yellow",
        "CRITICAL": "red",
    }
    color_tag = color_tags.get(record["level"].name, "")

    # JSON message handling
    try:
        data = json.loads(record["message"])
        if isinstance(data, dict) and "code" in data and "message" in data:
            # Special handling for structured error messages
            msg += f"<red>[{data['code']}]</red> <{color_tag}>{data['message']}</{color_tag}>{LINE_BREAK}"
        else:
            # Generic JSON handling
            msg += f"<{color_tag}>{record['message']}</{color_tag}>{LINE_BREAK}"
    except json.JSONDecodeError:
        # Handle non-JSON messages normally
        msg += f"<{color_tag}>{record['message']}</{color_tag}>{LINE_BREAK}"

    return msg

loguru_logger.remove()
loguru_logger.add(
    sys.stdout,
    format=format_record,
    level="DEBUG",
    colorize=True,
)

# Examples of different message types
loguru_logger.info("This is a regular message")
loguru_logger.info('{"key": "value", "number": 42}')
loguru_logger.error('{"code": "E001", "message": "Database connection failed"}')
INFO      05/Aug/25 15:17:56.160 - This is a regular message
ERROR     05/Aug/25 15:17:56.161 - __main__:56 - [E001] Database connection failed
--- Logging error in Loguru Handler #54 ---
Record was: {'elapsed': datetime.timedelta(seconds=849, microseconds=786244), 'exception': None, 'extra': {}, 'file': (name='1127580811.py', path='/tmp/ipykernel_1196518/1127580811.py'), 'function': '<module>', 'level': (name='INFO', no=20, icon='ℹ️'), 'line': 55, 'message': '{"key": "value", "number": 42}', 'module': '1127580811', 'name': '__main__', 'process': (id=1196518, name='MainProcess'), 'thread': (id=140358453032768, name='MainThread'), 'time': datetime(2025, 8, 5, 15, 17, 56, 160967, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'CEST'))}
Traceback (most recent call last):
  File "/home/aemonge/usr/docs/.venv/lib/python3.12/site-packages/loguru/_handler.py", line 165, in emit
    formatted = precomputed_format.format_map(formatter_record)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyError: '"key"'
--- End of logging error ---

Avoiding JSON Parsing Loops

When tracing operations that might involve JSON parsing, special care must be taken to avoid infinite loops. The raw=True option in trace logging prevents this issue, and using .bind() for contextual data is the recommended approach:

Code
import json
import sys
from loguru import logger as loguru_logger

loguru_logger.remove()
loguru_logger.add(
    sys.stdout,
    format="{time} | {level} | {message}",
    level="TRACE",
    colorize=True,
)

class SafeLogger:
    @staticmethod
    def trace(*args: object, **kwargs: object) -> None:
        """
        Log a trace message with raw=True to avoid JSON parsing loops.
        """
        # Using raw=True prevents potential loops when tracing JSON operations
        msg = " ".join(map(str, args))
        loguru_logger.opt(depth=1, raw=True).trace(msg)

    @staticmethod
    def info(*args: object, **kwargs: object) -> None:
        """
        Log an info message with proper context handling using bind().
        """
        msg = " ".join(map(str, args))
        # For contextual data, use bind() to avoid pitfalls
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).info(msg)
        else:
            loguru_logger.opt(depth=1).info(msg)

# Example usage showing proper context handling
safe_logger = SafeLogger()
safe_logger.trace("Tracing a JSON parsing operation")
safe_logger.info("User login successful", user_id=1234, action="login", result="success")
Tracing a JSON parsing operation2025-08-05T15:17:56.170057+0200 | INFO | User login successful

Adding Handlers

You can add multiple handlers to log to different destinations, such as files, with different formats or levels:

Code
logger.remove()  # Remove default handler
logger.add("file.log", level="INFO", rotation="10 MB")
logger.info("This log will go to both the console and a file.")

Advanced Features

Loguru supports asynchronous logging, which can improve performance in I/O-bound applications:

Code
logger.remove()  # Remove default handler
logger.add("async_log.log", enqueue=True)
logger.info("This log is written asynchronously.")

Sending Logs to a Remote Server

You can use Loguru to send logs to a remote server via an API. Here’s a quick example using the requests library:

Code
import requests
from loguru import logger

def send_log_to_server(message):
    url = "https://example.com/api/logs"
    payload = {"message": message}
    response = requests.post(url, json=payload)
    return response.status_code

logger.remove()  # Remove default handler
logger.add(send_log_to_server, level="INFO")
logger.info("This log will be sent to a remote server.")

Saving Logs to a File

Loguru makes it easy to save logs to a file. You can specify the file path, rotation, and retention policies. Here’s an example:

Code
logger.remove()  # Remove default handler
logger.add("logs/app.log", rotation="10 MB", retention="30 days")
logger.info("This log will be saved to a file.")

Final Code

Here’s a complete example of a custom logger using Loguru, with advanced features like custom formatting, exception handling, and asynchronous logging. This version properly uses bind() for contextual data:

Code
import os
import sys
from typing import Any, NoReturn

from loguru import logger as loguru_logger

LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG")

def format_record(record: Any) -> str:
    """
    Format the log record.

    Parameters
    ----------
    record : Any
        The log record.

    Returns
    -------
    str
    """
    # Precompute the level name with the colon
    level_with_colon = f"{record['level'].name}"

    # Format the message
    msg = f"<level>{level_with_colon:<9}</level> "
    if record["level"].name in ["TRACE", "INFO", "ERROR"]:
        msg += f"<italic>{record['time']:DD/MMM/YY HH:mm:ss.SSS}</italic> - "

    if record["level"].name in ["TRACE", "DEBUG", "WARNING"]:
        msg += f"<underline>{record['name']}:{record['line']}</underline> - "

    # Use the color tags specified in your level definitions
    color_tags = {
        "TRACE": "cyan",
        "DEBUG": "blue",
        "INFO": "green",
        "WARNING": "magenta",
        "ERROR": "yellow",
        "CRITICAL": "red",
    }
    color_tag = color_tags.get(record["level"].name, "")

    msg += f"<{color_tag}>{record['message']}</{color_tag}>{LINE_BREAK}"

    return msg


loguru_logger.remove()
loguru_logger.add(
    sys.stdout,
    format=format_record,
    level=LOG_LEVEL,
    colorize=True,
)


loguru_logger.level("TRACE", color="<cyan>")
loguru_logger.level("DEBUG", color="<blue>")
loguru_logger.level("INFO", color="<green>")
loguru_logger.level("WARNING", color="<magenta>")
loguru_logger.level("ERROR", color="<yellow>")
loguru_logger.level("CRITICAL", color="<red>")


class FancyLogError(Exception):
    """Exception from FancyLogger."""


class FancyLogger:
    """
    A example of a Fancy.

    Attributes
    ----------
    log_level : str
        The current logging level.
    """

    log_level: str = LOG_LEVEL

    def includes(self, level: str) -> bool:
        """
        Check if the current logging level is less or equal than the specified level.

        Parameters
        ----------
        level : str
            The level to compare against.

        Returns
        -------
        bool
            True if the current level is less severe, False otherwise.
        """
        level = level.upper()
        current_level = loguru_logger.level(self.log_level).no
        specified_level: int = loguru_logger.level(level).no
        return current_level <= specified_level

    @staticmethod
    def trace(*args: object, **kwargs: object) -> None:
        """
        Log a trace message, with file, line and date-time.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        None
        """
        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).trace(msg)
        else:
            loguru_logger.opt(depth=1).trace(msg)

    @staticmethod
    def debug(*args: object, **kwargs: object) -> None:
        """
        Log a debug message, with file and line.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        None
        """
        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).debug(msg)
        else:
            loguru_logger.opt(depth=1).debug(msg)

    @staticmethod
    def info(*args: object, **kwargs: object) -> None:
        """
        Log a info message, with date-time.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        None
        """
        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).info(msg)
        else:
            loguru_logger.opt(depth=1).info(msg)

    @staticmethod
    def warning(*args: object, **kwargs: object) -> None:
        """
        Log a warning message, with line and file.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        None
        """
        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).warning(msg)
        else:
            loguru_logger.opt(depth=1).warning(msg)

    @staticmethod
    def error(*args: object, **kwargs: object) -> None:
        """
        Log an error message and raises an exception, with date-time.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        None

        Raises
        ------
        FancyLogError
        """
        import traceback

        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).error(msg)
        else:
            loguru_logger.opt(depth=1).error(msg)
        traceback.print_exc()
        raise FancyLogError

    @staticmethod
    def critical(*args: object, **kwargs: object) -> NoReturn:
        """
        Log a critical message and exits with code 1.

        Parameters
        ----------
        *args: object
            The message(s) to log.
        **kwargs: object
            The kwargs sent to loguru.

        Returns
        -------
        NoReturn
        """
        msg = " ".join(map(str, args))
        # Using bind for contextual data, not for formatting
        if kwargs:
            loguru_logger.opt(depth=1).bind(**kwargs).critical(msg)
        else:
            loguru_logger.opt(depth=1).critical(msg)
        sys.exit(1)


logging = FancyLogger()

logging.info("Example with contextual data using bind", user_id=12345, action="login", status="success")
logging.info("This is a info log!")
logging.debug("This is a debug log!")
logging.trace("This is a trace log!")
logging.warning("This is a warning log!")
with contextlib.suppress(FancyLogError):
    logging.error("This is an error log!")
with contextlib.suppress(SystemExit):
    logging.critical("This is a critical log!")
INFO      05/Aug/25 15:17:56.829 - Example with contextual data using bind
INFO      05/Aug/25 15:17:56.830 - This is a info log!
DEBUG     __main__:253 - This is a debug log!
WARNING   __main__:255 - This is a warning log!
ERROR     05/Aug/25 15:17:56.831 - This is an error log!
CRITICAL  This is a critical log!
NoneType: None

Conclusion

Loguru simplifies logging in Python with its intuitive API and powerful features. Whether you need basic logging or advanced customization, Loguru has you covered. The examples provided demonstrate how to create a custom logger with Loguru, including custom formatting, exception handling, structured logging with JSON, and avoiding common pitfalls with contextual data. The final code properly uses bind() for contextual data rather than passing kwargs directly to logging methods, which is the recommended approach to avoid common pitfalls.