Loguru: Simple as Print, Flexible as Logging

Why Logging Is Better Than Print?

Compared to print, logging provides more control over what information is captured in the logs and how it is formatted.

With logging, you can log different levels (debug, info, warning, error) and selectively enable/disable log levels for different situations, allowing you to have more granular control over the log output.

For example, in the following code:

def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()

you can set the log level to “debug” to include all log levels:

2023-07-16 16:18:51.769 | DEBUG    | __main__:main:9 - This is a debug message
2023-07-16 16:18:51.769 | INFO     | __main__:main:10 - This is an info message
2023-07-16 16:18:51.769 | WARNING  | __main__:main:11 - This is a warning message
2023-07-16 16:18:51.769 | ERROR    | __main__:main:12 - This is an error message

or set it to “info” to include only info and higher levels:

2023-07-16 16:19:42.805 | INFO     | __main__:main:10 - This is an info message
2023-07-16 16:19:42.806 | WARNING  | __main__:main:11 - This is a warning message
2023-07-16 16:19:42.806 | ERROR    | __main__:main:12 - This is an error message

You can also direct log output to a file and include timestamps, which help track the sequence of events in the logs.

# example.log
2023-07-16 09:50:24 | INFO     | logging_example:main:17 - This is an info message
2023-07-16 09:50:24 | WARNING  | logging_example:main:18 - This is a warning message
2023-07-16 09:50:24 | ERROR    | logging_example:main:19 - This is an error message
2023-07-16 09:55:37 | INFO     | logging_example:main:17 - This is an info message
2023-07-16 09:55:37 | WARNING  | logging_example:main:18 - This is a warning message
2023-07-16 09:55:37 | ERROR    | logging_example:main:19 - This is an error message

Why Many People Are Not Using Logging?

Many developers still prefer using print statements over logging because print is simpler and doesn’t require as much setup. For small scripts and one-off tasks, the overhead of setting up a logging framework seems unnecessary.

import logging
# Require initial set up
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s"
    datefmt="%Y-%m-%d %H:%M:%S",
)
def main():
    logging.debug("This is a debug message")
    logging.info("This is an info message")
    logging.warning("This is a warning message")
    logging.error("This is an error message")
if __name__ == "__main__":
    main()

Wouldn’t it be nice if there is a library that allows you to leverage the power of logging while making the experience as simple as print?

That is when Loguru comes in handy. This article will show some Loguru features that make it a great alternative to the standard logging library.

Feel free to play and fork the source code of this article here:

View on GitHub

Elegant Out-of-the-Box Functionality

By default, logging gives boring and no very useful logs:

import logging
logger = logging.getLogger(__name__)
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()

Output:

WARNING:root:This is a warning message
ERROR:root:This is an error message

In contrast, Loguru generates informative and vibrant logs by default.

from loguru import logger
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()

Customize Logs

Format Logs

Formatting logs allows you to add useful information to logs such as timestamps, log levels, module names, function names, and line numbers.

The traditional logging approach uses the % formatting, which is not intuitive to use and maintain:

import logging
# Create a logger and set the logging level
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")

Output:

2023-07-16 14:48:17 | INFO | logging_example:main:13 - This is an info message
2023-07-16 14:48:17 | WARNING | logging_example:main:14 - This is a warning message
2023-07-16 14:48:17 | ERROR | logging_example:main:15 - This is an error message

In contrast, Loguru uses the {} formatting, which is much more readable and easy to use:

from loguru import logger
logger.add(
    sys.stdout,
    level="INFO",
    format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} - {message}",
)

Save Logs to Files

Saving logs to files and printing them to the terminal using the traditional logging module requires two extra classes FileHandler and StreamHandler.

import logging
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.FileHandler(filename="info.log", level=logging.INFO),
        logging.StreamHandler(level=logging.DEBUG),
    ],
)
logger = logging.getLogger(__name__)
def main():
    logging.debug("This is a debug message")
    logging.info("This is an info message")
    logging.warning("This is a warning message")
    logging.error("This is an error message")
if __name__ == "__main__":
    main()

However, with Loguru, you can attain the same functionality with just the add method.

from loguru import logger
logger.add(
    'info.log',
    format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} - {message}",
    level="INFO",
)
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()
 

Rotate Logs

Rotating logs prevents the size of log files from getting too large by periodically creating new log files and archiving or removing older ones.

In the logging library, rotating logs requires an additional class called TimedRotatingFileHandler. The following code switches to a new log file every week (when="WO", interval=1) and retains up to 4 weeks’ worth of log files (backupCount=4).

import logging
from logging.handlers import TimedRotatingFileHandler
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Create a formatter with the desired log format
formatter = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = TimedRotatingFileHandler(
    filename="debug2.log", when="WO", interval=1, backupCount=4
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()

In Loguru, you can replicate this behavior by adding the rotation and retention arguments to the add method. The syntax for specifying these arguments is readable and easy to use.

from loguru import logger
logger.add("debug.log", level="INFO", rotation="1 week", retention="4 weeks")
def main():
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
if __name__ == "__main__":
    main()

Filter

Filtering in logging allows you to selectively control which log records should be output based on specific criteria.

In the logging library, filtering logs requires creating a custom logging filter class.

import logging
logging.basicConfig(
    filename="hello.log",
    format="%(asctime)s | %(levelname)-8s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    level=logging.INFO,
)
class CustomFilter(logging.Filter):
    def filter(self, record):
        return "Hello" in record.msg
# Create a custom logging filter
custom_filter = CustomFilter()
# Get the root logger and add the custom filter to it
logger = logging.getLogger()
logger.addFilter(custom_filter)
def main():
    logger.info("Hello World")
    logger.info("Bye World")
if __name__ == "__main__":
    main()

In Loguru, you can simply use a lambda function to filter logs.

from loguru import logger
logger.add("hello.log", filter=lambda x: "Hello" in x["message"], level="INFO")
def main():
    logger.info("Hello World")
    logger.info("Bye World")
if __name__ == "__main__":
    main()

Catch Exceptions

Conventional logs for exceptions can be ambiguous and challenging to debug:

import logging
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
def division(a, b):
    return a / b
def nested(c):
    try:
        division(1, c)
    except ZeroDivisionError:
        logging.exception("ZeroDivisionError")
if __name__ == "__main__":
    nested(0)
Traceback (most recent call last):
  File "/Users/khuyentran/Data-science/productive_tools/logging_tools/catch_exceptions/logging_example.py", line 16, in nested
    division(1, c)
  File "/Users/khuyentran/Data-science/productive_tools/logging_tools/catch_exceptions/logging_example.py", line 11, in division
    return a / b
ZeroDivisionError: division by zero

The exceptions displayed above are not very helpful as they don’t provide information about the values of c that triggered the exceptions.

Loguru enhances error identification by displaying the entire stack trace, including the values of variables:

from loguru import logger
def division(a, b):
    return a / b
def nested(c):
    try:
        division(1, c)
    except ZeroDivisionError:
        logger.exception("ZeroDivisionError")
if __name__ == "__main__":
    nested(0)

Loguru’s catch decorator allows you to catch any errors within a function. This decorator also identifies the thread on which the error occurs.

from loguru import logger
def division(a, b):
    return a / b
@logger.catch
def nested(c):
    division(1, c)
if __name__ == "__main__":
    nested(0)

But I don’t want to add more dependencies to my Python project

Although incorporating Loguru into your project requires installing an additional library, it is remarkably lightweight and occupies minimal disk space. Moreover, it helps in reducing boilerplate code, making your project easier to maintain and significantly reducing the friction associated with using logging.


I love writing about data science concepts and playing with different data science tools. You can stay up-to-date with my latest posts by:

2 thoughts on “Loguru: Simple as Print, Flexible as Logging”

Comments are closed.

Scroll to Top