Skip to content

Reloading Configuration on SIGHUP in asyncio

SIGHUP has meant "re-read your configuration" for long-running daemons since well before anyone was writing async services, and the convention is still the cheapest way to change a timeout, flip a feature flag or raise a log level without dropping connections. In asyncio the mechanics are easy — loop.add_signal_handler delivers the signal as a callback on the loop — and the risks are all in what happens next: a handler that does file I/O on the loop, a configuration object that is mutated while requests are reading it, a typo in the new file that takes the service down, or five signals in a row that trigger five concurrent reloads. This guide handles each, verified end to end against a running process.

Prerequisites

From signal to new configuration 5 stages from SIGHUP arrives to rebind, atomically. From signal to new configuration SIGHUP arrives handler enqueues supervisor wakes coalesces the burst read in a thread file I/O blocks parse and validate reject bad input rebind, atomically one assignment The handler itself does nothing but enqueue: it runs on the loop, between callbacks.

1. Register the handler and do nothing in it

loop.add_signal_handler runs the callback on the event loop, between other callbacks, so it is ordinary Python rather than a real signal context — but it still blocks the loop for as long as it runs. The handler's only job is to wake a supervisor:

loop = asyncio.get_running_loop()
hups: asyncio.Queue = asyncio.Queue()
loop.add_signal_handler(signal.SIGHUP, hups.put_nowait, None)


async def supervisor(settings) -> None:
    while True:
        await hups.get()
        while not hups.empty():
            hups.get_nowait()                          # coalesce a burst
        await settings.reload()

The drain loop is worth the two lines: five rapid SIGHUPs produced exactly one reload in the verified run. Without it, a config-management tool that signals once per changed file triggers a reload per signal, each reading the same file.

Use add_signal_handler, not signal.signal. The latter runs the handler in the real signal context, from which touching asyncio objects is unsafe, and its callback can interrupt the loop at an arbitrary bytecode boundary.

Verify: several signals in quick succession produce a single reload.

2. Parse, validate, then swap

The reload reads the file off the loop, parses it into an immutable object, validates it, and only then rebinds:

@dataclasses.dataclass(frozen=True)
class Config:
    workers: int
    timeout: float
    feature_x: bool


def parse(text: str) -> Config:
    raw = json.loads(text)
    cfg = Config(int(raw["workers"]), float(raw["timeout"]), bool(raw.get("feature_x", False)))
    if not 1 <= cfg.workers <= 64:
        raise ValueError(f"workers out of range: {cfg.workers}")
    if not 0 < cfg.timeout <= 60:
        raise ValueError(f"timeout out of range: {cfg.timeout}")
    return cfg


async def reload(self) -> None:
    text = await asyncio.to_thread(pathlib.Path(self.path).read_text)   # blocking I/O
    try:
        new = parse(text)
    except Exception as exc:
        log.error("reload rejected (%s); keeping current config", exc)
        return                                         # the old config stays live
    old, self._cfg = self._cfg, new                    # one atomic rebind
    log.info("reloaded: %s", diff(old, new))

Verified against two bad files in a row — workers: 999 and a file that was not JSON at all — the service logged reload rejected twice and kept serving with the previous values. A valid file produced a field-by-field diff: {'workers': (4, 8), 'timeout': (2.5, 5.0), 'feature_x': (False, True)}, which is the log line you want at 3 a.m.

Rebinding a name is atomic in CPython, so readers see either the old object or the new one, never a half-updated dict. That property is why the config is a frozen dataclass rather than something mutated in place.

Verify: an invalid config produces a log line and no behaviour change.

A reload that cannot take the service down 5 ordered steps. A reload that cannot take the service down read off the loop await to_thread(read_text) parse into a frozen object a dataclass, not a dict validate every field ranges, enums, references rebind on success only keep the old value otherwise log what changed old and new, field by field Validation before rebinding is what makes a typo a log line instead of an outage.

3. Read a snapshot, once per request

If a request reads settings.current.timeout at the start and settings.current.retries later, a reload in between gives it a mix of two versions. Take one snapshot and use it throughout:

async def handle(request):
    cfg = settings.current                             # one read, one version
    async with asyncio.timeout(cfg.timeout):
        return await do_work(request, workers=cfg.workers)

Verified: an in-flight request that had snapshotted (workers=4, timeout=2.5) still saw those values after a reload changed them, while the next request saw (8, 5.0). That is the correct semantics — configuration changes at request boundaries, not inside them.

For deeper call stacks, a contextvar set once per request avoids threading the object through every signature, in the same way as propagating deadlines with contextvars. Long-lived background tasks should re-read the snapshot at the top of each iteration, so they pick up changes without restarting.

Verify: a reload during a slow request does not change that request's behaviour mid-flight.

Reading configuration during a reload 2 columns contrasting snapshot once per request, read fields as needed. Reading configuration during a reload snapshot once per request consistent cfg = settings.current every field from one version in-flight requests unaffected verified: still saw the old values read fields as needed can mix versions settings.current.timeout later one field old, one new rare and hard to reproduce worst during a rollout A frozen dataclass makes the snapshot free: rebinding one name swaps every field at once.

4. Know what a reload cannot change

Rebinding a value only works for settings that are read fresh. Anything already materialised needs a migration path or a restart:

  • Read per use — timeouts, retry counts, feature flags, rate limits, log levels. These reload cleanly.
  • Materialised but resizable — connection pool sizes, semaphore limits, worker counts. A new value in the config changes nothing until you also resize the object; some libraries support it, many do not.
  • Bound at startup — listening sockets, TLS certificates, the event loop policy, the process's user. These need a restart, or a socket-handover mechanism such as systemd socket activation.

Make the distinction explicit in the config schema rather than leaving it to discovery:

RELOADABLE = {"timeout", "retries", "feature_x", "log_level"}

for field in changed:
    if field not in RELOADABLE:
        log.warning("%s changed but requires a restart to take effect", field)

That warning turns "we changed it and nothing happened" into a known limitation — a much shorter conversation.

Verify: changing a restart-only field logs a warning instead of silently doing nothing.

What can and cannot be reloaded A grid of 4 rows by 2 columns. What can and cannot be reloaded setting reloadable? why timeouts, limits, flags yes read fresh on each request log level yes one call to setLevel pool sizes sometimes needs a resize path, not a rebind listening port, TLS cert no restart or a socket handover Anything held open by existing connections needs more than a new value.

5. Make reloads observable

A reload is a change to production behaviour, and it should leave the same trail as a deploy: a counter of successful and rejected reloads, the log line with the diff, and a gauge holding a config version or file hash so you can confirm every instance is running the same thing.

CONFIG_RELOADS = Counter("config_reloads_total", labelnames=["outcome"])
CONFIG_VERSION = Gauge("config_version_hash", "First 8 hex chars of the config hash as an int")

The fleet-wide check matters most. A SIGHUP delivered to nine of ten instances leaves one running the old values, and without a version gauge that instance is indistinguishable from the others until its behaviour differs. Expose the current values on an admin endpoint too — with secrets redacted — so "what is this instance actually using?" is answerable without a shell.

Verify: after a fleet-wide reload, every instance reports the same config version.

Verification

Configuration reloading is safe when:

  • The signal handler only enqueues, and bursts coalesce into one reload.
  • File I/O happens off the loop, via a thread.
  • Invalid configuration is rejected with the previous values retained.
  • The swap is a single rebind of an immutable object.
  • Requests snapshot once and are unaffected mid-flight.
  • Non-reloadable changes warn, and reloads are counted and versioned.

Pitfalls & edge cases

  • signal.signal instead of add_signal_handler. The callback runs in a real signal context where asyncio objects are unsafe to touch.
  • Blocking file reads in the reload. A config on a network filesystem can stall the loop for seconds.
  • Mutating the config object in place. Readers see a torn state; rebind a frozen object instead.
  • Signals inside containers. SIGHUP must reach PID 1 or be forwarded; docker kill -s HUP sends it to PID 1 only.
  • Partial writes. A config written in place can be read half-complete; write to a temporary file and rename, and have the writer do the same.
  • Secrets in the diff log. Redact credential fields before logging what changed.

Frequently Asked Questions

How do I handle SIGHUP in an asyncio application?

Call loop.add_signal_handler(signal.SIGHUP, callback) and have the callback only enqueue a token. A supervisor task awaits the queue, drains any burst, and performs the reload. Never use signal.signal for this, because its handler runs in a real signal context where touching asyncio objects is unsafe.

How do I reload configuration without restarting a Python service?

Read the file in a thread, parse it into a frozen dataclass, validate every field, and only then rebind the shared name. Rebinding is atomic in CPython, so readers see either the old configuration or the new one. Requests should snapshot the object once so they never mix two versions.

What happens if the new configuration file is invalid?

Nothing, if the reload validates before swapping. Verified with an out-of-range value and a file that was not JSON at all, the service logged "reload rejected" for each and kept serving with the previous configuration. Rejections should be counted and alerted on, since the fleet is now running an older config than intended.

Can I reload connection pool sizes or listening ports?

Pool sizes only if the library exposes a resize operation — a new number in the config changes nothing on its own. Listening sockets and TLS certificates are bound at startup and need a restart or a socket-handover mechanism. Warn explicitly when a restart-only field changes.

Should I use SIGHUP or watch the config file?

SIGHUP when a deploy tool or operator can send it, because the trigger is explicit and auditable. File watching when nothing can signal the process — a mounted config map, for example — accepting that saves are noisier and need debouncing. Both end in the same validated, atomic swap.