Logging all the Way with 'Structlog', 'Loguru' and others
- Mic

- Jan 29
- 8 min read
In this post, we’ll take another look at logging in Python. We’ve already covered Python’s built-in logging module and how far it can take you on its own. This time, the focus shifts slightly: instead of how to use logging, we’ll look at a handful of libraries that either build on it or try to improve on its rougher edges.
The ecosystem around logging is surprisingly rich, and different tools optimise for different pain points—structured output, developer ergonomics, performance, or simply nicer-looking logs. The libraries we’ll look at here are:
structlog
loguru
picologging
python-json-logger
rich
For each of these, we’ll highlight a few key differences compared to the standard logging module and briefly discuss what they bring to the table. This is not meant to be an exhaustive survey of all logging frameworks out there—just a small, opinionated selection of tools I’ve run into over time and found interesting enough to warrant a closer look.

Structlog: Logging with Structure ... a lot
The advantages and goals of structlog are pretty much spelled out in the name. Instead of emitting a single formatted string, the idea is to log structured data: key–value pairs that carry meaning and can be filtered, queried, or transformed downstream.
With classic logging, the log message is the primary artifact, and any structure is usually encoded implicitly inside that string. With structlog, the structure is the message.
For a simple example, let’s say we have the following two functions
A simple function to handle a payment request that calls a payment logic, charge_customer. The payment logic simply checks whether the amount is too high and gives a value error in that case. Of special note is the argument token in the function, it should simulate something like an API key that we might need for a payment system.
Initialising the logging with structlog is fairly similar to the standard logging library
Now we can start adding logging information at various spots in the code. For brevity we only focus on the charge_customer function here, although we would add this to all functions in reality.
As one can see, we only added a logging call at the start of the function and at the end. The idea is that the event we are looking is the event 'Charging a customer' and we log when we start and when we succeed as well as with what values the function was called. If we now run this
At first glance, this output mostly looks nicer: a timestamp, a clear log level, an event name, and a neatly rendered list of extra fields. It’s fair to ask whether this is really different from using the standard logging module with a custom formatter.
The key difference is that with structlog, the structure isn’t just something you format at the end—it’s something you can manipulate along the way.
One practical example is repeated context.
In our function, both log.info calls include the same contextual data, such as user_id and amount_cents. With classic logging, that usually means either repeating yourself or stuffing everything into extra over and over again. With structlog, you can instead bind that context to the logger once:
What’s happening here is subtle but important:
bind returns a new logger, called charge_log here, with additional context attached
That context is automatically included in every subsequent log call for this logger
You can still add or override fields on individual events, as seen with the token data
This turns the logger into something that behaves a bit like a scoped object: it carries context as it moves through your code. As the function progresses, you’re logging events, not reconstructing state every time you want to emit a message.
But the real difference lies in the logging pipeline. Instead of treating logging as a final formatting step, we can define a pipeline that processes log events as they flow through the system—enriching them with additional information or deliberately stripping fields out. This is particularly useful for sensitive data; for example, we may want to ensure that a transaction token never appears in the logs.
Let’s take a look at the following logging pipeline and walk through it one step at a time.
This configuration defines how our logging should behave. Going through it line by line:
structlog.processors.add_log_level: attaches the log level (INFO, WARNING, ERROR, …) to each log event
structlog.processors.TimeStamper(fmt="iso"): adds a timestamp in ISO format
structlog.processors.format_exc_info: includes formatted exception tracebacks when an error is logged
structlog.dev.ConsoleRenderer(): renders the final log output to the console in a human-readable form
logger_factory=structlog.stdlib.LoggerFactory(): tells structlog to delegate the actual log emission to the standard logging module and its handlers
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO): ensures that the full processing pipeline is only applied to log messages with level INFO or higher
cache_logger_on_first_use=True: instructs structlog to build the logging pipeline once and reuse it, instead of reconstructing it for every log call
Since we use the structlog.strdlib.LoggerFactory here, we have to also include the standard logger via
We also don’t need to worry about formatting ourselves—that responsibility is handled entirely by structlog. The choice of processors determines how log events are ultimately rendered. For example, using structlog.processors.JSONRenderer() instead of structlog.dev.ConsoleRenderer() would produce JSON output rather than a colourised, human-readable string.
Once we have a pipeline in place, we can also extend it with our own processors. To illustrate that, we’ll add two custom processors—add_app_metadata and redact_secrets—at the beginning of the pipeline.
This simply injects additional context that we want every log entry to carry—for example, which system the log originated from or which environment the application is currently running in.
This processor removes specific fields if they happen to make their way into a log entry. It acts as a final safety net, ensuring that sensitive information does not accidentally leak into the logs.
A lot more to see
These examples only scratch the surface of what structlog can do. Beyond defining custom processors tailored to a specific use case, the library also provides a range of built-in processors—for example, support for context variables, which is particularly useful in asynchronous applications.
Loguru: Simple and Quick
Loguru takes almost the opposite approach to structlog. Instead of relying on an elaborate configuration with processing pipelines, it focuses on being immediately usable, aiming to provide sensible defaults and a good developer experience with minimal setup.
Simply add
Out of the box, you get a complete logging readout, including a timestamp, log level, module name, line number, and the log message itself.
On top of that, loguru comes with a few additional conveniences. Similar to structlog, it allows you to bind extra contextual data directly to the logger, so that information is automatically included in subsequent log messages without having to repeat it each time.
Similar to the standard logging module, simply binding additional data does not change the visible output by default. The logger is aware of the extra context, but it does not render it automatically.
The easiest way to make this information visible is to switch the logger to JSON output immediately by adding a JSON formatter afterwards.
In the resulting JSON output, all additional context is preserved as structured fields. Alternatively, you can define a different format or route the logs to another destination by using the same add command.
This adds all extra data features to the logging output, while the following only adds a specific one.
A quick glance shows that we immediately run into a familiar problem: not every log message comes with a user_id. In those cases, we can address this by adding the following command.
This sets a default value for user_id for all logging messages, that can be overwritten by any specific call.
Simple but Quick
In contrast to structlog, the main appeal of loguru is how quickly it can be put to use with minimal configuration. Setting up a rotating log file that records only warnings and above can be done by adding just two parameters to an add call: rotation="1 MB" and level="WARNING".
The result is a clean, readable logging setup with very little effort. While it doesn’t emphasise structured output in the same way structlog does, it is often a better fit for smaller projects or situations where a fast, low-friction setup is more valuable than maximum flexibility.
Picologging: Logging but faster
There isn’t much to say about picologging, because switching to it can be as simple as changing the import:
With that single line, you don’t need to modify any existing logging code. Calls such as logging.getLogger behave the same way as before—the only difference is which implementation sits underneath.
If we apply this change to the examples from the post on the standard logging module, we are effectively using picologging instead.
So what’s the advantage? Performance. Picologging is implemented largely in C, which moves a significant portion of the logging logic and decision-making out of Python and into native code. This makes it noticeably faster.
That speed-up may not matter in most applications, but if your data pipelines or high-throughput systems are being slowed down by logging overhead, swapping in picologging can be a very pragmatic improvement.
Python-json-logger: Logging but in JSON
While picologging integrates seamlessly into the standard logging framework by focusing purely on performance improvements, python-json-logger takes a different approach. It is essentially a JSON formatter for Python’s built-in logging module.
If we revisit the example from the post on the standard logging library, the only change required is to replace the formatter:
After attaching a StreamHandler as usual and emitting a log message,
the output becomes a JSON log entry. This entry contains the timestamp, log level, logger name, the message "Application started", and the additional version field provided via extra.
Importantly, this extra data is included automatically, without having to write a custom formatter. The values can be fully contextual, coming directly from variables or object attributes at runtime.
That is essentially all there is to python-json-logger: a lightweight way to produce structured JSON logs while continuing to rely on the standard logging API.
Rich: Consoles can be so pretty
Calling Rich a logging library would be underselling it. Its primary goal is to make terminal output as readable and expressive as possible. It provides features such as progress bars, colours and emojis, italic and bold text, improved rendering for standard data types, tables, and much more.
What we are interested in here is the logging support that comes with Rich, specifically the RichHandler. In the minimal logger example from the post on the standard logging library, this simply means adding or changing the following:
With that small adjustment, log messages are rendered as a clean, colourised, and well-structured console output, making them significantly easier to read during development.
Finding the Right Logging Style for Your Project
Python’s built-in logging module already provides a solid and flexible foundation, and for many projects it is more than sufficient. The libraries we’ve looked at don’t replace that foundation so much as they explore different trade-offs on top of it.
structlog shines when logging is treated as structured data rather than formatted text. Its processor pipeline and context handling make it particularly well suited for larger systems, distributed services, and asynchronous applications where logs are meant to be consumed by machines as much as by humans.
loguru, on the other hand, optimises for developer experience. It offers sensible defaults, minimal setup, and a pleasant out-of-the-box output. While it doesn’t emphasise structure to the same degree, it is often the fastest way to get a robust logging setup running in smaller projects.
picologging focuses almost exclusively on performance. By acting as a drop-in replacement for the standard logging module and moving much of the work into C, it addresses a very specific problem: logging overhead in high-throughput environments.
python-json-logger takes a minimalist approach. It doesn’t change how you log, only how logs are rendered, making it a simple and effective choice when JSON output is required but the standard logging API should remain unchanged.
Finally, Rich is less about logging semantics and more about presentation. Its value lies in making logs easier to read during development by improving how they are displayed in the terminal.
In the end, there is no single “best” logging solution. The right choice depends on what you optimise for: structure, speed, simplicity, or readability. The good news is that Python’s logging ecosystem is rich enough to let you pick the tool that fits your problem—without having to fight the language to do so.



Comments