Python logging validation covering existing root handlers, basicConfig behavior, and log delivery

basicConfig Ran. Why Is Python Still Using the Old Handler?

Thu, Oct 22, 2026

logging.basicConfig() can return normally while leaving the handler, formatter, and level you asked for completely unused. In CPython's documented model, that is not necessarily a logging failure: basicConfig() normally does nothing when the root logger already has a handler. A prior module-level convenience call can install that handler before your startup code reaches its own configuration. A framework can install one intentionally. A NullHandler can also make the root's handler list non-empty without producing output. By contrast, a named logger that has no applicable handlers can fall through to lastResort without attaching anything to root. Those paths look similar from a terminal and require different repairs. Python's logging reference documents the no-op, convenience-function, force, and lastResort behavior.

The operational deliverable here is therefore not “make logging print.” It is an approved route and a startup-order decision. For an owned scope, ACCEPT only after the intended sentinel reaches the intended local sink exactly once with the reviewed level and format. REPAIR an ignored configuration, a duplicate path, or a level contract that filters the sentinel. RERUN when environment or ordering evidence is incomplete. HOLD destructive reconfiguration when another component owns the root logger. The lab is deliberately bounded to CPython 3.13.5, standard-library logging, one fresh isolated interpreter per case, in-memory streams, and captured stderr; it says nothing about remote collectors, file rotation, or distributed delivery.

Define the delivery contract and freeze the startup timeline

The first diagnostic mistake is to treat a completed configuration call as delivery evidence. basicConfig() has no success receipt that says “the record you care about will use this handler.” Its documented behavior includes an intentional no-op when root already has handlers, so a normal return establishes only that the call did not raise under the arguments and state it saw. It does not establish handler identity, formatting, effective level, propagation, or final sink. The acceptance contract has to name those properties separately.

For a standalone CLI that owns root, write the contract in operational terms before touching configuration: the owned logger is root or a named descendant that is intended to propagate to root; an INFO sentinel such as TOKEN must produce exactly NEW:INFO:TOKEN\n; it must appear in the reviewed stream and nowhere else; and the logger's effective level must admit INFO. Python defines a logger's effective level by walking ancestors when the logger itself is NOTSET; the root logger starts at WARNING unless configuration changes it. A handler can impose another threshold after the logger admits the record, so an inventory that records only the logger's nominal level is incomplete.

For an embedded application, invert the question. Start by identifying the scope you actually own. If a framework, host process, notebook kernel, test runner, or embedding application owns root, your contract should usually be phrased around an application logger such as fixture.application, not around replacing global state. The standard library's cookbook explicitly treats handler configuration as an application responsibility and warns library authors not to install handlers other than NullHandler on their own loggers. That principle is useful here even when the component is not literally a published library: ownership should follow the layer that controls the process-wide logging policy.

Freeze the startup timeline next. Record sys.version, the executable, operating system, entry point, configuration revision, and the moment at which each startup phase can first call logging. Capture the root handler list before application configuration and immediately after it. For each relevant named logger, record its directly attached handlers, propagate, configured level, and getEffectiveLevel(). If another component installs a root handler, record its identity rather than “cleaning it up” to make the experiment pass. The question is who got there first and whether that owner is authorized.

A notebook or long-lived REPL is a poor baseline for this problem because logger objects and handlers persist for the life of that interpreter. The Python HOWTO itself tells readers to use a newly started interpreter for configuration examples, and it warns that configuration should precede logging calls whose output you expect to control. In the fixture below, every case is therefore launched through python -I in a new child process. Isolated mode is not magical logging hygiene, but the new process gives each case a fresh root logger state and prevents one case's handler graph from becoming the next case's premise.

This startup focus is intentionally different from logrotate and writer continuity, where the central evidence is the live process's open file identity after pathname rotation. Here there is no file to rotate and no inode to inspect; the evidence is the logger/handler graph plus exact sentinel receipts. That neighboring Refonte Learning playbook is useful as a methodological comparison because it also separates a successful control-plane action from proof of the data path, but its repair is outside this article's scope.

A compact pre-change contract looks like this:

Field

Owned standalone root

Embedded/hosted process

Configuration owner

Application entry point

Host/framework for root; application for its named logger

Required sentinel

INFO TOKEN

INFO TOKEN

Approved local destination

Reviewed root handler stream

Reviewed application-owned stream

Receipt count

Exactly one

Exactly one

Propagation

Usually True for descendants

Often False on the owned boundary logger

Destructive root replacement

Allowed only under explicit ownership

HOLD unless host owner approves

Missing evidence

RERUN

RERUN

That table is a decision boundary, not a stylistic preference. Once ownership and receipt requirements are written down, “Python logging basicConfig not working” becomes a finite routing investigation: inventory the graph, reproduce the first owner, reconcile the sentinel, and choose a repair that does not exceed your authority.

Build and rerun the isolated handler-and-sink fixture

The fixture separates three things that are often conflated during debugging: process evidence, diagnostic stderr, and the synthetic log sinks. The child case writes one JSON object to standard output. The parent captures that JSON and captures stderr independently. The intended and pre-existing handlers write to separate in-memory StringIO objects named new and old. That design matters because a diagnostic print routed through logging could itself create or use the root handler being investigated. Evidence collection must not mutate the state under test.

The fixture source is exactly 98 lines and has SHA-256 dec1ad704dedb755a75e426f28f68f74c05e53a4e9cf5d89baf58d806ab2733e. A validation run used the unmodified code block in an isolated environment. The interpreter reported CPython 3.13.5, Linux 6.18.55-x86_64-with-glibc2.41, and GCC 14.2.0; the run started at 2026-10-22T07:11:09.900194+00:00, finished at 2026-10-22T07:11:16.266826+00:00, reproduced the same source hash, and returned PASS_9_CASES. This validation run supplements the preparation run; neither should be generalized to other Python releases or hosts. The documentation consulted for this article is published as Python 3.13.16 within the 3.13 family, while the executed interpreter is 3.13.5. The family documentation is the contract baseline, not evidence that 3.13.16 executed locally.

Save the following unchanged as logging_lab.py and run python logging_lab.py in a disposable working directory:

from future import annotations
import hashlib, io, json, logging, platform, subprocess, sys
from datetime import datetime, timezone
from pathlib import Path


def child(case: str) -> dict:
    root = logging.getLogger()
    assert not root.handlers, 'Run each case in a fresh isolated interpreter'
    old, new = io.StringIO(), io.StringIO()
    lib = logging.getLogger('fixture.library')
    previous = None

    class Tracked(logging.StreamHandler):
        def init(self, stream):
            super().__init__(stream)
            self.close_calls = 0
        def close(self):
            self.close_calls += 1
            super().close()

    if case == 'L2':
        logging.warning('BOOT')  # Module-level convenience call configures root.
    if case in {'L3', 'L4', 'L9'}:
        previous = Tracked(old)
        previous.setFormatter(logging.Formatter('OLD:%(levelname)s:%(message)s'))
        root.addHandler(previous)
        root.setLevel(logging.ERROR)
    if case == 'L5':
        lib.warning('BOOT')  # Named logger uses lastResort; root stays unconfigured.
    if case == 'L6':
        root.addHandler(logging.NullHandler())
    if case == 'L7':
        previous = Tracked(old)
        previous.setFormatter(logging.Formatter('OLD:%(levelname)s:%(message)s'))
        lib.addHandler(previous)
    if case == 'L8':
        lib.setLevel(logging.ERROR)
    before = list(root.handlers)
    if case == 'L9':
        # This application owns only its named logger, not the host's root.
        app = logging.getLogger('fixture.application')
        h = logging.StreamHandler(new)
        h.setFormatter(logging.Formatter('NEW:%(levelname)s:%(message)s'))
        app.addHandler(h)
        app.setLevel(logging.INFO)
        app.propagate = False
        app.info('TOKEN')
    else:
        logging.basicConfig(stream=new, level=logging.INFO,
                            format='NEW:%(levelname)s:%(message)s',
                            force=(case in {'L4', 'L7', 'L8'}))
        logger = lib if case in {'L7', 'L8'} else root
        logger.info('TOKEN')
        if case in {'L2', 'L3'}:
            logger.error('ERROR_TOKEN')
    return dict(case=case, root_level=root.level,
                root_handlers_before=len(before), root_handlers_after=len(root.handlers),
                prior_root_handlers_retained=bool(before) and root.handlers == before,
                named_handlers=len(lib.handlers),
                old=old.getvalue(), new=new.getvalue(),
                close_calls=(previous.close_calls if previous else None))


def main():
    start = datetime.now(timezone.utc).isoformat()
    rows = []
    expected_new = {'L1', 'L4', 'L5', 'L7', 'L9'}
    for i in range(1, 10):
        case = f'L{i}'
        p = subprocess.run([sys.executable, '-I', str(Path(__file__).resolve()), '--case', case],
                           capture_output=True, text=True, timeout=10, check=True)
        row = json.loads(p.stdout)
        row['stderr'] = p.stderr
        row['returncode'] = p.returncode
        assert row['new'] == ('NEW:INFO:TOKEN\n' if case in expected_new else '')
        expected_old = ('OLD:ERROR:ERROR_TOKEN\n' if case == 'L3' else
                        'OLD:INFO:TOKEN\n' if case == 'L7' else '')
        assert row['old'] == expected_old
        assert row['close_calls'] == (1 if case == 'L4' else 0 if case in {'L3','L7','L9'} else None)
        if case == 'L2':
            assert p.stderr == 'WARNING:root:BOOT\nERROR:root:ERROR_TOKEN\n'
        elif case == 'L5':
            assert p.stderr == 'BOOT\n' and row['root_handlers_before'] == 0
        else:
            assert p.stderr == ''
        row['approved_route'] = case in {'L1', 'L4', 'L5', 'L9'}
        rows.append(row)
    print(json.dumps(dict(status='PASS_9_CASES', started=start,
        finished=datetime.now(timezone.utc).isoformat(), python=sys.version,
        platform=platform.platform(), source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        cases=rows), indent=2))

if name == '__main__':
    if len(sys.argv) == 3 and sys.argv[1] == '--case':
        print(json.dumps(child(sys.argv[2])))
    else:
        main()

The harness deliberately treats zero subprocess return codes as insufficient. Every child exits successfully because the negative cases are diagnostic cases, not crashes. The parent then asserts exact contents. L2, for example, is a successful experiment precisely because it proves the candidate NEW route did not take effect: new is empty, root's existing default handler remains, the INFO token is filtered, and ERROR_TOKEN goes to captured stderr. “The test passed” therefore means the case matched its expected failure mode, not that the candidate route is approved.

The fixture also tracks closure before interpreter shutdown. That distinction matters because the logging package registers logging.shutdown() with atexit, and shutdown flushes and closes handlers later. If you waited until process teardown to count closes, you could not tell whether force=True closed the prior handler during reconfiguration or whether ordinary shutdown did it afterward. The Python reference specifies that force=True removes and closes existing handlers attached to the root logger before applying the new root configuration.

Do not overinterpret the close_calls assertion. Tracked.close() proves that the handler's close method was invoked. For a StreamHandler, close() is inherited from Handler and does not itself close an arbitrary underlying stream; the handler reference describes StreamHandler.close() as inherited and doing no output, while Handler.close() performs handler cleanup. A FileHandler has additional file-closing behavior. The fixture therefore tests handler lifecycle, not a universal promise about stream ownership.

When adapting this harness to a real service, add inventory fields rather than replacing receipt assertions with more inventory. Useful additions are id(handler) or a stable test label, handler class, handler level, formatter string, named logger propagate, configured logger level, and getEffectiveLevel(). Handler counts answer “how many.” Exact receipts answer “what was delivered.” Identity answers “did the old object survive.” You need all three dimensions to approve a route.

The startup-order analogue is similar to Python initialization boundaries: an early side effect can move the point at which an application contract is established. That neighboring article is about plugin registration and lazy imports, not logging, but its evidence discipline is relevant: freeze the process, identify the first lifecycle owner, and test the boundary directly rather than inferring readiness from an earlier successful call.

Diagnose the first owner, lastResort, NullHandler, and named-logger graph

L2 is the canonical “basicConfig existing root handlers” failure. The first statement is logging.warning('BOOT'). Python documents that module-level debug, info, warning, error, and critical operate on root and call basicConfig() automatically when root has no handlers. That first warning therefore creates the default root handler before the explicit configuration line runs. When the later basicConfig(stream=new, level=INFO, format=...) executes without force=True, it sees a non-empty root handler list and intentionally does nothing. The rerun retained one root handler, kept root at WARNING (30), delivered no INFO token to new, and wrote WARNING:root:BOOT\nERROR:root:ERROR_TOKEN\n to captured stderr. The correct repair is startup order: configure the owned root before any such convenience call can occur.

L3 removes the convenience-call ambiguity and proves the same contract under explicit prior ownership. A tracked handler is attached directly to root and root is set to ERROR. The subsequent basicConfig() is a no-op. The root remains at effective level ERROR; the prior handler object is retained; INFO TOKEN is filtered before it can reach the requested NEW stream; and ERROR_TOKEN is emitted as exactly OLD:ERROR:ERROR_TOKEN\n. No close occurs before shutdown. In a standalone application where that old handler was accidentally installed by your own earlier bootstrap, this is a REPAIR candidate. In a framework-owned root, the same evidence is a HOLD boundary until the root owner authorizes replacement. The mechanism is identical; the authority is not.

L6 prevents another false shortcut: “the root handler must be a real sink if it blocks basicConfig().” A NullHandler is still a handler in root.handlers. Python documents NullHandler as a no-op handler that performs no formatting or output. Because the basicConfig() guard is based on the presence of root handlers, not on whether they emit bytes, a root NullHandler makes the later call a no-op. In the rerun root remained at WARNING, new stayed empty, and no stderr output appeared. The repair is not to blame the stream or formatter; repair the handler-state assumption and identify who attached NullHandler.

L5 is the counterexample that keeps the diagnosis honest. The statement lib.warning('BOOT') uses a named logger, not the module-level convenience function. The logger has no handlers, root has no handlers, and propagation finds no configured handler. Python's reference says that when no applicable handlers exist, the event can be sent to logging.lastResort, a StreamHandler writing to sys.stderr at level WARNING; the result is the bare message rather than the default root format. In the rerun stderr contained exactly BOOT\n, but root_handlers_before was still zero. The subsequent basicConfig() therefore installed the intended root handler, set root to INFO, and delivered NEW:INFO:TOKEN\n. Early visible output did not imply early root configuration.

That difference is why “I saw a warning before my config” is not enough evidence. Ask which API emitted it and inventory root immediately afterward. A module-level logging.warning(...) can trigger basicConfig(); a named logger.warning(...) can instead fall back to lastResort. They can both print to stderr, but only one necessarily leaves a root handler behind in this clean setup. Route debugging has to distinguish observable text from the graph that produced it.

Named loggers add two more dimensions: their own level and their own handlers. Python's logger model sends an admitted record to handlers on the logger and, when propagate is true, to handlers on ancestors. The documentation explicitly warns that attaching handlers to both a logger and an ancestor can emit the same record multiple times. Also, ancestor logger levels are not re-applied during propagation; the originating logger's admission decision happens first, then ancestor handlers are offered the record.

L7 makes the duplicate route concrete. fixture.library gets a tracked OLD handler. Root starts clean. basicConfig(..., force=True) configures root, but force is documented to remove and close handlers attached to root; it does not say that it walks every named logger and deletes their handlers. The library's named handler therefore remains. lib.info('TOKEN') is admitted at effective level INFO; its own handler emits OLD:INFO:TOKEN\n; propagation continues to the new root handler, which emits NEW:INFO:TOKEN\n. The old named handler's close_calls remains zero. The candidate configuration successfully created the new route and still fails the contract because delivery is duplicated.

L8 shows the inverse: a correct root route can exist and still receive nothing because the named logger rejects the record before propagation. fixture.library is set to ERROR; force=True installs a root handler and sets root to INFO; then lib.info('TOKEN') is discarded by the named logger's effective threshold. new remains empty. No amount of root formatter inspection fixes that. The repair belongs to the effective-level contract for the emitting logger. Python defines getEffectiveLevel() specifically to expose this threshold.

These cases justify a graph-oriented inventory. For every logger that matters, capture: configured level, effective level, propagate, directly attached handlers, each handler's level and formatter, and the next ancestor with handlers. Then reconcile a real sentinel through that graph. Switching from basicConfig() to dictConfig() may make the configuration more explicit, but it does not answer who owns root. The logging.config documentation presents dictConfig() and fileConfig() as configuration mechanisms; ownership and replacement policy remain application decisions.

The practical debugging sequence is short: locate the first root handler; decide whether its owner is legitimate; locate every handler on the emitting named logger and its ancestors; calculate the emitting logger's effective level; and only then choose a repair. Avoid broad “reset all logging” loops. They erase the evidence and can break a host that deliberately configured global logging before your component loaded.

Choose the repair according to configuration ownership

There are three defensible repair patterns in this lab, and they are not interchangeable. The first is early root configuration. Use it when the application is a standalone process and explicitly owns root. Move the reviewed configuration to the entry point before imports or startup paths that can call module-level logging convenience functions or otherwise attach root handlers. The Python HOWTO says basicConfig() should come before logging calls whose events need the desired handling, and the API reference documents why: once root has handlers, a later call without force=True does nothing.

The second pattern is deliberate root replacement with force=True. L4 is the positive case: root begins with one tracked handler at ERROR; basicConfig(..., force=True) removes and closes it, installs a new root handler, changes root to INFO, and the sentinel appears exactly once as NEW:INFO:TOKEN\n. The tracked old handler reports one close before interpreter shutdown. That is an ACCEPT only under the fixture's owned standalone assumption. The Python contract is clear that force=True removes and closes existing handlers attached to root. It is equally important what the contract does not say: it does not grant your component permission to replace someone else's handlers, and it does not remove handlers attached to named loggers.

Do not turn force=True into a cargo-cult fix for “Python logging force handler validation.” In an embedded host, root may carry framework formatters, error-reporting hooks, console routing, test capture, or operational policy. Destroying those handlers may make your component's local test pass while violating the host's contract. HOLD the destructive change until the root owner approves it. If approval arrives, rerun the whole handler graph after replacement; do not assume that replacing root also eliminates named handlers, as L7 demonstrates.

The third pattern is scoped named-logger configuration, represented by L9. Root starts with a host-owned tracked handler and level ERROR. The application configures fixture.application with its own StreamHandler(new), formatter, and INFO level, then sets propagate=False. The INFO TOKEN lands once in new; the old root handler remains the same object, stays open, and receives nothing. Root stays at ERROR. This is the cleanest route when your component owns only its namespace and the host does not require application records to propagate through root.

A bounded named route is not automatically superior. It creates a second configuration boundary, so it must be explicit and reviewed. The application owner must decide whether bypassing root is acceptable, whether host tooling expects propagation, and whether descendants inherit the application logger's policy. But L9 proves a specific point: you can establish an owned logging route without resetting global state. That is often the right response to a host-owned root.

The broader monitoring and logging tool responsibilities discussion at Refonte Learning sits downstream from this decision. Prometheus, Grafana, ELK, and Loki concern monitoring, visualization, aggregation, and analysis; they do not decide whether a local LogRecord in this process reached the handler you intended. This playbook stops at the local standard-library boundary and makes no claim about collector durability or remote ingestion.

Use this ownership decision table before changing code:

Observed owner/state

Candidate action

Decision

Reason

Root empty; application owns process

Configure root once at entry point

ACCEPT after exact sentinel

Matches L1 and prevents early no-op

Your own early code configured root incorrectly

Move configuration earlier; optionally replace in controlled startup

REPAIR, then RERUN

Startup-order defect is owned

Root has application-owned obsolete handler

force=True may be reviewed

ACCEPT only after close + receipt evidence

L4 proves bounded replacement

Root has framework/host-owned handler

Do not force replacement unilaterally

HOLD

Ownership is unresolved

Root has NullHandler unexpectedly

Find attaching owner; correct assumption/order

REPAIR

Handler presence blocks basicConfig()

Named logger has extra handler plus propagates

Remove/review named route or stop propagation

REPAIR

L7 duplicates the record

Named logger effective level filters sentinel

Change logger-level contract if authorized

REPAIR

Root cannot admit a record already rejected

Host owns root; application owns namespace

Configure named logger, usually with reviewed propagate=False

ACCEPT after exact sentinel

L9 preserves host state

The repair itself should be followed by validation code, not merely by an absence of exceptions. For an owned root, an adaptation can capture the handler object after configuration and assert both graph state and a harmless sentinel in a test stream. In production, do not swap real streams just to test them; use a startup integration test or a request-scoped synthetic event in an approved sink. Never put credentials, tokens, or private request bodies into the sentinel.

For configuration systems more elaborate than basicConfig(), keep the same ownership gate. dictConfig() can express handlers, formatters, logger levels, root configuration, and propagation more declaratively, but changing APIs is not a repair by itself. A configuration can be syntactically valid and still disable or duplicate the wrong logger. The configuration reference also notes behavior around existing non-root loggers, which is another reason to review the full graph rather than equating “configuration loaded” with “route accepted.”

Reconcile all nine cases and turn the evidence into a regression gate

The nine-case ledger is the acceptance artifact. The “Effective level after” column below includes the effective root level after the configuration step. For fixture.library, NOTSET means it inherits from root except in L8, where it is explicitly ERROR. L5 is special: before basicConfig(), the library warning sees the initial root threshold and lastResort; after configuration, its effective level becomes INFO. L9 uses a separate application logger at INFO with propagation disabled while the library logger would still inherit the host root's ERROR level.

Case

Root handlers before → after

Effective level after

Retained identity / named state

Close before shutdown

Exact receipts

Decision

L1

0 → 1

root INFO; library INFO

No prior root; no named handler

N/A

NEW=NEW:INFO:TOKEN\n; OLD=''; stderr=''

ACCEPT owned root

L2

1 → 1

root WARNING; library WARNING

Prior default root retained

N/A

NEW=''; stderr=WARNING:root:BOOT\nERROR:root:ERROR_TOKEN\n

REPAIR startup order

L3

1 → 1

root ERROR; library ERROR

Prior tracked root retained

0

OLD=OLD:ERROR:ERROR_TOKEN\n; NEW=''

REPAIR only with owner approval; otherwise HOLD

L4

1 → 1

root INFO; library INFO

Prior root replaced

1

NEW=NEW:INFO:TOKEN\n; OLD=''

ACCEPT only owned standalone scope

L5

0 → 1

library WARNING → INFO; root INFO after config

No root handler attached by BOOT

N/A

stderr=BOOT\n; NEW=NEW:INFO:TOKEN\n

ACCEPT bounded setup

L6

1 → 1

root WARNING; library WARNING

Root NullHandler retained

N/A

NEW=''; OLD=''; stderr=''

REPAIR handler-state assumption

L7

0 → 1

root INFO; library INFO

Named OLD handler survives; propagation true

named OLD 0

OLD=OLD:INFO:TOKEN\n; NEW=NEW:INFO:TOKEN\n

REPAIR duplicate route

L8

0 → 1

root INFO; library ERROR

No named handler; named level blocks INFO

N/A

NEW=''; OLD=''; stderr=''

REPAIR effective-level contract

L9

1 → 1

host root ERROR; app logger INFO

Prior root retained; app owns separate handler; propagate=False

root OLD 0

NEW=NEW:INFO:TOKEN\n; OLD=''; stderr=''

ACCEPT scoped alternative


This table shows why handler counts alone are weak evidence. L2, L3, and L6 all have one root handler before and after, but their semantics differ: a default auto-installed StreamHandler, an explicitly installed tracked handler, and a NullHandler. L4 also has one before and one after, yet identity changes and the old handler is closed. L7 has a perfectly plausible single root handler after configuration and still duplicates because the second handler is attached to the named logger. The acceptance assertion must therefore combine graph state with exact content and count.

It also shows why a record's absence does not identify the filter point. In L2, root remains at WARNING. In L3, root remains at ERROR. In L6, root's default WARNING level remains and the only root handler discards everything it receives. In L8, root is correctly INFO, but the named logger is ERROR. The same “NEW is empty” symptom has four different root causes. The diagnostic path should calculate effective level at the emitting logger, then walk handlers, rather than toggling root level repeatedly until something appears.

Turn the ledger into a startup regression gate whenever the entry point, dependency order, framework version, or logging configuration changes. The Python API behavior here is mature, but startup order is application state: a new import that logs a warning early can turn a previously effective basicConfig() into a no-op without changing the configuration line itself. A framework upgrade can install a handler earlier. A test runner can add capture. A library can change from silent import to a module-level convenience call. The regression test should prove your contract in the actual supported launch path, not only in a tiny unit test.

A useful gate captures four bundles together. First, environment: Python version, platform, entry point, source/configuration revision, and relevant framework/dependency versions. Second, graph: root and owned named logger levels, effective levels, propagation flags, handler identities/classes/levels/formatters, and before/after counts. Third, lifecycle: which previous handlers were retained, removed, or closed before shutdown. Fourth, receipts: exact sentinel content in every candidate local sink and stderr, including zeros. Missing any of those when the result is ambiguous is a RERUN, not an invitation to fill in the gap from memory.

The gate should preserve negative cases. L2, L3, L6, L7, and L8 are valuable because they prove the test would detect the specific failure modes you care about. Deleting them after the happy path passes weakens the suite into a demonstration. In reliability testing, a diagnostic assertion can succeed by correctly proving that a route is wrong. Approval is a separate policy decision encoded after evidence is collected.

This local acceptance boundary is narrower than general observability in DevOps. Metrics, traces, dashboards, and aggregated logs can reveal service behavior, but they do not replace proof that the Python process attached the expected handler and emitted the exact sentinel to the expected local sink. Conversely, a passing fixture here does not prove that a remote collector received or retained anything. Those are separate systems with separate failure modes.

Set rerun triggers explicitly. Rerun when Python changes across supported minor families; when an entry point or launcher changes; when framework startup changes; when imports that can log move before configuration; when a new handler is added to an owned named logger; when propagation policy changes; when a logger level becomes configurable; or when a previous root owner changes. A documentation patch update alone is not proof of runtime divergence, but it is a prompt to re-read the relevant contract when behavior claims are material. Keep the tested interpreter version adjacent to the results so readers do not mistake a documentation URL for the execution artifact.

Finally, keep the sentinel synthetic and low-risk. A request-scoped token can include a test identifier or build revision, but it should not contain credentials, authorization headers, private request bodies, or customer data. The purpose is to reconcile routes, not to create a new privacy problem. The same boundary aligns with Refonte Learning's discussion of API logging and security boundaries, which emphasizes keeping secrets and sensitive data out of logs; that article is broader than this local handler test.

Recover safely, decide ACCEPT/REPAIR/RERUN/HOLD, and retain ownership evidence

When a route is wrong, preserve the state that explains it before changing anything. Capture the handler inventory, effective levels, propagation flags, formatter identities, sink contents, stderr, source/configuration revision, and startup sequence. Then restore or install the reviewed configuration according to ownership. Do not iterate over logging.Logger.manager.loggerDict and strip handlers simply because the current output is inconvenient. Such cleanup destroys causal evidence and can violate other components' contracts.

Recovery also has a hard historical limit: fixing the route now cannot recreate records that were filtered or discarded earlier. L2's missing INFO TOKEN, L6's missing INFO TOKEN, and L8's logger-level rejection are not recoverable from the logging subsystem after the fact. If the event mattered to business correctness, audit, or security, assess that gap separately using whatever authoritative application data exists. Reopening the right route establishes future delivery; it does not backfill history. That distinction is a core reason to use synthetic startup validation before relying on the route for consequential events.

Use the four decisions consistently. ACCEPT means the scope is owned or explicitly approved, the handler graph matches the reviewed design, the effective level admits the sentinel, the intended sink contains the exact record exactly once, unintended sinks contain none, and any expected replacement/close event is observed. REPAIR means the evidence is complete and identifies a defect you are authorized to change: ignored basicConfig(), wrong startup order, duplicate named-plus-root routing, an unintended NullHandler, or a mismatched logger level. RERUN means evidence is incomplete, contaminated by a reused interpreter, missing environment metadata, missing sink capture, or inconsistent with the stated fixture. HOLD means the proposed fix would replace or remove configuration outside your ownership, most notably a framework-owned root.

Evidence state

Decision

Required next action

Owned route; exact one-time receipt; graph matches review

ACCEPT

Retain ledger and regression test

Complete evidence shows no-op, duplicate, or level mismatch

REPAIR

Change only owned startup/configuration scope, then rerun

Missing version/order/handler/receipt evidence

RERUN

Reproduce in a fresh process with complete capture

Root belongs to framework/host and repair would remove its handlers

HOLD

Obtain owner approval or use an owned named route

The startup-order decision follows directly. For an owned standalone application, configure logging at the application entry point before any startup action that may perform module-level convenience logging or attach root handlers. Keep library code on named loggers and let the application decide the handlers. For a hosted application, treat root as host policy unless the integration contract says otherwise. Configure an application-owned named logger with an explicit level, explicit handler, and deliberate propagation setting when that is the approved boundary. L9 is the model for the latter, not a guarantee that every framework permits or prefers the same arrangement.

Assign ownership in the runbook. The application owner approves the logger namespace, severity contract, format, and startup placement. The platform or framework owner approves root-level policy in shared hosts. Library maintainers should avoid installing output handlers and should document logger names. Reliability/DevOps owners maintain the regression gate and evidence retention, but they should not silently claim authority over application semantics. Security/privacy owners define what synthetic fields are safe. These assignments prevent a common failure mode where everybody can edit logging but nobody owns the delivery contract.

Retain the source digest, tested interpreter, timestamps, case ledger, and configuration revision with the test artifact. The preparation run and the validation run described above both used the same source SHA-256 and CPython 3.13.5; that concordance increases confidence in the fixture reproduction, but it is still bounded evidence. It does not prove every future patch release, framework integration, or host startup sequence.

The final approved routes from this lab are intentionally narrow. L1 approves configuring a clean root that the application owns. L4 approves force=True only where the application owns the standalone root and the replacement was reviewed. L5 approves the distinction between lastResort output and root configuration, followed by normal owned root setup. L9 approves a scoped application logger that preserves a host-owned root. L2, L3, L6, L7, and L8 remain negative controls whose successful execution proves the test can recognize ignored configuration, retained prior ownership, no-op handlers, duplicate delivery, and effective-level filtering.

For teams building broader operational skills, the DevOps Engineering Program page currently describes a three-month program at 12–14 hours per week, with coverage including CI/CD automation, cloud infrastructure, monitoring and logging, expert guidance, and potential virtual internship participation. Its admission language refers to learners working toward a bachelor's or higher qualification, and it associates successful completion with a Training Certificate and a Certificate of Internship. Those are descriptions of the published program, not a promise that this basicConfig() laboratory is in the curriculum and not an employment guarantee.

The durable operational lesson is simpler than any specific API call: approve the route, not the configuration statement. The Python 3.13 logging contract gives you enough observables to do that: handler lists, effective levels, propagation, documented basicConfig() behavior, and explicit sink output. A root configuration that returns normally but changes nothing is understandable once the existing handler owner is visible. A warning on stderr is understandable once lastResort is distinguished from root configuration. A duplicate is understandable once named handlers and propagation are included in the graph. And a safe repair becomes obvious once ownership is written down before anyone reaches for force=True.