SQL¶
A practical guide to the SQL clink runs: the statements, clauses, types and functions it supports, and how to put them to work. This is the reference you write pipelines from. For how the frontend turns SQL into operators (parse, bind, plan, optimise), see the SQL frontend internals.
clink runs streaming SQL. A query is a standing pipeline over a source that
may never end, not a one-shot request against a table at rest. You declare where
data comes from and where results go, then wire them with INSERT INTO ... SELECT;
the job runs until you stop it (or until a bounded source drains).
The grammar is PostgreSQL 16, parsed with libpg_query, plus a small set of
streaming extensions: window functions in GROUP BY, MATCH_RECOGNIZE, and the
AI table functions. Most of what clink cannot support is rejected at compile time
with a source position rather than failing halfway through a run. A few constructs
parse but are deliberately ignored; those are called out under
What is not supported.
Running a script¶
Put statements in a .sql file and run it:
clink run pipeline.sql
Statements execute top to bottom. A typical script declares a source table,
declares a sink table, then issues one INSERT INTO sink SELECT ... FROM source
to start the standing job. clink run executes the whole script embedded in a
single process. Two flags matter for production runs:
--checkpoint-dir <dir>turns on periodic checkpointing so the job can recover.--capture-dir <dir>records the run so it can be replayed deterministically.
See Embedded execution for the full CLI.
Your first pipeline¶
Read newline-delimited JSON, keep the checkout clicks, write them out:
CREATE TABLE clicks (user_id BIGINT, url TEXT, ts BIGINT)
WITH (connector='file', format='json', path='/tmp/clicks.ndjson',
event_time_column='ts', watermark_lag_ms='200');
CREATE TABLE checkout_hits (user_id BIGINT, url TEXT)
WITH (connector='file', format='json', path='/tmp/checkout.ndjson');
INSERT INTO checkout_hits
SELECT user_id, url
FROM clicks
WHERE url LIKE '%checkout%';
Everything else in this guide builds on that shape: a source table, a transformation, a sink.
Tables and connectors¶
A table is a named binding to an external source or sink. It carries a column
schema and a WITH (...) option bag:
CREATE TABLE [IF NOT EXISTS] <name> ( <col> <type> [, ...] )
WITH ( <key>='<value>' [, ...] )
Every WITH value is a single-quoted string literal, including numeric ones
(watermark_lag_ms='200', not 200). Columns are name TYPE pairs; table-level
constraints (PRIMARY KEY (...), CHECK, UNIQUE) are rejected, and inline
NOT NULL / PRIMARY KEY parse but are ignored. Every column is nullable.
Choosing the channel¶
clink moves records on one of two channels, chosen from the schema and options:
- Row channel: set
format='json', or declare more than one column. This is what you want almost always. Joins,MATCH_RECOGNIZEand the AI functions require it. - String channel: a single
TEXT/VARCHARcolumn with noformat='json', for raw line-oriented data.
A table with more than one column and no format='json' is a compile error, so
if in doubt, declare format='json'.
Common options¶
The connector decides which options apply; these are the ones most pipelines set. Per-connector options live in the connector reference.
| Option | Meaning |
|---|---|
connector |
required; the source or sink kind (see below) |
format |
'json' selects the Row channel |
event_time_column |
the column carrying event time; enables windowing |
watermark_lag_ms |
how far watermarks trail the max seen event time (out-of-orderness) |
idle_timeout_ms |
advance watermarks when a source partition goes quiet |
allowed_lateness_ms |
grace period after a window fires (default 0) |
late_records_to_dlq |
'true' routes fully-late records to a dead-letter path |
mode |
'append' (default), 'upsert', or 'cdc' |
primary_key |
comma-separated key columns; required for mode='upsert' |
delivery_guarantee |
'at_least_once' (default) or 'exactly_once' |
partition_by |
partition column(s) for a file or object-store sink |
Event time and watermarks¶
Windowing, interval joins and MATCH_RECOGNIZE are driven by event time, not
arrival time. Declare which column carries it and how far out of order it may be:
CREATE TABLE bids (auction BIGINT, price BIGINT, datetime BIGINT)
WITH (connector='kafka', format='json', topic='bids',
'bootstrap.servers'='localhost:9092',
event_time_column='datetime', watermark_lag_ms='2000');
A watermark of "max event time seen minus watermark_lag_ms" then advances the
window machinery. A source with no event_time_column cannot drive event-time
windows.
Connector catalogue¶
connector names the kind. Sources you can read from include file, kafka,
rabbitmq, websocket, nats, pulsar, postgres, mysql, clickhouse,
parquet, s3_parquet (and gcs_parquet / azure_parquet / webhdfs_parquet),
kinesis, http_poll, pubsub, redis, iceberg, nexmark, lookup and
queryable_state. Sinks you can write to include file, kafka, parquet,
s3 / s3_parquet, postgres, mysql, clickhouse, http, elasticsearch,
opensearch, splunk_hec, prometheus, firehose, dynamodb, redis,
changelog, and the debugging sinks print, collect and blackhole.
A connector must be linked into the runtime you run. The planner always emits the right factory name; a connector that is not linked fails at deploy time, not compile time.
Data types¶
Types are nullable everywhere. libpg_query normalises some spellings before clink sees them, so several aliases resolve to the same type.
| SQL spellings | Stored as |
|---|---|
BIGINT, INT8 |
64-bit integer |
INTEGER, INT, INT4 |
32-bit integer |
SMALLINT, INT2 |
16-bit integer |
REAL, FLOAT4 |
32-bit float |
DOUBLE PRECISION, FLOAT8, DOUBLE |
64-bit float |
BOOLEAN, BOOL |
boolean |
TEXT, VARCHAR, VARCHAR(n), CHAR, STRING |
UTF-8 string (length modifiers ignored) |
NUMERIC / DECIMAL (p,s) |
exact fixed-point, up to 38 digits (default (38,9)) |
TIMESTAMP (p) |
timestamp, precision p in 0-9 (default microseconds) |
TIMESTAMPTZ, TIMESTAMP WITH TIME ZONE |
timestamp, UTC |
DATE |
date |
TIME |
time of day |
BYTEA |
binary |
Composite and array types (available as column types):
| SQL spelling | Meaning |
|---|---|
T[], T[][], T ARRAY |
array (nested per dimension) |
ARRAY<T> |
array (angle form) |
MAP<K, V> |
map |
ROW<f1 T1, f2 T2, ...> |
struct with named fields |
MULTISET<T> |
bag (list semantics) |
They nest, for example MAP<TEXT, ARRAY<INT>> or ROW<id BIGINT, attrs MAP<TEXT,TEXT>>.
CREATE TABLE t (
id BIGINT,
amount DECIMAL(18,2),
tags MULTISET<TEXT>,
addr ROW<city TEXT, zip INT>
) WITH (connector='file', format='json', path='/tmp/t.ndjson');
Selecting, filtering, projecting¶
SELECT chooses and computes columns; WHERE filters rows. Predicates support
the usual comparisons and combinators:
| Form | Notes |
|---|---|
= <> != < <= > >= |
column-vs-literal, column-vs-column, or expression operands |
AND OR NOT |
boolean combinators |
x BETWEEN lo AND hi |
x must be a bare column |
x IN (v, ...) / NOT IN |
literal value lists |
x IS NULL / IS NOT NULL |
two-valued |
s LIKE 'pat' |
% and _ wildcards (there is no ILIKE) |
EXISTS (subquery) / NOT EXISTS |
correlated single-table subquery |
col IN (subquery) / NOT IN |
single- or multi-column, NULL-aware |
col op (SELECT agg(x) FROM ...) |
scalar subquery, must be an aggregate |
SELECT user_id, url
FROM clicks
WHERE user_id BETWEEN 5 AND 10
AND url LIKE '%checkout%'
AND url IS NOT NULL;
SELECT * FROM orders
WHERE user_id IN (SELECT user_id FROM vips);
Functions¶
A function name that clink does not recognise as a built-in is resolved as a user-defined function (see Functions you define).
String¶
| Function | Purpose |
|---|---|
upper(s), lower(s), initcap(s) |
case folding and title case |
length(s), char_length(s) |
byte length |
s1 \|\| s2, concat(a, ...) |
concatenation (any NULL makes the result NULL) |
substring(s, start[, len]) |
1-based, bounds clamp |
position(needle IN haystack) |
1-based index, 0 if absent |
replace(s, from, to) |
replace all occurrences |
btrim/ltrim/rtrim(s[, chars]) |
trim (whitespace by default) |
lpad/rpad(s, len[, pad]) |
pad or truncate to length |
left(s, n), right(s, n) |
prefix / suffix |
split_part(s, delim, idx) |
1-based field; split_index is 0-based |
regexp_extract(s, pattern[, group]) |
first match (ECMAScript regex) |
starts_with(s, prefix) |
boolean |
reverse(s), repeat(s, n), ascii(s), chr(n) |
byte utilities |
Numeric¶
| Function | Purpose |
|---|---|
+ - * / % |
arithmetic (see numeric model) |
abs, sign, floor, ceil/ceiling |
rounding and sign |
round(x[, digits]), trunc(x[, digits]) |
round / truncate to digits |
sqrt, exp, ln, log10 |
transcendentals |
power(b, e) / pow(b, e), mod(a, b) |
power and modulo |
Date and time¶
Timestamps are handled as UTC integers, with no locale or timezone database, so results are reproducible.
| Function | Purpose |
|---|---|
extract(<field> FROM ts) |
year, month, day, hour, minute, second, dow, doy, quarter, epoch |
date_trunc('<unit>', ts) |
truncate to second...year |
date_format(ts, '<fmt>') |
format with yyyy MM dd HH mm ss |
to_timestamp(s[, '<fmt>']) |
parse (default 'yyyy-MM-dd HH:mm:ss') |
SELECT id, EXTRACT(YEAR FROM ts) AS yr, DATE_TRUNC('hour', ts) AS hr FROM t;
Conditional and null¶
| Form | Purpose |
|---|---|
CASE WHEN p THEN v [WHEN ...] [ELSE v] END |
searched CASE; only the first matching branch evaluates |
coalesce(a, ...) |
first non-NULL |
nullif(a, b) |
NULL if the two are equal |
greatest(...), least(...) |
NULL-ignoring extremum |
CAST¶
CAST(x AS <type>)
Supported targets are integer (BIGINT/INT/SMALLINT), float
(DOUBLE PRECISION/REAL), NUMERIC/DECIMAL (precision and scale carried
through), text (TEXT/VARCHAR) and BOOLEAN. Casting to date, timestamp or
BYTEA is not supported.
JSON and collections¶
| Form | Purpose |
|---|---|
json_value(j, path) |
scalar at a path (as text) |
json_query(j, path) |
object/array at a path (as JSON text) |
json_exists(j, path) |
boolean |
json_object(k, v, ...) |
build a JSON object |
ARRAY[e1, e2, ...], a[i] |
array literal, 1-based subscript |
MAP(k1, v1, ...), m['k'] |
map literal and lookup |
ROW(v1, ...), (r).f |
row literal and field access |
cardinality(x), element(x) |
element count; sole element of a singleton |
JSON paths support $, .key and [index].
Deterministic by design
Two things follow from clink keeping SQL results reproducible:
- No wall-clock functions.
now(),current_timestamp,current_date,current_time,localtimeandlocaltimestampare rejected. Drive time from anevent_time_columninstead. A replay then reproduces a run exactly. - Exact numerics. Integers are held as 64-bit and stay exact well past
2^53; a fractional literal becomes an exact
DECIMAL, not a lossy double;SUM(BIGINT)andDECIMAL(p,s)arithmetic are exact end to end (overflow and divide-by-zero yield NULL). DeclareDOUBLE PRECISIONif you would rather have speed than exactness.
Aggregating¶
GROUP BY produces one row per key. Every non-aggregate item in the SELECT must
be a group key (there is no SELECT * with GROUP BY).
| Aggregate | Notes |
|---|---|
COUNT(x), COUNT(*) |
row / non-null counts |
SUM(x), AVG(x) |
sum keeps integers and decimals exact; avg is DOUBLE |
MIN(x), MAX(x) |
input type |
STDDEV[_POP\|_SAMP], VARIANCE, VAR_POP, VAR_SAMP |
DOUBLE |
STRING_AGG(x[, sep]) / LISTAGG |
string concatenation (default separator ,) |
ARRAY_AGG(x) / COLLECT |
collect values into an array |
PERCENTILE(x, f), APPROX_PERCENTILE(x, f) |
fraction f in [0,1] |
HAVING filters grouped results and requires a GROUP BY. DISTINCT inside an
aggregate is supported only for COUNT, STRING_AGG and ARRAY_AGG.
SELECT url, COUNT(*) AS n, COUNT(DISTINCT user_id) AS uniques
FROM clicks
GROUP BY url
HAVING n > 10;
Unbounded state
A plain (non-windowed) GROUP BY, SELECT DISTINCT, and stream-stream joins
keep per-key state for the life of the job, with no TTL in this version. Use
them over bounded sources, or window the aggregation so state is released when
a window fires.
Windows¶
Window aggregation groups by a time window. The window is a table-valued function
in the GROUP BY, taking the event-time column as its first argument. Sizes are
milliseconds as a plain integer, or an INTERVAL '<n>' SECOND|MINUTE|HOUR|DAY.
| Window | Arguments |
|---|---|
TUMBLE(time_col, size) |
fixed, non-overlapping |
HOP(time_col, size, slide) |
sliding; size then slide |
SESSION(time_col, gap) |
closes after gap of inactivity |
CUMULATE(time_col, step, size) |
growing; step then size |
Note the argument order: HOP takes size then slide, CUMULATE takes step then
size. Pass the same column you declared as event_time_column; watermarks only
advance on that column. The synthetic columns window_start and window_end
(epoch-ms BIGINT) are projectable whenever a window function is present.
SELECT bidder, COUNT(*) AS num, window_start AS ws, window_end AS we
FROM bids
GROUP BY TUMBLE(datetime, INTERVAL '10' SECOND), bidder;
Joins¶
A join reads as FROM a JOIN b ON <condition>. Only ON is accepted;
USING (...) and NATURAL JOIN are not supported. In clauses after the join,
refer to columns either qualified (a.x) or flattened (a_x).
Inner equi-join. The common case:
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;
Interval join. Both sides declare an event_time_column; the condition is an
equality plus a time band. State is bounded by the band, so this suits streams:
SELECT * FROM clicks c JOIN impressions i
ON c.user_id = i.user_id
AND c.click_ts BETWEEN i.imp_ts - INTERVAL '5' SECOND
AND i.imp_ts + INTERVAL '10' SECOND;
Lookup (enrichment) join. The right table is a dimension declared
WITH (connector='lookup', function='<registered_fn>'), resolved per row:
SELECT * FROM orders o JOIN customers c ON o.cust = c.id;
Semi / anti joins are expressed with IN / NOT IN / EXISTS / NOT EXISTS
(see filtering).
Running totals and top-N¶
OVER aggregates compute a value per row against a partition, ordered by the
event-time column:
SELECT *, SUM(amount) OVER (PARTITION BY user_id ORDER BY ts) AS running
FROM payments;
OVER supports SUM/COUNT/AVG/MIN/MAX, plus FIRST_VALUE, LAST_VALUE and
LAG(col[, offset]), with an optional ROWS/RANGE BETWEEN n PRECEDING AND CURRENT ROW
frame on the plain aggregates. LEAD, NTILE and FOLLOWING frames are not
supported.
Top-N per key uses a ranking function in a subquery with an outer bound:
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn
FROM clicks
) sub
WHERE rn <= 3;
ROW_NUMBER, RANK and DENSE_RANK are supported. The outer bound must be
rn <= N, rn < N or rn = 1.
Pattern matching¶
MATCH_RECOGNIZE finds sequences of rows within a partition, ordered by event
time. It needs the Row channel (format='json').
SELECT * FROM ticks MATCH_RECOGNIZE (
PARTITION BY symbol
ORDER BY ts
MEASURES FIRST(down.price) AS start_price, LAST(down.price) AS bottom
PATTERN (start down+ up)
DEFINE down AS price < PREV(price),
up AS price > PREV(price)
);
Supported: PARTITION BY, a single event-time ORDER BY, MEASURES with
FIRST / LAST / CLASSIFIER(), PATTERN with the quantifiers + * ?, {n}
and {n,m}, per-row DEFINE predicates using PREV(col), ONE ROWS PER MATCH
(default) or ALL ROWS PER MATCH, and AFTER MATCH SKIP PAST LAST ROW.
Because PREV is NULL on the first row of a partition, start the pattern on an
anchor variable (PATTERN (start down+ up)), not on a PREV-conditioned one.
Grouped sub-patterns, PERMUTE, reluctant quantifiers (+?), PREV with an
offset, and other AFTER MATCH SKIP modes are not supported.
Views and materialised views¶
A view is a named query, expanded inline wherever it is referenced. It is session-scoped, not persisted:
CREATE VIEW recent_checkouts AS
SELECT user_id, url FROM clicks WHERE url LIKE '%checkout%';
A materialised view maintains a query into a backing table. freshness
chooses how it is kept up to date: '0' (or continuous) keeps a live
maintenance job; a positive duration such as '5m' or '1h' recomputes on that
interval and atomically overwrites the backing.
CREATE MATERIALIZED VIEW user_totals
WITH ('freshness'='0', connector='file', format='json', path='/tmp/user_totals.ndjson')
AS SELECT user_id, SUM(amount) AS total FROM events GROUP BY user_id;
REFRESH MATERIALIZED VIEW user_totals; -- interval (full-refresh) views only
AI in SQL¶
clink treats models and vector search as first-class SQL. See the connector reference for provider details.
Declare a model with typed inputs and outputs and a provider:
CREATE MODEL embedder
INPUT (text TEXT)
OUTPUT (vec DOUBLE PRECISION ARRAY)
WITH ('provider'='http', 'endpoint'='http://localhost:8900/embed');
Providers are http (POST features as a JSON row, read named output columns back),
onnx (a local model file, opt-in build) and in-process closures.
Score rows with ML_PREDICT, which appends the model's output columns:
INSERT INTO scored
SELECT * FROM ML_PREDICT(TABLE reviews, MODEL sentiment, DESCRIPTOR(body));
Retrieve by similarity with VECTOR_SEARCH. It takes a query table and column,
a corpus table, the corpus vector column, and top_k, and appends a score:
INSERT INTO hits
SELECT * FROM VECTOR_SEARCH(
TABLE queries, embedding, -- query rows and their vector column
documents, DESCRIPTOR(vec), -- corpus and its vector column
5, -- top-k
metric = 'cosine' -- or 'l2', 'dot'
);
metric is cosine (default), l2 or dot. corpus_refresh_ms re-scans the
corpus on an interval so a growing corpus is picked up without a restart.
filter_eq scopes each query to the corpus rows whose columns equal the query's,
as a genuine pre-filter (it scores only the matching subset, so it does not lose
recall the way filtering a top-k afterwards would):
SELECT * FROM VECTOR_SEARCH(
TABLE queries, embedding, documents, DESCRIPTOR(vec), 5,
filter_eq = 'q_system:system' -- query column : corpus column
);
A comma separates multiple bindings. A NULL query value imposes no constraint on that binding.
Functions you define¶
Scalar UDF. An expression over the named parameters, in SQL:
CREATE OR REPLACE FUNCTION with_tax(amount BIGINT) RETURNS BIGINT
AS 'amount + amount / 10' LANGUAGE SQL;
The body may use any built-in scalar function, CASE and arithmetic. A UDF is
then callable anywhere a built-in is.
Aggregate UDF (UDAF) is provided as a WebAssembly module (opt-in build):
CREATE OR REPLACE AGGREGATE weighted_sum(BIGINT)
(language = 'wasm', module = '/path/wsum.wasm', result_type = 'BIGINT', export = 'wsum');
A wasm UDAF works anywhere a built-in aggregate does, including windowed
GROUP BY. Remove either with DROP FUNCTION / DROP AGGREGATE.
Set operations and ordering¶
UNION (distinct), UNION ALL, INTERSECT, INTERSECT ALL, EXCEPT and
EXCEPT ALL combine two queries; the branches must match in column count and type.
ORDER BY requires a LIMIT at the top level (an unbounded sort has no meaning on
a stream), and its columns must be in the SELECT output. LIMIT n [OFFSET m]
bounds the result.
SELECT url FROM clicks_a
INTERSECT ALL
SELECT url FROM clicks_b;
LIMIT is per-subtask
At a parallelism above 1, each subtask emits up to n rows, so a global
LIMIT needs a single-source pipeline.
Managing the catalogue¶
| Statement | Purpose |
|---|---|
ALTER TABLE t ADD/DROP/ALTER COLUMN ... |
evolve a base table's schema |
ALTER TABLE t SET/RESET (k='v', ...) |
change WITH options |
ALTER TABLE t RENAME [COLUMN old] TO new |
rename a table or column |
DROP TABLE/VIEW/MATERIALIZED VIEW [IF EXISTS] n |
remove an object (kind must match) |
SHOW TABLES |
list registered tables |
ANALYZE TABLE t [(cols)] |
collect statistics for the optimiser (bounded sources) |
EXPLAIN <SELECT\|INSERT> |
print the optimised plan with row estimates |
ALTER applies to base tables only, and refuses to drop the event-time column, a
primary-key column, or the last column.
What is not supported¶
Being explicit so you are not surprised at compile time:
- Schema-qualified names. Use unqualified names;
schema.tableis rejected in queries and its prefix dropped in DDL. The namespace is flat. - No wall-clock functions (
now()and friends) - drive time from event time. - No
INSERT ... VALUES, noCREATE TABLE AS SELECT/SELECT INTO(use a materialised view), noILIKE, noJOIN ... USING/NATURAL JOIN, noWATERMARK FOR ...clause (use theWITHoptions), no temporal orBYTEAcasts. - No state TTL on unbounded joins,
DISTINCT, or unboundedGROUP BYin this version. - Silently accepted, then ignored:
WITH CHECK OPTIONon a view; the options toEXPLAIN ANALYZE/EXPLAIN VERBOSE(no execution or timing); inlineNOT NULL/PRIMARY KEYcolumn constraints;HAVINGwithoutGROUP BY.
See also¶
- SQL frontend internals - how a query becomes operators.
- Connectors - per-connector options and formats.
- Embedded execution - the
clink runCLI, capture and checkpointing. - Capabilities - the full engine feature catalogue.