Quickstart | Tutorials | | Documentation
SQLFlow is a stream processing engine that lets you define pipelines with just SQL. It consumes a stream, runs DuckDB SQL over each batch, and writes the result out. Think of it as a lightweight, single-binary Flink.
- Sources: Kafka, WebSockets, webhooks.
- Sinks: Kafka, ClickHouse, Iceberg, the console, or anything DuckDB can
COPYto (PostgreSQL, S3, parquet, MotherDuck, DuckLake). - Built on DuckDB and Apache Arrow: ~900k messages/sec on a laptop, in about a quarter GiB of memory.
- One Go binary, one Docker image, one YAML file per pipeline. No cluster.
- Coverage and invariant matrix: what is tested, and separately, what is proven — regenerated from the suites on every push.
One click runs SQLFlow on Render: a webhook that accepts metrics, a one-minute
aggregation into Postgres, and an HTTP API that reads them back. It asks for
one secret and is a paid deploy. See render/.
- Build a binary (see Installation for prebuilt binaries and Docker):
make sqlflow
- Validate a pipeline against test data, without a broker.
dev invokeruns the config's handler over a JSONL fixture and prints the result:
./bin/sqlflow dev invoke dev/config/examples/basic.agg.mem.yml dev/fixtures/simple.json
{"city":"New York","city_count":28672}
{"city":"Baltimore","city_count":28672}
- Start Kafka locally:
docker-compose -f dev/kafka-single.yml up -d
- Publish test messages. The publisher is a Python script. uv reads
uv.lockand builds the environment on first run:
uv run python cmd/publish-test-data.py --num-messages=10000 --topic="input-simple-agg-mem"
- Start a Kafka consumer to watch the output:
docker exec -it kafka1 kafka-console-consumer --bootstrap-server=kafka1:9092 --topic=output-simple-agg-mem
- Run the pipeline:
./bin/sqlflow run -c dev/config/examples/basic.agg.mem.yml --max-msgs=10000
The consumer prints one row per city:
{"city":"San Francisco","city_count":177}
{"city":"New York","city_count":236}
{"city":"Miami","city_count":203}
{"city":"Baltimore","city_count":180}
SQLFlow reaches DuckDB through the Arrow ADBC driver manager, which dlopens
libduckdb at runtime. The binary is not standalone: wherever you run it,
that shared library has to be present. SQLFLOW_DUCKDB_LIB points at it;
without that variable SQLFlow looks in /opt/homebrew/lib/libduckdb.dylib on
macOS and /usr/local/lib/libduckdb.so on Linux.
The pinned DuckDB version lives in one place, the DUCKDB_VERSION file.
The published image bakes in a matching libduckdb.so and sets
SQLFLOW_DUCKDB_LIB, so nothing else is needed. Images are multi-arch
(linux/amd64, linux/arm64):
docker run --rm \
-v $(pwd)/dev:/tmp/conf \
turbolytics/sql-flow:latest \
dev invoke /tmp/conf/config/examples/basic.agg.mem.yml /tmp/conf/fixtures/simple.json
To build the image from the repo, make sqlflow-image — it prints the tag it
built, derived from git describe.
Release binaries are published for linux and macOS on amd64 and arm64. Download
the one matching your platform, then install a matching libduckdb:
chmod +x sqlflow_<version>_<os>_<arch>
./scripts/install-libduckdb.sh /usr/local/lib
export SQLFLOW_DUCKDB_LIB=/usr/local/lib/libduckdb.so
./sqlflow_<version>_<os>_<arch> version
scripts/install-libduckdb.sh always fetches the linux libduckdb.so, for
the architecture it detects. It is for linux hosts and containers only. On
macOS, brew install duckdb puts the library at the default path and no
environment variable is needed.
Requires Go 1.26+, a C toolchain (cgo is mandatory), and libduckdb.
# macOS
brew install duckdb
make sqlflow
# linux
./scripts/install-libduckdb.sh /usr/local/lib
export SQLFLOW_DUCKDB_LIB=/usr/local/lib/libduckdb.so
make sqlflow
The binary lands at bin/sqlflow.
A pipeline has three parts:
Source — Kafka, a WebSocket, or webhooks, modelled as a stream of messages.
Handler — DuckDB SQL executed over a batch of that stream: filter, aggregate, enrich, or drop data.
Sink — where the SQL result goes: Kafka, ClickHouse, Iceberg, the console,
or — through the sqlcommand sink — anywhere DuckDB can write.
A config file names all three, plus optional commands run before the pipeline
starts (attaching databases, creating tables) and optional tables the engine
manages across the pipeline's lifetime:
- Streaming Data Transformations: Clean data and types and publish the new data (example config).
- Stream Enrichment: Add data to an input stream and publish the new data (example config).
- Data aggregation: Aggregate input data batches to decrease data volume (example config).
- Tumbling Window Aggregation: Bucket data into arbitrary time windows (such as "hour" or "10 minutes") (example config).
- Run SQL against the Bluesky Firehose: Execute SQL against any websocket source, such as the Bluesky firehose (example config).
- Stream Data to Iceberg: Stream writes to an Iceberg catalog.
- Stream Data to ClickHouse: Insert stream processing outputs into ClickHouse (example config).
- Enrich Streams with Postgres Data: Query postgres during stream processing to enrich stream data.
- Sink Kafka to Postgres: Insert stream processing outputs into postgres.
sqlflow [command]
| Command | Purpose |
|---|---|
run |
Run a pipeline against a live source |
serve |
Serve a config's named SQL datasets over HTTP |
validate |
Check a pipeline offline and report every fault at once |
dev invoke |
Run a pipeline's handler against a static file |
config validate |
Validate a config against the JSON Schema |
config example |
Print a commented example configuration |
tail |
Print every message from a config's source |
version |
Print version, commit and Go version |
Runs the pipeline: consume, batch, execute SQL, sink, commit offsets.
sqlflow run <config> [flags]
sqlflow run -c <config> [flags]
| Flag | Default | Description |
|---|---|---|
-c, --config |
(required) | Path to the config file, unless given positionally |
--max-msgs |
0 |
Stop after N messages; 0 is unlimited. --max-msgs-to-process is an alias |
--metrics |
(off) | Metrics exporter. Only prometheus is supported; serves /metrics on :8000 |
--stats-json |
(off) | Write final run stats as JSON to this path |
--pprof |
false |
Serve pprof on :6060, and enable block/mutex profiling |
--stats-json writes a small object, useful for CI assertions:
$ sqlflow run -c dev/config/examples/benchmark.structured.mem.yml \
--max-msgs=2000 --stats-json=/tmp/stats.json
...
{"messages_consumed":2000,"num_errors":0}
Serves named SQL over HTTP. A serve config declares datasets, each a name and
a fixed SQL statement with typed parameters. serve attaches the data through
the config's commands, then answers each request by binding its parameters
into that statement. Nothing in a request becomes SQL text.
sqlflow serve <config> [flags]
sqlflow serve -c <config> [flags]
| Flag | Default | Description |
|---|---|---|
-c, --config |
(required) | Path to the serve config, unless given positionally |
--pprof |
false |
Serve pprof on :6060 |
A serve config is its own file, with commands and serve and no
pipeline. sqlflow validate checks it against the serve schema, and
sqlflow config example --serve prints every key. This one serves a Postgres
view:
commands:
- name: pin the session timezone
sql: SET TimeZone='UTC';
- name: load postgres
sql: |
INSTALL postgres;
LOAD postgres;
- name: attach postgres read-only
sql: ATTACH '{{ SQLFLOW_POSTGRES_URI }}' AS pg (TYPE POSTGRES, READ_ONLY);
serve:
http:
addr: "0.0.0.0:8080"
cors:
allowed_origins: [https://example.com]
clients:
- name: demo-page
id: "{{ SQLFLOW_SERVE_CLIENT_ID }}"
limits:
max_rows: 10000
timeout_seconds: 10
pool:
# Requests answered at once. Each session is a backend session; a
# concurrent query costs a few MiB. Omit for the default, 4.
size: 4
metrics:
# Serve GET /metrics on this listener, without a client id. Off by default:
# the listener is public and the labels name every dataset.
enabled: false
datasets:
- name: posts_by_lang
description: Posts per bucket per language.
params:
- {name: since, type: timestamp}
- {name: lang, type: string}
grains:
1h:
sql: |
SELECT bucket, lang, posts FROM pg.posts_per_hour_by_lang
WHERE bucket >= coalesce($since, now() - INTERVAL '7 days')
AND lang = coalesce($lang, lang)
ORDER BY bucket, langParameters are string, integer, or timestamp. A timestamp is RFC 3339
with an offset; encode a + as %2B in a URL. An absent parameter binds
NULL, so default it in SQL with coalesce. Every statement must use every
declared parameter. A dataset has either sql or grains, and a request
names a grain with ?grain=.
An integer param may declare min, max, or both. A request outside them
is 400 invalid_param, and the message names the bounds. Nothing is clamped:
a clamp would answer 200 with less than the caller asked for. /v1/datasets
lists the bounds with the param.
params:
- {name: top, type: integer, min: 1, max: 20}A dataset with grains can declare a time range instead, and let the server choose the grain:
- name: posts_by_lang
params:
- {name: since, type: timestamp}
- {name: until, type: timestamp}
- {name: lang, type: string}
range: {since: since, until: until, default: 24h}
grains:
5m:
max_range: 1d
sql: |
SELECT bucket, lang, posts FROM pg.posts_per_5m_by_lang
WHERE bucket >= $since AND bucket < $until
AND lang = coalesce($lang, lang)
1h:
max_range: 14d
sql: ...
1d:
max_range: 365d
sql: ...range names the two timestamp params that bound the range, and default
is the width a request gets without since. Each grain's max_range is the
widest range it serves. For each request:
untildefaults to the time the request arrived, andsincetountilminusdefault.- Without
grain, the server picks the grain with the narrowestmax_rangethat coversuntil − since. Six hours gets5m, three days1h, ninety days1d. A named grain answers when itsmax_rangecovers the range. - The server binds the resolved
sinceanduntil, neverNULL, so the SQL filters on them directly with nocoalesce. - The response carries the grain it chose and the range it resolved:
"grain": "1h", "range": {"since": "…", "until": "…"}.
A range wider than the named grain's max_range, or wider than every
grain's, is 400 range_too_wide, and the message names the grains that fit.
Nothing is cut from the left of a chart without saying so. Durations are a
whole number and one unit: s, m, h or d.
Every request runs its query unless the dataset opts in:
serve:
cache:
max_mb: 16 # optional. Bounds the cache; enables nothing
datasets:
- name: pipeline_status
cache: {ttl_seconds: 15}
sql: ...
- name: posts_by_lang
cache: {ttl_seconds: 30}
range: {since: since, until: until, default: 24h}
grains:
5m:
bucket: 5m # how wide one bucket of this grain is
max_range: 1d
sql: ... WHERE bucket >= $since AND bucket < $until
1d:
bucket: 1d
cache: {ttl_seconds: 600} # optional: this grain's own TTL
max_range: 365d
sql: ...A cached answer is the encoded result, served for at most ttl_seconds after
the query that produced it started. Nothing is invalidated, because
serve is never told that the backend changed: after a backfill, wait one TTL
or restart. max_mb is what every cached dataset shares, 16 by default. The
least recently used answer goes first, after every expired one, and an answer
over a quarter of the bound is returned and not kept.
A grain may carry a cache block of its own, and its ttl_seconds replaces
the dataset's for that grain. Use it on the coarse grains. A year of days
changes by one open bucket, and its rounded until only moves at midnight, so
the TTL is the only thing that re-runs the widest query there is: at the
dataset's 30 seconds, twice a minute, for a change no chart can show. The
dataset's block is still the opt-in, and a grain's block without it is
refused.
A dataset with a range declares bucket on every grain. since and until
are rounded up to it, the statement binds the rounded values, and range
echoes them, so every request inside one bucket-wide window is one question
with one answer. A chart polled by a hundred tabs runs its query once per
window, not once per tab.
The rounding returns the same rows only for SQL like the above: since
and until compared with a column whose values sit on bucket boundaries,
half-open, >= $since AND < $until. Two ways to break it, and serve cannot
detect either, which is why caching is the author's claim and off by default:
bucket <= $untiladmits the bucket that starts at the roundeduntil.- Filtering a raw timestamp,
event_at >= $since, drops the events betweensinceand the boundary it rounds up to.
sqlflow rollup serve writes bucket on every grain and SQL of the right
shape. bucket must divide one day, so a week is refused: weeks start on a
Monday and the epoch was a Thursday.
Concurrent requests for one question run one query. That query finishes even if every caller gives up, because the engine would have finished it anyway, and its answer is the next caller's hit.
A cached dataset's response says what happened:
"cache":"hit","age_ms":12400,"queued_ms":0,"elapsed_ms":0
cache is miss when this request ran the query, hit when the answer came
from memory, and shared when it waited on a query another request had
started. age_ms is how long ago that query started. queued_ms and
elapsed_ms are this request's own, so a hit reports zero and not the
original query's time. The request log carries cache too, and
/v1/datasets lists each dataset's ttl_seconds and each grain's bucket.
Responses stay Cache-Control: no-store: the cache is inside serve, and
nothing between it and the caller holds an answer. Each instance holds its
own, so two behind a load balancer may answer up to one TTL apart.
With serve.metrics.enabled, a server with a cached dataset adds four
metrics: sqlflow_serve_cache_requests_total by dataset and outcome,
sqlflow_serve_cache_evictions_total by reason (size or expired), and
the gauges sqlflow_serve_cache_bytes and sqlflow_serve_cache_entries.
Expired answers are reclaimed when the next one is stored, so after a quiet
spell the gauges still count them. sqlflow_serve_query_duration_seconds
counts only requests that ran a query; a hit shows in
sqlflow_serve_request_duration_seconds.
dev/config/serve/local.cached.yml caches one dataset and says why it does
not cache the other.
Routes, all GET:
| Route | client_id |
Returns |
|---|---|---|
/healthz |
none | 200 {"status":"ok"} when a session answers SELECT 1; 503 {"status":"busy"} when none is free, {"status":"unavailable"} when the query fails. HEAD is answered too, for monitors |
/metrics |
none | Prometheus text, only when serve.metrics.enabled is set |
/v1/datasets |
required | Every dataset: its params, and its SQL as written |
/v1/datasets/{name} |
required | Rows |
$ curl 'localhost:8080/v1/datasets/posts_by_lang?client_id=<id>&grain=1h&lang=en'
{"dataset":"posts_by_lang","grain":"1h",
"columns":[{"name":"bucket","type":"TIMESTAMP WITH TIME ZONE"},...],
"rows":[{"bucket":"2026-09-10T00:00:00Z","lang":"en","posts":102340}],
"row_count":1,"truncated":false,"queued_ms":0,"elapsed_ms":41}
A zoned timestamp is UTC. A decimal is a string of its exact digits. NaN
and infinities are strings. truncated: true means max_rows cut the result.
elapsed_ms is the query. queued_ms is how long the request waited for a
session, which is what rises when the pool is too small for the load.
Every route is GET, and /healthz also answers HEAD. A HEAD of a
dataset would run its query, borrow a session and discard the rows, so it is
refused with 405.
A client id identifies a caller. It does not authenticate one: a browser page ships it in plain sight, as an analytics snippet ships its site key. It names the caller in the request log, and deleting it cuts the caller off. Do not serve through it anything that the public may not read.
The id travels as ?client_id=<id> and not in an Authorization header, for
two reasons. A header named for authorization reads as a leaked credential to
everyone who opens the page's source. A GET with no custom header is also a
CORS simple request, so a browser sends it without a preflight. client_id
is no dataset's param: it never reaches the SQL or the cache key, and a
dataset cannot declare a param of that name.
Deprecated: this release still answers Authorization: Bearer <id> when a
request has no client_id, and still reads serve.auth.tokens, each token as
a client whose id is the token. Both log a warning. The next release refuses
both.
Every refusal is {"error": {"code", "message"}}:
| Status | Code |
|---|---|
400 |
unknown_param, invalid_param, missing_grain, unknown_grain, range_too_wide |
401 |
unauthorized |
404 |
unknown_dataset, not_found |
405 |
method_not_allowed |
500 |
query_failed. DuckDB's error goes to the server log, with passwords redacted, never to the caller |
504 |
query_timeout |
At start, serve prepares every statement, so a missing table or a syntax
error stops the process with user.sql.invalid rather than failing the first
request. rate_limit is reserved: the config accepts the key, and a non-zero
value stops the process with user.config.serve_reserved.
What to know before you deploy it:
- Attach
READ_ONLY.serveruns the dataset SQL as written. A write in it runs too, unless the attachment refuses. - Aggregate in the backend. DuckDB pushes filters and projections into an
attached Postgres and runs
GROUP BYitself, so a rollup over raw rows copies every row to DuckDB first.sqlflow rollupkeeps pre-aggregated tables in Postgres and generates the datasets that read them. A Postgres view with theGROUP BYalso pushes the filter in, but re-aggregates the range on every request. - Bound Postgres connections. An attachment opens up to
pg_connection_limitconnections, 64 by default. Set it low for a small database, as a command. The pool does not multiply it: with eight sessions scanning at once and a limit of four, the measured peak was four, so the connections are shared across sessions rather than opened per session. - A timeout stops reading, not always the query. At the deadline the
caller gets
504, and the reader stops at the next batch and is released, which is ADBC's equivalent of cancelling. An operator that runs long before yielding a batch still runs to the end, holding its session. Bound the part that is usually slow in the backend instead: a libpq connection string takesoptions='-c statement_timeout=30000', so an attached Postgres enforces its own ceiling. - A pool serves requests.
serve.pool.sizesessions answer at once, four by default. A request waits for a free session, and that wait counts toward the dataset's timeout, so an exhausted pool answers504 query_timeout.queued_msin the response andsqlflow_serve_session_wait_secondsin the metrics say whether the pool is the limit.
Generates rollup tables in Postgres, the triggers that keep them current, and
the serve datasets that read them, from one declaration. The pipeline keeps
writing its finest grain; the database keeps every coarser grain as each write
commits, and serve reads pre-aggregated rows.
sqlflow rollup install -c rollups.yml
sqlflow rollup run -c rollups.yml [--metrics prometheus]
sqlflow rollup verify -c rollups.yml [--since T]
sqlflow rollup test -c rollups.yml --dsn DSN [--tests FILE] [--seed N] [--keep]
sqlflow rollup ddl -c rollups.yml [--backend postgres]
sqlflow rollup serve -c rollups.yml [--dataset NAME]
sqlflow rollup check -c rollups.yml --serve FILE [--migration FILE]
| Command | Does |
|---|---|
install |
Creates the tables, functions and triggers the file declares, in one transaction, and records what it applied in sqlflow_rollup_state. Refuses a change that would corrupt stored rows. Fills nothing. |
run |
Installs, then leads the file's rollups and fills every table install marks, one chunk at a time, beside a live pipeline. Several may run: one leads, the rest stand by. --metrics prometheus serves /metrics and /healthz on :8000. |
verify |
Recomputes every table from the table it is built from and exits system.rollup.drift when a bucket differs, printing the first rows that do. It only reads. --since takes an RFC 3339 time or a duration such as 36h. |
test |
Installs the file in a schema it creates on --dsn, writes a seeded workload and any fixture cases through the triggers, and exits user.config.rollup_test_failed when a table differs from its source or from a case's expected rows. Drops the schema unless --keep. |
ddl |
Prints a migration: the same tables, functions and triggers, and a backfill in one transaction. For a team that applies SQL itself. Connects to nothing. |
serve |
Prints the serve datasets, to paste into a serve file's datasets. |
check |
Exits 10 when the serve file, or the migration when given, differs from what the declaration generates, or when a dataset could answer more rows than its max_rows. |
install, run and verify connect to store.postgres.dsn:
store:
type: postgres
postgres:
dsn: "{{ SQLFLOW_POSTGRES_URI }}"Run install in a deploy's entrypoints, before sqlflow serve prepares its
statements, and run as its own process. install locks each source for
its DDL only, a few milliseconds. run fills history newest day first. A
chunk takes the triggers' own bucket locks without ever waiting on them:
when a write holds one, the chunk steps back and tries again, so the
pipeline keeps writing.
run also checks each table's newest two buckets every minute against the
table it is built from. It counts drift in rollup_drift_buckets and logs
the first rows that differ. Drift leaves /healthz alone: it belongs to
the data, and a restart cannot fix it, so alert on the metric's rate. A
disabled trigger, a hand edit or a write that bypassed the triggers causes
drift. Rewrite the bucket's source rows to re-merge it.
With a turbostats block that sets report_to, run reports itself to
TurboStats as sqlflow run and sqlflow serve do. The leader sends each
table's newest bucket under one store id, and per rollup its verify and
drift totals, backfill progress, least complete closed bucket and trigger
cost. A file that reports declares at most 10 tables; split a larger
deployment across files, one run each.
turbostats:
id: "{{ SQLFLOW_TURBOSTATS_ID }}"
report_to: "{{ SQLFLOW_TURBOSTATS_REPORT_TO }}"
key: "{{ SQLFLOW_TURBOSTATS_KEY }}"test checks a declaration before it ships. Point --dsn at a scratch
Postgres where the team's migrations have run, never production: the
command connects only there, never to store.postgres.dsn. It clones each
source into a schema named sqlflow_test_ and 16 hex characters, installs
the rollups there, and writes 20 batches per rollup across the 2026-03-08
daylight saving change, each in a session zone of UTC, America/New_York
or Asia/Kolkata. Every table must equal a from-scratch GROUP BY of its
source, and every bucket must lie on its grain's UTC boundary. --tests
adds fixture cases for what those two rules cannot see, such as last and
count_buckets; dev/config/rollups/bluesky.test.yml
shows the format. The output starts with the seed, and --seed repeats a
run. The role on --dsn needs CREATE on the database. Ctrl-C or SIGTERM
ends a run and still drops its schema; a run killed with SIGKILL leaves
it, so drop a leftover sqlflow_test_ schema with DROP SCHEMA ... CASCADE.
ddl and check suit a team that applies SQL through its own migration
runner. install and run do it for you. dev/config/rollups/bluesky.yml
is a complete declaration, and sqlflow validate checks a rollups file
against its schema and rules.
Each grain is re-merged from the grain below it. When a statement writes the finer table, a statement-level trigger locks the coarse buckets it touched and recomputes them, in the writer's transaction. A minute written twice replaces its count at every grain instead of adding to it.
A rollup reads a table no rollup makes. To roll up a rollup's table further, add a grain or a dimension set to that rollup.
Writers to one rollup take turns: each trigger takes the rollup's lock first, so two writers never deadlock. Write the source once per transaction, as the Postgres sink does. A write left open in a transaction holds up every other write to its rollup until it ends. Each flush re-merges its buckets for every dimension value, so its cost grows with the values in a bucket, not the rows it wrote. Several processes writing one source pay that cost once each, and they take turns doing it.
Measures are sum, min, max, last and count_buckets, which counts the
source buckets present, such as minutes observed. avg, gauge and
histogram are reserved.
A sum is stored as a bigint. Add numeric: double to store a
double precision, which keeps a fraction: a gauge of 0.73 summed as an
integer is 1, and 0.25 is 0. For an average, keep a sum and a count and divide when you
read. That is exact at every grain, and an average of averages is not.
last keeps the value of a bucket's latest finer bucket, which is what a
gauge reads as: queue depth now. It takes the source column's type. A last
needs a dimension set that keeps every source dimension, and a generated
dataset cannot serve it, because the last value of the series folded into
other belongs to none of them. Write that dataset in serve.yml.
dev/config/rollups/metrics.yml declares
both.
What to know before you deploy it:
- Postgres 15 or later, and a writer in
READ COMMITTED, the default. The trigger refuses any other isolation level, because its lock only works when each statement reads a new snapshot. ddl's migration blocks the source's writers until it commits, and its backfill reads the whole source table.installandrundo not: the backfill runs in chunks under per-bucket locks.- A trigger error fails the writer's transaction. A pipeline writing the source stops with it.
- Deletes do not propagate. Rollups outlive a retention job on the source.
- Changes are additive. Adding a grain or a dimension set is a new migration holding the regenerated script. Removing, renaming, or changing a measure's type needs a migration written by hand.
- A served dataset has at most one dimension, and folds it to its top
values.
checkprovesmax_buckets × (top.max + 1) <= max_rows, sotruncatednever happens. - A served dataset can be cached.
cache_ttl_seconds: 30on a serve dataset generates itscacheblock, andcache_ttl_by_grain: {1h: 120, 1d: 600}generates a longer one on the grains it names. Every generated grain carries itsbucket, which is its name, and the generated SQL compares the bucket column half-open, the shape the cache needs.checkholds the serve file to both.
Checks a pipeline without running it. validate reaches no broker and no
sink, executes nothing from the commands block, and reports every fault it
finds in one pass rather than stopping at the first.
sqlflow validate <config> [--json]
It renders the config's template, then checks the result against the config schema. Both halves of the template's variable use are reported, which is what makes a misspelled name obvious:
$ sqlflow validate pipeline.yml
pipeline.yml:8:49: error: [user.config.template_undefined] template variable
SQLFLOW_AZURE_CONNECTION_STRING is not defined and renders as an empty
string, but a similar name is supplied and never read
did you mean: [SQLFLOW_AZURE_STORAGE_CONNECTION_STRING]
supplied but never read: [SQLFLOW_AZURE_STORAGE_CONNECTION_STRING]
A variable the config reads with no default filter is a required input. When
it is unset, validate warns rather than failing, so a config still checks in
CI where no secret is set. A missing name that closely resembles a supplied one
is a different matter: that resemblance is evidence of a typo, and it fails.
--json emits the same report as a document, with a checks array beside the
diagnostics. A check that could not run reports skipped with a reason and
never reports pass, so a consumer can tell "checked and fine" from "not
checked".
Exit codes follow the error taxonomy: 0 when the config is sound, 10 for a
fault the user has to fix.
Runs the config's commands, tables and handler over a JSONL fixture and
prints the resulting rows to stdout, one JSON object per line. The sink is
deliberately not exercised, so this is safe to run against a production
config. This is the fastest way to iterate on SQL.
sqlflow dev invoke <config> <fixture>
Renders the config's Jinja2 template, then validates the result against the JSON Schema.
$ sqlflow config validate dev/config/examples/basic.agg.mem.yml
dev/config/examples/basic.agg.mem.yml: valid
Prints a fully commented YAML skeleton generated from the schema — every key,
its description, and the accepted enum values. --serve prints the serve
config's skeleton instead.
Connects a config's source and prints every message to stdout, with no handler or sink. Useful for confirming a source is configured correctly.
sqlflow tail -c <config>
$ sqlflow version
sqlflow v1.0.4
commit: 55c3129
go: go1.26.8
A config is a YAML file rendered as a Jinja2 template before it is parsed.
Every SQLFLOW_* environment variable is injected into the template context
under its own name, which is how configs stay portable across environments:
brokers: [{{ SQLFLOW_KAFKA_BROKERS|default('localhost:9092') }}]Two extra template variables are always defined: STATIC_ROOT (from
SQLFLOW_STATIC_ROOT, default /tmp/sqlflow/static) and
SQL_RESULTS_CACHE_DIR (from SQLFLOW_SQL_RESULTS_CACHE_DIR, default
/tmp/sqlflow/resultscache).
Parsing is strict: an unknown key is an error rather than a silently ignored setting.
The top-level shape:
commands: # optional: SQL run once, before the pipeline starts
tables: # optional: tables created at startup, optionally window-managed
pipeline: # required
name:
description:
batch_size: # required, >= 1
flush_interval_seconds: # optional; unset means only batch_size triggers a batch
state: # optional; makes state durable, see Durable state
on_error: # optional
source: # required
handler: # required
sink: # requiredbatch_size is how many messages accumulate before the handler runs.
flush_interval_seconds bounds the wait: once the interval elapses a partial
batch is executed anyway, so a low-traffic topic still makes progress. When
--max-msgs ends a run, the final partial batch is executed on exit.
source:
type: kafka
kafka:
brokers: [localhost:9092]
group_id: my-consumer-group
auto_offset_reset: earliest # or latest
topics:
- input-topicOffsets are committed after the batch has been handled and the sink has flushed, and only up to the last message the pipeline actually processed, not to wherever the consumer has read ahead to. See Delivery guarantees.
SASL / TLS. Set security_protocol to one of PLAINTEXT, SSL,
SASL_PLAINTEXT, SASL_SSL:
source:
type: kafka
kafka:
brokers: [localhost:9093]
group_id: test
auto_offset_reset: earliest
security_protocol: SASL_SSL
ssl:
ca_location: /certs/ca-cert.pem
certificate_location: /certs/client-cert.pem
key_location: /certs/client-key.pem
key_password: testpass
endpoint_identification_algorithm: 'none' # disables hostname verification
sasl:
mechanism: PLAIN # or SCRAM-SHA-256, SCRAM-SHA-512
username: user
password: bitnami
topics:
- input-sasl-tls-1The same security_protocol / ssl / sasl block works on the Kafka sink.
See kafka.sasl-tls.yml.
Two limits, both of which fail loudly rather than silently: GSSAPI is
rejected, and an encrypted PEM key is rejected with instructions to convert it
(openssl pkcs8 -topk8 -nocrypt). key_password only covers unencrypted PEMs.
Kafka metadata. A Kafka source exposes kafka_topic, kafka_partition and
kafka_offset to InferredMemBatch handler SQL, if the SQL selects them.
source:
type: websocket
websocket:
uri: wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.postReconnects with backoff. See the bluesky examples.
Listens for POST /events on addr, by default 0.0.0.0:8001, and
optionally validates an HMAC-SHA256 signature:
source:
type: webhook
webhook:
addr: "0.0.0.0:8001" # optional, default 0.0.0.0:8001
signature_type: hmac
hmac:
header: 'X-Hub-Signature-256'
sig_key: 'sha256'
secret: "{{ SQLFLOW_GITHUB_WEBHOOK_SECRET }}"
max_body_bytes: 26214400 # optional, default 25 MiB
max_connections: 64 # optional, default 64A platform that assigns the port passes it through the template:
addr: "0.0.0.0:{{ PORT }}". SQLFlow refuses at startup an addr that is
not a host:port.
To send a signed event, compute an HMAC-SHA256 of the exact bytes of the body
with the shared secret, hex-encode it, and put it in the configured header
with the sha256= prefix:
export SQLFLOW_GITHUB_WEBHOOK_SECRET=shhh
body='{"action":"opened","number":1}'
sig="sha256=$(printf '%s' "$body" \
| openssl dgst -sha256 -hmac "$SQLFLOW_GITHUB_WEBHOOK_SECRET" \
| awk '{print $NF}')"
curl -s -X POST http://localhost:8001/events \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: $sig" \
--data-binary "$body"
{"status":"received"}The signature covers the raw bytes, so the body sent must be the body signed:
--data-binary and printf '%s' keep it byte for byte, where -d and echo
can alter whitespace or add a newline. GitHub signs the same way, so a
repository webhook with the same secret is accepted as is.
ack decides what the 200 means. The default, on_receive, answers once
the body is queued for the pipeline; a crash before the batch flushes loses
an event the sender was told it had delivered. ack: after_flush answers
once the batch holding the body has flushed to the sink and its position is
committed, with {"status":"flushed"}; a flush that fails answers 503
{"detail":"Not flushed"}, and so does a pipeline that stops first, so the
sender retries. The cost is the wait: a request waits for its batch to fill
or for flush_interval_seconds, whichever comes first, so a lone sender
waits the whole interval (measured p50 1.00s, p99 1.04s at 1s). A sender
that hangs up keeps its place in the queue; nobody else is given its answer.
after_flush is refused on a pipeline that windows, because a window holds
a row for minutes; put the webhook in front of a Kafka topic and window in a
second pipeline, as metering/ingest.yml
does.
Responds 200 on accept, 400 for a missing signature, 403 for an invalid one,
413 for a body over max_body_bytes, and 408 for a body that stalls. The
body bound applies before the body is read and before the signature is
checked, so an unsigned oversized request never holds memory past it. The
default is 25 MiB, GitHub's payload ceiling.
GET /healthz on the same address answers {"status":"ok"} while the source
admits deliveries, and 503 once it is closing. It needs no signature and reads
no body, so a platform's health check or an uptime monitor can call it, and
HEAD works too. A queue that is full is backpressure, not failure, and still
answers 200. Health checks are not counted in webhook_requests_total, which
counts deliveries. The pipeline's own /healthz, on the metrics listener, is
unchanged: this one exists because a platform that routes a single port to a
service, as Render does, checks health on that port.
The listener holds at most max_connections open connections; past that, a
new connection waits in the kernel backlog until one closes. Three fixed
timeouts release a slot: 10s for a connection to send its request headers,
60s to send the body, and 60s of idle keep-alive. A delivery that is in and
waiting on the pipeline is under no timeout.
sqlflow config validate checks the webhook block, including max_body_bytes
and max_connections, against the same JSON Schema sqlflow run loads.
All three handlers take sql. The batch is exposed to that SQL as a table.
type |
Batch table | Notes |
|---|---|---|
handlers.InferredMemBatch |
batch |
Schema inferred from the JSON, in memory |
handlers.InferredDiskBatch |
batch |
Same, but buffered through disk via read_json_auto |
handlers.StructuredBatch |
the table named by table |
Schema declared up front; fastest |
Inferred handlers derive the Arrow schema from the messages themselves:
- The column set comes from the first message in the batch. A top-level key that first appears in a later message is not a column.
- Nested struct fields are unioned across the whole batch: a key inside an object that appears only in a later message still becomes a field, null in the rows that lack it. Without this, whether a nested field existed would depend on which message happened to arrive first.
- Types are promoted across the batch (
intwidens todouble), JSON arrays become lists (of scalars, structs, or further lists), and JSON string escapes are decoded. A value that cannot be promoted fails the batch.
handler:
type: 'handlers.InferredMemBatch'
sql: |
SELECT properties.city as city, count(*) as city_count
FROM batch
GROUP BY cityInferredDiskBatch additionally accepts sql_results_cache_dir (default
/tmp/sqlflow/resultscache). It stages fixed filenames there, so two pipelines
must not share a cache directory.
StructuredBatch takes a table you declared in commands, and parses
directly into that schema. This is the fastest handler — no inference, and a
zero-copy Arrow ingest. The table is truncated at the start of every batch.
commands:
- name: create source buffer table
sql: |
CREATE TABLE source (
event STRING,
properties STRUCT(city TEXT)
);
pipeline:
handler:
type: "handlers.StructuredBatch"
table: source
sql: |
SELECT properties.city as city, COUNT(*) as count
FROM source
GROUP BY properties.citysink.type is one of console, kafka, sqlcommand, iceberg,
clickhouse, noop. An omitted type falls back to console.
# console — one JSON object per row on stdout
sink:
type: console
# noop — discard (used for benchmarking)
sink:
type: noop
# kafka — one JSON message per row; Flush blocks on broker acks
# before offsets are committed
sink:
type: kafka
kafka:
brokers: [localhost:9092]
topic: output-topic
# key: customer — optional; each record is keyed by that column's text,
# so rows with one value land on one partition. A null fails the batch.
# security_protocol / ssl / sasl as per the Kafka source
# clickhouse — the table must exist; columns are matched by name
sink:
type: clickhouse
clickhouse:
dsn: clickhouse://default:@localhost:8123/default # self-hosted, HTTP
# dsn: clickhouses://default:<pw>@<host>.clickhouse.cloud:8443/default # Cloud, TLS
table: events
# iceberg — catalog resolved from .pyiceberg.yaml, exactly as pyiceberg does
sink:
type: iceberg
iceberg:
catalog_name: default
table_name: default.city_eventsClickHouse. The DSN scheme picks the protocol: clickhouse:// is HTTP on
8123, clickhouses:// is HTTP over TLS on 8443 (ClickHouse Cloud), tcp:// /
natives:// the native protocol on 9000 / 9440. Scalars, DateTime,
Date, Enum, LowCardinality, Nullable and Array(T) columns all map;
UUID, IPv4 and Decimal accept their textual form as a string. Map and
Tuple are not supported. Measured throughput is ~50k rows/sec into a local
ClickHouse from one process.
Iceberg. SQL-backed catalogs only (sqlite://); a REST catalog is an
error.
sqlcommand is the general escape hatch: the batch is exposed to your SQL
as the table sqlflow_sink_batch, and DuckDB does the writing. This is how to
reach PostgreSQL, S3, local parquet, MotherDuck and DuckLake:
sink:
type: sqlcommand
sqlcommand:
substitutions:
- var: $sqlflow_uuid
type: uuid4 # the only supported substitution type
sql: |
COPY sqlflow_sink_batch
TO '/tmp/sqlflow/out/$sqlflow_uuid.parquet'
(FORMAT 'parquet');sink.format.type: parquet is parsed and ignored.
ClickHouse, Iceberg and Postgres flushes retry when the destination is not answering.
Omit the block to accept the defaults. Set max_attempts: 1 to turn retrying
off. The Kafka sink ignores this block: franz-go already retries a produce
with its own backoff.
sink:
type: clickhouse
retry:
max_attempts: 4 # total attempts, including the first
initial_backoff_ms: 100 # doubles each attempt
max_backoff_ms: 2000 # ceiling on the backoff
deadline_seconds: 10 # bounds the whole ladder, not one attemptThe values shown are the defaults.
Keep deadline_seconds below pipeline.flush_interval_seconds. The retry
runs inside the open state transaction, and a ladder that outlives the flush
interval freezes the window clock.
The ladder retries only what another attempt could change. The error code decides:
| Error | Retried | Why |
|---|---|---|
Any user.* code, including user.sink.encode_failed and user.sink.type_unsupported |
No | The config, SQL or a value is wrong. It fails identically every time. |
system.sink.write_failed |
No | The destination answered and refused the write. |
system.sink.unreachable |
Yes | The destination may come back. |
| An error with no code | Yes | A driver's timeout or reset arrives unclassified. The deadline bounds the cost. |
Any other system.* code |
Yes |
A failure that is not retried keeps its own code and exit code. A retried
failure that outlasts the ladder is reported as system.sink.unreachable,
exit 12, and sink_retry_count_total counts each attempt after the first.
user.sink.encode_failed is the sink's client refusing a value before
anything reaches the network, such as a timestamp string the ClickHouse
driver's DateTime layout cannot parse. Cast or format the column in the
handler SQL. The message names the column and quotes the value.
pipeline.on_error.policy is RAISE (default), IGNORE, or DLQ. It is
applied at both the handler.write and handler.invoke phases.
pipeline:
on_error:
policy: DLQ
dlq: # a full sink definition, any sink type
type: kafka
kafka:
brokers: [localhost:9092]
topic: dlq-topicDLQ without a dlq block is a startup error. DLQ records carry four string
columns: error, message, phase (handler.write or handler.invoke) and
timestamp. See kafka.dlq.yml.
source.error.policy is parsed but unused; use pipeline.on_error.
A pipeline that aggregates needs its state to survive a restart. Give it a file:
pipeline:
state:
path: /var/lib/sqlflow/state.dbDuckDB then runs on that file instead of in memory, and a sqlflow_offsets
table lives there beside the tables your handler writes. Every batch commits
the handler's writes and the Kafka offsets that produced them in one
transaction. On startup the pipeline reads those offsets and resumes from them.
Without a state path, DuckDB runs in memory. A crash mid-window loses that
window's aggregate while its offsets are already committed, so the consumer
group reports no lag and a restart replays nothing. Set a state path for any
pipeline with a tables block.
Two consequences worth knowing:
- The state file is the source of truth. When it disagrees with the consumer group, the pipeline resumes from the file's offsets. It applies them while joining the group, so a restart works even while the previous process is still a member, which it is for the session timeout after a crash.
- Window close predicates are evaluated at most one
flush_interval_secondsbehind the wall clock. A stateful pipeline holds one transaction open per batch and DuckDB'snow()is the transaction's start time, so the pipeline commits on the flush tick even when idle to keep that clock moving. - DuckDB locks the file exclusively. One state file belongs to one running
pipeline, and no other process can read it, not even read-only. Use the
/statsendpoint to inspect a running pipeline.
Durable state costs throughput. See What durable state costs for the numbers and the batch size to use.
SQLFlow gives two guarantees. Which one applies depends on where the data lands.
Pipeline state is exactly-once relative to offsets. With a state path set, the tables your handler writes and the offsets that produced them commit in a single transaction. A crash replays exactly the batches whose state did not commit, so a windowed aggregate is neither short nor double-counted across a restart.
External sinks are at-least-once. Kafka, ClickHouse, Iceberg and
sqlcommand are flushed before that transaction commits. A crash in between
replays the batch and the sink sees those rows twice. Committing first would
move offsets past rows the sink never received, which loses them silently, so
the duplicate is the deliberate choice. Use a sink that absorbs it:
ReplacingMergeTree in ClickHouse, an upsert in sqlcommand, or a downstream
dedupe on a key.
Window sinks are at-least-once for the same reason. A tumbling window is published before its rows are deleted, and that delete commits with the pipeline's next batch. A crash in between republishes the window.
Without a state path there is no state guarantee at all: handler state does not survive the process.
SIGTERM and SIGINT drain the pipeline. The drain runs in order:
- Stop consuming.
- Write the batch it had buffered.
- Run each window's final pass.
- Commit state and offsets.
- Exit 0.
A supervisor that stops a pipeline this way loses nothing. Skipping the drain loses no data either, because the buffered batch replays from the last committed offset. It costs that duplicate work, and it republishes any window that closed during shutdown.
The drain is bounded by pipeline.drain_deadline_seconds, thirty by default.
A sink that blocks past it fails the drain with the incomplete-drain code and
exit 15, rather than holding the process open until the supervisor escalates
to SIGKILL. Whatever the final pass did not publish stays in its window table
for the next start.
A table declared under tables.sql can carry a window. The handler appends
each batch's counts to the table, keyed by a bucket start, and the engine does
the rest: it keeps a watermark, and after each commit that moves it, tells the
window's manager to publish every bucket the watermark has passed and delete
what nothing more can arrive for.
tables:
sql:
- name: agg_cities_count
sql: |
CREATE TABLE agg_cities_count (
bucket TIMESTAMPTZ, city VARCHAR, count INT
);
window:
time_column: bucket
size_seconds: 3600
grace_seconds: 0
idle_close_seconds: 60
allowed_lateness_seconds: 0 # optional; see Late rows
emit_sql: | # optional; default SELECT * FROM closed
SELECT bucket, city, sum(count)::INT AS count
FROM closed
GROUP BY ALL
sink:
type: kafka
kafka:
brokers: [localhost:9092]
topic: output-tumbling-window-1
pipeline:
source:
type: kafka
kafka:
brokers: [localhost:9092]
group_id: tumbling
topics: [tumbling-window]
event_time: {path: timestamp, format: rfc3339}
handler:
type: handlers.InferredMemBatch
sql: |
INSERT INTO agg_cities_count BY NAME
SELECT
time_bucket(INTERVAL '1 hour', event_time) AS bucket,
properties.city AS city,
count(*) AS count
FROM batch
GROUP BY bucket, cityThe handler must cut time_column as time_bucket(INTERVAL '<size>', event_time): the engine decides each record's lateness from that bucket, and
sqlflow validate refuses any other expression. event_time is the column
the source fills from the record's own time; see Event time under each
source.
The watermark. The engine asserts it, the window follows it. One instant
per window, in event time, written to sqlflow_watermarks in the same commit
as the rows it describes, never moving backwards. Its meaning is a promise:
as of this commit, every row this pipeline will ever write for this window has
time_column at or after the watermark. A bucket is closed when its end,
time_column + size_seconds, is at or before it; sqlflow_windows holds what
each window has closed up to, and a pass moves that to the assertion and
publishes the buckets between them. That is the whole decision -- no clock, no
liveness reading, one comparison in one clock.
The engine computes it per source partition, from the event time of the records it has placed:
candidate(p) = newest event time from p − grace_seconds
W = min over the partitions that could still deliver
A partition that has delivered nothing holds W open: it may still bring
data older than anything seen, so nothing closes until it speaks. A partition
lagging another holds the buckets they share, so a fast partition cannot
close a slow one's bucket. A partition silent for idle_close_seconds leaves
the minimum, and when every partition has, the stream is done with what it
has: W becomes the newest event time plus one bucket, which closes
everything held. Idleness is measured on the engine's monotonic clock from the
later of the partition's last record and its assignment, so a wall clock
stepping forward is not silence and an outage counts for nothing.
A partition whose session failed holds the minimum at its last position and is never idle: it may come back with a backlog for the buckets this process holds. A partition another member now holds is gone from the minimum, because its rows are that member's and holding for them would freeze this process's windows for good. A Kafka consumer waiting to rejoin its group, and a websocket reconnecting, are the first case: the window holds through the outage, and the clock restarts on the return. A source that can never deliver holds every bucket open until it does, which the log says.
Nothing polls. The engine signals the window's manager after the commit that
moved its watermark, and the manager passes: publishes what closed, deletes
what is past its lateness, records where it closed up to. It passes once when
it starts and once on the drain, which asserts first so the final pass sees
the watermark up to the signal, and otherwise only when told. A pipeline's
idle ticks write nothing unless a watermark moved -- no statement, no WAL
append, no fsync -- so a quiet stream costs one write, when its last partition
goes idle. sqlflow_progress is liveness for /stats and /healthz and for
SQL to read; no window decision rests on it.
Every decision a window makes is a row in one of two tables, rendered from the code to docs/windows/decisions.md.
Late rows. A record whose bucket ended at or before the watermark is
late. The engine decides that when the record arrives, from its event_time
and the window's size, before the handler runs -- the way Flink's window
operator does. With allowed_lateness_seconds absent or 0 a late record is
refused and counted in window_late_rows_total{outcome="refused"}; the
window table never holds it. With a positive value a closed bucket's rows are
kept that long, a late record within it is written, and the bucket is
republished as a whole value -- emit_sql over every row it has -- so the
sink must replace by key; sqlflow validate refuses an append-only sink and
warns for one it cannot classify. Past end + allowed_lateness_seconds the
rows are deleted and later records are refused. Each republication counts in
window_recomputes_total, and the rows that caused it in
window_late_rows_total{outcome="recomputed"}. A rising refused count means
the grace is too short for the stream, or the lateness is.
The watermark is the minimum over the partitions, so a slow partition holds
the buckets it shares open rather than arriving late; the grace is the
allowance for reordering within a partition. Size it from
window_late_rows_total.
Several workers, and a worker that loses its disk. A windowed pipeline over Kafka commits, per partition, the position before the lowest offset still feeding a bucket it retains, not the position it has processed. Kafka is then the durable copy of the open buckets: a worker that starts without the window's rows, on a new disk or after a rebalance, replays from there and rebuilds each retained bucket whole. The consumer group's lag therefore reads as the retained span, size + grace + lateness of records, while the pipeline is up to date. Each commit also carries the window's closed watermark as commit metadata, and a worker that takes the partition over refuses what the previous owner had already finalized, so a late record the old worker refused is not published alone over the full count by the new one.
With partition_owned: true, every row of the window belongs to the Kafka
partition in its kafka_partition column, and one process writes each
(bucket, partition) key. When the group revokes a partition, the worker
deletes that partition's rows, offset records and stored position in one
transaction before the rebalance completes, and the new owner recounts them
from the committed position; without it, the old owner's partial count for
the bucket races the new owner's whole one. A worker that stops deletes its
rows before it leaves the group, and a worker that starts deletes what its
state file holds, since another member may have consumed those partitions
meanwhile: a restart recounts the open buckets from Kafka rather than
resuming. It needs a Kafka source with one topic, kafka_partition in the
table and written by the handler, a postgres upsert sink with
kafka_partition in its key, and every window in the pipeline set the same
way; sqlflow validate and run refuse anything else. One limit: a
partition that reaches a worker whose watermark is more than
allowed_lateness_seconds past the partition's open buckets has those rows
refused as late, so set the lateness above the time a rebalance takes, which
after a crash is the group's session timeout. See
metering/count.yml, and
internal/metering for the scenarios that hold it: a worker joining,
leaving, killed with its disk kept or lost, every request sent twice, and a
rebalance under load.
emit_sql shapes the rows before the sink. It reads one relation, closed,
holding every row of every bucket that just closed. Cast a sum back to the
column's type, because DuckDB widens integers to HUGEINT, and use
GROUP BY ALL, which groups by the output columns.
Do not put a UNIQUE INDEX or PRIMARY KEY on a window table. DuckDB
never frees rows deleted from an indexed table. The engine deletes every
bucket it publishes, so an index grows memory without bound, with or without a
state path. See #268. A
keyed table with no window, such as a running total, can keep its index.
Guarantees. The window runs on a connection of its own and reads committed
rows only, so a batch that rolls back was never published. Collect, write and
flush happen before the delete, and the delete and the watermark commit
together, so a sink failure leaves the bucket in the table and stops the
process rather than dropping the bucket. A crash between the flush and that
commit republishes the bucket on the next start, so give a window sink a key
it can deduplicate on. With allowed_lateness_seconds set, the pass the
manager runs when it starts republishes every bucket still inside its
lateness, because the late rows a crash interrupted were recorded in memory;
each is the whole bucket, so a sink that replaces by key ends where it would
have. One final pass runs on shutdown, inside the drain deadline. With a state
path the watermark survives a restart; without one the window table and its
watermark are lost together.
sqlflow validate checks the declaration: the time column must be declared
TIMESTAMPTZ in the table's CREATE and computed as
time_bucket(INTERVAL '<size>', event_time) in the handler's SQL, a
structured handler's batch table must declare event_time TIMESTAMPTZ,
emit_sql must read closed, allowed_lateness_seconds above zero must pair
with a sink that replaces by key, and the old manager block, late_rows and
poll_interval_seconds are each refused with the key that replaces them. A
file that carries any of them does not run. Each removal is a major version
with its migration in the changelog: a declaration cannot be derived from two
arbitrary predicates, nothing is left for an interval to pace, and reemit's
delta is replaced by the whole value a recompute publishes.
See tumbling.window.yml and
kafka.stateful.window.yml.
--metrics prometheus serves /metrics on :8000. The tables below are every
instrument exported, all under the meter name sqlflow except the two webhook
ones.
The instrument name and the Prometheus series name differ: the exporter appends
the unit, then _total for counters, and skips the unit when the name already
contains it. Only the series name is queryable, so both are listed. A test
asserts this table against the running exporter.
Row accounting. Four counts follow a row through the pipeline:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
message_count |
message_count_messages_total |
counter | — |
handler_rows_read |
handler_rows_read_total |
counter | — |
sink_rows_accepted |
sink_rows_accepted_total |
counter | sink, role |
sink_rows_written |
sink_rows_written_total |
counter | sink, role |
sink_flush_num_rows |
sink_flush_num_rows |
gauge | — |
Sink health:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
sink_flush_count |
sink_flush_count_flushes_total |
counter | result |
sink_flush_latency |
sink_flush_latency_seconds |
histogram | — |
sink_retry_count |
sink_retry_count_total |
counter | sink |
Pipeline:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
batch_processing_latency |
batch_processing_latency_seconds |
histogram | — |
error_count |
error_count_total |
counter | class, domain, code, phase |
phase_duration |
phase_duration_seconds |
histogram | phase |
handler_checkpoints_skipped |
handler_checkpoints_skipped_total |
counter | — |
phase_duration decomposes batch time. It carries the same six phases
error_count does — handler.write, handler.invoke, sink.write,
sink.flush, state.commit, handler.init — so one query says where the time
went:
sum(rate(phase_duration_seconds_sum[5m])) by (phase)
Read it with error_count to tell slow from broken. phase_duration records
whether or not the phase succeeded, which no other latency here does:
sink_flush_latency and state_commit_latency record after their error
returns, so a sink that takes thirty seconds to fail leaves them flat.
handler.write is measured around the whole message loop rather than each
write, so it also carries the loop's own bookkeeping. Timing each write cost
4.1x on that path; see BenchmarkConsumeLoopWritePath.
Every latency histogram shares one set of bucket boundaries, from 100
microseconds to 60 seconds. The OTel SDK's defaults are millisecond-shaped and
these instruments record seconds, so before this every histogram_quantile
over them returned a number under five seconds and meant nothing.
handler_checkpoints_skipped counts batches whose checkpoint the structured
handler skipped. That handler checkpoints after each batch to reclaim the rows
it truncated. DuckDB refuses while another connection holds an uncommitted
update or DDL, such as a window saving its watermark, and the next batch
reclaims them instead. A count that rises with every batch means a write stays
open for longer than a batch, and memory grows until it closes.
Source:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
source_read_latency |
source_read_latency_seconds |
histogram | — |
consumer_lag |
consumer_lag_messages |
gauge | topic, partition |
consumer_lag_observed_timestamp |
consumer_lag_observed_timestamp_seconds |
gauge | — |
message_payload_bytes |
message_payload_bytes_total |
counter | — |
message_payload_bytes is the bytes of every message value received, over the
same messages message_count counts, so the two divide to an average message
size. It is payload, not wire bytes: keys, headers, framing and TLS are
excluded, and under Kafka compression the wire carries fewer bytes than this.
Reference tables, recorded once at startup for each table the handler SQL joins, with or without a state path:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
reference_table_rows |
reference_table_rows |
gauge | table |
Windows, one set per declared window:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
window_watermark_seconds |
window_watermark_seconds |
gauge | window |
window_closed |
window_closed_total |
counter | window |
window_late_rows |
window_late_rows_total |
counter | window, outcome |
window_recomputes |
window_recomputes_total |
counter | window |
window_close_lag_seconds |
window_close_lag_seconds |
gauge | window |
window_newest_bucket_start_seconds |
window_newest_bucket_start_seconds |
gauge | window |
window_closed_total is present from startup at zero, so a windowed pipeline
is never mistaken for one without windows before its first close.
window_late_rows_total is the engine's: outcome="refused" counts records
refused before the handler, outcome="recomputed" counts records admitted
within the lateness, each counted once the commit that admitted it lands.
window_recomputes_total counts the buckets republished whole for them.
window_close_lag_seconds is how far the window's closes trail the data it
holds, in event time, and it is the number to alert on: zero while closes keep
up, zero after an idle close, growing while rows arrive and closes fail. It
uses no clock, so a sparse stream does not read as stalled and a host whose
clock is wrong reports it correctly; a stream going quiet shows in
pipeline_last_message_timestamp instead. window_newest_bucket_start_seconds
ahead of a trusted clock means rows are stamped in the future, which moves the
watermark past every correctly-timed row and refuses them as late. It is
updated on every pass that finds rows, even one whose close fails.
window_watermark_seconds trails the newest bucket by grace_seconds by
design, which makes its age the number to size grace_seconds from rather
than one to alert on.
Durable state, present only when the pipeline declares a state path. An absent series and an empty state are different facts, so a pipeline with state in memory reports nothing rather than zero:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
state_commit_count |
state_commit_count_commits_total |
counter | result |
state_commit_latency |
state_commit_latency_seconds |
histogram | — |
state_db_size_bytes |
state_db_size_bytes |
gauge | — |
state_table_rows |
state_table_rows |
gauge | table |
Webhook source, under the meter sqlflow.sources.http and present only with
a webhook source:
| Instrument | Series | Type | Attrs |
|---|---|---|---|
webhook_requests_total |
webhook_requests_total |
counter | status_code |
webhook_request_duration_seconds |
webhook_request_duration_seconds |
histogram | status_code |
$ sqlflow run -c <config> --metrics=prometheus &
$ curl -s localhost:8000/metrics | grep message_count
message_count_messages_total{otel_scope_name="sqlflow",...} 154635
consumer_lag is the one to alert on. It is the broker's high watermark minus
the offset the pipeline has finished with, so it measures the work the
pipeline still owes rather than what its consumer group has been told. It is
measured when a message is processed, so read it with
consumer_lag_observed_timestamp: a consumer cut off from its brokers keeps
its last reading while the backlog grows. It covers only the partitions this
instance holds, and a partition taken away in a rebalance leaves the series.
Each adjacent ratio isolates one kind of loss:
handler_rows_readovermessage_count— parse and DLQ loss. A message the handler rejected never reaches the SQL.sink_rows_acceptedoverhandler_rows_read— whatever the SQL does. A join that drops, aWHERE, aGROUP BY. Its meaning depends on the pipeline, which is why SQLFlow reports the numbers and leaves the threshold to you.sink_rows_writtenoversink_rows_accepted— delivery loss. A ratio that stays below 1 is a sink that is not draining.
The role attribute separates the pipeline sink from the DLQ and from a window
manager's sink. Sum across roles and a rejected record counts as a delivered
one.
Every ratio is a floor, not an equality. SQLFlow is at-least-once: a crash between the flush and the offset commit replays the batch, and the sink writes those rows again, so a ratio can exceed 1 after a normal recovery. Alert on a ratio that is low. Never alert on one that is not exactly 1, or a healthy restart pages somebody.
The sink's buffer depth is sink_rows_accepted_total - sink_rows_written_total.
There is no gauge for it: a counter difference gives the depth and a rate,
and it cannot misreport itself the way a sink's own count can.
An enrichment pipeline joining a dimension table that never loaded shows up
here as sink_rows_accepted collapsing against handler_rows_read — and at
startup, as a warning naming the empty table.
--metrics prometheus also serves /stats on :8000, which reports what is
on disk right now:
$ curl -s localhost:8000/stats
{"state":{"path":"/var/lib/sqlflow/state.db","size_bytes":2109440,
"tables":[{"table":"agg_city_count","rows":1440}],
"offsets":[{"topic":"events","partition":0,"offset":98213}]}}
Reads come from a second connection, so a scrape never blocks a batch and
never reports rows a rollback then erased. A pipeline with no state path
serves no /stats.
| Variable | Purpose |
|---|---|
SQLFLOW_DUCKDB_LIB |
Path to libduckdb. Defaults per-OS as described in Installation |
SQLFLOW_LOG_LEVEL |
Log level, default INFO (DEBUG, INFO, WARN/WARNING, ERROR) |
SQLFLOW_SQL_RESULTS_CACHE_DIR |
Staging dir for InferredDiskBatch, default /tmp/sqlflow/resultscache |
SQLFLOW_STATIC_ROOT |
STATIC_ROOT template variable, default /tmp/sqlflow/static |
PYICEBERG_HOME, PYICEBERG_CATALOG__* |
Iceberg catalog resolution, same as pyiceberg |
SQLFLOW_* |
Anything else is injected into the config template context under its own name |
- An empty batch produces no output rather than an error. A batch is legitimately empty when a fixture is empty or every message in it was rejected; the handlers return no table and the sink is not called.
- A missing field and an explicit
nullare the same value. Both read as SQLNULL, so they aggregate into one group. StructuredBatchtruncates its table at the start of every batch.- DuckDB version. The engine loads whatever
libduckdbyou install;DUCKDB_VERSIONpins what the image and benchmarks use. - Templating is gonja (Jinja2 for Go). Every example config renders under it, asserted by a test.
Measured with make benchmark-container: Apple M1 Pro (10 cores, 32 GB),
Docker 20.10.13, DuckDB v1.5.2, Go 1.25.5, single-partition Kafka
(confluentinc/cp-kafka:7.3.2), 300,000 JSON messages aggregated into DuckDB.
Every run uses a fresh topic and consumer group, so runs are hermetic.
| Handler | batch_size |
Throughput | Peak memory (container) | Peak working set |
|---|---|---|---|---|
handlers.StructuredBatch |
500 | ~305k msgs/sec | 254 MiB | 167 MiB |
handlers.StructuredBatch |
2000 | ~685k msgs/sec | 255 MiB | 172 MiB |
handlers.StructuredBatch |
5000 | ~927k msgs/sec | 240 MiB | 155 MiB |
handlers.InferredMemBatch |
500 | ~159k msgs/sec | 256 MiB | 171 MiB |
handlers.InferredMemBatch |
2000 | ~229k msgs/sec | 264 MiB | 181 MiB |
handlers.InferredMemBatch |
5000 | ~256k msgs/sec | 255 MiB | 171 MiB |
Two memory figures, both sampled from the container's cgroup by the benchmark script: peak memory is everything the container is charged for (page cache and lazily-freed pages included) — the provisioning ceiling; peak working set is anonymous memory, comparable to RSS — what the engine actually holds. Memory is flat across handlers and batch sizes at roughly a quarter GiB, so throughput scales with batch size without buying it with memory.
Setting pipeline.state.path puts every batch in a DuckDB transaction. That
transaction is one fsync per batch, so its cost per message falls as batches
grow. Same pipeline, same 300,000 messages, state in memory against state on
disk:
batch_size |
State in memory | Durable state | Cost |
|---|---|---|---|
| 500 | ~95,600 msgs/sec | ~51,100 msgs/sec | 47% |
| 2000 | ~184,600 msgs/sec | ~130,600 msgs/sec | 29% |
| 5000 | ~201,600 msgs/sec | ~171,500 msgs/sec | 15% |
Use a batch of at least 5000 for a stateful pipeline. Below 2000 the commit dominates and you pay for durability on every message instead of amortising it across a batch.
Memory is unchanged: peak working set stayed within 191-218 MiB across every run above, with and without state. Durability costs throughput, not memory.
state_commit_latency reports the per-batch commit time on the metrics
endpoint, so you can see this cost on your own hardware rather than inferring
it from the table.
Reproduce both arms:
make benchmark-container NUM_MESSAGES=300000 BATCH_SIZE=5000 \
CONFIG=dev/config/examples/benchmark.stateful.mem.yml
STATE_PATH=/tmp/bench-state.db make benchmark-container NUM_MESSAGES=300000 BATCH_SIZE=5000 \
CONFIG=dev/config/examples/benchmark.stateful.mem.yml
Delete the state file between runs. A second run resumes from the first run's offsets and consumes nothing, which reports a meaningless number rather than failing.
To reproduce:
make start-backing-services
make benchmark-container NUM_MESSAGES=300000 BATCH_SIZE=5000
make benchmark-container NUM_MESSAGES=300000 BATCH_SIZE=5000 \
CONFIG=dev/config/examples/benchmark.inferred.mem.yml
Docker Desktop's host→container port-forwarding caps Kafka fetches at roughly
10-15 MB/s. That starves the pipeline and understates throughput by about
10x — you will measure the NAT, not the engine. make benchmark-container
builds a Linux sqlflow binary and runs it on the same docker network as the broker,
which is the only way to get a number that reflects the engine.
make benchmark runs the same workload from the host. It is fine for a quick
smoke test, but do not quote its numbers.
make release-binaries # artifacts land in dist/
SQLFlow cannot be cross-compiled the usual way, and it is worth understanding why before you try. The ADBC driver manager is a cgo package:
CGO_ENABLED=0does not merely produce a degraded binary, it fails to compile:internal/duckdb/open.go:36:20: undefined: drivermgr.Driver.CGO_ENABLED=1 GOOS=linux go buildon a mac hands the C files to the host clang, which cannot target linux, and the build dies inruntime/cgo.
So each target needs a C toolchain for that target, and the matrix is built three different ways:
| Target | How it is built | Host requirement |
|---|---|---|
linux/amd64 |
docker run --platform linux/amd64 |
docker (+ binfmt/qemu if the host is arm64) |
linux/arm64 |
docker run --platform linux/arm64 |
docker (+ binfmt/qemu if the host is amd64) |
darwin/arm64 |
native go build |
macOS + Xcode command line tools |
darwin/amd64 |
go build with -arch x86_64 |
macOS + Xcode command line tools |
A macOS host with docker produces all four. A linux host produces only the two linux targets — darwin binaries would need a macOS SDK and an osxcross-style toolchain, which this repo deliberately does not ship. Targets that cannot be built on the current host are reported as skipped, not faked. Windows is not a target.
The resulting binaries are dynamically linked and dlopen libduckdb; they are not standalone. See Installation.
Pushing the tag publishes the release. .github/workflows/release.yml runs
on any v* tag: it builds linux/amd64 on an amd64 runner and linux/arm64
on an arm64 one, pushes each by digest, assembles one manifest, runs the
published binary on real hardware of each architecture, and only then moves
latest.
make test-image # functional tests against the image
git tag -a v1.0.4 -m "..." && git push origin v1.0.4
Tag first and tag on main: VERSION comes from git describe, and the
workflow refuses a tag that is not an ancestor of origin/main — v1.0.5 was
tagged from a feature branch and shipped unmerged commits.
Nothing in a release is cross-compiled, and that is not a preference. v2026.09.19 was published from a mac whose amd64 half was cross-compiled under Go 1.26; the binary segfaulted at package init, while the identical source built natively on amd64 passed all 19 release tests the same day. A workstation can neither build that half correctly nor run it to find out: it checks the foreign architecture under emulation, which passed for every release before that one. Each architecture is now built and run on its own machine.
make release-image still exists for a dry run
(RELEASE_OUTPUT=--output=type=cacheonly) and for an emergency publish from a
workstation. Prefer the workflow; a workstation publish must be followed by
make release-image-verify, and on an arm64 host that check cannot tell a
working amd64 binary from a broken one.
| Variable | Default | Purpose |
|---|---|---|
RELEASE_PLATFORMS |
linux/amd64,linux/arm64 |
Architectures to build |
RELEASE_LATEST |
1 |
Also tag latest; set 0 when re-publishing an older tag |
RELEASE_OUTPUT |
--push |
Set --output=type=cacheonly for a dry run that publishes nothing |
SQLFLOW_IMAGE |
turbolytics/sql-flow:$(VERSION) |
Full image reference |
make release-image-verify reads the registry back and runs the published
image on each architecture: it fails unless the version tag carries both
architectures, latest resolves to the identical manifest digest, and
sqlflow version on each platform reports the tag. A single-arch publish, a
latest left on an older release, or an emulated build that never actually
ran all look fine locally and are only visible from outside.
In the workflow each platform is native, so neither emulation nor
cross-compilation is involved. make release-image on one host is different:
it builds the foreign architecture with Debian's cross toolchain, because
CGO_ENABLED=1 is required for the ADBC driver manager and an emulated build
takes far longer. That path is known-broken for linux/amd64 under Go
1.26 — see above. Each platform fetches its own libduckdb either way,
because scripts/install-libduckdb.sh branches on the target architecture.
Publishing happens in CI, on the tag. A workstation can still publish in an
emergency, and scripts/verify-release-image.sh is what checks either — it
reads the registry back rather than the local daemon.
Why this target exists:
v1.0.0was published by hand from a mac with a plaindocker build, so it went out arm64-only and did not run on amd64 at all.docker tag+docker pushhas the same failure mode — it flattens a manifest list down to one architecture. To point an existing tag at another release, copy the manifest instead:docker buildx imagetools create -t turbolytics/sql-flow:latest turbolytics/sql-flow:v1.0.4.
Additional examples are available in the wiki: Tutorials.
Every example config lives in dev/config/examples/.
Running SQL against the Bluesky firehose is a single configuration file:
The following command starts a bluesky consumer and prints every post to stdout:
./bin/sqlflow run -c dev/config/examples/bluesky/bluesky.raw.stdout.yml
Checkout the configuration files here
The following configuration writes to an Iceberg table using a local SQLite catalog:
- Initialize the SQLite iceberg catalog and test table.
PYICEBERG_HOMEpoints at the directory holding.pyiceberg.yaml, which defines thesqlflow_testcatalog the example config expects:
PYICEBERG_HOME=$(pwd)/dev/config/iceberg uv run python cmd/setup-iceberg-local.py setup
created default.city_events
created default.bluesky_post_events
Catalog setup complete.
- Start Kafka Locally
docker-compose -f dev/kafka-single.yml up -d
- Publish Test Messages to Kafka
uv run python cmd/publish-test-data.py --num-messages=5000 --topic="input-kafka-mem-iceberg"
- Run SQLFlow, which reads from Kafka and writes to the iceberg table locally
PYICEBERG_HOME=$(pwd)/dev/config/iceberg \
./bin/sqlflow run -c dev/config/examples/kafka.mem.iceberg.yml --max-msgs=5000
- Verify iceberg data was written by querying it with duckdb
$ duckdb -c "select count(*) from '/tmp/sqlflow/warehouse/default.db/city_events/data/*.parquet';"
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 5000 │
└──────────────┘
SQLFlow was written in Python before v1. That implementation has been removed. v1 reads the same configuration files, so moving a pipeline means swapping the image or binary: the config, the templating and the DuckDB SQL carry over.
Seven things changed:
- UDFs are not supported. Pre-v1 took Python UDFs through a
udfs:block. Define the function in DuckDB instead — a macro, an extension, or anATTACHed database that provides it. Audfs:block is now a hard error naming the functions, rather than a silent skip that surfaces later as an opaque binder error. - The command line is accepted as it was.
run pipeline.yml --max-msgs-to-process=Nworks unchanged.-cand--max-msgsare the native spellings. - Console output is one JSON object per line rather than a Python list of dicts. Same rows, different rendering.
- An empty batch produces no output. It used to raise.
StructuredBatchtruncates its table every batch. It used to keep it.- Log format is zap's console format.
SQLFLOW_LOG_LEVELstill accepts Python's level names. - DuckDB is whatever
libduckdbyou install, and the image pins 1.5.x. Pre-v1 pinned 1.3.1, so "same SQL, same result" is not guaranteed across that gap.
Published python-<sha> tags remain on Docker Hub. Reproducing one means
checking out a commit from before the removal.
The sqlflow/ directory still exists, and holds test harness rather than an
engine: settings.py, kafka.py, fixtures/ and logging.py. The image
tests under tests/release and the dev scripts in cmd/ import them.
make sqlflow # build bin/sqlflow
make test-go # build, vet, gofmt check, unit tests
make test-growth # the growth tier, bounded; test-growth-full runs it full
make test-image # build the image and run tests/release against it
The tests run in four tiers, in this order:
- Unit,
go test -short -race ./...: near instant. - Integration,
go test -run '^TestIntegration' ./...: the function, against a real service in a container. - Growth,
make test-growth: non-functional. Tests namedTestGrowth…drive a component for many iterations and fail if memory grows with the work. A branch runs them bounded; main and a release run them full length. - Release,
make test-image: the shipped image.
Growth follows unit and integration, because a leak check is worth reading only once the function it drives is proven.
tests/release and the coverage matrix are Python. Both run through uv,
which builds the environment from uv.lock on first use. There is no
pip install step, and no dependency is resolved at install time.
The matrix generator is scripts/coverage_matrix/, one module per concern
with a test file each under tests/tooling/. Its own docstring lists them.
Coverage and invariant matrix — what is tested, and separately, what is proven. It is generated from the suites on every push and gates the merge.
What is committed is one status per feature and per invariant, under
docs/coverage/status/. Adding a test inside a feature that is already
covered changes none of it. The page is rendered from those files and the
registries alone, so make coverage-page regenerates it in a second, with no
Docker and no test run. Test names and counts are in the coverage report CI
publishes on every run.
Contributing — the verification a pull request carries,
including the ten-minute memory soak (make soak) required of any change that
allocates per message or per request.
Contributor License Agreement — one comment on your first pull request. You keep your copyright; the agreement grants the license that lets the project ship your work, and the patent grant that goes with it.
CI runs all four tiers on every push. Backing services for local development:
make start-backing-services
make stop-backing-services
Like SQLFlow? Use SQLFlow? Feature Requests? Please let us know! danny@turbolytics.io
