Performance#
When you use GIQL:
GIQL parses the query and identifies genomic operators
Operators are expanded into SQL predicates
You execute the SQL on your database backend or analytics engine
The system optimizes the query and executes it
Performance depends on both the generated SQL and how the target data system plans, optimizes, and executes it. Some common performance bottlenecks include:
Full table scans: No indexes to speed up filtering
Cartesian products: Large cross joins without early filtering
Missing chromosome filters: Comparing features across all chromosomes
Inefficient join order: Small tables should drive joins
Streaming#
Analytics engines like DuckDB and Polars support streaming data sources in sequences of small “record batches”, enabling parallel processing and out-of-core workflows on files that may be much larger than memory.
For delimited text files, you can use native APIs:
DuckDB:
import duckdb
from giql import transpile
conn = duckdb.connect()
# DuckDB can query CSV/TSV files directly
conn.execute("""
CREATE VIEW peaks AS
SELECT * FROM read_csv('peaks.bed', delim='\t',
columns={'chrom': 'VARCHAR', 'start': 'INTEGER',
'end': 'INTEGER', 'name': 'VARCHAR',
'score': 'INTEGER', 'strand': 'VARCHAR'})
""")
sql = transpile(
"SELECT * FROM peaks WHERE interval INTERSECTS 'chr1:1000-2000'",
tables=["peaks"],
)
df = conn.execute(sql).fetchdf()
Polars:
import polars as pl
from giql import transpile
lf = pl.scan_csv("peaks.bed", separator="\t",
new_columns=["chrom", "start", "end", "name", "score", "strand"])
sql = transpile(
"SELECT * FROM peaks WHERE interval INTERSECTS 'chr1:1000-2000'",
tables=["peaks"],
)
ctx = pl.SQLContext(peaks=lf)
df = ctx.execute(sql).collect()
For specialized NGS formats, you can supply streaming data using the oxbow package:
import duckdb
import oxbow as ox
from giql import transpile
conn = duckdb.connect()
# Load a streaming data source as a DuckDB relation
peaks = ox.read_bed("peaks.bed").to_duckdb(conn, "peaks")
sql = transpile(
"SELECT * FROM peaks WHERE interval INTERSECTS 'chr1:1000-2000'",
tables=["peaks"],
)
df = conn.execute(sql).fetchdf()
import polars as pl
import oxbow as ox
from giql import transpile
# Load a streaming data source as a Polars LazyFrame
lf = ox.read_bed("peaks.bed").pl(lazy=True)
sql = transpile(
"""SELECT *, CLUSTER(interval) AS cluster_id
FROM features
ORDER BY chrom, start
""",
tables=["peaks"],
)
ctx = pl.SQLContext(peaks=lf)
ctx.execute(sql).sink_parquet("peaks_clustered.parquet")
Indexing#
If your data source is a database table, you can create indexes on genomic columns for faster queries:
-- DuckDB or SQLite
CREATE INDEX idx_features_position
ON features (chrom, start, "end")
For single-table queries (filtering):
CREATE INDEX idx_table_position ON table_name (chrom, start, "end")
For join queries:
-- Index both tables involved in joins
CREATE INDEX idx_variants_position ON variants (chrom, start, "end")
CREATE INDEX idx_genes_position ON genes (chrom, start, "end")
For strand-specific queries:
CREATE INDEX idx_features_strand ON features (chrom, strand, start, "end")
Create indexes when:
Tables are very large
You’re running repeated queries on the same tables
Join queries are slow
Filtering by genomic position is common
Skip indexes when:
Tables are small
You’re doing one-time analysis
Full table scans are acceptable
Query Optimization Patterns#
Pre-filter by Chromosome#
Always include chromosome filtering when joining tables:
-- Good: Explicit chromosome filter
SELECT a.*, b.name
FROM features_a a
JOIN features_b b ON a.interval INTERSECTS b.interval
WHERE a.chrom = 'chr1'
Use Selective Filters Early#
Apply selective filters before joins:
-- Good: Filter before joining
WITH filtered_variants AS (
SELECT * FROM variants
WHERE quality >= 30 AND filter = 'PASS'
)
SELECT f.*, g.name
FROM filtered_variants f
JOIN genes g ON f.interval INTERSECTS g.interval
Limit Result Sets#
Use LIMIT for exploratory queries:
SELECT * FROM variants
WHERE interval INTERSECTS 'chr1:1000000-2000000'
LIMIT 100
Use DISTINCT Wisely#
DISTINCT can be expensive. Only use when necessary:
-- If you just need to check existence, use EXISTS instead
SELECT a.*
FROM features_a a
WHERE EXISTS (
SELECT 1 FROM features_b b
WHERE a.interval INTERSECTS b.interval
)
Optimizing K-NN Queries#
The NEAREST operator can be expensive for large datasets. Optimize with:
1. Use max_distance to limit search space:
SELECT peaks.name, nearest.name, nearest.distance
FROM peaks
CROSS JOIN LATERAL NEAREST(
genes,
reference := peaks.interval,
k := 5,
max_distance := 100000 -- Only search within 100kb
) AS nearest
2. Request only the k you need:
-- Good: Request exactly what you need
NEAREST(genes, reference := peaks.interval, k := 3)
-- Wasteful: Request more than needed
NEAREST(genes, reference := peaks.interval, k := 100)
3. Index the target table:
CREATE INDEX idx_genes_position ON genes (chrom, start, "end")
Efficient Clustering#
For large datasets, consider pre-sorting:
WITH sorted AS (
SELECT * FROM features
ORDER BY chrom, start
)
SELECT *, CLUSTER(interval) AS cluster_id
FROM sorted
Efficient Merging#
Filter before merging to reduce data volume:
WITH filtered AS (
SELECT * FROM features
WHERE score >= 10
)
SELECT MERGE(interval), COUNT(*) AS count
FROM filtered
Analyzing Query Performance#
Using EXPLAIN#
Analyze query execution plans by running EXPLAIN on the transpiled SQL:
from giql import transpile
sql = transpile(
"""
SELECT a.*, b.name
FROM variants a
JOIN genes b ON a.interval INTERSECTS b.interval
""",
tables=["variants", "genes"],
)
# Run EXPLAIN on your database connection
# conn.execute(f"EXPLAIN {sql}")
# DuckDB also supports EXPLAIN ANALYZE for actual timing
Backend-Specific Tips#
DuckDB IEJoin Dialect#
By default (dialect=None / "datafusion") a column-to-column
INTERSECTS join emits the naive overlap predicate — a plain ON
a.chrom = b.chrom AND a.start < b.end AND b.start < a.end condition — and
lets the engine’s own optimizer plan it. On DuckDB and DataFusion this
becomes a hash join keyed on chrom with the two position inequalities
as a residual join filter, correct for both inner and outer joins with no
special handling. This is the lowest-common-denominator plan: standard SQL
that every target runs.
For column-to-column INTERSECTS joins (INNER, SEMI, ANTI, and LEFT /
RIGHT outer) on DuckDB, the dialect="duckdb" opt-in instead
transpiles the join into a per-chromosome dynamic-SQL pattern. Each
per-chromosome subquery contains only inequality predicates, which DuckDB
plans through its range-join family (IE_JOIN or
PIECEWISE_MERGE_JOIN). Shapes this path declines fall through to the
naive predicate above.
from giql import transpile
sql = transpile(
"""
SELECT a.chrom, a.start, b.start
FROM peaks a
JOIN genes b ON a.interval INTERSECTS b.interval
""",
tables=["peaks", "genes"],
dialect="duckdb",
)
# sql is a multi-statement script — see the next example for execution.
The output is a multi-statement string of the form:
SET VARIABLE __giql_iejoin_<token> = COALESCE((... string_agg per chromosome ...), '<empty schema>');
SELECT ... FROM query(getvariable('__giql_iejoin_<token>')) AS __giql_iejoin_wrapper
Each <token> is a 128-bit digest of the variable’s own rendered value,
so the SET VARIABLE name stays collision-resistant when outputs from
many transpile() calls are interleaved in a single DuckDB session — an
outer-join decomposition emits two such variables, one per half. Because
the name addresses the content, re-running one query shape rebinds its own
names rather than leaving a fresh pair behind on every call, which matters
because session variables are global session state that the emitted script
cannot release: the final statement has to be the SELECT. The outer SELECT’s
wrapper-relation alias is constant (__giql_iejoin_wrapper) because
it isn’t user-visible and doesn’t need to vary per call.
Inside the per-chromosome string-builder, chromosome names are emitted
via replace(chrom, '''', '''''') wrapped in single quotes, so
chromosome identifiers containing literal single quotes interpolate
safely into the dynamic SQL.
The output is a multi-statement script that depends on session state: two
statements for the shapes above, and three for the outer-join
decomposition, which declares one session variable per half. Execute it
through a single DuckDB connection’s .execute(). DuckDB ≥1.4 (the
version pinned by GIQL’s development and CI environment) accepts
multi-statement strings in one call and returns the result of the final
statement. SQLAlchemy’s text(),
pandas.read_sql_query, and similar wrappers that split or rewrite
the string may drop the SET VARIABLE and produce empty or NULL
results.
import duckdb
from giql import transpile
conn = duckdb.connect()
conn.execute("CREATE TABLE peaks (chrom VARCHAR, \"start\" INTEGER, \"end\" INTEGER)")
conn.execute("CREATE TABLE genes (chrom VARCHAR, \"start\" INTEGER, \"end\" INTEGER)")
sql = transpile(
"""
SELECT a.chrom, a.start, b.start
FROM peaks a
JOIN genes b ON a.interval INTERSECTS b.interval
""",
tables=["peaks", "genes"],
dialect="duckdb",
)
rows = conn.execute(sql).fetchall()
The dialect rewrites the whole query, so the supported shape is
SELECT <qualified columns, scalar expressions over them, or plain
aggregates> FROM <base table> {INNER|SEMI|ANTI|LEFT|RIGHT} JOIN <base
table> {ON|WHERE} <one column-to-column INTERSECTS> — the JOIN and the
column-to-column INTERSECTS are both required (literal-range
INTERSECTS against a single table falls through to the default
predicate generator). LEFT and RIGHT accept ON only: the
outer-join decomposition rejects any top-level WHERE, for the reason
given under Outer joins below.
Join variants. INNER JOIN is the default. SEMI JOIN returns
distinct left rows that have at least one overlapping match; ANTI
JOIN returns left rows with no overlapping match. Both restrict the
outer SELECT to left-side projections — any b.col reference (or an
aggregate over b.col) raises ValueError; a right-side b.*
declines to the naive-predicate plan with every other star (which then
rejects the out-of-scope right table at bind time). ANTI partitions on the
same chromosome INTERSECT as INNER and SEMI, then unions one
non-partitioned branch carrying the left rows whose chromosome the right
table lacks. Those rows cannot match, so giving each such chromosome its
own branch would scan both tables to prove an emptiness the partition
already knows.
DuckDB plans all three variants through its IE_JOIN /
PIECEWISE_MERGE_JOIN sort-merge range-join family. INNER emits a
per-chromosome inner join directly. SEMI and ANTI are emitted as a
correlated WHERE EXISTS / WHERE NOT EXISTS subquery over the
per-chromosome right partition, because DuckDB plans a bare SEMI
JOIN / ANTI JOIN inequality overlap as a quadratic
BLOCKWISE_NL_JOIN (a nested loop) rather than the range-join fast
path — the correlated form reaches the fast path while preserving exact
semantics (#208). Expect large speedups over the naive predicate for all
three variants at scale, subject to the contig-cardinality caveat below.
The contig-cardinality ceiling. The rewrite emits one UNION ALL
branch per distinct chromosome, so its cost scales with that cardinality
while the plain predicate’s does not. That is the whole trade: on a
primary assembly the partitioned form wins by orders of magnitude, and on
a scaffold-level one it loses by them. Measured on 262,144 intervals per
side, LEFT join, partitioned against naive: 0.79s against 4.53s at 24
contigs, roughly level at 150, then 9.75s against 0.18s at 1,000 and
57.2s against 0.11s at 3,000.
The partition’s cardinality is a property of the data, which no
transpile-time check can see, so the choice is made at execution time. The
SET VARIABLE statement counts the partition it is about to build and,
above MAX_PER_CHROM_PARTITIONS (256), binds the same query with the
chromosome equality inlined rather than partitioned out. The ceiling sits
above every standard reference assembly’s primary-chromosome count and
below the measured crossover, so a normal genome takes the fast form and a
scaffolded one degrades to the plan it would have had anyway.
count_overlaps. The idiomatic per-interval overlap count — SELECT
a.cols, COUNT(b.col) FROM a LEFT JOIN b ON a.interval INTERSECTS
b.interval GROUP BY a.cols — is also accelerated (#209). A plain
LEFT JOIN inequality overlap would otherwise decline to the naive
predicate outright — a HASH_JOIN on chrom with the position
inequalities as a residual filter, quadratic when the chromosome key has
low cardinality. The outer-join decomposition below does not rescue it:
that rewrite rejects any GROUP BY, so this shape never reaches it.
Instead the dialect computes the counts through the INNER IE_JOIN path
and
wraps them in a zero-fill LEFT JOIN back onto the distinct left keys,
so left intervals with no overlap report 0. This covers a single
COUNT(<right column>) (or COUNT(DISTINCT <right column>)) with a
GROUP BY on the projected left columns. COUNT(*), non-COUNT
aggregates, and ORDER BY / LIMIT / HAVING fall out of this fast
path, and each of them is declined by the outer-join decomposition too, so
they take the naive-predicate plan.
Outer joins. A LEFT / RIGHT JOIN ... ON a.interval INTERSECTS
b.interval whose projections are all side-attributable qualified columns
is decomposed rather than emitted as an outer join (#95): the matched pairs
come from the INNER IE_JOIN path, and the preserved side’s unmatched
rows come from the NOT EXISTS ANTI path, combined with UNION ALL
and NULL-filled on the other side’s columns. Both halves reach IE_JOIN.
Both halves partition on the chromosome INTERSECT, since a chromosome
present on one side only yields no pairs. What completes the union is what
the unmatched half adds on top: one non-partitioned branch for the
preserved side’s chromosomes the other table lacks, so rows there still
surface with NULLs without a per-chromosome branch apiece.
A third branch completes it. Both partitions are built from SELECT
DISTINCT <chrom>, and a NULL chromosome renders as a NULL literal that
string_agg skips — so neither half emits a branch for it, and rows whose
chromosome is NULL would be dropped by the very join that exists to preserve
them. Those rows can never match (the partition is an equality, and NULL =
x is never true), so the emission unions them in directly, NULL-filled on
the other side. That is one extra scan of the preserved side, which DuckDB
normally prunes to nothing on the strength of null statistics: measured at
0.25% of total runtime on a base table and 2% on a CSV-backed view, and
falling as a share as the join gets more expensive. It is cheap enough
that folding it into the unmatched half would not repay the complexity.
Emitting a per-chromosome LEFT JOIN instead — the obvious alternative —
is not viable: DuckDB reports IE_JOIN for that plan, yet runs it in
about 0.04s at 100,000 rows per side and fails to finish within 300s at
4,194,304, where the decomposition returns in about two seconds. Plan
inspection alone does not distinguish the two; only execution at scale
does.
FULL OUTER has no two-half form here and still takes the naive plan, as
does any outer join carrying a star or expression projection, a top-level
modifier (GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET
/ DISTINCT), a WHERE clause, a residual predicate in its ON, a
self-join, a TABLESAMPLE on either operand, or two preserved-side
projections whose output names differ only in case.
Note
The star projection is the caveat most likely to surprise. The idiomatic
bedtools -loj translation — SELECT a.*, b.name or SELECT a.*,
b.* — is a star projection, so it declines and dialect="duckdb"
changes nothing for it. GIQL does not read table schemas, so it cannot
enumerate a star into the side-attributable columns the decomposition
needs (#202). Spell the columns out to take the fast path.
On top of the INNER / SEMI / ANTI core shape the dialect also absorbs several common decorations (the outer-join decomposition declines all of them):
Outer modifiers.
ORDER BY/LIMIT/OFFSET/DISTINCTon the outer query are appended to the outer SELECT; column references inORDER BYare rewritten to the wrapper relation’s inner aliases.Aggregates.
GROUP BY/HAVINGwith aggregate functions (COUNT,SUM,MIN,MAX,AVG,COUNT(DISTINCT a.col), …) are appended to the outer SELECT. Aggregate arguments must be table-qualified (COUNT(*)andCOUNT(a.col)are both accepted).Scalar expression projections. A SELECT-list expression keeps the IEJoin plan when four properties hold: it is row-wise, it binds no names, it carries no star, and at least one leaf is a qualified column.
GREATEST(a.start, b.start),LEAST(a."end", b."end") - GREATEST(a.start, b.start),a.start + 1, aCASE, a cast, string concatenation, a comparison, and the ordinary scalar functions (COALESCE,LENGTH,UPPER,SUBSTRING,DATE_TRUNC, …) all qualify. Each leaf column is projected into the per-chromosome subquery and the expression is rebuilt on the outer SELECT over those inner columns, so the join itself is untouched. This is what puts thebedtools intersectdefault (clipped overlap span) and-wo(pairs plus overlap width) shapes on the fast path.The leaves rather than the computed value go inward deliberately. Computing the expression inside the subquery would narrow the wrapper relation, but the session-variable name is a content digest of that subquery’s SQL, so the expression’s text would fold into the digest and
a.start + 1/a.start + 2/ … would each mint a distinctSET VARIABLEon a long-lived connection — trading the bounded variable set above for an unbounded one. Leaf dedup means the bedtools shapes pay nothing for this, since they already project their leaves as plain columns.An aliased expression keeps its alias; an unaliased one is named
__giql_expr_<n>by position, as an unaliased aggregate is named__giql_agg_<n>, because DuckDB’s automatic name would be derived from the rewritten text. A synthesized name is chosen around the names the caller wrote, so it never duplicates one — DuckDB accepts duplicate result-column names silently, which would makefetchdf()and any name-keyed access collapse or shadow a column. Two same-named user columns (SELECT a.start, b.start) are left as the caller wrote them, because the naive plan reports the duplicate too.A projection failing any of the four properties falls back; see Projections the IEJoin cannot rebuild below. Under a
LEFT/RIGHTouter join an expression projection still declines, since the decomposition’s unmatched half only NULL-fills columns.Extra JOIN / WHERE predicates. Additional non-INTERSECTS predicates ANDed onto the join ON or WHERE (e.g.
a.score > b.scoreorWHERE a.score > 100) are inlined into each per-chromosome subquery’s ON, so DuckDB filters them inside each IEJoin candidate set. Limitations: the dialect peels extra predicates only across top-levelANDconnectives, so predicates that wrap theINTERSECTSinOR/NOT/ parentheses still fall back. Predicates whose AND-tree residual contains a subquery, aggregate, or window function fall back too (DuckDB forbids window functions insideJOIN ON, and subqueries / aggregates inside an IEJoin candidate set would either break the planner or produce semantically wrong results). If you hit the WHERE-INTERSECTS-plus-extra-JOIN-ON-predicate shape described in #94 on the defaultdialect=Nonepath,dialect="duckdb"is one workaround — the dialect inlines extras directly into each per-chromosome subquery and is unaffected by that bug.
The dialect splits unsupported shapes into two buckets. Soft-fallback
shapes route to the naive-predicate plan automatically and return correct
results; within those shapes the dialect kwarg is safe to set
without risk of silent incorrectness. Hard-error shapes (enumerated
further below) raise ValueError at transpile time — the dialect
deliberately refuses them rather than silently producing the wrong
SQL.
The soft-fallback shapes are:
Outer joins.
FULL OUTER JOIN ... ON ... INTERSECTS ...falls back to the naive-predicate plan;LEFT/RIGHTare decomposed onto the IEJoin path instead (see Outer joins above), except for the shapes listed there. A fallback also applies to any outer join that keeps its side modifier while theINTERSECTSlives in the top-levelWHERE(e.g.LEFT JOIN ... ON TRUE WHERE a.interval INTERSECTS b.interval) — there the filter discards the very unmatched rows the outer join preserves, so it is a different query.SEMI / ANTI join with the INTERSECTS in the WHERE. A
SEMI/ANTIjoin whose column-to-columnINTERSECTSsits in the top-levelWHERErather than its ownON(e.g.ANTI JOIN ... ON TRUE WHERE a.interval INTERSECTS b.interval) falls back. The right table is out of scope in theWHEREafter a left-only join, so the reference plans reject it with a binder error; the dialect declines so it surfaces that same error instead of relocating the predicate into the join and inventing anti/semi-overlap results. The idiomatic form with theINTERSECTSin the joinONis unaffected.Star projections. Any star in the top-level SELECT list — bare
*,a.*,b.*, ora.* AS x— falls back. Building the dialect’s per-chromosome dynamic SQL requires enumerating the star’s columns at transpile time, but only the registered genomic columns (chrom/start/end/strand) are known — arbitrary user columns are invisible. The naive-predicate plan expands the real star against DuckDB’s live schema, so declining keeps the projection identical across backends rather than silently narrowing it.Projections the IEJoin cannot rebuild. The dialect’s projection rebuild handles a qualified column (
a.col), a scalar expression over qualified columns (GREATEST(a.start, b.start),a.start + 1— see Scalar expression projections above), or a plain aggregate over qualified columns (SUM(a.score)/COUNT(*)). The remaining SELECT-list shapes the naive plan compiles for free fall back, sodialect="duckdb"stays consistent with every other backend instead of hard-erroring or, for the aggregate-nested star, miscompiling. Each falls back for one of the four properties above:not row-wise — a window aggregate (
SUM(a.score) OVER (...)), aFILTERclause, an aggregate nested in an expression (COUNT(*) * 2), or a row-multiplying call (unnest([a.start, b.start]));binds names — a scalar subquery, bare or inside an expression, and a lambda (
list_transform(b.arr, x -> x + a.start)). A lambda’s body resolves against the surrounding relation’s aliases, and the rebuild replaces the join with the wrapper relation, so a parameter sharing a join alias’s name would bind differently;carries a star — a star nested in an aggregate argument or expression (
COUNT(a.*)/MIN(COLUMNS(*))/COLUMNS(*) + 1), which a schema-less rebuild cannot enumerate;no qualified column leaf — a bare literal (
100,1 + 1,CAST(100 AS INT)). There is nothing to attribute to a join side, and one such SELECT item declines the whole query, soSELECT a.start, 'tag' AS srctakes the naive plan.
Two further shapes fall back for reasons specific to the round trip through sqlglot. A subscript (
a.arr[1]) falls back because GIQL reads with a zero-based index offset and DuckDB writes with a one-based one, so re-emitting the subtree would shift the literal by one. And a function sqlglot does not model — parsed as an anonymous call, which includes DuckDB aggregates such asproduct,histogramandquantile_cont— falls back because nothing tells the rebuild whether it aggregates, multiplies rows, or binds names. Beyond these named cases the rebuild verifies the round trip rather than predicting it: it re-emits the tree with DuckDB’s generator, re-reads it, and declines unless the tree comes back unchanged. An expression projection under aLEFT/RIGHTouter join also falls back, because the decomposition’s unmatched half only NULL-fills side-attributable columns. A projection whose column the rebuild cannot attribute to a join side (unqualified, an unknown table, or the right side under a SEMI / ANTI join) raises at transpile time instead (see the hard-error list below) — the unknown-table and right-side cases are rejected by the naive plan too, and a bare unqualified column, though it may be naive-valid when unambiguous, has no side the dialect can infer without a live schema.NATURAL joins.
NATURAL JOINfalls back because the dialect cannot enumerate shared columns at transpile time (only the registeredchrom/start/end/strandcolumns are known).USING joins. Single-column
USING(<chrom_col>)admits (the per-chromosome partition is exactly the equi-join). Multi-columnUSINGandUSING(<non-chrom-col>)fall back; inline support is a documented follow-up.Multiple INTERSECTS predicates. Queries with more than one column-to-column
INTERSECTS(anywhere in the AST, including subqueries) fall back.INTERSECTS references unrecognized join sides. A column-to-column INTERSECTS whose operands don’t reference the FROM table’s alias, or where the second operand’s alias doesn’t resolve to a registered JOIN target, falls back to the naive-predicate plan.
Self-joins. Joining a table to itself falls back.
OR / NOT / paren-wrapped extra predicates. See the Extra JOIN / WHERE predicates note above.
Subquery, aggregate, or window-function extra predicates. Predicates whose AND-tree residual contains a subquery, an aggregate function, or a window function (
ROW_NUMBER() OVER (...)etc.) fall back rather than inline into the per-chromosome subquery’sON(DuckDB forbids window functions inONclauses, and aggregates / subqueries inside an IEJoin candidate set would either break the planner or produce semantically wrong results).Subquery inside GROUP BY / HAVING / ORDER BY. A modifier clause containing a nested subquery (including
EXISTS (SELECT ...)) falls back to the naive-predicate plan, because the dialect’s modifier-ref rewriter is not scope-aware and would corrupt the subquery’s column scope. Subqueries hidden inside aggregate arguments in the SELECT list fall back for the same reason.Top-level WITH clauses. A query with a leading
WITHfalls back so the CTE survives.More than two tables. Any extra FROM-clause table (auxiliary comma-join or non-INTERSECTS
JOIN) falls back.Non-base-table operands. A subquery, CTE, or GIQL table-function (e.g.
DISJOIN(genes)) used as a join operand falls back.DISTINCT ON (…). Only plain
DISTINCTis absorbed; theDISTINCT ONvariant falls back.
Query-shape ValueError raises (the dialect deliberately refuses
these rather than silently miscompile):
Unqualified columns. A bare column reference (
SELECT score) raises — the dialect path needs to know which side each output column comes from, and has no live schema to infer it (so an unambiguous unqualified column that the naive plan would accept still raises here). Usea.colora.col AS x. (BareSELECT *does not raise; like every star it falls back to the naive-predicate plan — see the soft-fallback list above. An expression / window aggregate /FILTERclause over an unqualified column raises the same “requires qualified projections” error — once qualified, a scalar expression is rebuilt on the IEJoin path, and a window /FILTERwrapper falls back to the naive plan rather than erroring. A scalar subquery always falls back, even over an unqualified inner column, since that column resolves against the subquery’s own scope.) Every one of these diagnostics names the offending leaf rather than the projection around it, soGREATEST(a.start, c.start)reportsc.startinstead of prescribing that a column already qualified be qualified.Unknown table qualifier.
c.colwhen the only sides areaandb, at any depth inside an expression.Unknown alias in GROUP BY / HAVING / ORDER BY. A modifier-clause column qualified with a table alias outside the join’s two sides (
c.col) raises with acannot resolve … in <CLAUSE>message naming the expected aliases.Right-side reference in SEMI / ANTI joins.
b.color an aggregate overb.colin the outer SELECT, GROUP BY, HAVING, ORDER BY, or aggregate argument underSEMI JOIN/ANTI JOINraises; SEMI / ANTI return left-side columns only. (A right-sideb.*falls back to the naive-predicate plan with every other star — see the soft-fallback list above.)Catalog- or schema-qualified column.
mycat.myschema.a.colraises, in a projection as well as in an extra predicate. The alias-rewriter remapsato the inner subquery’s owna, and themycat.myschemaprefix would survive that rewrite and address a different relation; the naive plan rejects the form at bind time too.Aggregate of unqualified column.
SUM(score)raises; useSUM(a.score)orSUM(b.score). (A qualified aggregate the IEJoin cannot rebuild —SUM(a.score) OVER (...),SUM(a.score) FILTER (WHERE ...),COUNT(*) * 2,COUNT(a.*)— falls back to the naive plan instead; see the soft-fallback list above.)Catalog/schema-qualified extra predicates.
mycat.myschema.a.colin an extra predicate raises; the alias rewriter would leave the catalog/schema qualifier intact in the inner subquery, where it references a different relation than intended.Unqualified or unknown-aliased extra predicates.
WHERE strand = '+'(unqualified) orWHERE c.score > 0(alias outside the join’s two sides) raise with an actionable message naming the expected aliases. The naive-predicate plan would either silently bind the column wrong or defer the error to the downstream engine, so the dialect surfaces the user mistake at transpile time instead.
The one argument-validation ValueError raise (it fires before any AST
inspection and is independent of the query shape):
Unknown dialect string. A dialect that resolves to neither a built-in target (
None,"duckdb","datafusion") nor a custom target registered on the plugin hub raises with the offending value echoed.
Literal-range INTERSECTS (e.g. WHERE interval INTERSECTS
'chr1:1000-2000') is single-table and has no column-to-column join
to rewrite, so the dialect declines and the standard range-predicate
emission handles it identically to the dialect=None path.
DuckDB Optimizations#
Use columnar strengths:
DuckDB is columnar, so queries that select few columns are faster:
-- Faster: Select only needed columns
SELECT chrom, start, "end", name
FROM features
WHERE interval INTERSECTS 'chr1:1000-2000'
Parallel execution:
DuckDB automatically parallelizes queries. For very large datasets, ensure you’re not limiting parallelism.
SQLite Optimizations#
Use covering indexes:
-- Include commonly selected columns in the index
CREATE INDEX idx_features_covering
ON features (chrom, start, "end", name, score)
Analyze tables:
ANALYZE features