---
title: "aggregateGroupByDuration vs aggregateGroupByPeriod: fixed buckets or calendar buckets"
canonical: "https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period"
cluster: "Health Connect API"
primary_query: "aggregateGroupByDuration vs aggregateGroupByPeriod"
last_reviewed: "2026-10-04"
description: "Duration buckets are fixed lengths of time; Period buckets follow the calendar. When to use each, the LocalDateTime rule, DST and sparse results."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"aggregateGroupByDuration vs aggregateGroupByPeriod: fixed buckets or calendar buckets\", AIFitnessAPI, https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period"
---

# aggregateGroupByDuration vs aggregateGroupByPeriod: fixed buckets or calendar buckets

> Use aggregateGroupByDuration when every bucket should be the same length of physical time, such as each minute or hour, and aggregateGroupByPeriod when buckets follow the calendar, such as days, weeks or months. Google's reference says a Period day may be 23, 24 or 25 hours long because of daylight saving time, while a Duration slice is fixed. Period requests need a LocalDateTime TimeRangeFilter, because an Instant range causes an IllegalStateException, and both methods return a sparse list that leaves out buckets with no data.

- Canonical: https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period
- Last reviewed: 2026-10-04
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "aggregateGroupByDuration vs aggregateGroupByPeriod: fixed buckets or calendar buckets", AIFitnessAPI, https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period

---

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](https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient), the [`AggregateGroupByDurationRequest`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/request/AggregateGroupByDurationRequest) and [`AggregateGroupByPeriodRequest`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/request/AggregateGroupByPeriodRequest) references, their result classes, the [`TimeRangeFilter` reference](https://developer.android.com/reference/kotlin/androidx/health/connect/client/time/TimeRangeFilter) and the [aggregate data guide](https://developer.android.com/health-and-fitness/health-connect/aggregate-data), all fetched October 4, 2026. How `aggregate` itself works, including deduplication, is on the [AggregateRequest page](/health-connect-api/aggregate-request).

## The two methods side by side

```kotlin
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

```kotlin
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

```kotlin
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 `Instant` range with a Period.** The aggregate guide: "When running bucket aggregates per period, make sure that the start and end times set are not using the `Instant` class. Otherwise, the app returns an `IllegalStateException`."
- **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. Use `Period.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, `zoneOffset` is 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](/architecture/timezones-and-day-boundaries), with a tested implementation in the [day-boundary rollup recipe](/cookbook/day-boundary-rollup).

## FAQ

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

[Permalink](https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period#faq-1)

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

[Permalink](https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period#faq-2)

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

[Permalink](https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period#faq-3)

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

[Permalink](https://aifitnessapi.com/health-connect-api/aggregategroupbyduration-vs-period#faq-4)
