Querying Historical Tables¶
Historical tables are queried with the same AMI SQL used against realtime tables, with a defined set of rules covering joins, indexes, and predicate forms.
Overview¶
A historical table answers the same SELECT statements as a realtime table, in the same Center, and can be read alongside live data. A query filtered on a partition column reads only the partitions that can match, so its cost tracks the data it needs rather than the size of the table:
See Partitioning for how that elimination works. The differences from a realtime table are confined to three areas, each covered below: how two historical tables join, how indexes behave, and which predicate forms are accepted.
Joins¶
Two historical tables can be joined. Both operands are ordinary historical tables, created as in Creating a Historical Table:
There is no need to stage either side into a temporary table first. The join reads both historical tables directly.
The four-clause rule¶
A join has to be written in a specific shape. Each clause has one accepted form, and the wrong form fails in a distinct way:
| Clause | Required form | If written the wrong way |
|---|---|---|
SELECT |
Table-qualified, Table.col. Output columns come back named Table_col |
— |
ON |
Qualified equality only, Trades.qty==Fills.qty |
— |
WHERE |
Unqualified, col, not Table.col |
Unknown namespace variable: ZZ_FS_SRC.qty |
| aliases | Not supported at all | Class not found: ZZ_FS_SRC |
So the SELECT and ON clauses qualify their column names with the table, and the WHERE clause must not. Table aliases cannot be used anywhere in the statement.
Join limits¶
- Two historical tables per join. A third fails with
Multiple JOIN statements not supported: JOIN. LEFT JOINworks, but the right-hand table cannot be constrained. AWHEREorONcondition on the right-hand table of aLEFT JOINis not applied.
Indexes¶
An index speeds up lookups on a non-partition column:
SORT is optional and is the only index type available. There is no PRIMARY or UNIQUE index: an index does not enforce uniqueness, and an indexed column may hold duplicate values. An index is a lookup accelerator, nothing more.
A few rules apply:
- One index per column, one column per index. A second index on the same column fails with
Duplicate Index on column: <name> on <col>. Indexes are single-column; there are no composite indexes. - The column must exist. Indexing a column that is not on the table fails with
Column not found for table <table>: <col>. - A partition column cannot be indexed. It fails with
Can not create INDEX on PARTITION column: d. This is not a limitation to work around: partition pruning already does the job an index would, without opening any file. Index the columns you filter on within a partition. See Partitioning.
A new index is held in memory until the table is optimized, which flushes it to disk, one index per partition. See Optimization.
Warning
Do not index a nullable column. On a column that holds any null, creating an index changes the result of MIN(). The identical aggregate query returns the true minimum before the index and null after it, and it does not recover after optimize. MAX() and COUNT(*) are unaffected.
| State | MIN(qty) |
MAX(qty) |
COUNT(*) |
|---|---|---|---|
| no index | 5 |
30 |
5 |
after CREATE INDEX idx ON t (qty) |
null |
30 |
5 |
Index only columns that never hold null.
Warning
Do not create an index on a BOOLEAN column. Creation is accepted, but the first insert then fails with Uncaught error: null <-- java.lang.NullPointerException and leaves the table unusable.
Predicate forms¶
Most WHERE predicates behave as they do against a realtime table. Four cases do not.
Nulls compare in two different ways. A null sorts below every value in a direct comparison, and drops out of the result once an expression wraps the column:
Two predicates on one column throw when one of them wraps the column in an expression. Plain range predicates are fine:
A subquery in IN throws. WHERE qty IN (SELECT ...) surfaces Uncaught error: null <-- java.lang.NullPointerException rather than a clean rejection.
There is no LIKE operator, and no COUNT_UNIQUE:
LIKEreturnsNo LIKE operator: use ~~ for a filter match, or =~ for a regex. Use~~for a filter match or=~for a regex.COUNT_UNIQUEreturnsNo such method: COUNT_UNIQUE(long). It does not exist.
Every error string above, with its cause and correction, is collected in Troubleshooting.
Aggregates¶
Standard aggregates work against a historical table, on their own or with GROUP BY:
Aggregate output columns are named <FUNCTION>_<column> — SUM_qty, AVG_px, MIN_qty — and COUNT(*) comes back as COUNT_.
Two cautions apply:
MIN()returnsnullon a column that holds nulls once that column is indexed. See the index warning under Indexes; do not index a nullable column.COUNT_UNIQUEdoes not exist (No such method: COUNT_UNIQUE(long)).
Note
Retrieving aggregate results through an AI client's MCP tools returns a deferred handle, not rows, the same as any other data query. Use a query transport, or verify by row count. See Operating over MCP.