SQL Integration¶
Apache DataFusion is a fast, extensible query engine for building data-centric systems in Rust. The paimon-datafusion crate provides a full SQL integration that lets you create, query, and modify Paimon tables.
Setup¶
[dependencies]
paimon = "0.3.0"
paimon-datafusion = "0.3.0"
datafusion = "54.0.0"
tokio = { version = "1", features = ["full"] }
Mosaic support is always available and currently read-only. SQL queries can read existing .mosaic files, but Paimon Rust does not write Mosaic data files yet.
SQL Support Scope¶
paimon-datafusion currently targets Apache DataFusion 54.x. The workspace pins datafusion = "54.0.0".
SQL support has two layers:
- DataFusion provides the parser, query planner, optimizer, execution engine, expressions, scalar functions, aggregate functions, and window functions. SQL statements that
SQLContextdoes not intercept are delegated to DataFusion. This includes the DataFusion SQL surface forSELECTqueries, CTEs (including recursive CTEs), subqueries, joins includingLATERALjoins, SQL lambda functions, grouping,HAVING, window clauses,QUALIFY, set operations,ORDER BY,LIMIT/OFFSET,EXPLAIN, information-schema commands such asSHOW TABLES,DESCRIBE,COPY, and ordinaryINSERT. - Paimon-specific table management and row-level writes are implemented by
SQLContext. This includes PaimonCREATE TABLE,ALTER TABLE,DROP TABLE,CREATE TEMPORARY TABLE,CREATE TEMPORARY VIEW, REST Catalog persistentCREATE VIEW,DROP VIEW, andCREATE FUNCTION,DROP TEMPORARY TABLE/VIEW,INSERT OVERWRITE ... PARTITION,UPDATE,DELETE,MERGE INTO,TRUNCATE TABLE,ALTER TABLE ... DROP PARTITION,CALL sys.*, Paimon time travel, andSET/RESET 'paimon.*'.
Not every DataFusion DDL/DML statement maps to a Paimon table operation. For Paimon catalogs, CREATE EXTERNAL TABLE, LOCATION, CREATE MATERIALIZED VIEW, and persistent CREATE TABLE AS SELECT are rejected or not implemented. Persistent CREATE FUNCTION is supported only for the REST Catalog SQL scalar form documented below. DataFusion COPY can export query results to files; it does not create or commit Paimon table files.
For the exact delegated SQL grammar, see the DataFusion SQL Reference.
Registering Catalog¶
Register an entire Paimon catalog so all databases and tables are accessible via paimon.database.table syntax:
use std::sync::Arc;
use paimon::{CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::SQLContext;
async fn example() -> Result<(), Box<dyn std::error::Error>> {
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, "file:///tmp/paimon-warehouse");
let catalog = Arc::new(FileSystemCatalog::new(options)?);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog).await?;
let df = ctx.sql("SELECT * FROM paimon.default.my_table").await?;
df.show().await?;
Ok(())
}
SQLContext::new creates a session context with the Paimon relation planner and
the catalog-independent path_to_descriptor and descriptor_to_string scalar
functions pre-registered. Use register_catalog(...).await to add one or more
Paimon catalogs; registering a catalog also registers the built-in scalar
function blob_view (alias sys.blob_view) and the built-in table-valued
functions (vector_search, hybrid_search, and full_text_search when the
fulltext feature is enabled) against it. It also manages session-scoped
dynamic options internally for SET/RESET support.
REST Catalog Views and SQL Functions¶
When the registered catalog is a Paimon REST Catalog, SQLContext can read, execute, create, and drop persistent views and can create SQL scalar functions.
Create a persistent view with this syntax:
Drop a persistent view with this syntax:
For example:
CREATE VIEW paimon.reporting.daily_orders (order_date, order_count) AS
SELECT order_date, COUNT(*)
FROM orders
GROUP BY order_date;
The defining query is planned before the view is created. Its output types and
nullability become the stored REST view schema, with field IDs assigned from
zero. An optional column list changes only the output names and must contain
exactly one unique name per query column. IF NOT EXISTS is passed to the
catalog so the REST server handles concurrent creates atomically.
Unqualified relations and REST SQL functions in the defining query resolve in
the new view's owning catalog and database, not the session's current database.
The canonical query is stored as both the default query and the datafusion
dialect definition.
Persistent CREATE VIEW and DROP VIEW are currently implemented by REST
Catalog. DROP VIEW sends a direct delete request; IF EXISTS ignores only a
missing-view response. Bare, two-part, and three-part names are supported, but
only one target may be dropped per statement. Other catalog implementations may
return Unsupported. CREATE OR REPLACE VIEW, materialized/secure views, view
comments or options, vendor-specific create modifiers, persistent ALTER VIEW,
and DROP VIEW modifiers such as CASCADE, RESTRICT, or PURGE are not
supported.
Persistent views resolve through the normal DataFusion catalog path, so they can be queried wherever a table can be used:
For a view, the datafusion entry in schema.dialects is preferred. If that
entry is absent, DataFusion uses the view's default schema.query. Unqualified
relations inside the stored query resolve against the view's owning catalog and
database, not the caller's current database. The REST-declared output fields
are authoritative: query results are matched by position, renamed to the
declared field names, and cast to the declared types. Recursive view
dependencies are rejected during planning.
REST SQL scalar functions support bare names in the current catalog/database and fully qualified three-part names:
SELECT normalize_score(score) FROM scores;
SELECT paimon.reporting.normalize_score(score) FROM scores;
Create a persistent REST SQL scalar function with this syntax:
CREATE FUNCTION [IF NOT EXISTS] function_name(
[parameter_name data_type, ...]
)
RETURNS data_type
[LANGUAGE SQL]
RETURN scalar_expression;
For example:
CREATE FUNCTION paimon.reporting.add_tax(amount DECIMAL(12, 2))
RETURNS DECIMAL(12, 2)
RETURN amount * DECIMAL '1.10';
SELECT add_tax(total) FROM orders;
Bare, two-part (database.function), and three-part
(catalog.database.function) names are accepted as creation targets. Unquoted
names are normalized and quoted names are preserved. Calls remain limited to
bare or three-part names; two-part function calls are not supported.
Parameters must be named and cannot have modes or defaults. Zero parameters
are allowed. Inputs and the single return value are stored as nullable fields;
parameter IDs start at zero and the return field has ID 0 and name result.
The canonical, unexpanded RETURN expression is stored in
definitions.datafusion with type: "sql".
LANGUAGE SQL is optional and SQL is the default, matching Databricks SQL
function syntax. IMMUTABLE is not required; when omitted, determinism is
inferred from the planned expression. An explicit IMMUTABLE clause remains
accepted for compatibility.
Before the REST create request is sent, SQLContext expands dependencies using
the new function as a candidate, validates argument substitution and the
declared return cast, and builds both logical and physical DataFusion plans in
the function's owning catalog/database. This rejects undeclared identifiers,
recursive dependencies (including indirect recursion), non-deterministic REST
dependencies, subqueries/table access, aggregate or window functions,
Stable/Volatile DataFusion functions, and incompatible return types. The
function is stored as deterministic only after the planned expression passes
these checks.
IF NOT EXISTS still validates the proposed definition first. The REST server
then handles the create atomically; only an already-existing function error is
ignored.
A function is executable only when all of the following are true:
definitions.datafusionexists and hastype: "sql";- input parameters are declared and the call supplies the exact number of positional expression arguments;
- exactly one return parameter is declared;
- the function is deterministic;
- the SQL definition is a scalar expression that references inputs by their declared parameter names.
Nested REST SQL functions are supported. Bare function names inside a stored definition resolve in that function's owning catalog/database. Recursive function dependencies, missing DataFusion SQL definitions, undeclared identifiers, named arguments, and incompatible return types fail during planning. If no REST function exists for a bare name, normal DataFusion built-in or registered-function resolution continues.
Function expansion is implemented by SQLContext::sql. Queries executed
directly through a raw DataFusion SessionContext do not expand REST SQL
functions. Two-part function names such as database.function(...), lambda or
file definitions, aggregate/table/multi-return functions, and non-deterministic
functions are not supported. CREATE OR REPLACE/ALTER/TEMPORARY FUNCTION,
non-SQL bodies, STABLE/VOLATILE, null-input/parallel/security/SET clauses,
options/remote functions, and persistent ALTER FUNCTION / DROP FUNCTION
are also not supported. Catalog implementations other than REST Catalog may
return Unsupported for persistent function creation.
Data Types¶
The following SQL data types are supported in CREATE TABLE and mapped to their corresponding Paimon types:
| SQL Type | Paimon Type | Notes |
|---|---|---|
BOOLEAN |
BooleanType | |
TINYINT |
TinyIntType | |
SMALLINT |
SmallIntType | |
INT / INTEGER |
IntType | |
BIGINT |
BigIntType | |
FLOAT / REAL |
FloatType | |
DOUBLE / DOUBLE PRECISION |
DoubleType | |
VARCHAR / TEXT / STRING / CHAR |
VarCharType | |
BINARY / VARBINARY / BYTEA / BYTES |
VarBinaryType | |
VARIANT |
VariantType | Semi-structured value encoded as value + metadata binary buffers |
BLOB |
BlobType | Binary large object |
DATE |
DateType | |
TIMESTAMP[(p)] |
TimestampType | Precision p: 0/3/6/9, default 3 |
TIMESTAMP WITH TIME ZONE |
LocalZonedTimestampType | |
DECIMAL(p, s) |
DecimalType | |
ARRAY<element> |
ArrayType | e.g. ARRAY<INT> |
MAP(key, value) |
MapType | e.g. MAP(STRING, INT) |
STRUCT<field TYPE, ...> |
RowType | e.g. STRUCT<city STRING, zip INT> |
For vector search tables created from SQL, use ARRAY<FLOAT> for embedding
columns. Existing Paimon tables may also expose logical VECTOR<FLOAT,N>
columns; DataFusion reads those as Arrow FixedSizeList<Float32>, and vindex
index creation uses N as the vector dimension. SHOW CREATE TABLE currently
does not round-trip VECTOR columns.
Blob Columns¶
BLOB columns store large binary values using Paimon's dedicated BLOB layout. Declare them as top-level columns and enable data evolution:
CREATE TABLE paimon.my_db.assets (
id INT,
picture BLOB
) WITH (
'data-evolution.enabled' = 'true'
);
For Java-compatible DDL, DataFusion also supports the BLOB comment directives used by Java Paimon. A binary column with one of these directive comments is normalized to a Paimon BLOB column in the core schema layer:
CREATE TABLE paimon.my_db.assets (
id INT,
picture BYTES COMMENT '__BLOB_FIELD; original image',
thumbnail BYTES COMMENT '__BLOB_DESCRIPTOR_FIELD; descriptor bytes',
picture_ref BYTES COMMENT '__BLOB_VIEW_FIELD; upstream image reference'
) WITH (
'data-evolution.enabled' = 'true'
);
The directive is stripped from the stored column comment; text after the first
semicolon is kept as the real comment. The directives also populate the matching
table options. A comment directive that starts with __BLOB but is not one of
the supported directives is rejected.
| Comment directive | Table option | Storage semantics |
|---|---|---|
__BLOB_FIELD |
blob-field |
Store BLOB bytes in dedicated .blob files |
__BLOB_DESCRIPTOR_FIELD |
blob-descriptor-field |
Store serialized BlobDescriptor bytes inline |
__BLOB_VIEW_FIELD |
blob-view-field |
Store serialized BlobViewStruct bytes inline |
For serialized BlobDescriptor values supplied by another Paimon engine,
length = -1 means reading from offset to the end of the referenced object.
The offset must be non-negative, and lengths below -1 are invalid.
The same directives are supported by ALTER TABLE ... ADD COLUMN.
Blob Descriptor Functions¶
path_to_descriptor(path) converts a string path into Java-compatible
BlobDescriptor bytes with offset 0 and length -1. Its alias is
sys.path_to_descriptor(path). The function only serializes the path; it does
not access the referenced object or validate that it exists.
descriptor_to_string(descriptor) converts serialized descriptor bytes to the
same string representation used by Java Paimon. Its alias is
sys.descriptor_to_string(descriptor). Invalid descriptor bytes return an
error. Both functions return NULL for NULL input.
SELECT sys.descriptor_to_string(
sys.path_to_descriptor('file:///tmp/image.png')
);
-- BlobDescriptor{version=2, uri='file:///tmp/image.png', offset=0, length=-1}
Blob View¶
Blob View stores an inline reference to a BLOB value in another table, using a
Java-compatible BlobViewStruct payload. It is useful when one table should
point at media or large binary content owned by an upstream table without
copying the bytes at write time.
Declare Blob View columns as top-level BLOB columns and list them in the
blob-view-field table option:
CREATE TABLE paimon.my_db.asset_refs (
id INT,
picture BLOB
) WITH (
'data-evolution.enabled' = 'true',
'row-tracking.enabled' = 'true',
'blob-view-field' = 'picture'
);
Use blob_view(table, field_name_or_id, row_id) or sys.blob_view(...) to
create the reference. The table argument may be table, database.table, or
catalog.database.table; the stored reference contains the resolved
database.table, field id, and row id. In typical SQL, read _ROW_ID from a
row-tracking source table:
CREATE TABLE paimon.my_db.assets (
id INT,
picture BLOB
) WITH (
'data-evolution.enabled' = 'true',
'row-tracking.enabled' = 'true'
);
INSERT INTO paimon.my_db.asset_refs (id, picture)
SELECT
id,
sys.blob_view('my_db.assets', 'picture', "_ROW_ID")
FROM paimon.my_db.assets;
By default, RESTCatalog-backed reads resolve Blob View fields to the upstream
BLOB value by reusing the table's REST environment. Other catalog types
currently preserve the raw serialized BlobViewStruct bytes. Set the dynamic
option paimon.blob-view.resolve.enabled to false to preserve raw references
even for RESTCatalog-backed reads:
SET 'paimon.blob-view.resolve.enabled' = 'false';
SELECT id, picture FROM paimon.my_db.asset_refs;
RESET 'paimon.blob-view.resolve.enabled';
Like ordinary BLOB reads, paimon.blob-as-descriptor = true makes resolved Blob
View columns return serialized BLOB descriptors instead of loading the BLOB
bytes.
Variant Usage¶
VARIANT stores semi-structured data using the same logical value + metadata binary shape as Paimon Java. Use it for JSON-like fields whose schema may differ row by row.
Create VARIANT columns like ordinary table columns:
CREATE TABLE paimon.my_db.user_events (
user_id BIGINT NOT NULL,
event_time TIMESTAMP,
payload VARIANT,
attributes VARIANT,
dt STRING,
PRIMARY KEY (user_id, dt)
) PARTITIONED BY (dt)
WITH ('bucket' = '4');
VARIANT columns can be nullable or NOT NULL:
CREATE TABLE paimon.my_db.variant_examples (
id INT NOT NULL,
payload VARIANT NOT NULL,
optional_payload VARIANT
);
Do not use VARIANT as a partition column. Partition values must be scalar strings, numbers, dates, or timestamps that can be encoded as stable partition names.
Use parse_json when inserting JSON text into a VARIANT column:
INSERT INTO paimon.my_db.user_events VALUES
(
1,
TIMESTAMP '2024-01-01 10:00:00',
parse_json('{"event":"login","device":{"os":"ios","version":17},"score":98.5}'),
parse_json('{"city":"Beijing","tags":["new","mobile"],"vip":true}'),
'2024-01-01'
);
parse_json rejects invalid JSON and duplicate object keys. Use try_parse_json when malformed JSON should become SQL NULL instead of failing the query:
INSERT INTO paimon.my_db.user_events
SELECT
user_id,
event_time,
try_parse_json(raw_payload),
try_parse_json(raw_attributes),
dt
FROM staging_events;
SQLContext::new registers Spark-compatible scalar functions for common VARIANT workflows:
SELECT
user_id,
variant_get(payload, '$.event', 'string') AS event_name,
variant_get(payload, '$.device.os', 'string') AS os,
variant_get(payload, '$.score', 'double') AS score,
variant_get(attributes, '$.tags[0]', 'string') AS first_tag
FROM paimon.my_db.user_events
WHERE variant_get(attributes, '$.vip', 'boolean') = true;
Supported functions:
| Function | Notes |
|---|---|
parse_json(json) |
Parses a JSON string into VARIANT; invalid JSON returns an error |
try_parse_json(json) |
Parses a JSON string into VARIANT; invalid JSON returns NULL |
variant_get(v, path[, type]) |
Extracts a path; missing paths return NULL; invalid casts return an error |
try_variant_get(v, path[, type]) |
Extracts a path; missing paths, invalid paths, and invalid casts return NULL |
is_variant_null(v) |
Returns true for JSON null inside VARIANT; SQL NULL returns false |
Path syntax supports the root path $, object access ($.field), quoted object access ($["field"] or $['field']), array indexes ($[0]), and nested combinations such as $.items[0].price.
The optional type argument is a string literal. Supported result types are variant (or omitted), boolean, byte / tinyint, short / smallint, int / integer, long / bigint, float, double, decimal(p, s), and string.
When type is omitted or set to variant, variant_get returns a nested VARIANT value that can be passed to another variant_get call:
SELECT
variant_get(
variant_get(payload, '$.device'),
'$.os',
'string'
) AS os
FROM paimon.my_db.user_events;
Missing paths return SQL NULL. JSON null is represented as a non-SQL-null Variant value, so use is_variant_null when you need to distinguish it:
Variant Shredding¶
Variant shredding stores selected fields from a VARIANT column as typed
physical fields in Parquet files while keeping the logical table schema as
VARIANT. Reads are automatic: when a projected VARIANT column is stored in
shredded physical form, Paimon Rust assembles it back into the normal
value + metadata representation before returning the batch.
Use a configured shredding schema when the hot fields are known in advance:
CREATE TABLE paimon.my_db.shredded_events (
user_id BIGINT,
payload VARIANT
) WITH (
'file.format' = 'parquet',
'variant.shreddingSchema' =
'{"type":"ROW","fields":[{"name":"payload","type":{"type":"ROW","fields":[{"name":"event","type":"STRING"},{"name":"score","type":"DOUBLE"},{"name":"city","type":"STRING"}]}}]}'
);
The configured schema is a Paimon ROW type encoded as JSON. Field IDs may be
omitted; Paimon Rust assigns them by position. Each top-level field name must
match a VARIANT column to shred. The field's type describes the typed fields
to extract from that Variant value; values that do not match the typed field
still remain in the Variant payload so the logical value can be rebuilt on read.
Use inferred shredding when the hot fields should be discovered from the first rows written by each data-file writer:
CREATE TABLE paimon.my_db.inferred_events (
user_id BIGINT,
payload VARIANT
) WITH (
'file.format' = 'parquet',
'variant.inferShreddingSchema' = 'true',
'variant.shredding.maxInferBufferRow' = '4096',
'variant.shredding.maxSchemaDepth' = '50',
'variant.shredding.maxSchemaWidth' = '300',
'variant.shredding.minFieldCardinalityRatio' = '0.1'
);
When both configured and inferred shredding are set, the configured schema takes
precedence. Shredding currently applies to Parquet data-file writes; ordinary
non-shredded VARIANT files continue to read normally.
Current limitations:
schema_of_variant,schema_of_variant_agg,to_variant_object,variant_explode, andvariant_explode_outerare not implemented yet.variant_getcurrently casts to scalar types andVARIANT. It does not yet cast directly toARRAY,MAP, orSTRUCT.- Simple
variant_getandtry_variant_getexpressions over aVARIANTcolumn, a literal path, and a scalar literal type can be pushed into scans as Variant extraction fields for projections and filters. Predicate translation throughvariant_getis still not applied to Paimon/Parquet statistics; DataFusion evaluates those filters after reading the extracted field.
With a raw DataFusion SessionContext, register these scalar functions explicitly:
DDL¶
CREATE DATABASE / CREATE SCHEMA / DROP SCHEMA¶
CREATE TABLE¶
CREATE TABLE paimon.my_db.users (
id INT NOT NULL,
name STRING,
age INT,
PRIMARY KEY (id)
) WITH ('bucket' = '4');
IF NOT EXISTS is supported:
Unsupported syntax (will return an error):
- CREATE EXTERNAL TABLE
- LOCATION
- CREATE TABLE AS SELECT
Partitioned Tables¶
Use PARTITIONED BY to specify partition columns. Partition columns must already be declared in the column definitions and must not include a type:
CREATE TABLE paimon.my_db.events (
id INT NOT NULL,
name STRING,
dt STRING,
PRIMARY KEY (id, dt)
) PARTITIONED BY (dt)
WITH ('bucket' = '2');
Invalid usage (will return an error):
-- Partition columns must not specify a type
CREATE TABLE paimon.my_db.events (
id INT NOT NULL,
dt STRING
) PARTITIONED BY (dt STRING);
Complex Types¶
CREATE TABLE paimon.my_db.complex_types (
id INT NOT NULL,
tags ARRAY<STRING>,
props MAP(STRING, INT),
address STRUCT<city STRING, zip INT>,
PRIMARY KEY (id)
);
DROP TABLE¶
DROP VIEW¶
Drop one persistent view from a REST Catalog:
DROP VIEW active_users;
DROP VIEW IF EXISTS my_db.active_users;
DROP VIEW IF EXISTS paimon.my_db.active_users;
IF EXISTS ignores only a missing view; authorization, server, and network errors are still returned. Only one persistent view target is supported per statement. CASCADE, RESTRICT, PURGE, and other drop modifiers are rejected. Catalog implementations without persistent view support return Unsupported.
CREATE TEMPORARY TABLE¶
Create an in-memory temporary table from a query result. Temporary tables exist only for the lifetime of the SQLContext instance and are automatically cleaned up when the context is dropped.
-- Without column types (types inferred from the query)
CREATE TEMPORARY TABLE paimon.my_db.source AS SELECT * FROM (VALUES (1, 'alice'), (2, 'bob')) AS t(id, name);
-- With explicit column types (recommended when integer precision matters)
CREATE TEMPORARY TABLE paimon.my_db.source (id INT, name STRING) AS SELECT * FROM (VALUES (1, 'alice'), (2, 'bob')) AS t(id, name);
IF NOT EXISTS is supported — if the table already exists, the statement is silently ignored:
Note: When using
VALUESwithout explicit column types, DataFusion infers integer literals asInt64. If the temporary table will be used as a source inMERGE INTOagainst a Paimon table withInt32columns, specify the column types explicitly to avoid type mismatch errors.
CREATE TEMPORARY VIEW¶
Create a temporary view from a query:
IF NOT EXISTS is supported:
CREATE TEMPORARY VIEW IF NOT EXISTS paimon.my_db.active_users AS SELECT * FROM paimon.my_db.users WHERE id > 0;
DROP TEMPORARY TABLE / DROP TEMPORARY VIEW¶
Remove a temporary table or view:
DROP TEMPORARY TABLE paimon.my_db.source;
DROP TEMPORARY TABLE IF EXISTS paimon.my_db.source;
DROP TEMPORARY VIEW paimon.my_db.active_users;
DROP TEMPORARY VIEW IF EXISTS paimon.my_db.active_users;
ALTER TABLE¶
-- Add a column
ALTER TABLE paimon.my_db.users ADD COLUMN email STRING;
-- Drop a column
ALTER TABLE paimon.my_db.users DROP COLUMN age;
-- Rename a column
ALTER TABLE paimon.my_db.users RENAME COLUMN name TO username;
-- Rename a table
ALTER TABLE paimon.my_db.users RENAME TO members;
-- Set table properties
ALTER TABLE paimon.my_db.users SET TBLPROPERTIES('data-evolution.enabled' = 'true');
IF EXISTS is supported:
DML¶
The table type determines which row-level DML operations are supported:
| Operation | Append-only table | Primary-key table | Data-evolution row-tracking table (no primary key) |
|---|---|---|---|
INSERT INTO |
Supported | Supported | Supported |
INSERT OVERWRITE |
Supported | Supported | Supported |
INSERT OVERWRITE ... PARTITION |
Supported for partitioned tables | Supported for partitioned tables | Supported for partitioned tables |
TRUNCATE TABLE |
Supported | Supported | Supported |
ALTER TABLE ... DROP PARTITION |
Supported for partitioned tables | Supported for partitioned tables | Supported for partitioned tables |
UPDATE |
Supported via Copy-on-Write | Not supported | Supported via row-id update |
DELETE |
Supported via Copy-on-Write | Not supported | Supported when deletion vectors are enabled |
MERGE INTO |
Supported via Copy-on-Write | Not supported | Supported for matched UPDATE, matched DELETE with deletion vectors, and not-matched INSERT |
A data-evolution row-tracking table must have both 'data-evolution.enabled' = 'true' and 'row-tracking.enabled' = 'true', and must not have primary keys. DELETE and matched DELETE in MERGE INTO additionally require 'deletion-vectors.enabled' = 'true'. Primary-key row-level UPDATE, DELETE, and MERGE INTO are not supported even when data evolution is enabled.
INSERT INTO¶
INSERT INTO ... SELECT ... is also supported:
For VARIANT columns, convert JSON text with parse_json or try_parse_json:
INSERT INTO paimon.my_db.user_events (user_id, event_time, payload, attributes, dt)
VALUES (
1,
TIMESTAMP '2024-01-01 10:00:00',
parse_json('{"event":"login","device":{"os":"ios"}}'),
try_parse_json('{"vip":true,"tags":["mobile"]}'),
'2024-01-01'
);
For primary-key tables, records with duplicate keys are deduplicated according to the merge engine (default: Deduplicate engine, where the last written value wins).
Mosaic Read Scope¶
The Mosaic reader supports scalar, temporal, array, and map columns. It uses row-group statistics for conservative pruning when they are present. This pruning is not row-level filter enforcement; DataFusion still applies SQL filters above the reader to produce exact query results.
Unsupported or limited Mosaic areas include writing .mosaic files, emitting manifest value_stats for Mosaic writes, Mosaic bloom filters, and Mosaic-specific performance tuning.
INSERT OVERWRITE¶
For partitioned tables, INSERT OVERWRITE replaces only the affected partitions. For unpartitioned tables, it replaces the entire table:
-- Dynamic partition overwrite: overwrites only the dt='2024-01-01' partition
INSERT OVERWRITE paimon.my_db.events VALUES ('2024-01-01', 10, 'new_alice');
Hive-style static partition overwrite is also supported via the PARTITION clause. The source query provides only non-partition columns, and partition values are specified explicitly:
-- Static partition overwrite: explicitly specify the target partition
INSERT OVERWRITE paimon.my_db.events PARTITION (dt = '2024-01-01')
VALUES (10, 'new_alice'), (20, 'new_bob');
-- With a SELECT source
INSERT OVERWRITE paimon.my_db.events PARTITION (dt = '2024-01-01')
SELECT id, name FROM source_table;
For multi-level partitioned tables, you can specify a subset of partition columns. Unspecified partition columns are read from the source query (dynamic partition). All sub-partitions under the specified partition are replaced:
-- Only dt is static; all data under dt='2024-01-01' is replaced.
-- region comes from the source data.
INSERT OVERWRITE paimon.my_db.events PARTITION (dt = '2024-01-01')
VALUES ('us', 10, 'alice'), ('eu', 20, 'bob');
UPDATE¶
For append-only tables (no primary key), updates are executed using Copy-on-Write:
For data-evolution row-tracking tables without primary keys, updates are executed with row-id-based partial-column writes. Primary-key tables are not supported for UPDATE.
DELETE¶
For append-only tables, deletes are executed using Copy-on-Write:
For data-evolution row-tracking tables without primary keys, deletes are executed via deletion vectors and require 'deletion-vectors.enabled' = 'true'.
DELETE is not supported on primary-key tables.
MERGE INTO¶
Standard SQL MERGE INTO syntax is supported, allowing INSERT, UPDATE, and DELETE in a single statement:
MERGE INTO paimon.my_db.target
USING source ON target.a = source.a
WHEN MATCHED THEN UPDATE SET a = source.a, b = source.b, c = source.c
WHEN NOT MATCHED THEN INSERT (a, b, c) VALUES (source.a, source.b, source.c);
Delete matched rows only:
UPDATE + INSERT combination:
MERGE INTO paimon.my_db.target
USING source ON target.a = source.a
WHEN MATCHED THEN UPDATE SET b = source.b
WHEN NOT MATCHED THEN INSERT (a, b, c) VALUES (source.a, source.b, source.c);
The source can also be a subquery:
MERGE INTO paimon.my_db.target
USING (SELECT * FROM other_table WHERE active = true) AS source
ON target.id = source.id
WHEN MATCHED THEN UPDATE SET name = source.name;
For append-only tables, MERGE INTO uses Copy-on-Write file rewriting and supports matched UPDATE, matched DELETE, and not-matched INSERT. For data-evolution row-tracking tables without primary keys, MERGE INTO uses the _ROW_ID virtual column for row-level tracking and supports matched UPDATE, matched DELETE when deletion vectors are enabled, and not-matched INSERT. Primary-key tables are not supported for MERGE INTO.
TRUNCATE TABLE¶
Truncate an entire table or specific partitions:
-- Truncate the entire table
TRUNCATE TABLE paimon.my_db.users;
-- Truncate specific partitions
TRUNCATE TABLE paimon.my_db.events PARTITION (dt = '2024-01-01');
DROP PARTITION¶
Drop specific partitions from a table using ALTER TABLE ... DROP PARTITION:
Multiple partition key-value pairs can be specified:
Procedures¶
Use CALL to invoke built-in procedures. All procedures are under the sys namespace.
create_tag¶
Create a named tag from a snapshot:
create_tag_from_timestamp¶
Create a named tag from a timestamp (finds the latest snapshot at or before the given time):
CALL sys.create_tag_from_timestamp(table => 'paimon.my_db.my_table', tag => 'my_tag', timestamp => 1234567890000);
delete_tag¶
Delete a named tag:
rollback_to¶
Rollback a table to a specific snapshot or tag:
-- Rollback to a snapshot
CALL sys.rollback_to(table => 'paimon.my_db.my_table', snapshot_id => 1);
-- Rollback to a tag
CALL sys.rollback_to(table => 'paimon.my_db.my_table', tag => 'my_tag');
rollback_to_timestamp¶
Rollback a table to a specific timestamp:
create_global_index¶
Build and commit a global index for a table column:
CALL sys.create_global_index(
table => 'paimon.my_db.my_table',
index_column => 'id',
index_type => 'btree'
);
CALL sys.create_global_index(
table => 'paimon.my_db.my_table',
index_column => 'tag',
index_type => 'bitmap'
);
index_type defaults to btree. BTree and bitmap global indexes support
scalar columns and do not accept the options argument yet. Bitmap global
indexes use the same on-disk file format as Java Paimon's
BitmapGlobalIndexFormat.
The current global-index builders require a row-tracking data-evolution table with global indexes enabled. They do not support primary-key tables or tables with deletion vectors enabled:
CREATE TABLE paimon.my_db.items (
id INT,
embedding ARRAY<FLOAT>
) WITH (
'bucket' = '1',
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true',
'global-index.enabled' = 'true',
'global-index.row-count-per-shard' = '100000'
);
For vector indexes backed by vindex, set index_type to one of ivf-flat,
ivf-pq, ivf-hnsw-flat, or ivf-hnsw-sq:
CALL sys.create_global_index(
table => 'paimon.my_db.items',
index_column => 'embedding',
index_type => 'ivf-flat',
options => 'ivf-flat.dimension=4,ivf-flat.nlist=256,ivf-flat.distance.metric=inner_product'
);
The options argument is a comma-separated key=value string. User options
override table options. Use keys prefixed by the selected index type, or set
field-level table options with fields.<column>.<option>:
CREATE TABLE paimon.my_db.image_items (
id INT,
embedding ARRAY<FLOAT>
) WITH (
'bucket' = '1',
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true',
'global-index.enabled' = 'true',
'fields.embedding.dimension' = '768',
'fields.embedding.distance.metric' = 'cosine',
'fields.embedding.nlist' = '1024'
);
Supported vindex options:
| Option | Default | Applies To | Description |
|---|---|---|---|
<index-type>.dimension |
128 |
all vindex types | Vector dimension for ARRAY<FLOAT> columns. Existing VECTOR<FLOAT,N> columns use N from the type. |
<index-type>.distance.metric |
inner_product |
all vindex types | Distance metric: inner_product, cosine, or l2. |
<index-type>.nlist |
256 |
all vindex types | Number of IVF lists. |
<index-type>.pq.m |
16 |
ivf-pq |
Number of product-quantization sub-vectors. The dimension must be divisible by this value. |
<index-type>.pq.use-opq |
false |
ivf-pq |
Whether to enable OPQ before PQ encoding. |
<index-type>.hnsw.m |
native default | HNSW vindex types | HNSW graph connectivity. |
<index-type>.hnsw.ef-construction |
native default | HNSW vindex types | HNSW construction beam width. |
<index-type>.hnsw.max-level |
native default | HNSW vindex types | Maximum HNSW graph level. |
Native vindex aliases are also accepted in the options string: dimension,
metric, nlist, pq.m, use-opq, and hnsw.*.
Inspect committed index files with the $table_indexes system table:
SELECT index_type, index_field_name, row_count, row_range_start, row_range_end
FROM paimon.my_db.items$table_indexes;
drop_global_index¶
Drop a committed sorted global index:
CALL sys.drop_global_index(
table => 'paimon.my_db.my_table',
index_column => 'id',
index_type => 'btree'
);
BTree and bitmap global indexes can be dropped through this procedure currently.
create_lumina_index¶
Build and commit a Lumina global vector index for a table column:
The optional index_type argument selects the Lumina index identifier. It defaults to
lumina. Valid values are lumina and the legacy-compatible lumina-vector-ann.
CALL sys.create_lumina_index(
table => 'paimon.my_db.my_table',
index_column => 'embedding',
index_type => 'lumina'
);
Optional Lumina builder settings can be supplied as comma-separated key=value pairs:
CALL sys.create_lumina_index(
table => 'paimon.my_db.my_table',
index_column => 'embedding',
options => 'lumina.index.dimension=128,lumina.encoding.type=pq'
);
Queries¶
Basic Queries¶
All DataFusion query capabilities are supported (JOINs, aggregations, subqueries, CTEs, etc.):
Variant Queries¶
Use variant_get to extract fields from VARIANT columns. Provide a target type string when the query needs a scalar result:
SELECT
user_id,
variant_get(payload, '$.event', 'string') AS event_name,
variant_get(payload, '$.device.os', 'string') AS device_os,
variant_get(attributes, '$.vip', 'boolean') AS is_vip
FROM paimon.my_db.user_events
WHERE variant_get(payload, '$.event', 'string') = 'login';
Use try_variant_get when incompatible values should return NULL:
SELECT
user_id,
try_variant_get(payload, '$.score', 'double') AS score
FROM paimon.my_db.user_events;
Column Projection¶
Only the required columns are read, reducing I/O:
Filter Pushdown¶
The following filter predicates are pushed down to the Paimon storage layer:
- Comparison:
=,!=,<,<=,>,>= - Logical:
AND,OR - Null checks:
IS NULL,IS NOT NULL - Range:
IN,NOT IN,BETWEEN - String predicates: positive
LIKE, including no-wildcard, prefix, suffix, contains, and more complex patterns.NOT LIKEandILIKEare evaluated by DataFusion as residual filters.
Filters on partition columns enable exact partition pruning, avoiding scans of irrelevant data.
COUNT(*) Pushdown¶
When the following conditions are met, COUNT(*) retrieves exact row counts directly from split metadata without a full table scan:
- All splits have a known
merged_row_count - No LIMIT clause
- Filter predicates only involve partition columns (Exact level)
Python Multimodal Helper Functions¶
When you use pypaimon_rust.datafusion.SQLContext, the Python binding registers a small set of scalar helper functions for BLOB-backed media and vector workflows. These helpers are Python-binding built-ins; they are not registered by the Rust paimon_datafusion::SQLContext.
Media helpers require the optional Python media dependencies:
| Function | Return Type | Description |
|---|---|---|
media_info(blob) |
STRING | JSON metadata for image, video, or audio input |
media_thumbnail(blob) |
BINARY | PNG thumbnail, using a default 320x320 bounding box |
media_thumbnail(blob, max_width, max_height) |
BINARY | PNG thumbnail constrained to the given dimensions |
video_snapshot(blob) |
BINARY | PNG frame near timestamp 0ms |
video_snapshot(blob, timestamp_ms) |
BINARY | PNG frame near the given timestamp |
video_frame(blob, frame_index) |
BINARY | PNG frame by zero-based decoded frame index |
vector_from_json(json) |
List<Float32> |
Converts a JSON float array string into an Arrow float vector |
vector_to_json(vector) |
STRING | Converts an Arrow float vector back to a JSON array string |
Invalid, NULL, unsupported, or undecodable media inputs return SQL NULL. Media functions read either inline bytes or BLOB descriptor bytes when the SQLContext has a registered Paimon catalog that can resolve the descriptor.
Example:
SELECT
id,
media_info(content) AS info_json,
media_thumbnail(content, 160, 90) AS preview_png,
video_frame(content, 10) AS frame_png
FROM paimon.my_db.assets;
Use vector_from_json to bridge JSON-encoded embeddings into lateral vector search queries:
WITH queries AS (
SELECT id, vector_from_json(embedding_json) AS embedding
FROM paimon.my_db.query_embeddings
)
SELECT q.id AS query_id, r.id AS result_id
FROM queries q
CROSS JOIN LATERAL vector_search(
'paimon.my_db.items',
'embedding',
q.embedding,
10
) AS r;
Vector Search¶
Paimon supports approximate nearest neighbor (ANN) vector search through global
vector indexes. DataFusion can search vindex indexes created by
CALL sys.create_global_index and Lumina indexes created by
CALL sys.create_lumina_index. The vector_search table-valued function is
registered as a UDTF on the DataFusion session context.
Registration¶
When you use a SQLContext, vector_search is registered automatically for every catalog you register — no extra setup is needed.
With a raw DataFusion SessionContext, register it explicitly:
use paimon_datafusion::register_vector_search;
register_vector_search(&ctx, catalog.clone(), "default");
Usage¶
| Argument | Type | Description |
|---|---|---|
table_name |
STRING | Table name, fully qualified (catalog.db.table) or short form |
column_name |
STRING | The vector column to search |
query_vector_json |
STRING | Query vector as a JSON array of floats |
limit |
INT | Maximum number of results (top-k) |
Example:
The function performs ANN search across all matching vector index files for the target column, merges results, and returns the top-k rows ordered by relevance score. If no matching index is found, an empty result is returned.
Refine / Rerank¶
Vector index search can optionally refine ANN results by reading the raw vectors for a larger candidate set, recomputing exact scores, and reranking the final top-k results. This is useful for quantized indexes, such as IVF-PQ, where ANN scores are approximate.
Refine is disabled by default. Configure a positive refine factor as a table
property. The search first requests limit * refine_factor candidates from the
index, then reranks those candidates by exact raw-vector scores and keeps the
requested limit rows. A factor of 1 still performs exact reranking over the
original limit candidates, but does not over-fetch additional candidates.
Omit the option to keep the default ANN-only behavior.
Set the option when creating the table:
CREATE TABLE paimon.my_db.items (
id INT,
embedding ARRAY<FLOAT>
) WITH (
'bucket' = '1',
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true',
'global-index.enabled' = 'true',
'fields.embedding.ivf.refine-factor' = '3'
);
Or enable it on an existing table:
Then run vector_search as usual:
Supported refine option names are refine_factor, refine-factor,
rerank_factor, and rerank-factor. Option lookup checks field-specific and
index-specific keys before global keys. For example, for an ivf-flat index on
column embedding, these keys are accepted, from more specific to more general:
| Example Key | Scope |
|---|---|
fields.embedding.ivf-flat.refine-factor |
This column and index type |
fields.embedding.ivf_flat.refine-factor |
This column and normalized index type |
fields.embedding.ivf.refine-factor |
This column and all IVF vector indexes |
fields.embedding.refine-factor |
This column |
ivf-flat.refine-factor |
This index type |
ivf_flat.refine-factor |
This normalized index type |
ivf.refine-factor |
All IVF vector indexes |
refine-factor |
All vector searches on the table |
Larger refine factors may improve recall and ordering quality, but they also increase index result merging, raw-vector reads, and exact scoring work. Use the smallest factor that provides the desired recall.
Lateral Joins¶
Use CROSS JOIN LATERAL when query vectors come from another relation. In this mode, the third vector_search argument is a column reference from the left side of the join instead of a JSON literal:
SELECT q.id AS query_id, r.id AS result_id
FROM paimon.my_db.queries q
CROSS JOIN LATERAL vector_search(
'paimon.my_db.items',
'embedding',
q.embedding,
10
) AS r
ORDER BY query_id, result_id;
The query-vector column must have Arrow type List<Float32> or FixedSizeList<Float32>. Null query-vector rows produce no joined results, and null elements inside a vector are rejected. The lateral form returns the left row joined with the top-k matching rows from the target Paimon table for that row's query vector.
Supported Metrics¶
The distance metric is configured at index creation time via table options:
| Metric | Description |
|---|---|
inner_product |
Inner product (default) |
cosine |
Cosine similarity |
l2 |
Euclidean (L2) distance |
Vindex Index Options¶
For vindex-backed search, build the index with
CALL sys.create_global_index and an index type such as ivf-flat or
ivf-pq. See create_global_index for the supported
index types, table requirements, and option keys.
Lumina Index Options¶
Lumina index behavior is configured via table options prefixed with lumina.:
| Option | Description |
|---|---|
lumina.index.dimension |
Vector dimension |
lumina.distance.metric |
Distance metric (inner_product, cosine, l2) |
lumina.index.type |
Index type (default: diskann) |
lumina.encoding.type |
Encoding type (default: pq) |
Lumina Environment¶
The Lumina native library must be available at runtime. Set the LUMINA_LIB_PATH environment variable to the path of the shared library, or place it in the platform default location.
Hybrid Search¶
Paimon supports hybrid search by combining multiple search routes and ranking the merged results. The hybrid_search table-valued function is registered as a UDTF on the DataFusion session context.
Hybrid search does not require the fulltext feature when all routes are vector routes. Enable fulltext only when you include full-text routes.
Registration¶
When you use a SQLContext, hybrid_search is registered automatically for every catalog you register — no extra setup is needed.
With a raw DataFusion SessionContext, register it explicitly:
use paimon_datafusion::register_hybrid_search;
register_hybrid_search(&ctx, catalog.clone(), "default");
Usage¶
| Argument | Type | Description |
|---|---|---|
table_name |
STRING | Table name, fully qualified (catalog.db.table) or short form |
vector_routes |
ARRAY | Vector route definitions; use array() when no vector route is needed |
full_text_routes |
ARRAY | Full-text route definitions; use array() for vector-only hybrid search |
limit |
INT | Maximum number of merged results (top-k) |
ranker |
STRING | Optional ranker: rrf (default), weighted_score, or mrr |
Route definitions use Spark-compatible array(named_struct(...)) syntax. A vector route accepts field (or vector_column), query_vector, optional limit, optional weight, and optional options:
SELECT *
FROM hybrid_search(
'paimon.my_db.items',
array(
named_struct(
'field', 'title_embedding',
'query_vector', array(1.0, 0.0, 0.0, 0.0),
'limit', 20,
'weight', 1.0
),
named_struct(
'field', 'body_embedding',
'query_vector', array(0.9, 0.1, 0.0, 0.0),
'limit', 20,
'weight', 0.7
)
),
array(),
10,
'rrf'
);
A full-text route accepts column, query, optional limit, and optional weight. Full-text routes require the fulltext feature:
SELECT *
FROM hybrid_search(
'paimon.my_db.docs',
array(
named_struct(
'field', 'embedding',
'query_vector', array(1.0, 0.0, 0.0, 0.0),
'limit', 20,
'weight', 1.0
)
),
array(
named_struct(
'column', 'content',
'query', 'paimon search',
'limit', 20,
'weight', 0.8
)
),
10,
'weighted_score'
);
The function searches each route independently, merges route results with the selected ranker, and returns the top-k matching rows from the target table. Its output also includes a nullable FLOAT metadata column named __paimon_search_score, which contains the final score produced by the selected ranker:
SELECT id, __paimon_search_score
FROM hybrid_search(
'paimon.my_db.docs',
array(
named_struct(
'field', 'embedding',
'query_vector', array(1.0, 0.0, 0.0, 0.0)
)
),
array(),
10,
'rrf'
)
ORDER BY __paimon_search_score DESC;
The metadata column is part of the DataFusion table function schema, so SELECT * includes it. Use an explicit ORDER BY __paimon_search_score DESC when result ranking order matters; scan output order is not an ordering guarantee.
Full-Text Search¶
Paimon supports full-text search via the Tantivy search engine. The full_text_search table-valued function is registered as a UDTF on the DataFusion session context.
Note: Full-text search requires the
fulltextfeature flag to be enabled on bothpaimonandpaimon-datafusioncrates.
[dependencies]
paimon = { version = "0.3.0", features = ["fulltext"] }
paimon-datafusion = { version = "0.3.0", features = ["fulltext"] }
Registration¶
When you use a SQLContext, full_text_search is registered automatically for every catalog you register (when the fulltext feature is enabled) — no extra setup is needed.
With a raw DataFusion SessionContext, register it explicitly:
use paimon_datafusion::register_full_text_search;
register_full_text_search(&ctx, catalog.clone(), "default");
Usage¶
| Argument | Type | Description |
|---|---|---|
table_name |
STRING | Table name, fully qualified (catalog.db.table) or short form |
column_name |
STRING | The text column to search |
query_text |
STRING | Search query (Tantivy query syntax) |
limit |
INT | Maximum number of results (top-k) |
Example:
The function searches across all Tantivy full-text index files for the target column, merges results by relevance score, and returns the top-k matching rows. If no matching index is found, an empty result is returned.
Time Travel¶
Paimon supports time travel queries to read historical data.
By Snapshot ID¶
By Tag Name¶
Use a quoted tag name with VERSION AS OF:
Resolution order: first checks if a tag with that name exists, then tries to parse it as a snapshot ID.
By Timestamp¶
Read data as of a specific point in time. The format is YYYY-MM-DD HH:MM:SS:
This finds the latest snapshot whose commit time is less than or equal to the given timestamp. The timestamp is interpreted in the local timezone.
Dynamic Options (SET / RESET)¶
Use SET to configure session-scoped Paimon dynamic options that apply to subsequent table loads:
Options prefixed with paimon. are handled by Paimon; all others are delegated to DataFusion. Dynamic options are applied at table load time via table.copy_with_options().
Example — enable BLOB descriptor mode:
SET 'paimon.blob-as-descriptor' = 'true';
SELECT * FROM paimon.my_db.assets;
RESET 'paimon.blob-as-descriptor';
Example — preserve Blob View references instead of resolving upstream BLOB values on RESTCatalog-backed reads:
SET 'paimon.blob-view.resolve.enabled' = 'false';
SELECT * FROM paimon.my_db.asset_refs;
RESET 'paimon.blob-view.resolve.enabled';
Temporary Tables¶
You can register in-memory temporary tables under any catalog. Temporary tables exist only for the lifetime of the SQLContext instance and are automatically cleaned up when the context is dropped.
The table name accepts flexible references, similar to DataFusion:
- "my_table" — uses the current catalog and current database
- "database.my_table" — uses the current catalog with the specified database
- "catalog.database.my_table" — fully qualified
register_temp_table¶
Register any Arc<dyn TableProvider> as a temporary table (including MemTable, ViewTable, custom providers, etc.):
use datafusion::arrow::array::Int32Array;
use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::datasource::MemTable;
let schema = Arc::new(Schema::new(vec![
Field::new("id", ArrowDataType::Int32, false),
Field::new("name", ArrowDataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["alice", "bob", "carol"])),
],
)?;
// Register a MemTable as a temp table
let mem_table = Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]])?);
ctx.register_temp_table("paimon.my_db.users", mem_table)?;
let df = ctx.sql("SELECT * FROM paimon.my_db.users WHERE id > 1").await?;
df.show().await?;
// Register a ViewTable as a temp table
use datafusion::datasource::ViewTable;
let view_table = Arc::new(ViewTable::new(logical_plan, Some(query_sql)));
ctx.register_temp_table("paimon.my_db.my_view", view_table)?;
CREATE TEMPORARY TABLE¶
You can also create temporary tables directly from SQL. See the DDL section for details.
CREATE TEMPORARY TABLE paimon.my_db.source (id INT, name STRING) AS SELECT * FROM (VALUES (1, 'alice'), (2, 'bob')) AS t(id, name);
CREATE TEMPORARY VIEW¶
Create a temporary view directly from SQL. See the DDL section for details.
Deregister¶
Use deregister_temp_table to remove a temporary table or view programmatically, or use the DROP TEMPORARY TABLE / DROP TEMPORARY VIEW SQL statements (see the DDL section):
Multiple temporary tables can share the same database — the database is created automatically on first use:
let mem_a = Arc::new(MemTable::try_new(schema_a, vec![vec![batch_a]])?);
let mem_b = Arc::new(MemTable::try_new(schema_b, vec![vec![batch_b]])?);
ctx.register_temp_table("my_db.table_a", mem_a)?;
ctx.register_temp_table("my_db.table_b", mem_b)?;
// Join two temp tables
let df = ctx.sql("SELECT * FROM paimon.my_db.table_a JOIN paimon.my_db.table_b ON a.id = b.id").await?;
System Tables¶
Access table metadata via the $ syntax.
$options¶
View all configuration options for a table:
Returns two columns: key (STRING) and value (STRING).
$schemas¶
View the schema history of a table:
Columns:
| Column | Type | Description |
|---|---|---|
schema_id |
BIGINT | Schema ID |
fields |
STRING | Field definitions (JSON) |
partition_keys |
STRING | Partition keys (JSON) |
primary_keys |
STRING | Primary keys (JSON) |
options |
STRING | Table options (JSON) |
comment |
STRING | Comment |
update_time |
TIMESTAMP | Update time |
$snapshots¶
View the snapshot history of a table:
Columns:
| Column | Type | Description |
|---|---|---|
snapshot_id |
BIGINT | Snapshot ID |
schema_id |
BIGINT | Schema ID |
commit_user |
STRING | Commit user |
commit_identifier |
BIGINT | Commit identifier |
commit_kind |
STRING | APPEND / COMPACT / OVERWRITE / ANALYZE |
commit_time |
TIMESTAMP | Commit time |
base_manifest_list |
STRING | Base manifest list file |
delta_manifest_list |
STRING | Delta manifest list file |
changelog_manifest_list |
STRING | Changelog manifest list file |
total_record_count |
BIGINT | Total record count |
delta_record_count |
BIGINT | Delta record count |
changelog_record_count |
BIGINT | Changelog record count |
watermark |
BIGINT | Watermark |
next_row_id |
BIGINT | Next row id |
$tags¶
View all named tags of a table:
Columns:
| Column | Type | Description |
|---|---|---|
tag_name |
STRING | Tag name |
snapshot_id |
BIGINT | Snapshot ID |
schema_id |
BIGINT | Schema ID |
commit_time |
TIMESTAMP | Commit time |
record_count |
BIGINT | Record count |
create_time |
TIMESTAMP | Tag creation time |
time_retained |
STRING | Retention duration |
$manifests¶
View manifest files of the latest snapshot:
Columns:
| Column | Type | Description |
|---|---|---|
file_name |
STRING | Manifest file name |
file_size |
BIGINT | File size in bytes |
num_added_files |
BIGINT | Number of added data files |
num_deleted_files |
BIGINT | Number of deleted data files |
schema_id |
BIGINT | Schema ID |
min_partition_stats |
STRING | Minimum partition stats, formatted as a Java row cast string |
max_partition_stats |
STRING | Maximum partition stats, formatted as a Java row cast string |
min_row_id |
BIGINT | Minimum row id covered (when row tracking is enabled) |
max_row_id |
BIGINT | Maximum row id covered (when row tracking is enabled) |
$partitions¶
View all partitions of a table with aggregated record counts and file sizes:
Columns:
| Column | Type | Description |
|---|---|---|
partition |
STRING | Partition spec, formatted as key1=val1/key2=val2 |
record_count |
BIGINT | Total record count across all data files in the partition |
file_size_in_bytes |
BIGINT | Total file size in bytes |
file_count |
BIGINT | Number of data files |
last_update_time |
TIMESTAMP | Latest data-file creation time |
created_at |
TIMESTAMP | Partition creation time (only available with metastore-tracked catalogs) |
created_by |
STRING | Snapshot id that created the partition (catalog-tracked only) |
updated_by |
STRING | Snapshot id that last updated the partition (catalog-tracked only) |
options |
STRING | Per-partition options as flat JSON (catalog-tracked only) |
total_buckets |
INT | Total bucket count for the partition (0 unless catalog-tracked) |
done |
BOOLEAN | Whether the partition is marked done (false unless catalog-tracked) |
$table_indexes¶
View committed global index files, including BTree indexes, vector indexes, and deletion-vector metadata:
Columns:
| Column | Type | Description |
|---|---|---|
partition |
STRING | Partition spec for the indexed data, or NULL for unpartitioned tables |
bucket |
INT | Bucket id covered by the index file |
index_type |
STRING | Index type, such as btree, bitmap, ivf-flat, lumina, or DELETION_VECTORS |
file_name |
STRING | Index file name under the table index directory |
file_size |
BIGINT | Index file size in bytes |
row_count |
BIGINT | Number of rows covered by the index file |
dv_ranges |
ARRAY | Deletion-vector ranges, only populated for deletion-vector metadata |
row_range_start |
BIGINT | First row id covered by the index file |
row_range_end |
BIGINT | Last row id covered by the index file |
index_field_id |
INT | Field id of the indexed column |
index_field_name |
STRING | Name of the indexed column |
$physical_files_size¶
Scan the table directory recursively and compute the total size of recognized physical files on disk, categorized by file type. This table is a diagnostic size summary; orphan cleanup needs file-level candidates and retention checks, not just aggregate size differences.
Files are classified by their table-relative path:
- manifest/manifest-*, manifest/manifest-list-*, and manifest/index-manifest-* → manifest
- statistics/* → manifest file counters for the current compatible output schema
- index/* → index
- <partition>/bucket-*/* and <partition>/bucket-postpone/* → data, using the table's partition depth
- unknown files are ignored by this summary
Columns:
| Column | Type | Description |
|---|---|---|
manifest_file_count |
BIGINT | Number of manifest files on disk |
manifest_file_size |
BIGINT | Total size of manifest files (bytes) |
data_file_count |
BIGINT | Number of recognized data files on disk |
data_file_size |
BIGINT | Total size of recognized data files (bytes) |
index_file_count |
BIGINT | Number of index files on disk |
index_file_size |
BIGINT | Total size of index files (bytes) |
$referenced_files_size¶
Compute aggregated manifest/data/index file size summaries for all snapshots referenced by a table, including snapshots from the main branch, tags, and other branches. This is useful for understanding storage usage and for orphan file analysis.
Historical snapshots may be in the process of being cleaned up — if a manifest file has already been deleted, it is gracefully skipped (counted as 0 files/bytes).
Columns:
| Column | Type | Description |
|---|---|---|
source |
STRING | Scope: total or branch:<name> |
manifest_file_count |
BIGINT | Number of manifest files |
manifest_file_size |
BIGINT | Total size of manifest files (bytes) |
data_file_count |
BIGINT | Number of data files |
data_file_size |
BIGINT | Total size of data files (bytes) |
index_file_count |
BIGINT | Number of index files |
index_file_size |
BIGINT | Total size of index files (bytes) |
The output contains one row per scope:
- total — sum across all branches and tags
- branch:main — main branch snapshots + tag snapshots
- branch:<name> — one row per other branch
To estimate possible orphan file size for recognized data files:
SELECT p.data_file_size - r.data_file_size AS orphan_data_size
FROM paimon.default.my_table$physical_files_size p,
paimon.default.my_table$referenced_files_size r
WHERE r.source = 'total';
Branch References¶
Read a table branch with Java-compatible $branch_<name> syntax:
System tables support the same branch syntax:
SELECT * FROM paimon.default.my_table$branch_b1$options;
SELECT * FROM paimon.default.my_table$branch_b1$snapshots;
Branch references are read-only in DataFusion. INSERT, UPDATE, DELETE,
MERGE INTO, TRUNCATE TABLE, and ALTER TABLE against a branch reference are
rejected.
Table Options¶
Set via WITH ('key' = 'value') at table creation time, or dynamically via SET.
Bucket Configuration¶
| Option | Description |
|---|---|
'bucket' = 'N' |
Fixed N buckets (e.g. 1, 2, 4) |
'bucket' = '-1' |
Dynamic bucket mode (HASH index) |
'bucket' = '-2' |
Postpone bucket mode (deferred assignment) |
'bucket-key' = 'col' |
Explicit bucket key column |
'bucket-function.type' = 'default' \| 'mod' \| 'hive' |
Function used to map fixed bucket keys to bucket ids |
Merge Engine¶
| Option | Description |
|---|---|
'merge-engine' = 'deduplicate' |
Deduplicate engine (default for PK tables), last write wins |
'merge-engine' = 'first-row' |
Keeps the first written row |
'merge-engine' = 'partial-update' |
Basic partial-update engine for PK tables |
'merge-engine' = 'aggregation' |
Basic aggregation engine for PK tables |
Rust currently supports merge-engine=aggregation in basic mode only. It works
with fixed buckets and ordinary dynamic buckets ('bucket' = '-1') when the
primary key includes all partition columns. It supports per-field aggregate
functions such as sum, min, max, value functions, boolean functions, and
listagg, plus fields.default-aggregate-function.
Sequence fields are always merged with last_value. Defining
fields.<sequence-field>.aggregate-function is rejected, matching Java schema
validation.
This is not full Java feature parity. Aggregation tables do not support retract
rows (DELETE / UPDATE_BEFORE), deletion vectors, cross-partition dynamic
bucket writes, or advanced aggregation options such as ignore-retract,
distinct, nested-key, count-limit, and sequence groups.
Global Index Options¶
Set these options when building global indexes with
CALL sys.create_global_index. The current DataFusion builders require
row-tracking and data evolution, and reject primary-key tables and tables with
deletion vectors enabled.
| Option | Default | Description |
|---|---|---|
row-tracking.enabled |
false |
Enables stable row ids required by global index files. |
data-evolution.enabled |
false |
Enables row-id-aware table evolution and partial-column writes. |
global-index.enabled |
false |
Enables global index metadata and global-index-aware reads. |
global-index.row-count-per-shard |
100000 |
Maximum row count per vector global-index shard. |
sorted-index.records-per-range |
100000 |
Maximum row count per BTree range. |
btree-index.fallback-scan-max-size |
256mb |
Maximum total size of selected BTree global-index files for fallback scans used by range/between and suffix/contains/complex LIKE predicates; 0 disables BTree fallback index scans. |
bitmap-index.fallback-scan-max-size |
256mb |
Maximum total size of selected bitmap global-index files for fallback scans used by range/between and suffix/contains/complex LIKE predicates; 0 disables bitmap fallback index scans. |
global-index.search-mode |
fast |
Global index coverage mode for reads: fast, full, or detail. |
Variant Shredding Options¶
Set these as table options when writing VARIANT columns to Parquet. The
logical table schema remains VARIANT; the options only affect the physical
file layout and automatic read-time assembly.
| Option | Default | Description |
|---|---|---|
variant.shreddingSchema |
unset | Configured shredding schema as a Paimon ROW type JSON string. Top-level field names match VARIANT column names, and their nested types describe the typed fields to extract. |
parquet.variant.shreddingSchema |
unset | Parquet-scoped alias for variant.shreddingSchema. |
variant.inferShreddingSchema |
false |
Enables per-writer schema inference for VARIANT columns when no configured shredding schema is set. |
parquet.variant.inferShreddingSchema |
false |
Parquet-scoped alias for variant.inferShreddingSchema. |
variant.shredding.maxInferBufferRow |
4096 |
Number of initial rows buffered per data-file writer before inferring the shredding schema. If fewer rows are written, inference runs when the writer is flushed or closed. |
variant.shredding.maxSchemaDepth |
50 |
Maximum nested depth considered by inference. |
variant.shredding.maxSchemaWidth |
300 |
Maximum number of inferred typed fields across inferred Variant schemas. |
variant.shredding.minFieldCardinalityRatio |
0.1 |
Minimum ratio of sampled non-null Variant values that must contain a field before inference keeps it as a typed field. |
Configured shredding takes precedence over inferred shredding. If a table has no
VARIANT columns, or none of these options enable shredding, Paimon Rust writes
the normal physical format without wrapping the writer.
Other Options¶
| Option | Description |
|---|---|
'sequence.field' = 'col' |
Sequence field used to determine which record wins during deduplication |
'row-tracking.enabled' = 'true' |
Enable stable row ids |
'data-evolution.enabled' = 'true' |
Enable data evolution (partial-column writes, row-level UPDATE/MERGE/DELETE) |
'global-index.enabled' = 'true' |
Enable global index metadata and reads |
'deletion-vectors.enabled' = 'true' |
Enable deletion vectors |
'cross-partition-update.enabled' = 'true' |
Allow cross-partition updates |
'changelog-producer' = 'input' |
Changelog producer (PK tables with input mode reject writes) |
Full Example¶
use std::sync::Arc;
use paimon::{CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::SQLContext;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create catalog
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, "file:///tmp/paimon-warehouse");
let catalog = Arc::new(FileSystemCatalog::new(options)?);
// Create SQL context and register catalog
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog).await?;
// Create database and table
ctx.sql("CREATE SCHEMA paimon.my_db").await?;
ctx.sql(
"CREATE TABLE paimon.my_db.users (
id INT NOT NULL,
name STRING,
PRIMARY KEY (id)
) WITH ('bucket' = '1')"
).await?;
// Insert data
ctx.sql("INSERT INTO paimon.my_db.users VALUES (1, 'alice'), (2, 'bob')")
.await?.collect().await?;
// Query
let df = ctx.sql("SELECT * FROM paimon.my_db.users ORDER BY id").await?;
df.show().await?;
Ok(())
}