DuckDB for streaming data
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.
COPY to (PostgreSQL, S3, parquet, MotherDuck, DuckLake).make sqlflowdev invoke runs 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}docker-compose -f dev/kafka-single.yml up -duv.lock and builds the environment on first run:uv run python cmd/publish-test-data.py --num-messages=10000 --topic="input-simple-agg-mem"docker exec -it kafka1 kafka-console-consumer --bootstrap-server=kafka1:9092 --topic=output-simple-agg-mem./bin/sqlflow run -c dev/config/examples/basic.agg.mem.yml --max-msgs=10000The 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.jsonTo 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___
./scripts/install-libduckdb.sh /usr/local/lib
export SQLFLOW_DUCKDB_LIB=/usr/local/lib/libduckdb.so
./sqlflow___ versionscripts/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.25+, 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 sqlflowThe 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:
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 |
sqlflow runRuns the pipeline: consume, batch, execute SQL, sink, commit offsets.
sqlflow run [flags]
sqlflow run -c [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}sqlflow serveServes 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 [flags]
sqlflow serve -c [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:
…Parameters 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:
…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 token is an identifier, not a secret: a browser page ships it in plain sight. It names the caller in the request log, and deleting it revokes the caller.
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:
READ_ONLY. serve runs the dataset SQL as written. A write in
it runs too, unless the attachment refuses.GROUP BY itself, so a rollup over raw rows
copies every row to DuckDB first. sqlflow rollup keeps pre-aggregated
tables in Postgres and generates the datasets that read them. A Postgres
view with the GROUP BY also pushes the filter in, but re-aggregates the
range on every request.pg_connection_limit connections, 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.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 takes
options='-c statement_timeout=30000', so an attached Postgres enforces its
own ceiling.serve.pool.size sessions 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 answers 504 query_timeout.
queued_ms in the response and sqlflow_serve_session_wait_secondsNo open issues yet, or sync has not completed.