Types

Every feature in FeatureQL has a type. Numerics use two words that do not overlap:

  • Category — exact or approximate. Exact values add without rounding error. Approximate values are binary floating point.
  • Family — integer and decimal under exact; floating under approximate (FLOAT, DOUBLE).
  • Rank — width inside a family: TINYINT < SMALLINT < INT < BIGINT, and FLOAT < DOUBLE.

Producing operators (+, -, *, >) mix within a category, so integer and decimal combine. // keeps the dividend's type instead. Keep-forms (COALESCE, IF, CASE, ARRAY, GREATEST) mix within a family only (ranks widen). Crossing categories is allowed only when the exact side is a literal (1 + 1e0, 1.5 * 2e0). Naming or computing the exact value removes that exception — X := 1; X + 1e0 is an error and you must cast.

FeatureQL translates each query to SQL for DuckDB, Trino, and serving (DataFusion), and each backend coerces types differently. What silently widens in one might truncate in another. Explicit casts keep the result the same everywhere.

Promotion rules, parametric defaults, and the conversion table are later on this page.

Type families

The types you meet first. For numerics, family is the second row of the glossary above — not another word for exact vs approximate.

FamilyCategoryWriteAlso accepted
IntegerexactBIGINTINT, SMALLINT, TINYINT
DecimalexactDECIMAL—
FloatingapproximateDOUBLEFLOAT
Text / boolean—VARCHAR, BOOLEANSTRING, BOOL
Temporal—TIMESTAMP, DATETIMESTAMPTZ, INTERVAL
Structured—ARRAY, ROWLIST, STRUCT

Catalog, geo/binary/JSON, and INDEX: All types . How you write a literal picks the family (1 vs 1.0 vs 1e0): Literals .

FeatureQL does not support MAP. For key/value lookups, build an INDEX with INDEX_UNIQUE() / INDEX_MULTI() — Lookup with indexes .

Inference

Most of the time you do not declare types. FeatureQL infers them from context:

  • Literals — 'Hello' is VARCHAR, 1 is BIGINT, 1.25 is DECIMAL(3,2), 1.25e0 is DOUBLE
  • Operators and functions — 1 + 2 produces BIGINT because both operands are BIGINT
  • Exact × approximate — a producing operator (+, -, *, >) accepts an exact literal next to an approximate value and unifies both operands to the approximate category. A computed or named exact value does not: write X::DOUBLE + 2e0. // does not take this exception: the result stays the dividend's type. Keep-forms (COALESCE, IF, CASE, ARRAY, GREATEST) never adapt across families.

When you need an explicit type

You declare a type in two places:

  • INPUT(BIGINT) — a placeholder you fill later in FOR. Binding is on Binding inputs .
  • EXTERNAL_COLUMNS(...) — column types for a warehouse (or other) source, because FeatureQL cannot infer them. The shape of a mapping is at the end of Friendly syntax and in full on Mapping to data sources .

Everything else is inferred from the expressions you write.

Casting

Two equivalent syntaxes — standard SQL and PostgreSQL shorthand:

FeatureQL
RETURN
    '123'::BIGINT, -- Standard SQL casting
    '123'::BIGINT, -- Shorthand syntax
    'Amount: ' || 123::VARCHAR || 'EUR'
;
Result
?_0 BIGINT?_1 BIGINT?_2 VARCHAR
123123Amount: 123EUR

The :: shorthand has higher precedence than most operators, which is why 123::VARCHAR || 'EUR' works without parentheses — the cast happens before the concatenation.

For casts that may fail at runtime, TRY CAST() returns NULL instead of raising an error.

Type inspection

TYPEOF() returns the FeatureQL type. SQLTYPEOF() returns the type in the target backend. These can differ — FeatureQL BIGINT is BIGINT on DuckDB, integer on Trino, and Int64 on serving:

FeatureQL
RETURN
    n := 1,
    fql_type := TYPEOF(n),
    backend_type := SQLTYPEOF(n)
;
Result
N BIGINTFQL_TYPE VARCHARBACKEND_TYPE VARCHAR
1BIGINTBIGINT

If a query works in one backend but not another, compare the two. SQLTYPEOF spelling is engine-owned; FeatureQL results are not. Same answer across backends . More inspection tools: Tools in the query .

Semantic annotations

A type can carry business meaning on top of the datatype:

BIGINT#CUSTOMERS
DECIMAL(18,2)@CURRENCY.USD

#CUSTOMERS marks a key as identifying a customer. @CURRENCY.USD marks what a number measures. The compiler checks and propagates these labels; they are erased before SQL runs.

That is how FeatureQL rejects a customer key compared to a product key — both are BIGINT, but they do not identify the same thing:

FeatureQL
DEFINE
    orders := ENTITY(),
    items := ENTITY(),
    order_id := INPUT(BIGINT#ORDERS),
    item_id := INPUT(BIGINT#ITEMS)
RETURN cross_entity_eq := order_id = item_id
FOR (order_id, item_id) := BIND_VALUES(ARRAY(ROW(1, 1), ROW(2, 3)))
;
Result
ERROR UE/ANNOTATIONS-NOT-ALLOWED

INPUT() and FOR here only supply the two keys; you will use them for real bindings later. Full model: Entities and keys and Units and measures .

Parametric defaults

Bare parametric types always expand to the same FeatureQL defaults on every backend:

Written asMeans
DECIMAL / NULL(DECIMAL) / CAST(… AS DECIMAL)DECIMAL(18,6)
TIMESTAMP / NULL(TIMESTAMP) / timestamp literalsTIMESTAMP(3)
TIMESTAMPTZ / NULL(TIMESTAMPTZ)TIMESTAMPTZ(3)

Digit literals keep inferred precision (1.123 is DECIMAL(4,3)). Prefer an explicit (p,s) when the column is money or another fixed scale (e.g. DECIMAL(10,2)), especially in EXTERNAL_COLUMNS / JDBC DDL — bare SQL DECIMAL on Trino is DECIMAL(38,0), which is not FeatureQL’s default.

FeatureQL
RETURN
    t_null := TYPEOF(NULL::DECIMAL),
    t_cast_lit := TYPEOF(10.05::DECIMAL),
    t_cast_int := TYPEOF(10::DECIMAL),
    t_cast_ws := TYPEOF(10.05::DECIMAL(18,6)),
    t_cast_money := TYPEOF(10.05::DECIMAL(10,2)),
    t_lit := TYPEOF(1.123),
    t_typed_lit := TYPEOF(DECIMAL '1.123')
;
Result
T_NULL VARCHART_CAST_LIT VARCHART_CAST_INT VARCHART_CAST_WS VARCHART_CAST_MONEY VARCHART_LIT VARCHART_TYPED_LIT VARCHAR
DECIMAL(18,6)DECIMAL(18,6)DECIMAL(18,6)DECIMAL(18,6)DECIMAL(10,2)DECIMAL(4,3)DECIMAL(4,3)

Timestamps follow the same pattern — a bare TIMESTAMP is TIMESTAMP(3):

FeatureQL
RETURN
    t_null := TYPEOF(NULL::TIMESTAMP),
    t_cast := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP),
    t_lit := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'),
    t_lit_ms := TYPEOF(TIMESTAMP '2024-01-01 12:00:00.123'),
    t_p0 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(0)),
    t_p3 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(3)),
    t_p6 := TYPEOF(TIMESTAMP '2024-01-01 12:00:00'::TIMESTAMP(6)),
    t_tstz := TYPEOF(NULL::TIMESTAMPTZ(3))
;
Result
T_NULL VARCHART_CAST VARCHART_LIT VARCHART_LIT_MS VARCHART_P0 VARCHART_P3 VARCHART_P6 VARCHART_TSTZ VARCHAR
TIMESTAMP(3)TIMESTAMP(3)TIMESTAMP(3)TIMESTAMP(3)TIMESTAMP(0)TIMESTAMP(3)TIMESTAMP(6)TIMESTAMPTZ(3)

Integer to DECIMAL promotion

On producing operators and comparisons, integers promote to DECIMAL when used alongside a DECIMAL value. This applies to both literal integers and computed integer features. Keep-forms do not promote; they only widen ranks or scales inside one family.

INPUT(BIGINT) below is a typed placeholder. DEFINE holds local definitions that are not returned — the same role as on Query structure .

DEFINE
    PRICE := 10.25,            -- DECIMAL(4,2)
    QUANTITY := INPUT(BIGINT),  -- a computed integer, not a literal
RETURN
    PRICE + 5,                 -- works: literal 5 promotes to DECIMAL
    PRICE + QUANTITY,          -- works: computed BIGINT also promotes to DECIMAL
    PRICE > QUANTITY,          -- works: comparison promotes too

When an integer is promoted, FeatureQL treats it as DECIMAL(N, 0) where N depends on the integer type: N=3 for TINYINT, N=5 for SMALLINT, N=10 for INT, N=20 for BIGINT.

FeatureQL
DEFINE
    price := 10.25
RETURN
    addition := price + 1,
    multi_step := 10 + price + 5,
    comparison := price > 10,
    addition_tinyint := price + TINYINT '1',
    addition_type := TYPEOF(addition),
    multi_step_type := TYPEOF(multi_step),
    addition_tinyint_type := TYPEOF(addition_tinyint)
;
Result
ADDITION DECIMAL(5,2)MULTI_STEP DECIMAL(6,2)COMPARISON BOOLEANADDITION_TINYINT DECIMAL(5,2)ADDITION_TYPE VARCHARMULTI_STEP_TYPE VARCHARADDITION_TINYINT_TYPE VARCHAR
11.2525.25true11.25DECIMAL(5,2)DECIMAL(6,2)DECIMAL(5,2)

This promotion does not apply inside ARRAY or ROW constructors. Those keep-forms require the same family — integer, decimal, or floating — not only the same category. Within a family, ranks and decimal precisions unify: ARRAY[1.25, 1.1] produces ARRAY(DECIMAL(3,2)), and ARRAY[ROW(2::BIGINT), ROW(INT '2')] produces ARRAY(ROW(field_1 BIGINT)).

FeatureQL
RETURN
    decimals := ARRAY(1.20, 1.25, 1.10),
    decimal_rows := ARRAY(ROW(1.2), ROW(1.25), ROW(1.))
;
Result
DECIMALS ARRAY<DECIMAL(3,2)>DECIMAL_ROWS ARRAY<ROW<field_1 DECIMAL(3,2)>>
[1.20, 1.25, 1.10][{field_1: 1.20}, {field_1: 1.25}, {field_1: 1.00}]

The rule to remember: the two exact families (integer, decimal) mix on producing operators and comparisons. They do not mix inside keep-forms (ARRAY, ROW, COALESCE, IF, CASE). When the compiler asks for a cast inside a constructor, add an explicit ::DECIMAL or write the literal as a decimal (e.g., 5.0 instead of 5).

Supported conversions

CAST() and :: support conversions between these type pairs:

FromTo
BIGINTDECIMAL, DOUBLE, VARCHAR
DECIMALBIGINT, DOUBLE, VARCHAR
DOUBLEBIGINT, DECIMAL, VARCHAR
VARCHARBIGINT, DECIMAL, DOUBLE, DATE, TIMESTAMP, BITSTRING
DATETIMESTAMP, VARCHAR
TIMESTAMPDATE, VARCHAR
INTERVALVARCHAR
BITSTRINGVARCHAR

Same-type casts (for example CAST(x AS BINARY) when x is already BINARY) are always allowed. You cannot cast to BINARY, EMPTY, or UNSPECIFIED from another type — produce BINARY with X'…' / ST_ASWKB(), and use typed NULL(…) instead of casting to EMPTY/UNSPECIFIED. Geospatial, JSON, and BOOLEAN are also outside this conversion table; use typed literals or constructors instead of CAST.

Complex types have structural constraints: arrays can only be cast to other arrays (or JSON), rows to other rows, and arrays of rows to other arrays of rows.

Patch an annotation with CAST() by writing _ to keep the datatype, such as CAST(raw_id AS _#CUSTOMERS) or CAST(raw_amount AS _@CURRENCY.USD). Drop a known identity with KEY_TO_SCALAR(). Change one known identity for another, or relabel a unit, with UNSAFE_CAST(). See Entities and keys and Units and measures .

Troubleshooting type errors

Error messageCauseFix
FUNCTION-SIGNATURE-UNKNOWN on + with a named exact value and a DOUBLEComputed exact × approximate (cross-category) is refusedCast the named side (value::DOUBLE + 2.5e0) or write both as approximate literals (1e0 + 2.5e0). An exact literal such as 1.5 + 1e0 is already legal. // keeps the dividend's type instead
Array or ROW constructor type mismatchMixing two families (for example integer and decimal) inside ARRAY[...] or ROW(...)Write all elements in the same family: ARRAY[10.25, 5.0] or ARRAY[10.25, 5::DECIMAL]
Entity mismatch in RELATED() or EXTEND()The VIA key has a different entity annotation than the target feature's INPUTCheck that your foreign key and the target entity's primary key share the same #ENTITY annotation
ENTITY-CAST-LABEL-ONLY when changing #ERegular CAST cannot change one entity to anotherUse UNSAFE_CAST(value AS _#OTHER). If you need this often, reconsider your entity model
ENTITY-CAST-ERASE-REQUIRES-UNSAFECAST(... AS _#UNDEFINED) is refusedDrop the identity with KEY_TO_SCALAR(value). The cast form of the same erase is UNSAFE_CAST(value AS _#UNDEFINED). Omitting # keeps the entity
ANNOTATIONS-NOT-ALLOWED on > / < / BETWEEN with an identity keyInteger and string keys accept = and != onlyOrder two keys of one family with IS_KEY_BEFORE(). Compare a key to a plain bound with KEY_TO_SCALAR(). DATE#, TIMESTAMP#, and geography keys already accept <, >, and BETWEEN
ANNOTATIONS-NOT-ALLOWED on order_id = item_idEquality across different entity familiesCompare keys within the same family, or drop one side with KEY_TO_SCALAR()
UNIT-LABEL-MISMATCH on +, -, or a comparisonThe two operands measure different thingsRescale with CONVERT_UNIT(), or multiply by a typed rate so the units cancel
UNIT-CAST-LABEL-ONLY when changing @URegular CAST cannot turn one measured unit into anotherCONVERT_UNIT() to rescale the value, or UNSAFE_CAST to relabel it without converting

Next