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.
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.
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.
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.
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.
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.
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.
Common Mistakes
- Error signature: p99 latency rises in step with log volume and flame graphs show the event loop parked in a
writesyscall. Root cause: a synchronousStreamHandlerorFileHandleris attached directly in an async service, so every emit blocks the loop. Remediation: put aQueueHandleron the hot path with aQueueListenerowning the real handler, or setenqueue=Trueon Loguru sinks. - Error signature: one request's
request_idortrace_idappears on another request's log lines under concurrency. Root cause: context was stored inthreading.local, which is keyed by OS thread while an event loop multiplexes many coroutines onto one thread. Remediation: switch tocontextvars.ContextVar, ormerge_contextvars/logger.contextualize, and clear the context in afinallyblock 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.QueuebehindQueueHandler, or Loguru's enqueue queue, was left unbounded, so producers outrun the drain thread indefinitely. Remediation: set an explicitmaxsize, 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_idfail 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 oneProcessorFormatterwith aforeign_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:
dictConfigran with the defaultdisable_existing_loggers=Trueand muted every logger created at import time, before configuration executed. Remediation: setdisable_existing_loggerstoFalseand 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(), orlogger.complete()thenlogger.remove(), in the shutdown hook, and give the container a grace period longer than the worst-case drain.
Related Reading
- Modern Python Logging Libraries Deep Dive — the parent guide covering the standard library, structlog, and Loguru side by side.
- structlog vs Loguru vs standard library logging — the scored head-to-head once you accept a dependency.
- structlog architecture and setup — the processor pipeline in full, including
ProcessorFormatter. - Loguru configuration and sinks — sink topology, rotation, retention, and
enqueue. - structlog JSON logging in Django — this page's bridge applied to Django's
LOGGINGdictionary. - Handler architecture in Python logging — the stdlib handler graph the libraries sit in front of.
- Taming third-party library loggers — find the real logger names, take back the handlers, and keep the records that earn their place.
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.