Loguru: Simple as Print, Powerful as Logging

Table of Contents

Loguru: Simple as Print, Powerful as Logging

Loguru: Simple as Print, Powerful as Logging

Why Should Data Scientists Switch from Print to Logging

As your data science projects evolve from notebooks to production-ready pipelines, print() becomes harder to manage.

For example, consider the following data science project where you want to track different stages like data loading, preprocessing, model training, and error handling:

print("Loaded 1000 rows from dataset.csv")
print("Started training RandomForest model")
print("Missing values detected in 'age' column")
print("Model training failed: insufficient memory")

Output:

Loaded 1000 rows from dataset.csv
Started training RandomForest model
Missing values detected in 'age' column
Model training failed: insufficient memory

This works fine locally, but in a production environment:

  • There’s no record of when these events occurred
  • There’s no way to save that record to a file for later inspection
  • There’s no indication of the severity of each message, making it hard to distinguish between general informational messages and serious runtime errors

Unlike print, the logging module supports log levels, output formatting, and destination control (file, stdout, etc.). Here’s a quick comparison:

# logging_example.py
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 main():
    logging.debug("Loaded 1000 rows from dataset.csv")
    logging.info("Started training RandomForest model")
    logging.warning("Missing values detected in 'age' column")
    logging.error("Model training failed: insufficient memory")


if __name__ == "__main__":
    main()

Output:

2025-05-03 14:14:32 | DEBUG | logging_example:main:11 - Loaded 1000 rows from dataset.csv
2025-05-03 14:14:32 | INFO | logging_example:main:12 - Started training RandomForest model
2025-05-03 14:14:32 | WARNING | logging_example:main:13 - Missing values detected in 'age' column
2025-05-03 14:14:32 | ERROR | logging_example:main:14 - Model training failed: insufficient memory

You can hide debug logs and focus only on more critical messages by changing the log level to INFO:

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",
)

Output:

2025-05-03 14:35:29 | INFO | logging_example:main:12 - Started training RandomForest model
2025-05-03 14:35:29 | WARNING | logging_example:main:13 - Missing values detected in 'age' column
2025-05-03 14:35:29 | ERROR | logging_example:main:14 - Model training failed: insufficient memory

Why Many Data Scientists Still Use Print

print() is fast, familiar, and doesn’t require setup. When exploring data or debugging inside a Jupyter notebook, it feels like the most convenient option.

print("Training complete")

Thus, many data scientists prefer using print statements, even though the built-in logging module offers greater structure, flexibility, and long-term maintainability.

Meet Loguru: The Best of Both Worlds

Loguru makes logging effortless without sacrificing power. There is no boilerplate, no custom handlers. Just drop it in and go.

from loguru import logger

def main():
    logger.debug("Loaded 1000 rows from dataset.csv")
    logger.info("Started training RandomForest model")
    logger.warning("Missing values detected in 'age' column, using median imputation")
    logger.error("Model training failed: insufficient memory")

if __name__ == "__main__":
    main()

Default output is colored, timestamped, and detailed.

What This Article Covers

While there are many articles out there about Loguru, most are not tailored for data scientists. This article focuses on the features most relevant to data science workflows and leaves out those that aren’t.

We’ll explore:

  • how to format logs
  • how to save them to files
  • how to rotate logs
  • how to filter log content
  • how to handle exceptions
  • how to produce colorful and structured outputs

Each of these features will be demonstrated side by side with the traditional logging approach, so you can see exactly how Loguru simplifies and improves the process.

The source code of this article can be found here:

Format Logs Easily

Formatting logs allows you to add useful information to logs such as timestamps, log levels, module names, function names, and line numbers. Here’s how to do it with both logging and Loguru:

Traditional Way

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

import logging

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",
)

Loguru Way

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

import sys
from loguru import logger

# Remove the default handler
logger.remove()

# Add a stream handler
logger.add(
    sys.stdout,
    format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} - {message}",
    level="INFO",
)

In the code above:

  • logger.remove() clears the default Loguru handler so that only your custom configuration is active.
  • logger.add(sys.stdout, ...) explicitly adds a stream handler that logs to the terminal using your specified format and log level.

Other common options for time formatting:

CategoryTokenOutput Example
YearYYYY2025
MonthMM01 … 12
DayDD01 … 31
Day of WeekdddMon, Tue, Wed
Hour (24h)HH00 … 23
Hour (12h)hh01 … 12
Minutemm00 … 59
Secondss00 … 59
MicrosecondSSSSSS000000 … 999999
AM/PMAAM, PM
TimezoneZ+00:00, -07:00

Save Logs to File

Saving logs to a file can help preserve important information over time and aid debugging. Here’s how to do it with both logging and Loguru:

Traditional Way

Saving logs to both a file and the terminal using the logging module requires setting up separate handlers:

  • FileHandler: writes log messages to a specified file so that they can be reviewed later
  • StreamHandler: sends log messages to the console (stdout), allowing you to see logs in real time during execution
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"),
        logging.StreamHandler(),
    ],
)

Loguru Way

Logging to a file using Loguru is simple: call the add() method with the file path, format, and log level. Loguru logs to the terminal by default, so calling add() for a file automatically saves logs to both the file and the terminal.

from loguru import logger

logger.add(
    "info.log",
    format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} - {message}",
    level="INFO",
)

Rotate and Retain Logs

Without log rotation, long-running processes like ETL jobs or model training can generate massive log files that waste disk space and are hard to manage. Automatic rotation keeps logs compact and readable.

Here’s how to do it with both logging and Loguru:

Traditional Way

To automatically rotate the log file using the logging module, you need to use TimedRotatingFileHandler, which has the following key parameters:

  • filename: the file where logs are written.
  • when: the time interval to trigger a new log file (e.g., 'S' for seconds, 'M' for minutes, 'H' for hours, 'D' for days, 'W0''W6' for weekdays, 'midnight' for daily at midnight).
  • interval: how often rotation should happen based on the unit provided in when.
  • backupCount: how many rotated log files to keep before old ones are deleted.

This setup gives you finer control, but requires more manual configuration than Loguru.

import logging
from logging.handlers import TimedRotatingFileHandler

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

handler = TimedRotatingFileHandler("debug.log", when="W0", interval=1, backupCount=4)
handler.setLevel(logging.INFO)
handler.setFormatter(
    logging.Formatter(
        "%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
)
logger.addHandler(handler)

Loguru Way

With Loguru, you can rotate and retain logs in a single line using the rotation and retention parameters in add():

  • rotation: when to create a new log file (e.g., size or time)
  • retention: how long to keep old log files

No extra classes or handlers required:

from loguru import logger

logger.add("debug.log", level="INFO", rotation="1 week", retention="4 weeks")

You can also customize log rotation and retention rules in Loguru using different triggers and strategies:

logger.add("file_1.log", rotation="500 MB")    # Automatically rotate if the file exceeds 500 MB
logger.add("file_2.log", rotation="12:00")      # Create a new log file daily at noon
logger.add("file_3.log", rotation="1 week")     # Rotate weekly

logger.add("file_X.log", retention="10 days")   # Keep logs for 10 days, then delete old ones

logger.add("file_Y.log", compression="zip")     # Compress rotated logs to save space

Filter Logs by Content

Filtering log messages helps you capture only the information you care about, such as messages containing specific keywords or values. Here’s how to do it with both logging and Loguru:

Traditional Way

To filter log messages based on custom content using the built-in logging module, you need to define and attach a custom Filter class to the logger:

import logging

logging.basicConfig(
    filename="hello.log",
    format="%(asctime)s | %(levelname)s | %(module)s:%(funcName)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    level=logging.INFO,
)


class CustomFilter(logging.Filter):
    def filter(self, record):
        return "Hello" in record.msg


# Get the root logger and add the custom filter to it
logger = logging.getLogger()
logger.addFilter(CustomFilter())


def main():
    logger.info("Hello World")
    logger.info("Bye World")


if __name__ == "__main__":
    main()

Output:

2025-05-03 15:10:30 | INFO | logging_example:main:22 - Hello World

Loguru Way

With Loguru, filtering log messages is simple: just pass a filter function to the add() method, no need to define a separate filter class:

from loguru import logger

logger.remove()
logger.add("hello.log", filter=lambda record: "Hello" in record["message"])

Output:

2025-05-03 15:12:00.180 | INFO     | __main__:main:8 - Hello World

Better Exception Logging

When exceptions occur, logging can help you understand not only what went wrong, but also where and why. Here’s how traditional logging compares with Loguru when it comes to capturing exception details:

Traditional Way

To catch and log exceptions using the built-in logging module, you typically wrap your code in a try-except block and call logging.exception() to capture the traceback:

import logging

def divide(a, b):
    return a / b

def main():
    try:
        divide(1, 0)
    except ZeroDivisionError:
        logging.exception("Division by zero")

main()

Output:

2025-05-03 15:23:09 | ERROR | logging_example:nested:18 - ZeroDivisionError
Traceback (most recent call last):
  File ".../logging_example.py", line 16, in nested
    division(1, c)
  File ".../logging_example.py", line 11, in division
    return a / b
           ~~^~~
ZeroDivisionError: division by zero

The stack trace is printed, but you don’t see the values of a and b, so you’re left guessing what inputs caused the failure.

Loguru Way

Loguru improves debugging by capturing the full stack trace and the state of local variables at each level.

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)

Output:

> File ".../catch_decorator.py", line 14, in <module>
    nested(0)
    └ <function nested at 0x106492520>

  File ".../catch_decorator.py", line 10, in nested
    division(1, c)
    │           └ 0
    └ <function division at 0x105051800>

  File ".../catch_decorator.py", line 5, in division
    return a / b
           │   └ 0
           └ 1

ZeroDivisionError: division by zero

In the traceback above, Loguru shows that a is 1 and b is 0, making it immediately clear what inputs caused the failure.

You can also capture and display full tracebacks in any function simply by adding the @logger.catch decorator:

from loguru import logger

def divide(a, b):
    return a / b

@logger.catch
def main():
    divide(1, 0)

main()

Output:

> File ".../catch_decorator.py", line 14, in <module>
    nested(0)
    └ <function nested at 0x100a5df80>

  File ".../catch_decorator.py", line 10, in nested
    division(1, c)
    │           └ 0
    └ <function division at 0x1003b5b20>

  File ".../catch_decorator.py", line 5, in division
    return a / b
           │   └ 0
           └ 1

ZeroDivisionError: division by zero

Pretty Logging with Colors

Traditional Way

Traditional logging does not support color formatting out of the box. You would need to install and configure a third-party library like colorlog to manually define colorized output formats.

import logging
from colorlog import ColoredFormatter

formatter = ColoredFormatter(
    "%(log_color)s%(asctime)s | %(levelname)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    log_colors={
        'DEBUG':    'cyan',
        'INFO':     'green',
        'WARNING':  'yellow',
        'ERROR':    'red',
        'CRITICAL': 'bold_red',
    }
)

handler = logging.StreamHandler()
handler.setFormatter(formatter)

logger = logging.getLogger(__name__)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)

logger.info("Colorized info message")

Loguru Way

By default, Loguru outputs logs with colorized formatting in the terminal. You can also customize the color for each log level using the colorize option and the {level.color} formatting token:

from loguru import logger
import sys

logger.remove()
logger.add(
    sys.stdout,
    colorize=True,
    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | <cyan>{message}</cyan>",
)

if __name__ == "__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:

Here’s a quick reference of available color and style tags you can use in your format strings:

ColorAbbreviationStyleAbbreviation
BlackkBoldb
BlueeDimd
CyancNormaln
GreengItalici
MagentamUnderlineu
RedrStrikes
Whitew
Yellowy

You can also combine colors and styles in your format string by nesting tags. For example: <red><bold>{message}</bold></red> will show the message in bold red text.

Summary: Why Loguru Wins

Featureprintloggingloguru
Log levels
File output
Log rotationManualOne-liner with rotation
FilteringCustom Filter classSimple function
Stack trace + variablesBasic tracebackRich context
Pretty loggingRequires colorlogBuilt-in
Customize format% formatting{} formatting
Setup timeNoneHighMinimal

But I Don’t Want More Dependencies

I understand that adding a new dependency might initially seem unnecessary, but Loguru is lightweight, well-maintained, and has zero configuration cost. It reduces code complexity, improves debuggability, and pays for itself even in small scripts.

pip install loguru

Should I Always Use Loguru Instead of Print?

print() is perfectly fine for quick checks or exploratory work inside a Jupyter notebook. It’s simple, fast, and requires no setup.

However, when your code starts to include multiple stages, like data loading, preprocessing, modeling, and evaluation, or needs to run reliably in production, it’s worth moving to a logging tool like Loguru.

References

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

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top

Work with Khuyen Tran

Work with Khuyen Tran