Skip to content

Scheduling Coroutines at Wall-Clock Times

asyncio's timers run on a monotonic clock: asyncio.sleep, loop.call_later and loop.call_at count elapsed seconds, not wall-clock time, which is what keeps them immune to clock changes — and what makes "run this at 02:30 every night" a job you have to build yourself. Measured on Python 3.14: a periodic loop that slept 50 ms after a 5 ms job drifted +1.049 s over 200 ticks; one that slept until start + k × interval drifted +0.001 s. Computing a delay by subtracting two time-zone-aware datetimes in the same zone gave 3 hours from 01:00 to 04:00 local on the night clocks went back, when 4 hours actually elapse — a job slept that long woke at 03:00 CET, an hour early; converting both to UTC first gave the right answer. A local time of 02:30 that does not exist on the spring-forward night resolved to 03:30 CEST, and one that occurs twice on the fall-back night resolved to its first occurrence unless fold=1 was set. With a simulated wall clock that jumped forward one second during a two-second wait, a single computed sleep fired 1.00 s late; sleeping in short chunks and re-reading the wall clock fired on time. This guide builds a scheduler that gets these right.

Prerequisites

1. Align periodic jobs to a fixed grid

The natural periodic loop runs the job, then sleeps for the interval. The period is then the interval plus the job's duration plus scheduling delay, and the error accumulates:

async def every_naive(interval, job):
    while True:
        await job()
        await asyncio.sleep(interval)               # period = interval + job + overhead

async def every_aligned(interval, job):
    loop = asyncio.get_running_loop()
    start, k = loop.time(), 0
    while True:
        await job()
        k += 1
        await asyncio.sleep(max(0.0, start + k * interval - loop.time()))

Measured with a 50 ms interval and a 5 ms job over 200 ticks: the naive loop's 200th tick came at 10.999 s instead of 9.950 s, 1.049 s late; the aligned loop's came at 9.951 s. If a job overruns its slot, the aligned loop starts the next one immediately; to skip missed slots instead, advance k to the next slot in the future. Both loops measure time with loop.time(), the monotonic clock, which is right for intervals. For many periodic jobs, the same grid-alignment applies per job; the structure of long-running periodic tasks is covered in running periodic tasks without drift.

Verify: after an hour, a periodic job's run count matches elapsed / interval within one.

Lateness of the 200th tick, 50 ms interval, 5 ms job 2 horizontal bars comparing sleep(interval) after the job with the others. Lateness of the 200th tick, 50 ms interval, 5 ms job sleep(interval) after the job +1,049 ms sleep until start + k x interval +1 ms Python 3.14; loop.time() as the reference. Drift is the job time and overhead, added on every tick.

2. Compute delays to wall-clock times through UTC

To run something at a time of day, compute the next occurrence in the job's time zone, then the delay until it — and do the subtraction in UTC. Python subtracts two aware datetimes that share a tzinfo as wall-clock times, ignoring the difference in their UTC offsets:

from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo

UTC = timezone.utc

def seconds_until(target: datetime) -> float:
    return (target.astimezone(UTC) - datetime.now(UTC)).total_seconds()

def next_occurrence(at: time, tz: ZoneInfo, after: datetime) -> datetime:
    local_after = after.astimezone(tz)
    candidate = datetime.combine(local_after.date(), at, tzinfo=tz)
    if candidate <= local_after:
        candidate = datetime.combine(local_after.date() + timedelta(days=1), at, tzinfo=tz)
    return candidate

Measured for Europe/Copenhagen on 25 October 2026, when clocks went back at 03:00: from 01:00 to 04:00 local, same-zone subtraction gave 3 hours, while 4 hours actually elapse; a job that slept the 3 hours woke at 03:00 CET, an hour early. On 29 March 2026, when clocks went forward, the same subtraction gave 3 hours where 2 elapse — an hour late. Converting both ends to UTC gave 4 and 2 hours respectively. The same trap applies to target - now written with datetime.now(tz); convert, or compare timestamps.

Verify: a unit test computes delays across both of the zone's DST transitions and checks them against UTC arithmetic.

Delay from 01:00 to 04:00 local, Europe/Copenhagen A grid of 2 rows by 4 columns. Delay from 01:00 to 04:00 local, Europe/Copenhagen night same-zone subtraction via UTC (real) effect of the wrong one 2026-10-25 (clocks back) 3 h 4 h wakes at 03:00 CET, 1 h early 2026-03-29 (clocks forward) 3 h 2 h wakes 1 h late Aware datetimes sharing a tzinfo subtract as wall-clock times.

3. Decide what non-existent and repeated times mean

A daily job at a local time between 02:00 and 03:00 meets two special nights a year in zones that observe DST. On the spring-forward night, 02:30 does not exist; on the fall-back night, it happens twice:

missing = datetime(2026, 3, 29, 2, 30, tzinfo=tz)              # 01:30 UTC = 03:30 CEST
first   = datetime(2026, 10, 25, 2, 30, tzinfo=tz, fold=0)      # 00:30 UTC (CEST)
second  = datetime(2026, 10, 25, 2, 30, tzinfo=tz, fold=1)      # 01:30 UTC (CET)

Measured: the non-existent 02:30 resolved to 03:30 CEST, so the job ran an hour later by the local clock; the repeated 02:30 resolved to its first occurrence by default and to the second with fold=1. Make the choice explicit for each job: "run once at the first 02:30", "skip the night 02:30 does not exist", or — simplest — schedule jobs outside the 02:00–03:00 window, or in UTC, where none of this happens. Record the decision with the job definition; it is part of the job's contract.

Verify: the scheduler's behaviour on both transition nights is tested and matches the documented choice.

4. Re-check the wall clock while waiting

A single long asyncio.sleep(delay) measures elapsed monotonic time. If the wall clock changes during the wait — an NTP step correction, a virtual machine resumed after being paused, a manual change — the sleep ends at the right number of seconds but the wrong time of day. On Linux, the monotonic clock also does not advance while the machine is suspended, so a laptop closed for an hour delays every pending sleep by an hour. Sleep in bounded chunks and recompute against the wall clock:

async def sleep_until(target: datetime, max_chunk: float = 60.0) -> None:
    while (remaining := seconds_until(target)) > 0:
        await asyncio.sleep(min(remaining, max_chunk))

Measured with a simulated wall clock that jumped forward by one second, half a second into a two-second wait (time scaled down to keep the test short): the single computed sleep fired 1.00 s after the intended wall-clock time; the chunked version, re-checking every 0.1 s, fired on time. With a 60 s chunk the error is at most a minute plus a jump, at the cost of one wake-up per minute per scheduled job. For backward jumps, the chunked loop waits for the clock to catch up, which keeps a job from running twice.

Verify: a test with a patched wall clock shows jobs firing within one chunk of their target after forward and backward jumps.

A wall-clock scheduler loop for one job A flow of 5 stages. A wall-clock scheduler loop for one job Next occurrence local time in the job's zone Resolve DST missing / repeated rule Delay via UTC never same-zone subtraction Sleep in chunks re-read the wall clock Run, then next from the scheduled time Monotonic sleeps for waiting; wall-clock arithmetic for deciding when.

5. Put it together, and keep it observable

A minimal wall-clock scheduler runs each job in its own task: compute the next occurrence, sleep until it in chunks, run the job with a deadline, repeat from the scheduled time rather than the completion time:

async def daily(at: time, tz: ZoneInfo, job, *, timeout: float = 600):
    scheduled = next_occurrence(at, tz, datetime.now(UTC))
    while True:
        await sleep_until(scheduled)
        log.info("running %s for %s", job.__name__, scheduled.isoformat())
        try:
            async with asyncio.timeout(timeout):
                await job()
        except Exception:
            log.exception("%s failed for %s", job.__name__, scheduled.isoformat())
        scheduled = next_occurrence(at, tz, scheduled + timedelta(seconds=1))

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(daily(time(2, 30), ZoneInfo("Europe/Copenhagen"), rotate_keys))
        tg.create_task(daily(time(6, 0), ZoneInfo("UTC"), send_reports))

Computing the next run from scheduled rather than from "now" keeps a slow job from shifting the schedule. If several replicas run the same scheduler, each scheduled time must run once overall, which needs the claim markers described in running singleton jobs across replicas. Log each run's scheduled and actual start times; the difference is the scheduler's own error and should stay within the chunk size.

Verify: logs show each job's scheduled time, actual start and duration, and starts stay within one chunk of schedule.

Which scheduling technique does this job need? A decision on What kind of schedule is it with 4 outcomes. Which scheduling technique does this job need? What kind of schedule is it? every N seconds grid: start + k x interval +0.001 s after 200 ticks at a time of day next local time, subtract in UTC not 1 h off at DST between 02:00 and 03:00 local explicit missing/repeated rule or move it long waits chunked sleep, re-check wall clock survives clock jumps asyncio sleeps measure elapsed time; wall-clock intent needs re-checking.

Verification

Wall-clock scheduling is correct when:

  • Periodic jobs follow a grid on the monotonic clock, without accumulated drift.
  • Delays to wall-clock times are computed in UTC, never by same-zone subtraction.
  • DST edge cases have explicit rules, tested on both transition nights.
  • Long waits re-check the wall clock in bounded chunks, and actual start times are logged.

Diagnostic Hook: chart each scheduled job's start delay — actual start minus scheduled time. A steady offset of exactly one hour, appearing twice a year, is the DST subtraction bug; a sudden offset matching a maintenance window or a host's suspend points at a monotonic sleep that never re-checked the clock.

Pitfalls & edge cases

  • Sleeping a full interval after each job. Measured: +1.049 s over 200 ticks.
  • target - now with the same zone on both sides. Measured: 3 h computed, 4 h real.
  • Local times between 02:00 and 03:00. They are missing or repeated twice a year.
  • One long sleep to a wall-clock time. Measured: 1.00 s late after a 1 s clock jump.

Frequently Asked Questions

How do I run an asyncio task at a specific time of day?

Compute the next occurrence in the job's time zone, convert it to UTC, and sleep until then in chunks of at most a minute, re-checking the wall clock; then compute the next occurrence from the scheduled time.

Why does my periodic asyncio task drift?

Sleeping a fixed interval after each run adds the run's duration every time: 200 ticks drifted 1.049 s. Sleep until start + k × interval instead, which drifted 1 ms.

Why did my scheduled job run an hour early after a DST change?

Subtracting two aware datetimes with the same tzinfo uses wall-clock times: from 01:00 to 04:00 on the night clocks went back gave 3 hours instead of 4. Convert both to UTC before subtracting.

Does asyncio.sleep follow the system clock?

No, it uses a monotonic clock, so a wall-clock jump does not change it: in a simulated test, a single long sleep fired 1.00 s late after a 1 s jump, while chunked re-checking fired on time.