Python Standard Library vs Third-Party Logging Libraries

The standard library logging module ships with every Python install and is thread-safe out of the box, but it produces unstructured text by default and leaves context propagation, JSON serialization, and non-blocking I/O for you to assemble by hand. This guide, part of the Modern Python Logging Libraries Deep Dive, works through where the standard library is sufficient and where structlog or Loguru earn their dependency, covering runtime cost, context propagation, configuration, and a clean migration path. It connects to the deeper guides on structlog architecture and setup and Loguru configuration and sinks, and to its focused child on structlog JSON logging in Django.

Build-it-yourself versus batteries-included logging The standard library gives a thread-safe logger but requires hand-built JSON formatting, contextvars wiring, and a queue handler. structlog and Loguru ship structured output, context propagation, and non-blocking sinks as defaults. stdlib logging thread-safe core: built in JSON formatter: you write it contextvars: you wire it QueueHandler: you assemble it zero dependencies structlog / Loguru structured output: default JSON renderer: built in context merge: built in non-blocking sink: a flag one added dependency
The standard library can do everything the libraries do; the difference is how much you assemble yourself.

The honest framing is not standard library versus third-party as if one always wins. Both reach the same destination — newline-delimited JSON on stdout with trace correlation and non-blocking I/O. The standard library asks you to assemble that from primitives; the libraries ship it as defaults and remove a class of subtle wiring mistakes. The sections below quantify the trade-off so you can decide per service.

The decision tends to break along three axes. The first is dependency tolerance: a library destined to be embedded in other people's code should add nothing, so the standard library wins by default, while an application service can absorb one well-chosen dependency without a second thought. The second is team size and fleet shape: a single service is cheap to wire by hand, but a fleet benefits from packaging one configuration that every service imports, which the libraries make far easier than copying a custom Formatter subclass around. The third is how much structured-logging discipline you can rely on developers to maintain unaided; the more a default does the right thing, the less it matters that a given engineer has never read the logging documentation.

Prerequisites

Pin every dependency so the JSON shape and processor APIs stay stable across deployments.

pip install \
  "structlog>=24.1.0,<26.0.0" \
  "loguru>=0.7.0,<0.8.0"

The standard library needs no install. The examples below assume Python 3.11 or newer, where contextvars is mature and asyncio correctly propagates context across tasks.

Concept & architecture

The standard library models logging as a graph: a Logger produces a LogRecord, the record passes through any attached Filter objects, and each Handler formats and emits it with its own Formatter. Structured output is not native — a LogRecord is a flat object with a msg string and an args tuple, so producing JSON means writing a Formatter subclass that pulls fields off the record and serializes them. Request context is equally manual: there is no built-in slot for a request_id, so you reach for contextvars and a custom formatter, or a LoggerAdapter.

structlog inverts the model. Instead of a record traversing handlers, an event_dict traverses an ordered list of processor functions, each free to add, remove, or transform keys, until a terminal renderer turns it into a string. Structured key-value data is the native unit of work, and JSON is one processor away. Loguru collapses everything into a single global logger that fans records out to registered sinks; each sink carries its own level, format, filter, and an enqueue flag for non-blocking delivery. The full processor model is detailed in structlog architecture and setup.

The decisive architectural fact is that all three converge through the standard library when you want them to. structlog can use structlog.stdlib.LoggerFactory() so its output flows through stdlib handlers, and Loguru ships an InterceptHandler that captures stdlib records into Loguru. That convergence is what makes incremental migration safe rather than a rewrite.

This convergence also resolves the most common objection to third-party logging: the fear of a hard dependency on a non-standard API. Because the library sits in front of the standard library rather than replacing it, the call sites that matter — the ones in your own code — can keep using logging.getLogger(__name__) while the configuration layer decides how records are rendered. The decision of which API new code calls becomes independent of how existing code logs. You can adopt structlog's event_dict style in a new module on Monday without touching the hundred modules that still call logger.info("%s", value), and both end up in the same JSON stream.

What the third-party libraries genuinely add, beyond ergonomics, is correct defaults for the hard parts. The standard library will happily let you attach a synchronous FileHandler to an async service, configure a formatter that produces invalid JSON when a field contains a quote, or wire contextvars in a way that leaks. structlog and Loguru ship the non-leaking context path, the escaping-correct JSON renderer, and the non-blocking sink as the obvious default rather than the expert option. The dependency, in other words, buys you a set of decisions already made correctly, which is most valuable precisely on the teams least likely to make them correctly by hand.

Step-by-step implementation

Step 1 — Establish the standard library baseline. Build a JSON Formatter that reads a request_id from a ContextVar, attach it to a StreamHandler, and set the level. This is the zero-dependency target the libraries will improve on.

import logging
import json
import contextvars
import sys

request_id = contextvars.ContextVar("request_id", default="unknown")


class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        log_obj = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
            "request_id": request_id.get(),  # pulled from context, not args
        }
        return json.dumps(log_obj)


def setup_stdlib_logging() -> logging.Logger:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JSONFormatter())
    logger = logging.getLogger("app")
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)
    return logger

Step 2 — Exercise the baseline under async. Setting the context variable inside the coroutine proves the formatter reads request-scoped state correctly.

import asyncio


async def run_stdlib_example():
    logger = setup_stdlib_logging()
    request_id.set("req-std-001")
    logger.info("Standard library log emitted")


if __name__ == "__main__":
    asyncio.run(run_stdlib_example())

Expected Output:

{"timestamp": "2026-06-19 10:00:00,123", "level": "INFO", "message": "Standard library log emitted", "request_id": "req-std-001"}

Step 3 — Replace the boilerplate with structlog. The same outcome — JSON, ISO timestamp, request context — comes from a processor list with no custom formatter class. merge_contextvars does what the hand-written request_id.get() did, for every bound key at once.

import structlog
import logging
import asyncio
from structlog.contextvars import bind_contextvars, clear_contextvars

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,    # replaces manual context read
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    logger_factory=structlog.PrintLoggerFactory(),
)


async def run_structlog_example():
    clear_contextvars()
    bind_contextvars(trace_id="w3c-trace-abc-123", service="payment-api")
    logger = structlog.get_logger()
    logger.info("processing_request", payload_size=1024)


if __name__ == "__main__":
    asyncio.run(run_structlog_example())

Expected Output:

{"event": "processing_request", "level": "info", "timestamp": "2026-06-19T10:00:00.123456Z", "trace_id": "w3c-trace-abc-123", "service": "payment-api", "payload_size": 1024}

Step 4 — Unify both sources through one renderer. When some code uses stdlib logging and some uses structlog, route stdlib LogRecords through structlog's ProcessorFormatter so every line shares one schema. A worked Django version of this is in structlog JSON logging in Django.

The bridge works because ProcessorFormatter accepts a foreign_pre_chain, a list of processors applied only to records that originate from the standard library rather than from structlog. A stdlib LogRecord enters as a flat object, the pre-chain enriches it with level, timestamp, and merged context exactly as structlog records get enriched, and then both kinds of record flow into the same terminal renderer. The result is that a line logged by a third-party HTTP client comes out with the same keys and the same JSON shape as a line your own code logged through structlog, and your aggregator needs exactly one parsing rule. Without this step, the two sources diverge, and the divergence usually surfaces weeks later as a dashboard query that quietly drops half its results.

Migration follows naturally from the bridge. You do not flip a switch; you change the configuration layer once, leave every existing logging.getLogger(__name__).info(...) call untouched, and adopt the structured event_dict style only in new code. Because both paths now share a renderer, the cutover is invisible downstream — the schema does not change as you migrate, only the call ergonomics in new modules. This is what makes the standard-library-as-facade pattern safe to adopt incrementally in a large codebase rather than as a risky big-bang rewrite.

Configuration reference

Capability stdlib logging structlog Loguru
Structured fields custom Formatter native event_dict bind() / extra
JSON output hand-written serializer JSONRenderer serialize=True or sink
Request context contextvars + formatter merge_contextvars logger.bind()
Level filter pre-serialize per-handler setLevel make_filtering_bound_logger per-sink level
Non-blocking I/O QueueHandler + QueueListener route via stdlib queue enqueue=True
Declarative config dictConfig (YAML/TOML) Python call logger.add() calls
Capture other library logs native LoggerFactory() InterceptHandler
Added dependencies none one one

Runtime cost & memory footprint

Three costs separate the options, and all three are usually smaller than intuition suggests. Import time is the first: pulling in structlog or Loguru adds roughly 10 to 50 milliseconds at process start, invisible to a long-running service but worth measuring for a cold-start-sensitive serverless function where it can be deferred behind the first request. Per-call CPU is the second: emitting structured JSON costs more than writing a preformatted string, on the order of 15 to 30 percent more per line in the rendering step, but that difference only applies to lines that survive the level filter, so it disappears for the DEBUG records you discard. Memory is the third: any non-blocking design holds a queue of pending records, and a bounded queue caps that cost predictably while an unbounded one is a latent out-of-memory bug.

The practical takeaway is that the cost of the library is dominated by the cost of the logs themselves. A service logging ten well-chosen lines per request will not notice which library produced them; a service logging a thousand lines per request will be expensive regardless of the library and needs sampling, not a faster serializer. Benchmark with realistic payloads before optimizing, because synthetic micro-benchmarks of an empty log call measure the one thing that never dominates a real workload.

Async & concurrency considerations

The standard library is thread-safe — it guards handler emission with a lock — but thread safety is not the same as async correctness. The real async hazard is context, not data races. A value stored in threading.local is keyed by OS thread, and an event loop multiplexes many coroutines onto one thread, so request A's request_id will be read by request B. contextvars.ContextVar fixes this because its values are bound to the logical context that asyncio copies per task. Every example above uses contextvars precisely for this reason; structlog's merge_contextvars is a thin, correct wrapper over the same mechanism.

The second consideration is blocking I/O. A stdlib StreamHandler or FileHandler writes synchronously, so under high concurrency the write stalls the event loop. Decouple it with a QueueHandler on the hot path feeding a QueueListener that owns the real handler on a background thread. Loguru's enqueue=True is the equivalent single flag. In both cases bound the queue so a slow sink applies backpressure instead of growing memory without limit during a traffic spike.

There is a measurable difference in where the work lands. The standard library QueueHandler enqueues the unformatted LogRecord and lets the listener thread format it, so both serialization and I/O move off the hot path. structlog renders the string inline before it reaches the stdlib queue, so only the I/O moves; the serialization stays on the caller, which is fine once the level filter has dropped sub-threshold records. Loguru with enqueue=True queues the whole record before formatting, matching the stdlib behavior. None of these is wrong, but knowing which work runs where matters when you are profiling a hot path and trying to explain why logging CPU shows up on the request thread rather than the background one.

Shutdown is the concurrency case teams forget. A background queue means records are still in flight when the process is asked to stop, so an abrupt exit loses them — and those tail records often explain the shutdown. Flush explicitly: listener.stop() for a stdlib QueueListener, or logger.complete() followed by logger.remove() for Loguru, inside the application's shutdown hook. Wire it into the same lifecycle that closes database connections so it always runs.

Production code examples

This end-to-end example shows the migration target: a single dictConfig-style setup where structlog's ProcessorFormatter renders both stdlib and structlog records as identical JSON, with non-blocking delivery through a queue.

import logging
import logging.handlers
import queue
import structlog

# Shared processor chain used by BOTH stdlib and structlog records
shared_processors = [
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso"),
]

structlog.configure(
    processors=shared_processors + [
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)

# One formatter renders stdlib LogRecords through the same chain to JSON
formatter = structlog.stdlib.ProcessorFormatter(
    foreign_pre_chain=shared_processors,
    processors=[
        structlog.stdlib.ProcessorFormatter.remove_processors_meta,
        structlog.processors.JSONRenderer(),
    ],
)

# Non-blocking delivery: hot path enqueues, listener thread writes
log_queue: queue.Queue = queue.Queue(maxsize=10_000)
real_handler = logging.StreamHandler()
real_handler.setFormatter(formatter)
listener = logging.handlers.QueueListener(log_queue, real_handler)

root = logging.getLogger()
root.addHandler(logging.handlers.QueueHandler(log_queue))
root.setLevel(logging.INFO)
listener.start()

# A legacy stdlib call and a structlog call now share one JSON schema
logging.getLogger("legacy.module").info("served via stdlib facade")
structlog.get_logger().info("served_via_structlog", route="/checkout")
listener.stop()

Tested with structlog>=24.1.0,<26.0.0. The two log lines come out with the same keys because both pass through shared_processors and the same JSONRenderer.

Expected Output:

{"event": "served via stdlib facade", "level": "info", "timestamp": "2026-06-19T10:00:01.000000Z"}
{"event": "served_via_structlog", "route": "/checkout", "level": "info", "timestamp": "2026-06-19T10:00:01.000100Z"}

Configuration & production hardening

The standard library's declarative configuration through logging.config.dictConfig is genuinely good, and it is the place the standard library most clearly holds its own: a single dictionary, often loaded from YAML, defines every logger, handler, formatter, and filter, and the same file can be validated at startup so a malformed routing rule fails fast rather than silently dropping logs. Where it strains is dynamic behavior — conditional sink selection, per-tenant routing, or runtime level changes — which the static dictionary expresses awkwardly and which the programmatic APIs of structlog and Loguru handle more naturally. A common hybrid is to use dictConfig for the stable handler-and-formatter skeleton and a small amount of code for the parts that genuinely vary at runtime.

Whichever stack you choose, the hardening checklist is the same. Configure exactly once at bootstrap and never reconfigure live. Validate configuration at startup and refuse to serve traffic on a malformed setup. Put redaction in the pipeline, not at call sites, so secret-named fields are masked regardless of what a developer passes. Bound every queue so a slow sink applies backpressure instead of exhausting memory. Flush the queue on shutdown so the tail of records survives termination. None of these is specific to a library; they are the difference between logging that helps during an incident and logging that becomes the incident.

Common mistakes

  • Blocking I/O in async event loops with stdlib handlers. StreamHandler and FileHandler write synchronously; under load the write stalls the loop. The symptom is rising p99 latency that correlates with log volume. Decouple with QueueHandler and QueueListener, or use Loguru's enqueue=True.
  • Using thread-local storage for request context. Values stored in threading.local leak across coroutines that share a thread, surfacing as one request's request_id or trace_id on another's logs. The root cause is thread-keyed storage in a single-threaded loop; switch to contextvars or merge_contextvars.
  • Unbounded queue growth in async sinks. A QueueHandler or Loguru sink without a maxsize consumes RAM until OOM during a traffic spike, since records arrive faster than the background thread drains them. Always set a bound so the queue applies backpressure and document the drop behavior.
  • Two parsers for one app. Letting stdlib and structlog emit different JSON shapes means your aggregator needs two parsing rules and joins break. Route both through one ProcessorFormatter and JSONRenderer so the schema is identical.

Frequently Asked Questions

Does adding a third-party logging library slow down application startup?

Importing structlog or Loguru adds roughly 10 to 50 milliseconds of import time, which is negligible for a long-running service but can matter for cold-start-sensitive serverless functions. If cold start is critical, import lazily or measure the delta before deciding.

Can I migrate from the standard library to structlog or Loguru without rewriting every log call?

Yes. Keep the standard library logging API as the facade for existing modules and route its records through a structlog ProcessorFormatter or a Loguru InterceptHandler. Old calls keep working while new code uses the richer structured API.

How do I keep log fields consistent across stdlib and third-party libraries in one app?

Route everything through one terminal renderer. With structlog's ProcessorFormatter, stdlib LogRecords and structlog events pass through the same processor chain and come out with identical field names and JSON shape, so a downstream parser sees one schema.

Is the standard library enough for production logging?

It can be. With dictConfig, a JSON formatter, a QueueHandler for non-blocking I/O, and contextvars for request context, the standard library produces correct structured logs with zero extra dependencies. Third-party libraries mainly reduce the boilerplate and ship better defaults.