Building a training set for a windowed aggregation feature reads every event inside each spine row’s window. The work grows with both the number of spine rows and the number of events per window.

For a feature defined with a materialized aggregation, Chalk can split historical events into fixed-width buckets, store a partial aggregate for each bucket as a tile, and compute each spine row’s value by merging the tiles that cover its window.

​
Building a training set from tiles

A training-set query for a materialized aggregation uses the planner options described in Offline queries. The examples on this page use the User.total_transaction_amount feature from Materialized Windowed Aggregations, which has 1d, 7d, and 30d windows and one-day buckets.

  1. Write tiles to offline storage that cover your spine’s input times and the windows before them. Run chalk aggregate backfill with --store-offline, or set up a scheduled backfill that targets offline storage:

    chalk aggregate backfill \
      --feature user.total_transaction_amount \
      --store-offline \
      --lower-bound 2025-01-01T00:00:00Z \
      --upper-bound 2026-04-14T00:00:00Z
    
  2. Query with use_tile_store, use_materialized_offline_query, and recompute_features=True:

    from datetime import datetime, timezone
    
    from chalk.client import ChalkClient
    
    client = ChalkClient()
    
    ds = client.offline_query(
        input={"user.id": [1, 2, 3]},
        input_times=[datetime(2026, 4, 1, tzinfo=timezone.utc)] * 3,
        output=[
            "user.total_transaction_amount__604800__",
            "user.total_transaction_amount__2592000__",
        ],
        recompute_features=True,
        planner_options={"use_tile_store": True, "use_materialized_offline_query": True},
    )
    

The tiles must cover each row’s whole window, not only its input time. For example, tiles from 2026-03-30 to 2026-04-01 can answer the 1d window at an input time of 2026-04-01, but not the 7d window. If the tiles do not cover a requested window, the query fails. For an aggregation with a continuous buffer, the query aggregates the trailing buffer span from raw events and the rest of the window from tiles. See Reading tiles from offline storage.

​
When bucketing helps

With use_materialized_offline_query, with or without use_tile_store, each row is computed by merging buckets. The two differ in where the buckets come from.

​
Backfills over keys with many events

Consider merchant.count_transactions_last_7d on a historical spine where some merchants have millions of transactions per day. With the default planner, each spine row for such a merchant aggregates every transaction in the seven days before its timestamp. Both the number of spine rows and the number of events per window grow with the merchant’s activity, so the work grows roughly with their product.

With use_materialized_offline_query, Chalk aggregates each merchant’s events into buckets once per query, and each row merges at most window / bucket_duration buckets, rounded up. Rows for the same merchant whose bounds fall in the same buckets are computed once. With use_tile_store, Chalk reads those buckets from tiles instead of aggregating the raw events.

​
Adding a new window to an existing aggregation

With use_tile_store, a "14d" window added to a feature that already declares "7d" and "30d" reads the tiles already written for those windows, as long as they cover its range, so it needs no new backfill. A new aggregation needs its own backfill. See Reading tiles from offline storage.

​
Trade-off: bucket granularity coarsens accuracy

Tiles are aligned on fixed bucket boundaries (see bucket_duration in MaterializationWindowConfig). When an offline query requests a window that does not fall exactly on a bucket boundary, Chalk must decide how to treat the partial bucket at the leading or trailing edge of the window. With use_materialized_offline_query=True, Chalk aggregates whole buckets, from the one that contains the window’s lower bound up to, but not including, the one that contains its upper bound, which means:

  • The bucket that contains the lower bound is included in full, so events from before the lower bound can be counted.
  • The bucket that contains the upper bound is left out in full, so events from before the upper bound can be missed.
  • The effective window for a row starts and ends on bucket boundaries, so it can differ from the literal window_interval requested at both ends.
  • Two spine rows whose shifted bounds fall in the same buckets see identical aggregate values. Without offsets, that means rows whose input_time falls in the same bucket.

Shorter buckets bring each row’s effective window closer to the requested window, at the cost of more tiles to store and merge.

To match how the online store rounds instead, use align_offline_chalk_window_with_materialization, described in Offline queries.

​
Worked example: which buckets a row uses

This example uses a feature with a three-hour window and one-hour buckets. One user has eight transactions on 2026-10-20 (UTC), and each amount is a power of two, so every sum identifies exactly which transactions were included.

Time (UTC)09:1009:4010:2011:0511:5012:1012:2012:40
Amount1248163264128
from datetime import datetime, timezone

from chalk import DataFrame, Windowed, offline, windowed
from chalk.features import _, features


@features
class Transaction:
    id: int
    amount: int
    user_id: "User.id"
    timestamp: datetime


@features
class User:
    id: int
    transactions: DataFrame[Transaction]
    spend: Windowed[int] = windowed(
        "3h",
        materialization={"bucket_duration": "1h"},
        expression=_.transactions[
            _.amount,
            _.timestamp <= _.chalk_now,
            _.timestamp > _.chalk_window,
        ].sum(),
        default=0,
    )


EVENTS = [
    ("09:10", 1), ("09:40", 2), ("10:20", 4), ("11:05", 8),
    ("11:50", 16), ("12:10", 32), ("12:20", 64), ("12:40", 128),
]


@offline
def get_transactions(
    uid: User.id,
) -> DataFrame[Transaction.id, Transaction.user_id, Transaction.amount, Transaction.timestamp]:
    rows = []
    for i, (hhmm, amount) in enumerate(EVENTS, start=1):
        hour, minute = map(int, hhmm.split(":"))
        rows.append(
            Transaction(
                id=i,
                user_id=uid,
                amount=amount,
                timestamp=datetime(2026, 10, 20, hour, minute, tzinfo=timezone.utc),
            )
        )
    return DataFrame(rows)

Query the feature at four times, once with the default planner and once with use_materialized_offline_query:

from datetime import datetime, timezone

from chalk.client import ChalkClient

client = ChalkClient()
times = [
    datetime(2026, 10, 20, 12, 34, tzinfo=timezone.utc),
    datetime(2026, 10, 20, 12, 59, tzinfo=timezone.utc),
    datetime(2026, 10, 20, 12, 0, tzinfo=timezone.utc),
    datetime(2026, 10, 20, 12, 1, tzinfo=timezone.utc),
]

for planner_options in [None, {"use_materialized_offline_query": True}]:
    ds = client.offline_query(
        input={"user.id": [1] * len(times)},
        input_times=times,
        output=["user.spend__10800__"],
        recompute_features=True,
        planner_options=planner_options,
    )
    print(ds.get_data_as_pandas()["user.spend__10800__"].tolist())
Query timeExact windowExact sumBuckets mergedMaterialized sum
12:3409:34 to 12:3412609:00, 10:00, 11:0031
12:5909:59 to 12:5925209:00, 10:00, 11:0031
12:0009:00 to 12:003109:00, 10:00, 11:0031
12:0109:01 to 12:013109:00, 10:00, 11:0031

At 12:34, the lower bound is 09:34 and the upper bound is 12:34. Aligned down to the hour, they are 09:00 and 12:00. Chalk merges the 09:00, 10:00, and 11:00 buckets. The 09:00 bucket includes the 09:10 transaction, which happened before the lower bound, and the 12:00 bucket is left out, so the 12:10 and 12:20 transactions are not counted even though they happened before the upper bound. The sum is 1 + 2 + 4 + 8 + 16 = 31. All four query times fall in the 12:00 hour, so they use the same buckets and get the same value.

​
Offsets

A bound that is offset from the query time is shifted before it is aligned. This feature leaves out the most recent hour by comparing against _.chalk_now - timedelta(hours=1). Add it to the User class above:

from datetime import timedelta

lagged_spend: Windowed[int] = windowed(
    "3h",
    materialization={"bucket_duration": "1h"},
    expression=_.transactions[
        _.amount,
        _.timestamp < _.chalk_now - timedelta(hours=1),
        _.timestamp >= _.chalk_window,
    ].sum(),
    default=0,
)

At 12:34, the bounds are 09:34 and 11:34, which align to 09:00 and 11:00. Chalk merges the 09:00 and 10:00 buckets, so the sum is 1 + 2 + 4 = 7. The exact window from 09:34 to 11:34 sums to 14. At 12:59, the bounds are 09:59 and 11:59, which align to the same buckets, so the materialized sum is again 7.