Changing Python Log Levels at Runtime

The information you need during an incident is DEBUG-level, and the service is running at INFO. Restarting to raise the verbosity loses the state you were trying to inspect. This page covers the three mechanisms for changing a level in a live process — an admin endpoint, a signal, a watched file — and the two rules that decide whether the change has any visible effect. It builds on log levels and severity mapping, part of the Python logging fundamentals and structured data section.

Two gates, and lowering one of them changes nothing A record must pass two independent checks before it is written. The first is the logger's effective level, found by walking up the hierarchy until a logger with an explicit level is reached. The second is the handler's own level, checked inside Handler.handle after the logger has already decided to emit. The upper path shows the common mistake: the logger is set to DEBUG at runtime but the handler was configured at INFO, so DEBUG records pass the first gate and are discarded at the second, and the operator concludes the level change did not work. The lower path shows the working configuration: the handler sits at NOTSET or DEBUG so it accepts whatever arrives, and volume is controlled entirely by logger levels, which are the thing you can change at runtime. logger.setLevel(DEBUG) — and still nothing appears logger.debug(…) a DEBUG record gate 1 · effective level DEBUG — passes gate 2 · handler level INFO — discards it here nothing logger.debug(…) the same record gate 1 · effective level DEBUG — passes gate 2 · handler level NOTSET — accepts everything written the rule that follows: put the handlers at NOTSET and control volume with logger levels, which are the levels you can change live a handler level is still useful for routing — an ERROR-only file sink, say — but it is a static decision, not an operational lever effective level = the nearest explicit level walking up the hierarchy, which is why setting it on a child never affects a sibling
Every "the level change did nothing" report is this diagram. The record passed the gate you moved and hit the one you forgot about.

Prerequisites

pip install "fastapi>=0.115.0,<1.0.0"
export LOG_ADMIN_TOKEN="$(openssl rand -hex 16)"
export LOG_LEVEL_TTL=900          # seconds before a runtime override expires

Implementation

Step 1 — Change the level on the logger you mean. setLevel clears the effective-level cache for the whole hierarchy itself, so no extra step is needed. Name the narrowest logger that covers what you are debugging: myapp.orders.checkout, not myapp.

import logging

def set_level(name: str, level: str) -> dict:
    logger = logging.getLogger(name)
    logger.setLevel(getattr(logging, level.upper()))     # clears the cache for you
    return {"logger": name, "level": logging.getLevelName(logger.level)}

Step 2 — Make sure the handlers will accept the records. This is the failure everyone hits once. Put handlers at NOTSET so they take whatever the logger sends, and keep volume control in the logger levels — the ones you can change at runtime.

for handler in logging.getLogger().handlers:
    handler.setLevel(logging.NOTSET)                     # accept whatever arrives

Step 3 — Expose it behind an admin route with a TTL. A debug level enabled during an incident and forgotten is a permanent cost increase. Give every override an expiry and a background task that restores the previous value.

import asyncio
import os
from fastapi import APIRouter, Header, HTTPException

router = APIRouter(prefix="/admin/logging")
_ADMIN_TOKEN = os.environ["LOG_ADMIN_TOKEN"]

@router.post("/level")
async def change_level(logger: str, level: str, x_admin_token: str = Header(default="")):
    if x_admin_token != _ADMIN_TOKEN:                    # never leave this open
        raise HTTPException(status_code=403)
    target = logging.getLogger(logger)
    previous = target.level
    target.setLevel(getattr(logging, level.upper()))
    logging.getLogger("admin").warning(
        "log level changed", extra={"target": logger, "level": level.upper()},
    )
    asyncio.create_task(_restore(logger, previous))      # expiry, not a promise to remember
    return {"logger": logger, "level": level.upper(), "expires_in": int(os.environ.get("LOG_LEVEL_TTL", 900))}

async def _restore(name: str, level: int) -> None:
    await asyncio.sleep(int(os.environ.get("LOG_LEVEL_TTL", 900)))
    logging.getLogger(name).setLevel(level)
    logging.getLogger("admin").warning("log level restored", extra={"target": name})

The change itself is logged at WARNING. An override that alters what the service records must be visible in what the service records, or the next person reading the log has no way to know why the volume changed.

Step 4 — Add a signal path. When the HTTP surface is the thing that is broken, a signal still gets through. SIGUSR2 is unused by the interpreter and safe to claim.

import signal

_LEVELS = [logging.INFO, logging.DEBUG]

def _cycle(signum, frame) -> None:
    root = logging.getLogger()
    nxt = _LEVELS[(_LEVELS.index(root.level) + 1) % len(_LEVELS)] if root.level in _LEVELS else logging.DEBUG
    root.setLevel(nxt)
    logging.getLogger("admin").warning("root level cycled", extra={"level": logging.getLevelName(nxt)})

signal.signal(signal.SIGUSR2, _cycle)
kill -USR2 "$(pgrep -f 'uvicorn app:app' | head -1)"

Keep the handler tiny. Signal handlers run between bytecodes on the main thread, and anything that acquires a lock there can deadlock against whatever held it when the signal arrived — setLevel and one log call is about the safe limit.

Three ways in, and what each one can reach Three mechanisms for changing a log level in a running service, compared on three properties. An admin HTTP endpoint is the most convenient and the most auditable, since the request is authenticated and the change can be logged with the caller's identity, but it reaches exactly one worker — whichever the load balancer routed to — and it is unavailable precisely when the HTTP surface is the thing that is broken. A signal reaches one process too, chosen by process id rather than by luck, and it still works when the application is not serving requests, but it carries no parameters so it can only cycle through preset levels. A watched configuration file or key reaches every worker on every host that reads it, survives restarts, and is the only one of the three that is declarative, at the cost of a polling delay and another piece of infrastructure to keep available. pick by what you need it to reach when things are bad admin endpoint signal watched file or key reach one worker whichever answered one process the one you chose every worker, every host and new ones on start audit authenticated caller who, when, what anonymous whoever had shell access version control reviewable, revertable when unhealthy unavailable the surface is the problem still works no request path needed works, after a poll 10–30 s of latency most services want the endpoint for convenience and the signal as the path that survives — the file when the fleet is large enough
The endpoint is the one you will use ninety percent of the time, and the signal is the one you will be glad of on the other ten.

Step 5 — Reach every worker. setLevel changes one interpreter. In a four-worker deployment, an HTTP call changes one quarter of the service. Where that matters, keep the desired levels in a small file that each worker re-reads on a timer.

import json, pathlib, threading, time

LEVELS_FILE = pathlib.Path("/etc/app/log-levels.json")

def _watch(interval: float = 15.0) -> None:
    last = None
    while True:
        try:
            stamp = LEVELS_FILE.stat().st_mtime
            if stamp != last:
                last = stamp
                for name, level in json.loads(LEVELS_FILE.read_text()).items():
                    logging.getLogger(name).setLevel(level)
        except FileNotFoundError:
            pass
        time.sleep(interval)

threading.Thread(target=_watch, name="log-level-watch", daemon=True).start()
An override with an expiry, and one without Two timelines of the same debug session. With a time-to-live, an engineer raises a logger to DEBUG at the start of an incident, log volume rises sharply for fifteen minutes, and the override then expires on its own and volume returns to baseline; a WARNING record marks both the change and the restoration, so the volume spike is explained in the log itself. Without one, the same override is applied, the incident is resolved, the engineer moves on, and the elevated volume continues — through the rest of the day, the weekend and the following weeks — until an unrelated deploy restarts the process and resets the level by accident. The cost accrues the whole time as ingestion and retention on records nobody reads, and the only signal that anything changed is a step in a billing chart that nobody connects to an incident three weeks earlier. log volume after a DEBUG override with a TTL 15 minutes expires on its own — and logs a WARNING saying so without one three weeks, until an unrelated deploy restarts the process ingestion and retention accrue the whole time, on records nobody reads the only signal is a step in a billing chart that nobody connects to an incident three weeks earlier
The second shape is not hypothetical — it is what happens whenever the restore depends on someone remembering after the incident is over.

Configuration options

Option Type Default Recommended
Handler level int as configured NOTSET, so logger levels are the lever
Override TTL seconds none 900 — expire, do not rely on memory
Admin auth none a token or your existing admin auth
Signal none SIGUSR2, with a tiny handler
Watch interval seconds 15–30 s
Change record level INFO WARNING — it must be visible

Verification

curl -s -XPOST -H "X-Admin-Token: $LOG_ADMIN_TOKEN" \
  'localhost:8000/admin/logging/level?logger=app.orders&level=DEBUG'

Expected Output:

{"logger": "app.orders", "level": "DEBUG", "expires_in": 900}
{"levelname": "WARNING", "name": "admin", "message": "log level changed", "target": "app.orders", "level": "DEBUG"}
{"levelname": "DEBUG", "name": "app.orders.checkout", "message": "cart recalculated", "items": 3}

The second line is the proof: a DEBUG record from a child of the logger you changed, which confirms both that the effective level propagated down the hierarchy and that the handler accepted it. If the first line appears and the second never does, the handler is still at INFO.

Common mistakes

The level changes and the output does not

Error signature: the admin endpoint returns success, logger.getEffectiveLevel() reports 10, and no DEBUG records appear. Root cause: the handler is at INFO and discards them after the logger let them through. Remediation: set handlers to NOTSET and keep volume control in logger levels.

One worker in four gets the change

Error signature: DEBUG records appear intermittently, roughly a quarter of the time. Root cause: the HTTP call reached one worker process. Remediation: distribute through a watched file or a control-plane message, or address workers individually by process ID.

DEBUG is still on three weeks later

Error signature: log volume and storage costs stepped up on an incident date and never came back down. Root cause: a runtime override with no expiry. Remediation: give every override a TTL, log the change at WARNING, and alert when a non-default level has been in place for longer than the TTL allows.

Making the override safe to hand out

A runtime level change is a production control, and the difference between a useful one and a liability is entirely in the guardrails around it. Four are worth building before anyone needs them at three in the morning.

Bound the blast radius. Refuse to set the root logger to DEBUG. It is almost never what the person wants — they want one subsystem — and it is reliably what they type when the service is on fire. Accepting a logger name and rejecting the empty string costs one line and prevents the change that turns a latency incident into a log-volume incident on top of it.

FORBIDDEN = {"", "root"}

if logger in FORBIDDEN and level.upper() == "DEBUG":
    raise HTTPException(status_code=400, detail="refusing DEBUG on the root logger")

Bound the duration. Covered above; the point worth repeating is that the TTL must survive the process that set it. An asyncio.sleep in a task works until the worker restarts, at which point the level resets by accident and nobody notices either the reset or the original override. If the change matters enough to audit, the desired state belongs in a file or a key that each worker re-reads.

Record who and why. An override changes what the service records, so it belongs in the record. A WARNING carrying the caller's identity, the target logger, the new level and the expiry means the next person reading the log can tell why the shape changed. Without it, a volume step in a chart is unexplained forever.

Expose the current state. A read endpoint that lists non-default levels is the thing that finds the override somebody forgot. It is three lines and it turns "is anything still turned up?" from an investigation into a query.

@router.get("/level")
async def list_overrides():
    out = {}
    for name in sorted(logging.Logger.manager.loggerDict):
        log = logging.getLogger(name)
        if log.level != logging.NOTSET:                 # an explicit level was set
            out[name] = logging.getLevelName(log.level)
    return {"overrides": out}
Guardrail Prevents Cost
Refuse DEBUG on root a volume incident during a latency incident one condition
A TTL that survives restarts a permanent override nobody remembers a file or a key
A WARNING recording the change an unexplained step in the volume chart one log call
A read endpoint overrides accumulating unnoticed three lines
Auth on the write path anyone changing production behaviour your existing admin auth

The alternative worth considering first

Before building the endpoint, it is worth asking whether the level is the right control at all. The reason to raise a level is almost always "I want more detail about the requests that are failing", and levels answer that badly: they give more detail about every request, including the overwhelming majority that are fine.

Two mechanisms answer the actual question better. A MemoryHandler that flushes on error gives full DEBUG context for exactly the requests that failed, permanently, with no operator action and no volume cost for the rest. And trace sampling with a rule that always keeps traces containing an error gives the same property for the trace signal.

Runtime level changes remain worth having for the cases those do not cover: a subsystem that is misbehaving without erroring, a suspicion about a code path that succeeds but does the wrong thing, or a library whose DEBUG output is the only documentation of its behaviour. Those are real, and they are a smaller set than the endpoint's usage suggests.

Frequently Asked Questions

Do I need to clear a cache after calling setLevel?

No — Logger.setLevel calls the manager's cache-clearing routine itself, so the effective-level cache is invalidated for the whole hierarchy. You would only need to touch _cache directly if you mutated a level attribute in place instead of going through setLevel, which is not something to do.

Why does setting DEBUG on my logger produce no extra output?

Almost always the handler. A record has to pass the logger's level and then the handler's level, so a logger at DEBUG feeding a StreamHandler left at INFO emits nothing new. Set the handler to DEBUG (or to NOTSET so it accepts whatever arrives) and control volume with logger levels instead.

Is changing a level at runtime thread-safe?

Yes. setLevel is a simple attribute assignment plus a cache clear, both under logging's module lock, and readers see either the old value or the new one. What is not safe is reconfiguring the whole graph with dictConfig while requests are in flight — that closes and replaces handlers, and records logged during the swap can hit a closed stream.

How do I turn DEBUG on for one endpoint only?

Name your loggers after the module path and enable the narrowest one, for example myapp.orders.checkout rather than myapp. If the distinction you need is per-request rather than per-module, do not use levels at all — use a MemoryHandler that flushes on error, so the detail exists for exactly the requests that failed.

What happens under multiple workers?

Nothing, in the other workers. setLevel changes one interpreter's state, so an HTTP call to change a level reaches whichever worker the load balancer picked. Either apply the change from a supervisor to every child, or store the desired levels in a small file or key that each worker re-reads on a timer.