aggregateGroupByDuration vs aggregateGroupByPeriod: fixed buckets or calendar buckets
Last verified October 4, 2026 · 4 min read
Both methods return one aggregate per bucket instead of one for the whole window. They differ in what a bucket is, and picking the wrong one produces charts that are off by an hour twice a year. Sources: the HealthConnectClient reference, the AggregateGroupByDurationRequest and AggregateGroupByPeriodRequest references, their result classes, the TimeRangeFilter reference and the aggregate data guide, all fetched October 4, 2026. How aggregate itself works, including deduplication, is on the AggregateRequest page.
The two methods side by side#
suspend fun aggregateGroupByDuration(request: AggregateGroupByDurationRequest): List<AggregationResultGroupedByDuration>
suspend fun aggregateGroupByPeriod(request: AggregateGroupByPeriodRequest): List<AggregationResultGroupedByPeriod>
Google describes each as "similar to aggregate but instead of returning one AggregationResult for the entire query's time interval, it returns a list" of rows, "each row keyed by start and end time." Its examples: "steps for today bucketed by hours" for Duration, "steps for this month bucketed by day" for Period. Both lists are "sorted by time in ascending order."
aggregateGroupByDuration | aggregateGroupByPeriod | |
|---|---|---|
| Slicer type | java.time.Duration | java.time.Period |
| Google's description | "fixed physical time intervals, such as per hour, per ten minutes or so" | "variable length time intervals, such as per day, which may be anywhere between 23, 24, or 25 hour" |
TimeRangeFilter | Instant or LocalDateTime pairs (aggregate guide) | LocalDateTime only; Instant causes an IllegalStateException |
Row startTime / endTime | Instant | LocalDateTime |
| Row zone information | zoneOffset | none |
| Reference label | "Added in 1.1.0" | "Added in 1.1.0" |
Each request class's own description points at the other: the Duration request says to "Prefer AggregateGroupByPeriodRequest, if you would like variable length time intervals, such as per day, which may or may not include DST (23 or 25 hour)."
Both constructors take the same four parameters: metrics, timeRangeFilter, timeRangeSlicer and dataOriginFilter (default emptySet()). For both, timeRangeSlicer is "The bucket size of each returned aggregate row. timeRangeFilter will be sliced into several equal-sized time buckets (except for the last one)."
Daily totals with a Period#
suspend fun dailySteps(client: HealthConnectClient, firstDay: LocalDate, days: Long) {
val rows = client.aggregateGroupByPeriod(
AggregateGroupByPeriodRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL),
timeRangeFilter = TimeRangeFilter.between(
firstDay.atStartOfDay(),
firstDay.plusDays(days).atStartOfDay()
),
timeRangeSlicer = Period.ofDays(1)
)
)
val byDay = rows.associate { it.startTime.toLocalDate() to it.result[StepsRecord.COUNT_TOTAL] }
// A day missing from byDay had no step records: show it as "no data".
}
LocalDate.atStartOfDay() gives the LocalDateTime values the Period form requires.
Hourly totals with a Duration#
suspend fun hourlySteps(client: HealthConnectClient, start: Instant, end: Instant) {
val rows = client.aggregateGroupByDuration(
AggregateGroupByDurationRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
timeRangeSlicer = Duration.ofHours(1)
)
)
for (row in rows) {
val localStart = row.startTime.atOffset(row.zoneOffset) // label in the wearer's local time
val steps = row.result[StepsRecord.COUNT_TOTAL]
}
}
zoneOffset exists for exactly this. Google: it is the "zoneOffset of underlying record within the slice. If underlying records have mixed ZoneOffset, the first one is returned. Use this to render result in user local time and handle scenarios involving Day Light Savings, such as "hourly steps on a given date"."
What LocalDateTime means here#
The TimeRangeFilter reference distinguishes the two time types. An Instant is "a specific point in time such as "2021-01-03 at 10:00 UTC+1"". A LocalDateTime is "a user experienced time concept such as "2021-01-03 at 10 o'clock", without knowing which time zone the user was at that time." It adds: "Record without specifying zoneOffset will assume the current system zone offset at query time."
The reference also shows why an Instant range is not the same as a local day for someone who travels: "If user created a Record at 2pm(UTC+1), crossed a time zone and created a new Record at 3pm(UTC). Filtering between 2pm(UTC) and 6pm(UTC) will include the record at 3pm(UTC) but not the record at 2pm(UTC+1), because 2pm(UTC+1) happened before 2pm(UTC)."
Pitfalls#
- An
Instantrange with a Period. The aggregate guide: "When running bucket aggregates per period, make sure that the start and end times set are not using theInstantclass. Otherwise, the app returns anIllegalStateException." - Expecting a row for every bucket. The guide says "the list can be sparse, so a bucket is not included in the list if it doesn't contain any data." Build the full list of expected buckets yourself and fill gaps as "no data".
Duration.ofDays(1)as a calendar day. A Duration is fixed physical time. On the days the clocks change, Google's own description says a calendar day runs 23 or 25 hours, so 24-hour slices drift away from midnight. UsePeriod.ofDays(1).- A short last bucket. Slices are equal-sized "except for the last one". If the range is not a whole number of slices, the final row covers less time than the others.
- Records that straddle a boundary. Google does not document how a record spanning two buckets, such as a 30-minute steps record across the top of an hour, is divided between them. Test your boundaries against known data before relying on per-bucket values.
- Mixed offsets in one slice. When a Duration slice holds records written with different offsets,
zoneOffsetis the first one, per the reference.
Choosing where a user's day starts, especially across travel, is a design decision of its own, covered in timezones and day boundaries, with a tested implementation in the day-boundary rollup recipe.
Frequently asked questions
- Should I use aggregateGroupByDuration or aggregateGroupByPeriod for daily step totals?
- aggregateGroupByPeriod with Period.ofDays(1). Google's reference describes a Period slice as a variable-length interval such as a day, which may be 23, 24 or 25 hours long, and the Duration request's own description says to prefer the Period request for per-day intervals that may include a daylight saving change. A 24-hour Duration is fixed physical time, so it drifts off midnight whenever the clocks change.
- Why does aggregateGroupByPeriod throw an IllegalStateException?
- The most likely cause is an Instant time range. Google's aggregate guide says that when running bucket aggregates per period, the start and end times must not use the Instant class, or the app gets an IllegalStateException. Build the TimeRangeFilter from LocalDateTime values instead, for example with LocalDate.atStartOfDay(). The read guide lists the same mistake as an example of a request that is not a valid construction.
- Why are some days missing from my aggregateGroupByPeriod results?
- Because the result list is sparse. Google's aggregate guide says a bucket is not included in the list if it does not contain any data, and the reference says a row is returned only if there are records to aggregate within its start and end time. Generate the full sequence of days you expect, then fill the days with no row as no data rather than zero.
- What is the zoneOffset on an AggregationResultGroupedByDuration?
- It is the offset of the records inside that slice. Google's reference describes it as the zone offset of the underlying record within the slice, returning the first one if the records have mixed offsets, and says to use it to render the result in the user's local time and to handle daylight saving cases such as hourly steps on a given date. Period results carry LocalDateTime start and end times and no offset.
Keep reading
Next steps
Was this page useful?
Independent comparison, last reviewed October 4, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All health connect api · by AIFitnessAPI