Configuring cron expressions for timezone-aware InfluxDB tasks
You run a daily rollup that must summarise each device’s telemetry over its local calendar day — 00:00 to 24:00 in America/New_York, Europe/Berlin, or Asia/Kolkata — but InfluxDB’s task scheduler evaluates every cron expression against a UTC clock and exposes no per-task timezone field. Set the expression naively and the rollup window slides an hour twice a year at daylight-saving boundaries, silently attributing readings to the wrong day and leaving gaps or double-counts in the aggregated bucket. This page shows the deliberate separation that fixes it: anchor the trigger to a conservative UTC cron, then correct the data window inside Flux, and rotate the expression programmatically only when a contract demands exact local timing. It is the timezone-specific companion to the broader cron & interval scheduling logic covered in the parent guide.
Prerequisites
Why the scheduler is UTC-only
The option task record accepts a cron field, but InfluxDB deliberately omits a timezone key so that fire times stay deterministic across every node in a clustered deployment — no node has to resolve a zone database or agree on DST rules at scheduling time. The scheduler computes the next timestamp matching your expression in UTC and fires there. That means a “local 02:00” rollup is only ever approximately local: correct for half the year and an hour off for the other half. Rather than fight this, you translate the local time to UTC once and push all timezone awareness down into the query, exactly where the downsampling & aggregation pipeline design actually consumes the window.
Solution walkthrough
1. Translate the local schedule to a conservative UTC cron
Pick a UTC fire time that lands after the latest possible local boundary you care about, in both DST states, so the preceding local day is always fully written before the rollup reads it. A daily 02:00 rollup for America/New_York maps to 0 7 * * * UTC under EST (UTC-5) and 0 6 * * * UTC under EDT (UTC-4). Choosing the later of the two — 0 7 * * * — guarantees the task never fires before the local day has closed.
option task = {
name: "daily_sensor_rollup",
// 02:00 America/New_York falls at 06:00 UTC (EDT) or 07:00 UTC (EST).
// Pin to the later UTC time so the local day is always complete first.
cron: "0 7 * * *",
offset: 5m,
}
The offset: 5m is orthogonal to the timezone question — it simply delays execution past the fire time so any late-arriving ingestion buffers drain before the window is read, the same mechanism explained in the parent scheduling guide. It does not change which data the query selects.
2. Realign the query window with timeShift()
A raw range(start: -24h) captures a UTC-aligned 24 hours, not a local-midnight-to-midnight day. To aggregate on local calendar boundaries, temporarily rewrite the _time column by the target offset, let aggregateWindow() bucket on the shifted clock, then reverse the shift before writing so the destination bucket keeps canonical UTC timestamps.
option task = {
name: "daily_sensor_rollup",
cron: "0 7 * * *",
offset: 5m,
}
// Standard-time baseline for America/New_York. See step 3 for DST handling.
targetOffset = -5h
from(bucket: "raw_telemetry")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> filter(fn: (r) => r._field == "temperature")
// Shift into local time so calendar bucketing aligns with local midnight.
|> timeShift(duration: targetOffset, columns: ["_time"])
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
// Shift back so stored timestamps remain UTC for downstream consumers.
|> timeShift(duration: -targetOffset, columns: ["_time"])
|> to(bucket: "aggregated_telemetry")
The critical parameter is createEmpty: false: for sparse IoT sensors it suppresses null-valued rows on days a device reported nothing, which would otherwise inflate cardinality and storage in aggregated_telemetry. Set it to true only when a downstream consumer genuinely needs an explicit “no data” marker per day.
3. Handle daylight-saving transitions explicitly
A fixed offset like -5h is wrong for half the year. When exact local-day boundaries are contractual, stop trusting a relative range and compute the local day explicitly from the current offset, then filter on absolute start and end timestamps. The transition days themselves are 23 or 25 hours long — a spring-forward day has no 02:00 local hour, a fall-back day has two — so a fixed 24-hour window will clip or overlap by exactly one hour unless the boundaries are computed rather than assumed.
import "date"
option task = {
name: "daily_sensor_rollup_dst_safe",
cron: "0 7 * * *",
offset: 5m,
}
// Choose the offset for the day being aggregated, not for "now".
// EST = -5h (standard), EDT = -4h (daylight).
targetOffset = -5h
localStart = date.truncate(t: now(), unit: 1d)
localEnd = date.add(d: 1d, to: localStart)
from(bucket: "raw_telemetry")
|> range(start: -26h) // widen to cover a 25h fall-back day
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> timeShift(duration: targetOffset, columns: ["_time"])
|> filter(fn: (r) => r._time >= localStart and r._time < localEnd)
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|> timeShift(duration: -targetOffset, columns: ["_time"])
|> to(bucket: "aggregated_telemetry")
Widening the read range to -26h guarantees the 25-hour fall-back day is fully covered; the explicit >= localStart and < localEnd filter then trims it back to a clean local day so no hour is double-counted.
4. Rotate the UTC cron programmatically before each transition
Because Flux has no live IANA zone database at scheduling time, the cleanest way to keep the trigger aligned is to flip the UTC expression twice a year from an external orchestrator. This is a small, targeted use of the wider Python client orchestration patterns: resolve the current offset for the zone with zoneinfo, then patch the task’s cron field.
import os
from datetime import datetime
from zoneinfo import ZoneInfo
from influxdb_client import InfluxDBClient
client = InfluxDBClient(
url=os.environ["INFLUX_URL"],
token=os.environ["INFLUX_TOKEN"],
org=os.environ["INFLUX_ORG"],
)
tasks_api = client.tasks_api()
def utc_cron_for_local_hour(zone: str, local_hour: int) -> str:
"""Return a UTC cron firing at `local_hour` local time, today's DST state."""
tz = ZoneInfo(zone)
offset = datetime.now(tz).utcoffset()
offset_hours = int(offset.total_seconds() // 3600)
utc_hour = (local_hour - offset_hours) % 24
return f"0 {utc_hour} * * *"
def align_task_cron(task_name: str, zone: str, local_hour: int):
tasks = tasks_api.find_tasks(name=task_name)
if not tasks:
raise ValueError(f"Task '{task_name}' not found.")
task = tasks[0]
new_cron = utc_cron_for_local_hour(zone, local_hour)
if task.cron == new_cron:
return task # already aligned; no-op keeps the run history clean
task.cron = new_cron
updated = tasks_api.update_task(task)
print(f"'{task_name}' cron -> {updated.cron}")
return updated
# Run this on a schedule that straddles both transition dates, e.g. weekly:
# align_task_cron("daily_sensor_rollup", "America/New_York", local_hour=2)
The if task.cron == new_cron guard makes the rotation idempotent — running it weekly is harmless and only issues an update on the two weeks a year the offset actually changes.
Gotchas and edge cases
Shifting _time after aggregation instead of before. aggregateWindow() buckets on whatever _time holds at that moment. If you call timeShift() after it, you have already bucketed on UTC days and merely relabelled the results — the boundaries are still wrong. The shift must wrap the aggregation: shift in, aggregate, shift out.
Using now()'s offset for a backfill. datetime.now(tz).utcoffset() returns today’s DST state. If you reprocess last winter’s data with a summer offset (or vice versa), every window is off by an hour. For historical backfills, resolve the offset for a timestamp inside the day being processed, not the wall clock at run time.
Assuming 24-hour days on transition dates. A fixed range(start: -24h) silently drops the extra hour on a fall-back day and pulls in a neighbouring hour on a spring-forward day. Either widen the read range and filter to explicit local boundaries (step 3), or accept that transition-day totals are approximate and document it.
Forgetting that whole-hour offsets are not universal. Zones like Asia/Kolkata (UTC+5:30) and Australia/Eucla (UTC+8:45) use fractional offsets, so the “flip the UTC hour” cron rotation in step 4 cannot express them precisely. For those zones, keep the cron on a safe whole-hour UTC anchor and rely entirely on the Flux window correction in steps 2–3.
Verification
Confirm the task fires and lands its window on the right local day by inspecting run history in the _tasks system bucket and comparing scheduledFor against your expected UTC time:
from(bucket: "_tasks")
|> range(start: -7d)
|> filter(fn: (r) => r._measurement == "runs")
|> filter(fn: (r) => r.status == "success")
|> keep(columns: ["_time", "scheduledFor", "startedAt"])
Then spot-check that the aggregated output carries exactly one point per local day with no gaps across a DST boundary:
from(bucket: "aggregated_telemetry")
|> range(start: -35d)
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> aggregateWindow(every: 1d, fn: count, createEmpty: true)
|> filter(fn: (r) => r._value != 1) // any row here is a gap or duplicate
A clean run returns no rows from the second query — every local day produced exactly one aggregate.
Related
- Cron & interval scheduling logic — how UTC fire times,
every, andoffsetanchor task execution. - Writing robust Flux scripts for automated data rollups — idempotent, replay-safe transformation logic for the query above.
- Using Python asyncio with the InfluxDB client v2 for batch tasks — scaling programmatic task management beyond a single rotation.
Up one level: Cron & Interval Scheduling Logic