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.

One local 02:00 rollup mapped to UTC in both DST states Two rows share one UTC timeline running 03:00 to 08:00. The EDT row (UTC minus 4) shows the previous local day ending at 04:00 UTC (local midnight) with the desired 02:00 rollup at 06:00 UTC. The EST row (UTC minus 5) shows the previous local day ending an hour later at 05:00 UTC with the desired 02:00 rollup at 07:00 UTC. An amber arrow marks the one-hour shift of the local-day edge across the DST transition. A vertical amber fire line at 07:00 UTC — the fixed cron 0 7 star star star — sits on the EST rollup and one hour after the EDT rollup, so it always fires after the local day is complete in both states. One local 02:00 rollup, two DST offsets — pin the cron to the later UTC hour cron 0 7 * * * · UTC EDT (UTC−4) prev. local day local 00:00 local 02:00 → 06:00 UTC +1 h · still safe 1 h DST shift EST (UTC−5) previous local day local 00:00 local 02:00 → 07:00 UTC fires on time 03:00 04:00 05:00 06:00 07:00 08:00 UTC clock → Cron fires at the later UTC hour, so the previous local day is always complete in both DST states.

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.

flux
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.

flux
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.

flux
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.

python
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:

flux
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:

flux
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.


Up one level: Cron & Interval Scheduling Logic