Logging for Python Library Authors

An application's logging is its own. A library's logging is a guest in someone else's process, and the conventions for library logging exist so that every application importing the library keeps complete control over what appears, where it goes and how it looks. Getting it wrong is easy and quietly damaging: a library that configures the root logger changes every application's output format; one that logs routine events at warning floods every service that uses it; one that logs request bodies leaks data it had no way to know was sensitive. This page covers the conventions. It is a task article under logging in Python runtimes and frameworks, part of the modern Python logging libraries deep dive section, and it is the other side of taming third-party library loggers.

Who decides what Two columns divide logging responsibilities between a library and the application that imports it. The library's column holds creating loggers named after its modules, choosing the level of each log call, choosing what information each record carries, and attaching a single NullHandler to its top-level logger. The application's column holds everything about output: which handlers exist, where records go, how they are formatted, which levels are enabled for which loggers including the library's, and whether the library's output appears at all. An arrow shows the one interface between them, the logger namespace: the application controls the library entirely by configuring loggers under the library's package name. A crossed-out box below shows the things a library must never do — call basicConfig, add handlers to the root logger, set levels on loggers it does not own, or set a formatter — each of which reaches across into the application's column. the library emits; the application decides the library decides logger names: getLogger(__name__) the level of each call what each record carries one NullHandler on its top logger the application decides handlers and destinations formatting enabled levels, per logger whether the library appears at all the only interface between them: the logger namespace never in a library: basicConfig · root handlers · levels on others' loggers · formatters each one reaches into the application's column
The logger namespace is the whole contract. A library that stays on its side of it can be controlled completely by any application, with no knowledge of how.

Prerequisites

The standard library is all a library should depend on for logging. Adding a third-party logging package as a library dependency imposes it on every application.

pip install "pytest>=8.0.0,<9.0.0"   # for the tests below

Implementation

Step 1 — Create loggers named after modules. logging.getLogger(__name__) at the top of each module produces loggers such as acme_client.http and acme_client.retry, all under the package's namespace. An application can then control the whole library with one entry for acme_client, or a single subsystem with acme_client.retry, without the library doing anything to enable it.

# acme_client/http.py
import logging

log = logging.getLogger(__name__)          # "acme_client.http"

Step 2 — Attach one NullHandler to the top logger. When an application has configured no logging at all, Python falls back to a last-resort handler that prints warnings and above to standard error. A NullHandler on the library's top-level logger means the library's records are discarded quietly in that case, and it has no effect whatsoever once the application configures logging.

# acme_client/__init__.py
import logging

logging.getLogger(__name__).addHandler(logging.NullHandler())

Step 3 — Never configure anything else. No handlers beyond that one, no formatters, no levels, and above all nothing on the root logger. A library cannot know whether the application logs JSON to standard output, text to a file, or records through OpenTelemetry. Any configuration it installs is wrong for most of its users.

# never, anywhere in a library
logging.basicConfig(level=logging.INFO)                 # takes over the root logger
logging.getLogger().addHandler(logging.StreamHandler()) # duplicates every record
logging.getLogger(__name__).setLevel(logging.DEBUG)     # overrides the application's choice

Step 4 — Choose levels for the application's operator. The people reading a library's warnings are operators of applications that use it, who did not write it and may not know its internals. Routine operation — a connection opened, a retry scheduled, a cache miss — is debug. An event an operator might want to see in production — a fallback endpoint in use, a deprecated option set — is info or warning. A warning should describe something the operator can act on; a library whose normal operation produces warnings trains every operator to ignore its warnings.

log.debug("retrying request", extra={"attempt": attempt, "delay_s": delay})
log.warning("primary endpoint unavailable, using fallback",
            extra={"primary": self._primary_host})

Step 5 — Keep secrets and payloads out. A library has no way to know which of its inputs its users consider sensitive, so it should assume all of them might be. Credentials, tokens and full request or response bodies do not belong in records. Identifiers, sizes, status codes and timings usually do, and give operators what they need without the risk.

# useful and safe
log.debug("request complete", extra={"method": method, "path": path,
                                     "status": resp.status, "bytes": len(resp.content),
                                     "duration_ms": elapsed_ms})

# never — the library cannot know what the body contains
log.debug("request body: %s", body)

Step 6 — Do not log exceptions you raise. An exception propagated to the caller will be handled or logged by the caller. Logging it inside the library as well produces two records for one failure, one of them without the application's context. Log only exceptions the library catches and handles internally — a retried timeout, a fallback taken — and let everything else carry its information in the exception itself.

What an application can do with a well-behaved library A well-behaved library with loggers acme_client.http and acme_client.retry is imported by an application. With one configuration entry, the application silences the whole library in production by setting acme_client to WARNING; with another, it enables debug output for acme_client.retry alone while investigating retry behaviour; and all of the library's records pass through the application's JSON formatter to its chosen destination without the library knowing any of this. A second library, badly behaved, called basicConfig at import. It installed a plain-text handler on the root logger, so the application's records now appear twice, once in the application's JSON and once in the library's plain text, and the application must remove the library's handler after import to recover. The note records that the application can fully control the first library and can only undo the second. what the application can do with each library well-behaved: named loggers + NullHandler silence it: acme_client → WARNING debug one part: acme_client.retry → DEBUG its records use the app's formatter full control, zero coordination badly behaved: basicConfig at import plain-text handler on the root every app record written twice behaviour depends on import order the app can only undo it good library logging is invisible until an application asks for it and then it is exactly as visible as the application wants
The well-behaved library is controlled; the badly behaved one has to be repaired. Every application that imports it pays that cost.

Making a library's logs useful to its users

Correct conventions stop a library from causing harm. A few further habits make its logs genuinely helpful to the people debugging applications that use it.

Use structured fields rather than formatted strings. Passing details through extra means applications with JSON formatters get queryable fields, and applications with text formatters lose nothing. A message like "request to %s failed after %d attempts" is readable; the same information as fields — host, attempts — is also filterable, which matters when an operator is trying to find every retry against one endpoint.

Keep messages constant. A fixed message with variable data in fields groups correctly in any log store, so an operator can count how often something happens. A message with values interpolated into it produces a distinct string per occurrence, and grouping fails.

Log decisions, not just events. "Using fallback endpoint because primary returned 503 three times" is far more useful to an operator than three records saying a request failed and one saying a request succeeded. The library knows why it did what it did; the application does not, unless the library says so.

Name the subsystems sensibly. Module-based logger names are the default and usually right. Where a module contains several independently interesting behaviours — connection management and retries, for instance — separate child loggers let operators enable debug output for exactly the behaviour they are investigating, which is the capability described in changing log levels at runtime.

Document the logger names. A short section in the library's documentation listing its loggers and what each one covers turns "why is this library slow" into a configuration change rather than a source-code reading exercise.

Libraries and the other signals

The same principle — emit, do not configure — applies to tracing and metrics, and it matters just as much there.

A library that wants to produce spans should depend on the OpenTelemetry API, not the SDK, and obtain a tracer through the global provider. If the application has configured the SDK, the library's spans join the application's traces automatically; if it has not, the API returns a no-op tracer and the library's instrumentation costs almost nothing. A library that creates its own tracer provider, or exports spans itself, has made the same mistake as one that calls basicConfig: it has decided on behalf of every application where telemetry goes.

Metrics follow the same shape. A library that records metrics should use the OpenTelemetry metrics API or accept a registry from the application, rather than registering collectors in a global registry or starting its own metrics endpoint. The first leaves the decision with the application; the second creates series and endpoints the application did not ask for and cannot easily remove. The broader context on instruments is in recording counters and histograms with OpenTelemetry.

The pattern across all three signals is that a library should be instrumented and never configured. Instrumentation — log calls, spans, metric recordings — describes what the library does. Configuration — handlers, exporters, providers, registries — decides what happens to that description, and that decision belongs to the application alone.

Which level for which event A table of events inside a library and the level each should be logged at. A retry of a failed network call: DEBUG for each attempt, WARNING only if all retries fail. A deprecated argument used: the warnings module rather than logging, so the application's warning filters apply. A configuration default chosen because none was given: DEBUG. An unrecoverable error that raises an exception: do not log it, raise it; the application decides whether to log. A per-call detail such as the URL requested: DEBUG. The note says a library that logs at INFO or above for normal operation forces every application to silence it. event in the library level a retry attempt DEBUG · WARNING only if all fail deprecated argument used warnings module, not logging a default was chosen DEBUG an error that raises raise it; do not also log it per-call detail DEBUG normal operation at INFO or above forces every application to silence the library
A library should be quiet by default and informative on request. Errors are raised, not logged twice.

Configuration options

Practice Do Don't
Logger creation getLogger(__name__) getLogger() or a fixed global name
Default output one NullHandler on the top logger basicConfig, root handlers
Levels debug for routine, warning for actionable warning for normal operation
Configuration none handlers, formatters, levels
Data identifiers, sizes, status, timings credentials, tokens, bodies
Exceptions log only those handled internally log and re-raise
Messages constant, details in extra interpolated values
Dependencies the standard library a third-party logging package

Verification

Test that importing the library changes nothing about the application's logging.

import logging

def test_import_does_not_configure_logging():
    root = logging.getLogger()
    before = list(root.handlers), root.level

    import acme_client  # noqa: F401

    assert (list(root.handlers), root.level) == before, "library touched the root logger"
    top = logging.getLogger("acme_client")
    assert [type(h).__name__ for h in top.handlers] == ["NullHandler"]
    assert top.level == logging.NOTSET, "library set its own level"

Expected Output:

.
1 passed

Common mistakes

basicConfig at import. Error signature: applications' output format changing when the library is upgraded. Root cause: the library configured the root logger. Remediation: remove it; add a NullHandler only.

Routine operation at warning. Error signature: operators adding the library to an ignore list. Root cause: normal events logged as warnings. Remediation: debug for routine, warning only for actionable conditions.

Log and re-raise. Error signature: every failure appearing twice, once without context. Root cause: the library logs exceptions it propagates. Remediation: let the caller log them.

Bodies and tokens in records. Error signature: a security report against the library. Root cause: logging data whose sensitivity the library cannot judge. Remediation: identifiers and metadata only.

A single global logger name. Error signature: applications unable to enable debug for one subsystem. Root cause: every module using the same logger. Remediation: getLogger(__name__) per module.

Frequently Asked Questions

Why should a library never call logging.basicConfig?

Because it configures the root logger for the whole process. The application loses control of its own output format and destination, may see duplicated records, and cannot easily undo it. Configuration is exclusively the application's decision.

What does NullHandler do?

Nothing, deliberately. Its presence on the library's top logger means that when an application has configured no logging at all, records from the library are silently discarded rather than triggering Python's last-resort handler, which prints warnings to standard error.

What level should library internals use?

DEBUG for routine operation — connection opened, retry scheduled, cache hit. INFO sparingly, for events an operator might genuinely want in production. WARNING and above only for conditions the application's operator should notice and can do something about.

Should a library log exceptions it raises?

Generally no. An exception raised to the caller will be handled or logged by the caller, and logging it inside the library as well produces two records for one failure. Log only exceptions the library handles itself and does not propagate.