Mapping InfluxQL GROUP BY time() to Flux aggregateWindow

Your building-automation historian ran for years on InfluxDB 1.8, where a handful of continuous queries rolled 10-second BACnet point readings — zone temperature, CO2 concentration, VAV damper position, chiller kW — into 5-minute and hourly means. Porting those queries to InfluxDB 2.x tasks looks mechanical until the first rollup lands on the wrong timestamp, an overnight window shifts by an hour across a daylight-saving change, and gaps that InfluxQL used to smooth with fill(previous) come back as nulls. GROUP BY time() and Flux’s aggregateWindow() are close cousins, but every InfluxQL clause — the interval, the offset argument, fill(), and tz() — maps to a specific aggregateWindow parameter whose default differs from the 1.x behaviour, and the defaults are exactly where a migration silently diverges. This page is a clause-by-clause translation reference, built around a BACnet historian, that you can apply while migrating legacy continuous queries to tasks.

Prerequisites

The one-page mapping table

Keep this beside the 1.x query text. Each InfluxQL fragment on the left has a single correct aggregateWindow counterpart on the right; the notes column flags where the default changes behaviour.

InfluxQL fragment Flux equivalent Watch out for
GROUP BY time(5m) aggregateWindow(every: 5m, ...) interval string is identical (s,m,h,d,w)
mean(value) fn: mean medianfn: median; countfn: count
SELECT mean("v") timestamp timeSrc: "_start" Flux defaults to _stop; InfluxQL stamps the window start
GROUP BY time(1h, 15m) (offset) offset: 15m offset shifts window edges, not the timestamp
fill(none) createEmpty: false Flux default is createEmpty: true — opposite of nothing
fill(null) createEmpty: true (default) emits empty windows with a null value column
fill(previous) fill(usePrevious: true) after the window not an aggregateWindow argument — a separate step
fill(0) fill(value: 0.0) after the window value type must match the column (0.0 for floats)
tz('Europe/London') option location = timezone.location(name: "Europe/London") governs where day/hour boundaries fall

Solution walkthrough

1. Translate the core interval, function, and timestamp position

Start with the simplest continuous query and get the timestamp right before touching anything else. Here is a representative 1.x CQ that produced 5-minute zone-temperature means:

sql
CREATE CONTINUOUS QUERY "cq_zone_temp_5m" ON "bms"
BEGIN
  SELECT mean("value") AS "value"
  INTO "bms"."autogen"."zone_temp_5m"
  FROM "bms"."autogen"."zone_temp"
  GROUP BY time(5m), "device_id"
END

The direct Flux translation reads the raw bucket, filters the measurement, and applies aggregateWindow with a matching interval and function:

flux
from(bucket: "bms-raw")
    |> range(start: -1h)
    |> filter(fn: (r) => r._measurement == "zone_temp" and r._field == "value")
    |> aggregateWindow(every: 5m, fn: mean, timeSrc: "_start", createEmpty: false)
    |> to(bucket: "bms-5m")

The load-bearing parameter is timeSrc: "_start". InfluxQL stamps each aggregated point with the start of its window; Flux’s aggregateWindow defaults to timeSrc: "_stop", stamping the end. Leave the default in place and every rolled-up point moves forward by one interval — 5 minutes here — so a dashboard overlaid on the 1.x archive shows a step discontinuity precisely at the migration cut-over. createEmpty: false reproduces fill(none), discussed next. Grouping in InfluxQL by a tag such as device_id needs no explicit clause: Flux preserves tag columns through the pipeline, so per-device windows fall out naturally as long as you do not group() them away before aggregating.

2. Reproduce fill() semantics exactly

fill() is where most migrations quietly change their output, because InfluxQL’s default (fill(null)) and Flux’s default (createEmpty: true) both emit rows for empty windows, but the two other common modes — previous and a constant — are not aggregateWindow arguments at all. They are separate downstream operations.

flux
import "timezone"

// fill(previous): carry the last known reading across a gap
from(bucket: "bms-raw")
    |> range(start: -3h)
    |> filter(fn: (r) => r._measurement == "co2" and r._field == "ppm")
    |> aggregateWindow(every: 15m, fn: mean, timeSrc: "_start", createEmpty: true)
    |> fill(usePrevious: true)

// fill(0): substitute a constant for empty windows (kW that should read zero when idle)
from(bucket: "bms-raw")
    |> range(start: -3h)
    |> filter(fn: (r) => r._measurement == "chiller" and r._field == "kw")
    |> aggregateWindow(every: 15m, fn: mean, timeSrc: "_start", createEmpty: true)
    |> fill(value: 0.0)

Two rules make this correct. First, fill() only has rows to operate on if the window emitted them, so createEmpty must be true (the default) whenever you intend to carry-forward or substitute — set it to false and the empty windows never exist, leaving nothing to fill. Second, fill(value:) is strictly type-checked: a float column needs 0.0, not 0, or the task fails at plan time. For a CO2 sensor that genuinely stopped reporting, prefer leaving the null visible over usePrevious, which fabricates a flat line that hides the outage — the same judgement that governs choosing a fallback strategy in cron and interval scheduling logic when a window arrives empty.

3. Port offsets and timezone boundaries with location

The trickiest 1.x queries carried an offset — GROUP BY time(1h, 15m) — and a timezone — tz('Europe/London'). In InfluxDB 2.x these are two distinct mechanisms. The numeric offset maps directly to aggregateWindow’s offset parameter; the timezone maps to a task-level location option that shifts where hour and day boundaries fall relative to UTC.

flux
import "timezone"

option location = timezone.location(name: "Europe/London")

option task = {name: "bms_hourly_rollup", every: 1h, offset: 5m}

from(bucket: "bms-raw")
    |> range(start: -task.every)
    |> filter(fn: (r) => r._measurement == "vav_damper" and r._field == "percent")
    |> aggregateWindow(every: 1h, offset: 15m, fn: mean, timeSrc: "_start", createEmpty: false)
    |> to(bucket: "bms-5m")

Do not confuse the two offset values. The offset: 5m inside option task delays when the task runs, giving late BACnet batches time to land before the rollup reads them. The offset: 15m inside aggregateWindow shifts the window edges themselves, reproducing the second argument of GROUP BY time(1h, 15m) so hourly buckets break at :15 past the hour rather than on the hour. Setting option location makes those boundaries follow local wall-clock time, so a daily rollup starts at local midnight and self-corrects across the spring and autumn daylight-saving transitions — the behaviour tz() gave you in 1.x. Without the location option, Flux windows on UTC, and every boundary in a non-UTC building drifts by the local offset, which is the single most common cause of “the hourly numbers don’t line up with the old system.” The broader migration walkthrough covers wiring these translated queries into a durable task in migrating legacy continuous queries to InfluxDB 2.x tasks.

Gotchas and edge cases

The _stop default silently shifts every timestamp by one interval. This is the failure that survives code review because the query runs and produces plausible numbers. A 5-minute mean that InfluxQL stamped at 10:00 gets stamped at 10:05 by default in Flux. When you plot the migrated series against the retained 1.x archive, the two are offset by exactly one window and correlations against other measurements break. Always set timeSrc: "_start" when the goal is byte-for-byte continuity with 1.x output, and only omit it when starting a fresh series where end-stamping is acceptable.

createEmpty: true combined with mean produces null-valued rows that break downstream math. InfluxQL fill(null) and Flux createEmpty: true both emit a row for an empty window, but that row’s value column is null. A later |> map() that multiplies kW by a tariff, or a sum across zones, will propagate or reject those nulls depending on the operation. If nothing downstream tolerates nulls, use createEmpty: false (matching fill(none)) so empty windows vanish entirely, or fill them deliberately with usePrevious/value as in Step 2 — never leave the choice implicit.

A fixed UTC offset instead of an IANA name freezes daylight saving. Reaching for tz('+00:00')-style thinking and hard-coding a UTC offset (or worse, adding a constant duration shift) makes summer readings wrong by an hour for half the year. Use timezone.location(name: "Europe/London") so the region’s DST rules apply automatically; the location option is evaluated per window, so boundaries stay anchored to local midnight across the transition dates without any manual correction.

Verification

Prove the translated query stamps windows where the 1.x query did, and that boundaries land on local time. First, confirm the timestamp position by comparing a known window against the retained archive:

flux
import "timezone"
option location = timezone.location(name: "Europe/London")

from(bucket: "bms-5m")
    |> range(start: 2026-02-24T00:00:00Z, stop: 2026-02-24T01:00:00Z)
    |> filter(fn: (r) => r._measurement == "zone_temp" and r._field == "value")
    |> keep(columns: ["_time", "_value", "device_id"])
    |> sort(columns: ["_time"])
    |> limit(n: 3)

The first _time must read 00:00:00, not 00:05:00 — a 00:05 first row means timeSrc reverted to _stop. Then count rolled-up points against the raw source to confirm no windows were dropped or duplicated by an offset error:

flux
raw = from(bucket: "bms-raw")
    |> range(start: -1h)
    |> filter(fn: (r) => r._measurement == "zone_temp" and r._field == "value")
    |> aggregateWindow(every: 5m, fn: count, timeSrc: "_start", createEmpty: false)
    |> group()
    |> sum()
    |> findRecord(fn: (key) => true, idx: 0)

A raw-window count that matches the number of rows written to bms-5m for the same hour confirms the interval and offset are aligned; a shortfall points to an offset value that pushed a boundary past your range() bounds.

FAQ

Does aggregateWindow need an explicit window() call like InfluxQL sub-queries did?

No. aggregateWindow fuses the windowing, aggregation, and un-windowing into one call, so it replaces the GROUP BY time() plus the implicit InfluxQL grouping in a single step. Reach for the separate window() and duplicate() primitives only when you need behaviour aggregateWindow cannot express, such as overlapping windows or custom boundary handling.

How do I translate GROUP BY time(5m) with multiple aggregate columns?

InfluxQL let one query select mean("temp"), max("temp") in a single GROUP BY time(). In Flux, run aggregateWindow once per function — a mean pipeline and a max pipeline — then union() or join() the results, or write each to its own field. Each pass keeps its own fn while sharing the same every, offset, and timeSrc, so the windows stay perfectly aligned.

Why does my count differ between InfluxQL and Flux for the same window?

Almost always a boundary or fill difference. InfluxQL fill(null) created rows Flux drops under createEmpty: false, or the timeSrc/offset mismatch moved points into an adjacent window. Align createEmpty with the original fill() and set timeSrc: "_start", then re-count; the two engines agree once the boundaries and empty-window handling match.

Up: Continuous Query Migration to Tasks