Skip to content

Groups & windows — many rows into one

The question for this lesson: every node so far has taken one row in and emitted one row out. But often you want a summary: total sales, a count per status, clicks per hour. That means turning many rows into one. How do you do that?

You use an aggregate node, and you tell it how to group the rows.

  • Use an aggregate node with group_by to collapse many rows into a summary.
  • Pick the right grouping: one summary overall, or one row per group.
  • Bucket time-stamped rows into a tumbling time window.

An aggregate collapses rows into a summary

Section titled “An aggregate collapses rows into a summary”

A transform reshapes each row on its own. An aggregate is different: it gathers many rows into a group and emits one row per group, using aggregate functions like count(*), sum(...), avg(...), min(...), and max(...).

The group_by setting decides what a “group” is:

  • group_by: []: an empty list means one group: everything. You get a single summary row.
  • group_by: [status]: one group per distinct status. You get one row for each.
- type: aggregate
name: summary
input: customers
config:
group_by: [] # one summary over all rows
cxl: |
emit customers = count(*) # how many rows came in
emit total_value = sum(lifetime_value.to_int())
🌱 New here? — count(*) and sum(...)

count(*) counts the rows in the group. sum(lifetime_value.to_int()) adds up a number across the group, with .to_int() first because lifetime_value is a declared string (the same text-to-number step from lesson 02). avg, min, and max work the same way.

This aggregate has group_by: [], so it folds all the customers into a single row. Predict the two numbers, then run.

pipeline.yaml // editable

One row out, no matter how many rows came in: that’s what group_by: [] means. customers is count(*) = 3; total_value is the sum, 80000 + 1200 + 4000 = 85200.

Now group instead of folding everything together. In the runner above, change the aggregate to group by status and carry that column through:

group_by: [status] # one group per distinct status
cxl: |
emit status = status # the grouping key, kept in the output
emit customers = count(*)
emit total_value = sum(lifetime_value.to_int())

Now author one from scratch. The question: per status, how many customers, and what is their average lifetime value? Start from the per-status aggregate above and swap the total for an average (avg is built in, like sum).

- type: aggregate
name: by_status
input: customers
config:
group_by: [status]
cxl: |
emit status = status # keep the grouping key
emit customers = count(*)
emit avg_value = avg(lifetime_value.to_int()) # avg, not sum

Over the same three customers, (active, 80000), (inactive, 1200), (active, 4000), that gives:

status,customers,avg_value
active,2,42000
inactive,1,1200

active averages (80000 + 4000) / 2 = 42000; inactive has just the 1200. Check your own with clinker run your_pipeline.yaml --dry-run (config + types) and --explain (the plan) before you run it for the numbers.

Sometimes the group you want is a slice of time: clicks per hour, sales per day. When your rows carry a timestamp, add a time_window to the aggregate. A tumbling window chops time into back-to-back, non-overlapping buckets (every hour, on the hour) and makes each bucket a group.

Here is Clinker’s real tumbling_clicks example, per-user clicks counted per hour:

- type: source
name: clicks
config:
name: clicks
type: csv
path: ./data/clicks.csv
options: { has_header: true }
watermark:
column: event_ts # which column carries event time
schema:
- { name: user_id, type: string }
- { name: event_ts, type: date_time }
- { name: kind, type: string }
- type: aggregate
name: hourly_clicks
input: clicks
config:
group_by: [user_id] # group by user AND by the time bucket below
time_window:
tumbling: { size: 1h } # one bucket per hour
cxl: |
emit user_id = user_id
emit n = count(*)

A user who clicks five times between 09:00 and 10:00 and twice between 10:00 and 11:00 produces two output rows: n = 5 for the 09:00 bucket and n = 2 for the 10:00 bucket:

user_id,n
u1,5
u1,2
🌱 New here? — watermark

A time window needs to know which column is the event time, so the source declares a watermark: { column: event_ts }. Without a watermark on the source, Clinker has no clock to bucket by, and a time_window won’t work. The column must be a date_time (or date).

Other window shapes (same idea, different slicing, and you’ll reach for these later): hopping windows overlap (e.g. a 1-hour window that advances every 15 minutes, so each row lands in several windows), and session windows group bursts of activity separated by a gap of idleness. Tumbling is the one to start with.