Streaming
Streaming in Pondra is SQL that keeps running. A materialized view is a query the node keeps
up to date as rows arrive, in the same commit as the rows, so a read never lags behind the
table. Windows and sessions are EMIT FINAL on the same statement, and stream joins and as-of joins
its options. Use this page when you’d otherwise reach for a Flink job.
Keep a query up to date
Section titled “Keep a query up to date”Make a table with a few rows in it. A view can start from rows that are already there.
CREATE TABLE clicks (user VARCHAR, page VARCHAR, ts TIMESTAMP, amount BIGINT);INSERT INTO clicks VALUES ('ann', 'home', '2026-09-29 10:00:05', 1), ('bob', 'home', '2026-09-29 10:00:40', 2), ('ann', 'cart', '2026-09-29 10:01:10', 3);CREATE MATERIALIZED VIEW IF NOT EXISTS spend AS SELECT user, count(*) AS n, sum(amount) AS total FROM clicks GROUP BY user;import pondradb = pondra.connect("http://localhost:8080")db.view("spend", "SELECT user, count(*) AS n, sum(amount) AS total FROM clicks GROUP BY user", materialized=True)import { connect } from "pondra";const db = connect("http://localhost:8080");await db.view("spend", "SELECT user, count(*) AS n, sum(amount) AS total FROM clicks GROUP BY user", { materialized: true });Now write more rows. The view already has them when the write is acknowledged:
INSERT INTO clicks VALUES ('ann', 'checkout', '2026-09-29 10:02:00', 5);SELECT * FROM spend ORDER BY user;user n totalann 3 9bob 1 2A view without GROUP BY (a filter, a projection, a join) appends its rows to its own table. A
view with GROUP BY keeps one running row per group, and any number of nodes add to it at once.
A view of a filter or a projection also follows an UPDATE or DELETE of its table: a row that
changes is changed in the view, and a row that no longer matches leaves it (see
Change rows).
A grouped view takes the aggregates you would write by hand: count, sum, avg, min, max,
stddev and variance (and their _samp and _pop forms), bool_and and bool_or, any
expression over them, HAVING, and ORDER BY … LIMIT. Its table keeps running totals as rows
arrive (sums, counts, and a variance’s moments), and each read works the answers out from them, so
they are as current as the rows:
CREATE MATERIALIZED VIEW IF NOT EXISTS habits AS SELECT user, count(*) AS n, avg(amount) AS mean, stddev_pop(amount) AS spread FROM clicks GROUP BY user HAVING count(*) > 1;SELECT * FROM habits;user n mean spreadann 3 3.0 1.632993161855452Medians and distinct counts: kept by key
Section titled “Medians and distinct counts: kept by key”count(DISTINCT …), median, percentiles, string_agg and array_agg need every row of a group,
not running totals. A view of them is kept by key: after each commit, the groups its rows
touched are worked out again from the table, and only those. Nothing is held in memory, and a
write costs a read of its own groups’ rows, not of the table.
CREATE MATERIALIZED VIEW IF NOT EXISTS reach AS SELECT page, count(DISTINCT user) AS users, median(amount) AS typical FROM clicks GROUP BY page;SELECT * FROM reach ORDER BY page;page users typicalcart 1 3.0checkout 1 5.0home 2 1.5Such a view is current a moment after the commit, not in it: read the table when you need the
answer as of your own write. WITH (lag = '1 minute') works it out at most once a minute, and
WITH (refresh = 'by key') keeps any grouped view this way. It reads one table, with no join or
subquery, and groups by columns it selects.
Anything else: run whole
Section titled “Anything else: run whole”Every other query is a materialized view too: a top N (ORDER BY … LIMIT), a window function,
DISTINCT, a subquery over its own table, a median across a join, or a filter on now(). Its query
runs whole again after a commit changes a table it reads, at most so often that its runs take a
tenth of the time (a query of 50 ms is current within half a second; one of 10 s, within 100 s).
A view that calls now(), or reads another lake or files, also runs every minute, or every lag.
CREATE MATERIALIZED VIEW IF NOT EXISTS biggest AS SELECT user, page, amount FROM clicks ORDER BY amount DESC LIMIT 2;SELECT * FROM biggest ORDER BY amount DESC;user page amountann checkout 5ann cart 3A row that comes out the same stays as it is, so the view’s change feed
shows only what moved. pondra.flows says how each view is kept (refresh: incremental,
by key or full), why (reason), and which tables it follows. WITH (refresh = 'full') runs any
view this way; WITH (refresh = 'incremental') refuses a view the rows alone can’t keep.
From a frame
Section titled “From a frame”A frame becomes a view the same way:
from pondra import colby_page = db.table("clicks").group_by("page").agg(pondra.len().alias("views"))by_page.to_view("views_by_page", materialized=True)db.sql("SELECT * FROM views_by_page ORDER BY page").rows()Windows: each group once it’s over
Section titled “Windows: each group once it’s over”A grouped view keeps running totals: a group changes with every row that arrives. Add EMIT FINAL
and the view keeps each group only once it is over, when event time has moved past it. Group by a
time bucket of an event-time column: date_bin, date_trunc, a date, or any expression that only
grows as time does.
CREATE TABLE taps (user VARCHAR, ts TIMESTAMP);CREATE MATERIALIZED VIEW per_minute WITH (lateness = '10 seconds') AS SELECT date_bin(INTERVAL '1 minute', ts) AS minute, user, count(*) AS n FROM taps GROUP BY 1, 2 EMIT FINAL;db.view("per_minute", "SELECT date_bin(INTERVAL '1 minute', ts) AS minute, user, count(*) AS n FROM taps GROUP BY 1, 2", materialized=True, lateness="10 seconds", emit="final")import { connect } from "pondra";const db = connect("http://localhost:8080");await db.view("per_minute", "SELECT date_bin(INTERVAL '1 minute', ts) AS minute, user, count(*) AS n FROM taps GROUP BY 1, 2", { materialized: true, lateness: "10 seconds", emit: "final" });lateness is how far out of order rows may arrive. The watermark is the newest event time in
the stream less that lateness, and a minute is over when the watermark passes its end. A row that
arrives before then counts, however out of order it is.
INSERT INTO taps VALUES ('ann', '2026-09-29 10:00:05'), ('ann', '2026-09-29 10:00:35'), ('bob', '2026-09-29 10:00:50'), ('ann', '2026-09-29 10:01:20'), ('bob', '2026-09-29 10:01:40'), ('bob', '2026-09-29 10:02:30');The leader looks for groups that are over twice a second, so give it a moment:
sleep 2SELECT minute, user, n FROM per_minute ORDER BY minute, user;minute user n2026-09-29T10:00:00 ann 22026-09-29T10:00:00 bob 12026-09-29T10:01:00 ann 12026-09-29T10:01:00 bob 1The 10:02 minute isn’t there yet. The newest row is 10:02:30, so the watermark is 10:02:20, before the minute’s end. The view’s query run on its own answers it as it is now, open minute and all:
SELECT date_bin(INTERVAL '1 minute', ts) AS minute, user, count(*) AS nFROM taps GROUP BY 1, 2 ORDER BY 1, 2;A view made over rows already in the table starts from them, and keeps each group of them that is over at once.
Late rows
Section titled “Late rows”A row that arrives after its group was kept is late. It is in the table, and the view keeps what
it had. pondra.flows counts each view’s late rows:
INSERT INTO taps VALUES ('ann', '2026-09-29 10:00:59');sleep 1SELECT name, late_rows FROM pondra.flows WHERE name = 'per_minute';name late_rowsper_minute 1If the count keeps growing, raise lateness: each group is then kept that much later.
When the stream goes quiet
Section titled “When the stream goes quiet”The watermark moves with the rows, so the last group of a stream that stops waits for the next
row. idle moves it on with the clock once no newer row has come for that long:
CREATE MATERIALIZED VIEW per_minute_now WITH (idle = '30 seconds') AS SELECT date_trunc('minute', ts) AS minute, count(*) AS n FROM taps GROUP BY 1 EMIT FINAL;A row that comes for a group kept that way is late.
Sliding windows
Section titled “Sliding windows”A total over the last five minutes, every minute, is a window frame over the minutes kept. Each row is counted once, in its minute, and the frame adds five of them up when it is read:
SELECT minute, user, sum(n) OVER (PARTITION BY user ORDER BY minute RANGE BETWEEN INTERVAL '4 minutes' PRECEDING AND CURRENT ROW) AS last_fiveFROM per_minute ORDER BY minute, user;Session windows
Section titled “Session windows”A session is one key’s rows with no gap longer than an interval between them. Group by the key and
SESSION(ts, INTERVAL …), then add EMIT FINAL. Pondra adds session_start and session_end (the
last row plus the gap) and keeps each session once, whole, when the watermark passes its end.
CREATE TABLE visits (user VARCHAR, ts TIMESTAMP, amount BIGINT);CREATE MATERIALIZED VIEW sessions WITH (lateness = '5 seconds') AS SELECT user, count(*) AS n, sum(amount) AS spent FROM visits GROUP BY user, SESSION(ts, INTERVAL '30 seconds') EMIT FINAL;INSERT INTO visits VALUES ('ann', '2026-09-29 10:00:00', 2), ('ann', '2026-09-29 10:00:10', 2), ('ann', '2026-09-29 10:00:20', 2), ('bob', '2026-09-29 10:00:00', 2), ('bob', '2026-09-29 10:00:25', 2), ('bob', '2026-09-29 10:02:00', 2), ('cy', '2026-09-29 10:05:00', 2);sleep 2SELECT user, n, spent, session_start, session_end FROM sessions ORDER BY user, session_start;user n spent session_start session_endann 3 6 2026-09-29T10:00:00 2026-09-29T10:00:50bob 2 4 2026-09-29T10:00:00 2026-09-29T10:00:55bob 1 2 2026-09-29T10:02:00 2026-09-29T10:02:30Bob’s rows at 10:00:25 and 10:02:00 are more than 30 seconds apart, so they are two sessions. Cy’s row moved the watermark to 10:04:55, which closed everything before it. A row that falls inside a session already kept is late, and is left out. A row behind the watermark whose session isn’t over yet still counts.
Join two streams
Section titled “Join two streams”join = 'streams' pairs a row of either table with the other table’s rows when it arrives and
with those that arrive after it, each pair once, in whichever order they come. within_secs
bounds how far apart in time a pair may be, so only that much of the other table is read.
CREATE TABLE orders (id BIGINT, amount DOUBLE, ts TIMESTAMP);CREATE TABLE payments (order_id BIGINT, paid DOUBLE, ts TIMESTAMP);CREATE MATERIALIZED VIEW paid WITH (join = 'streams', time = 'ts', within_secs = 600) AS SELECT o.id, o.amount, p.paid FROM orders o JOIN payments p ON o.id = p.order_id AND p.ts BETWEEN o.ts AND o.ts + INTERVAL '10 minutes';INSERT INTO payments VALUES (1, 9.5, '2026-09-29 10:00:30');INSERT INTO orders VALUES (1, 9.5, '2026-09-29 10:00:00'), (2, 4.0, '2026-09-29 10:00:10');INSERT INTO payments VALUES (2, 4.0, '2026-09-29 10:03:00'), (3, 1.0, '2026-09-29 10:30:00');sleep 2SELECT * FROM paid ORDER BY id;id amount paid1 9.5 9.52 4.0 4.0Payment 1 arrived before its order and still paired with it. Payment 3 has no order.
Keep the newest row by event time
Section titled “Keep the newest row by event time”A keyed table with SEQUENCE BY keeps, for each key, the row with the latest event time, not the last
row to arrive. This is Flink’s deduplication by event time.
CREATE TABLE latest (k BIGINT PRIMARY KEY, ts BIGINT, v VARCHAR) SEQUENCE BY ts;INSERT INTO latest VALUES (1, 100, 'new');INSERT INTO latest VALUES (1, 50, 'old');SELECT * FROM latest;k ts v1 100 newAs-of joins
Section titled “As-of joins”ASOF JOIN gives each row the other table’s row as it was at that moment: the latest quote at
or before each trade. MATCH_CONDITION takes >=, >, <= or <; a row with no match gets
NULLs.
CREATE TABLE quotes (sym VARCHAR, ts TIMESTAMP, price DOUBLE);CREATE TABLE trades (id BIGINT, sym VARCHAR, ts TIMESTAMP, qty BIGINT);INSERT INTO quotes VALUES ('ABC', '2026-09-29 09:30:00', 10.0), ('ABC', '2026-09-29 09:31:00', 11.0), ('ABC', '2026-09-29 09:32:00', 12.0);INSERT INTO trades VALUES (1, 'ABC', '2026-09-29 09:30:30', 5), (2, 'ABC', '2026-09-29 09:31:45', 2), (3, 'XYZ', '2026-09-29 09:31:45', 2);SELECT t.id, t.qty, q.price, t.qty * q.price AS valueFROM trades t ASOF JOIN quotes q MATCH_CONDITION (t.ts >= q.ts) ON t.sym = q.symORDER BY t.id;id qty price value1 5 10.0 50.02 2 11.0 22.03 2 NULL NULLPut the same query in a materialized view and each new trade is priced as of its own event time, even when it arrives after a newer quote:
CREATE MATERIALIZED VIEW priced AS SELECT t.id, t.qty, q.price, t.qty * q.price AS value FROM trades t ASOF JOIN quotes q MATCH_CONDITION (t.ts >= q.ts) ON t.sym = q.sym;Tasks: any streaming SQL
Section titled “Tasks: any streaming SQL”A view covers filters, joins and aggregates. A task runs any SQL over the new rows of a table whenever some commit, and appends the result to a target table, exactly once. The SQL may read the target too, which is how a task keeps state. Make the target in SQL, keyed or not, then make the task with HTTP:
CREATE TABLE totals (user VARCHAR PRIMARY KEY, total BIGINT, n BIGINT);curl -s -X POST localhost:8080/tasks/running_totals -d '{ "source": "clicks", "target": "totals", "key": ["user"], "sql": "SELECT c.user, coalesce(max(t.total), 0) + sum(c.amount) AS total, coalesce(max(t.n), 0) + count(*) AS n FROM clicks c LEFT JOIN totals t ON c.user = t.user GROUP BY c.user"}'The task runs over each commit’s new rows and writes one row per user to totals, so totals
holds each user’s running total. Make a task before the rows it should see arrive. Add
"shards": 4, "shard_by": "user" to spread the state over the nodes by the hash of a column.
Follow new rows
Section titled “Follow new rows”watch streams a table’s new rows the moment they commit, including a keyed table’s upserts and
deletes. changes adds every UPDATE and DELETE, each row with its _change_type.
import threadingfeed = db.watch("clicks")threading.Timer(0.3, lambda: db.append("clicks", [{"user": "cy", "page": "home", "ts": "2026-09-29T10:03:00", "amount": 4}])).start()print(next(feed))import { connect } from "pondra";const db = connect("http://localhost:8080");const feed = db.watch("clicks");setTimeout(() => db.append("clicks", [{ user: "dee", page: "home", ts: "2026-09-29T10:04:00", amount: 1 }]), 300);console.log((await feed.next()).value);await feed.return();curl -N "localhost:8080/watch/clicks" # new rows, as they commit (NDJSON)curl -N "localhost:8080/watch/clicks?changes=true" # every changecurl -N "localhost:8080/watch/clicks?after=1234" # replay from a position firstA replay with after goes back as far as the node keeps its log: start it with
--changelog-secs 86400 to keep a day.
Live queries
Section titled “Live queries”A live query sends its answer now and again within milliseconds of each commit that changes it. It isn’t sent again when the answer comes out the same, and commits to other tables don’t wake it. Nothing runs on the node once the client goes away.
live = db.live("SELECT count(*) AS n FROM clicks")print(next(live)) # the answer nowdb.append("clicks", [{"user": "eve", "page": "cart", "ts": "2026-09-29T10:05:00", "amount": 2}])print(next(live)) # and again after the commitlive.close()import { connect } from "pondra";const db = connect("http://localhost:8080");const live = db.live("SELECT count(*) AS n FROM clicks");console.log((await live.next()).value);await db.append("clicks", [{ user: "fay", page: "cart", ts: "2026-09-29T10:06:00", amount: 2 }]);console.log((await live.next()).value);await live.return();In an app, loop over it: for rows in db.live(sql): redraw(rows), or
for await (const rows of db.live(sql)) in JavaScript. The Postgres port can’t push an answer, so
it has no live queries.
What streaming doesn’t do yet
Section titled “What streaming doesn’t do yet”- There is one watermark per source table, not per partition or node. A source that goes quiet
holds it: its last sessions wait for more rows (a window view’s
idlemoves it on). - Top-N by event time, timers and
MATCH_RECOGNIZEdon’t exist. - A window view’s late rows are counted (
pondra.flows’late_rows), not kept apart; there’s no side output for them. A session view leaves its late rows out without counting them yet.