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 is part of the Modern Python Logging Libraries Deep Dive and 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, to the head-to-head scoring in structlog vs Loguru vs standard library logging, and to its focused walkthrough of structlog JSON logging in Django.

Build-it-yourself versus batteries-included logging Row by row: a thread-safe core is built into both; structured output, JSON serialization, request context, and non-blocking I/O are hand-assembled with the standard library but shipped as defaults by structlog and Loguru. The standard library wins the last row on dependency count. capability stdlib logging structlog / Loguru thread-safe core built in built in structured output custom Formatter native default JSON serialization you write it one renderer request context contextvars wiring merge / bind non-blocking I/O QueueHandler + Listener enqueue=True added dependencies none one
The standard library reaches every row on the right; the difference is how much of it you assemble yourself, and the price is one dependency.

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 package destined to be imported by 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. The standard library needs no install; the two libraries are pinned to compatible ranges so a minor release cannot silently change field names.

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

The examples below assume Python 3.11 or newer, where contextvars is mature and asyncio correctly copies context across tasks. Two environment variables belong in every container image regardless of which front end you choose: unbuffered output so a crash cannot strand records in a pipe buffer, and an explicit level that configuration reads rather than hard-coding.

export PYTHONUNBUFFERED=1
export LOG_LEVEL=INFO

Verify the two imports resolve before wiring anything, because an import-time failure inside logging configuration is the one error that has no logs to explain it.

python -c "import structlog, loguru; print('logging front ends ready')"

Expected Output:

logging front ends ready

Concept and Architecture

The standard library models logging as a graph: a Logger produces a LogRecord, the record passes through any attached Filter objects, propagates up the logger hierarchy, 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, exactly as walked through in structured logging with the Python standard library. 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, and the sink model in Loguru configuration and sinks.

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.

The corollary is a rule for library authors: if you publish a package that other applications import, log through logging.getLogger(__name__), attach no handlers, and let the application configure rendering. A library that imports Loguru or calls structlog.configure() at import time takes a decision that is not its to take, and typically produces duplicated output the first time an application configures its own root handler.

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.

Three front ends, one JSON stream structlog passes an event dictionary through an ordered processor chain to a JSON renderer. The standard library passes a LogRecord through filters, a handler, and a formatter. Loguru fans one logger out to sinks. The standard library sits in the middle as the meeting point: structlog can emit through stdlib.LoggerFactory, and Loguru's InterceptHandler captures stdlib records, so all three end in one JSON schema downstream. structlog event dict merge contextvars add level iso timestamp JSON renderer one event_dict, transformed in order structlog.stdlib.LoggerFactory() stdlib Logger LogRecord Filter Handler Formatter thread-safe, but structure is yours to add loguru InterceptHandler Loguru logger one logger, many sinks stdout · JSON line file · rotation queue · enqueue=True one JSON schema downstream
Each front end has its own internal model, but the standard library sits in the middle as the meeting point — which is what makes migration incremental rather than a rewrite.

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, and the thing you must actually build before claiming the dependency is unnecessary.

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 rather than a process-global that happened to be set at import time.

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"}

Note what the baseline already lacks after twenty lines of code: the timestamp is not ISO 8601, extra keyword arguments have nowhere to go, and any second context key means editing the formatter. Each of those is a small fix, and the sum of the small fixes is the boilerplate the libraries remove.

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, and the binding API is covered in depth in binding context variables in structlog.

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, including the LOGGING dictionary Django expects, 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.

Step 5 — Or bridge in the other direction with Loguru. If Loguru is the front end, the equivalent move is an InterceptHandler installed on the root logger, which converts each stdlib record into a Loguru call while preserving level and call site. The two bridges are mirror images: structlog pulls stdlib records into its processor chain, Loguru pulls them into its sink list.

import logging
from loguru import logger


class InterceptHandler(logging.Handler):
    """Forward standard library records into Loguru's sink list."""

    def emit(self, record: logging.LogRecord) -> None:
        try:
            level = logger.level(record.levelname).name  # stdlib name to Loguru name
        except ValueError:
            level = record.levelno                       # unknown name: pass the number
        frame, depth = logging.currentframe(), 2
        while frame and frame.f_code.co_filename == logging.__file__:
            frame = frame.f_back                         # report the caller, not logging.py
            depth += 1
        logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())


# force=True replaces handlers a framework installed before your bootstrap ran
logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)

Step 6 — Migrate the configuration layer, not the call sites. 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. The staged sequence for a large codebase is set out in migrating from standard logging to structlog.

Migrating the configuration layer, not the call sites Stage one is standard library only. Stage two installs the bridge, so stdlib records and structlog events render through one chain. Stage three writes new code against the structured API while old calls stay untouched. Stage four is full adoption. Underneath all four stages, the JSON schema seen by the aggregator never changes. migration order 1 stdlib only logging.getLogger() hand-written JSON 2 bridge installed ProcessorFormatter foreign_pre_chain 3 new code structured event_dict in new code old calls untouched 4 adoption complete one schema, two styles no rewrite needed one JSON schema downstream the aggregator's parsing rule never changes
Only the configuration layer moves between stages; the schema the aggregator parses is identical from stage one to stage four.

Configuration Reference

The first table is the capability matrix: what each front end gives you and what you assemble yourself.

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

The second table lists the specific knobs that decide production behaviour, whichever front end you land on.

Parameter Where it lives Type Default Production value
level root logger / dictConfig str / int WARNING INFO from LOG_LEVEL
disable_existing_loggers dictConfig bool True False
propagate per-logger bool True True, with handlers only on root
queue maxsize queue.Queue int 0 (unbounded) 10_000
respect_handler_level QueueListener bool False True
cache_logger_on_first_use structlog.configure bool False True
wrapper_class structlog.configure callable BoundLogger make_filtering_bound_logger(INFO)
foreign_pre_chain ProcessorFormatter list None the shared processor list
enqueue logger.add bool False True
serialize logger.add bool False True
diagnose logger.add bool True False — leaks locals
PYTHONUNBUFFERED environment str unset 1

Two entries deserve expansion because they are the most common silent misconfiguration. disable_existing_loggers defaults to True in dictConfig, which mutes every logger created before configuration ran — usually the ones inside libraries imported at module scope — and the symptom is a database driver that logs nothing in production and everything locally; the full behaviour is unpacked in configuring logging with dictConfig. respect_handler_level defaults to False on QueueListener, meaning the listener ignores the level you set on the real handler and emits everything the queue receives, which quietly defeats a per-handler level split between an info stream and an error stream.

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 back-pressure instead of exhausting memory. Flush the queue on shutdown so the tail of records survives termination. Set levels deliberately rather than by habit, following log levels and severity mapping, and pin the resulting checklist in a shared bootstrap module as described in how to configure Python logging for production. 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.

Where each configuration knob applies A record passes through six stages: call site, level gate, enrichment, render, queue, and sink. Each stage lists the standard library setting on the left and the structlog or Loguru equivalent on the right, so every parameter in the reference table maps to the exact stage it controls. stdlib logging stage structlog / Loguru call site logging.getLogger(__name__) pass %s args, not f-strings structlog.get_logger() logger.info(event, **fields) level gate root level from LOG_LEVEL disable_existing_loggers: False make_filtering_bound_logger(INFO) or per-sink level= enrichment contextvars + custom Formatter one edit per new field merge_contextvars logger.bind() / contextualize() render json.dumps in a Formatter escaping is on you JSONRenderer() foreign_pre_chain for stdlib records queue QueueHandler, maxsize=10_000 respect_handler_level=True route through the stdlib queue or enqueue=True per sink sink StreamHandler(sys.stdout) PYTHONUNBUFFERED=1 logger.add(sys.stdout) diagnose=False in production
Every knob in the tables above belongs to exactly one stage of a record's journey; the two columns are different names for the same six decisions.

Runtime Cost and 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.

Where the level filter sits is the single largest lever, and it differs by front end. The standard library evaluates Logger.isEnabledFor before building the LogRecord, so a suppressed logger.debug("%s", expensive) costs an attribute lookup and a comparison — provided you pass %s arguments rather than an f-string, because an f-string is evaluated at the call site whether or not the record survives. structlog's make_filtering_bound_logger short-circuits at the same point, returning a no-op method for levels below the threshold, and cache_logger_on_first_use=True removes the per-call factory lookup on top of that. Loguru evaluates the level per sink, so a record below every sink's threshold costs one comparison per sink and nothing more.

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. If serialization does turn out to dominate, the fix is almost always a faster encoder such as orjson behind a custom renderer, not a change of front end.

Where the time goes in one emitted line For all three front ends, serialization and the I/O write dominate the cost of an emitted line; the level check and record construction are small. A record suppressed by the level filter stops at the level check, so it costs a fraction of a full line no matter which library is in front. Proportions are illustrative shapes, not benchmark numbers. relative cost of one emitted line, by stage stdlib structlog Loguru suppressed stops at the level check — no record is built level check record build enrichment serialization queue handoff I/O write
Illustrative proportions, not a benchmark: serialization and the write dominate, and a filtered-out record never gets far enough to care which library it came from.

Async and 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, the mechanism explained in using contextvars for request tracing. Every example above uses contextvars precisely for this reason; structlog's merge_contextvars is a thin, correct wrapper over the same mechanism, and Loguru's logger.contextualize is backed by the same primitive.

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, as detailed in non-blocking logging with QueueHandler. Loguru's enqueue=True is the equivalent single flag. In both cases bound the queue so a slow sink applies back-pressure 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.

Process boundaries break both models in the same way. A QueueListener thread and a Loguru enqueue worker are threads, and threads do not survive fork(), so under a pre-fork server such as Gunicorn or uWSGI any logging configured before the fork lands in children without a live drain thread. Configure inside a post_fork hook, or in the application's lifespan startup, so each worker owns its own queue and handles; the cross-process variants of this failure are catalogued in thread-safe logging in multiprocessing.

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, and make sure the container's grace period exceeds the worst-case drain time.

Two concurrent requests, one queue, and what shutdown decides Two tasks on the same event loop bind their own trace identifiers through contextvars, so neither sees the other's value. Each log call hands a record to the bounded queue and returns immediately, and the listener thread formats and writes it. At SIGTERM, an abrupt exit loses whatever is still queued, while stopping the listener or calling logger.complete drains it first. event loop bounded queue listener thread task A binds trace=A task B binds trace=B contextvars keeps A and B separate on one thread A: enqueue record, no I/O on the loop B: enqueue record listener drains, formats, writes SIGTERM abrupt exit records still queued are lost listener.stop() / logger.complete() queue drained, tail survives
The loop hands off and returns; the only records that never reach the writer are the ones still queued when the process exits without draining.

Production Code Examples

Example 1 — One schema from two sources, with non-blocking delivery

This end-to-end example shows the migration target: a single setup where structlog's ProcessorFormatter renders both stdlib and structlog records as identical JSON, with delivery through a bounded queue so the request thread never waits on I/O.

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, respect_handler_level=True
)

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()   # drains the queue before the process exits

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"}

Example 2 — The zero-dependency equivalent with dictConfig

If the dependency is genuinely unavailable — a locked platform image, an embeddable package, a security review that has not cleared the wheel — the same shape is reachable with dictConfig alone. The custom formatter carries the context merge that merge_contextvars would have done, and the queue configuration is declarative from Python 3.12 onward.

import json
import logging
import logging.config
import contextvars
from datetime import datetime, timezone

log_context: contextvars.ContextVar[dict] = contextvars.ContextVar("log_context", default={})


class ContextJSONFormatter(logging.Formatter):
    """Emit one JSON object per record, merging the ambient request context."""

    RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__)

    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": datetime.fromtimestamp(
                record.created, tz=timezone.utc
            ).isoformat(),
            "level": record.levelname.lower(),
            "event": record.getMessage(),
            "logger": record.name,
            **log_context.get(),                    # request-scoped keys
        }
        # promote extra={...} keys without dragging in LogRecord internals
        payload.update(
            {k: v for k, v in record.__dict__.items() if k not in self.RESERVED}
        )
        if record.exc_info:
            payload["exception.type"] = record.exc_info[0].__name__
        return json.dumps(payload, default=str)


logging.config.dictConfig({
    "version": 1,
    "disable_existing_loggers": False,          # keep library loggers alive
    "formatters": {"json": {"()": ContextJSONFormatter}},
    "handlers": {
        "stdout": {"class": "logging.StreamHandler", "formatter": "json"},
        "queue": {                              # Python 3.12+ declarative queue
            "class": "logging.handlers.QueueHandler",
            "handlers": ["stdout"],             # wrapped by a listener dictConfig builds
            "respect_handler_level": True,
        },
    },
    "root": {"handlers": ["queue"], "level": "INFO"},
})

queue_handler = logging.getHandlerByName("queue")
queue_handler.listener.start()                  # dictConfig builds it, you start it

log_context.set({"request_id": "req-std-002", "service": "payment-api"})
logging.getLogger("checkout").info("payment_captured", extra={"amount_cents": 4999})
queue_handler.listener.stop()                   # drain before exit

Expected Output:

{"timestamp": "2026-06-19T10:00:02.500000+00:00", "level": "info", "event": "payment_captured", "logger": "checkout", "request_id": "req-std-002", "service": "payment-api", "amount_cents": 4999}

The output is field-for-field comparable to Example 1, which is the point: the standard library reaches the same destination, and the roughly forty lines of formatter and configuration above are precisely the dependency's purchase price. Weigh that against how many services must maintain the same forty lines.

Same output, two sets of moving parts Example one is a structlog configure call, a ProcessorFormatter with a foreign pre-chain, and a QueueHandler with a listener. Example two is a hand-written JSON formatter, a hand-rolled context merge with reserved-key filtering, and a dictConfig block whose listener you start yourself. Both emit the same JSON line. Example 1 · one dependency Example 2 · zero dependencies structlog.configure() one shared processor chain ProcessorFormatter foreign_pre_chain bridges stdlib QueueHandler + Listener bounded, drained on stop ContextJSONFormatter ~40 hand-written lines context merged by hand RESERVED keys filtered out dictConfig with a queue you start the listener the same JSON line either way event · level · timestamp · request_id · service
The dependency's purchase price is the right-hand column: the same three responsibilities, written and maintained by you.

Common Mistakes

  • Error signature: p99 latency rises in step with log volume and flame graphs show the event loop parked in a write syscall. Root cause: a synchronous StreamHandler or FileHandler is attached directly in an async service, so every emit blocks the loop. Remediation: put a QueueHandler on the hot path with a QueueListener owning the real handler, or set enqueue=True on Loguru sinks.
  • Error signature: one request's request_id or trace_id appears on another request's log lines under concurrency. Root cause: context was stored in threading.local, which is keyed by OS thread while an event loop multiplexes many coroutines onto one thread. Remediation: switch to contextvars.ContextVar, or merge_contextvars / logger.contextualize, and clear the context in a finally block at the request boundary.
  • Error signature: resident memory climbs steadily during a traffic spike and the process is OOM-killed while the log sink lags. Root cause: the queue.Queue behind QueueHandler, or Loguru's enqueue queue, was left unbounded, so producers outrun the drain thread indefinitely. Remediation: set an explicit maxsize, decide and document whether a full queue blocks or drops, and alert on queue depth.
  • Error signature: aggregator queries return roughly half the expected rows and joins on trace_id fail for records from library code. Root cause: stdlib records and structlog events are rendered by different formatters, so the two sources emit different key names and JSON shapes. Remediation: render both through one ProcessorFormatter with a foreign_pre_chain, or intercept stdlib records into Loguru, so a single parsing rule covers the process.
  • Error signature: a database driver or HTTP client logs nothing in production while logging normally in development. Root cause: dictConfig ran with the default disable_existing_loggers=True and muted every logger created at import time, before configuration executed. Remediation: set disable_existing_loggers to False and configure handlers on the root logger so library loggers propagate into it.
  • Error signature: the last records before a deploy or crash are missing, including the ones that would explain it. Root cause: the process exited with records still in the background queue and nothing drained it. Remediation: call listener.stop(), or logger.complete() then logger.remove(), in the shutdown hook, and give the container a grace period longer than the worst-case drain.
Six failures, pinned to where they start Bootstrap, context, render, handler, queue, and shutdown each own one failure: disable_existing_loggers left at True, request state in threading.local, two formatters producing two schemas, a synchronous handler on the async hot path, an unbounded queue, and an exit that never drains. Each row names the fix and the symptom it produces in production. along the record's path symptom in production 1 bootstrap disable_existing_loggers=True fix: set it to False library logs vanish in production 2 context context kept in threading.local fix: contextvars, cleared in finally trace_id lands on the wrong request 3 render two formatters, two schemas fix: one ProcessorFormatter queries return half the rows 4 handler sync handler on the hot path fix: QueueHandler or enqueue=True p99 rises with log volume 5 queue unbounded queue.Queue fix: maxsize plus a depth alert RSS climbs, then an OOM-kill 6 shutdown exit without draining the queue fix: listener.stop() / complete() the last lines before exit missing
Each failure has one stage where it starts and one signature it produces downstream; matching the two is most of the diagnosis.

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.

Should a library I publish on PyPI use structlog or Loguru?

No. A published library should call logging.getLogger(__name__), attach no handlers, and let the importing application decide how records are rendered. Adding a logging dependency forces that choice on every consumer and can produce duplicate or double-formatted output.