Units and measures

A unit tells FeatureQL what a number means. Add it to an input once, and the compiler carries it through later calculations. That is how FeatureQL catches mistakes such as adding dollars to euros while the numbers still look plausible.

Write the unit after @ on mapped source columns:

ORDERS := ENTITY(),
ORDER_ID := INPUT(BIGINT#ORDERS),

ORDER_FACTS := EXTERNAL_COLUMNS(
    order_id BIGINT#ORDERS BIND TO ORDER_ID,
    revenue  DECIMAL(18,2)@CURRENCY.USD,
    distance DOUBLE@LENGTH.METER,
    FROM TABLE(sales.orders)
)

For the full type syntax, see Types .

Add the unit where data enters

Describe the value as it is stored. If the source stores cents, use CURRENCY.USD_CENT. Do not label the value as dollars and hope every reader remembers the hidden scale.

Everything built from that input inherits the unit:

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_cents BIGINT@CURRENCY.USD_CENT,
        distance_m DOUBLE@LENGTH.METER,
        duration_s DOUBLE@DURATION.SECOND
        FROM CSV(
            order_id,revenue_cents,distance_m,duration_s
            1,12550,3600.0,900.0
            2,8400,1200.0,600.0
        )
    ),
    revenue_usd := CONVERT_UNIT(facts[revenue_cents], CURRENCY.USD),
    speed_kmh := CONVERT_UNIT(facts[distance_m] / facts[duration_s], (LENGTH.KILOMETER/DURATION.HOUR))
RETURN
    order_id,
    revenue_usd,
    speed_kmh,
    t_revenue := TYPEOF(revenue_usd),
    t_speed := TYPEOF(speed_kmh)
FOR
    order_id := BIND_VALUES(ARRAY(1, 2))
;
Result
ORDER_ID BIGINT#ORDERSREVENUE_USD DECIMAL(20,2)@CURRENCY.USDSPEED_KMH DOUBLE@(LENGTH.KILOMETER / DURATION.HOUR)T_REVENUE VARCHART_SPEED VARCHAR
1125.5014.4DECIMAL(20,2)@CURRENCY.USDDOUBLE@(LENGTH.KILOMETER/DURATION.HOUR)
284.007.2DECIMAL(20,2)@CURRENCY.USDDOUBLE@(LENGTH.KILOMETER/DURATION.HOUR)

The example converts cents to dollars and metres per second to kilometres per hour. TYPEOF() confirms the resulting units — Tools in the query .

Choose unit names from All dimensions and units . The common shapes are:

DOUBLE@LENGTH.METER
DOUBLE@(LENGTH.KILOMETER / DURATION.HOUR)
DOUBLE@(LENGTH.METER ^ 2)
DOUBLE@AREA.ACRE
DOUBLE@VOLUME.US_LIQUID_GALLON

Use powers for exact derived quantities:

  • square metres: @(LENGTH.METER ^ 2)
  • cubic metres: @(LENGTH.METER ^ 3)

Use named units when the name carries a scale: AREA.HECTARE, AREA.ACRE, VOLUME.LITER, or VOLUME.US_LIQUID_GALLON. One litre is not the same label as one cubic metre; it converts to 0.001 cubic metres.

Convert values explicitly

CONVERT_UNIT() changes both the value and its unit:

LITERS := CONVERT_UNIT(GALLONS, VOLUME.LITER)
SQUARE_METERS := CONVERT_UNIT(ACRES, (LENGTH.METER ^ 2))

FeatureQL never converts units silently. Values can be added or compared only after their units match. Use CONVERT_UNIT() for fixed measurement conversions; use a typed rate for conversions such as euros to dollars. Both directions keep the target unit on the result type: cents to dollars and dollars to cents, hours to seconds, kilometres to metres.

RELATED(SUM(amount)) and RELATED(AVG(amount)) keep the operand unit. A MACRO(...) USING parameter that has a unit is checked at the call site the same way a UDF parameter is. One ARRAY slot — including ARRAY_CONCAT and set operations — cannot mix units; ARRAY_SUM() / ARRAY_AVG() keep the element unit. Named ROW fields and ZIP(usd AS u, eur AS e) may carry different units side by side.

When choosing US or Imperial units:

  • Use a complete volume name such as VOLUME.US_LIQUID_GALLON, VOLUME.US_DRY_GALLON, or VOLUME.IMPERIAL_GALLON.
  • Use the shared names LENGTH.FOOT, LENGTH.MILE, MASS.POUND, and AREA.ACRE for modern international definitions.
  • Use US_SURVEY names only for legacy US survey data.

Temperature is the exception to normal composition. Celsius and Fahrenheit differ by an offset, so CONVERT_UNIT() can convert one to the other, but temperature units cannot appear inside a compound unit such as degrees per hour.

Remember three arithmetic rules

  1. Add, subtract, or compare: both sides need the same unit. A bare number such as + 5 is an error; write CAST(5 AS _@CURRENCY.USD), or use 0, which is compatible with any unit. Comparing a labelled value to a bare number warns.
  2. Multiply or divide: units combine and cancel. Metres divided by seconds become metres per second. A literal scale such as * 2 keeps the measured unit.
  3. If one side is an unlabelled feature: multiplication cannot guess a result unit, so the label is dropped and FeatureQL warns. Addition of a labelled value to an unlabelled feature is an error — declare the missing unit. Two unlabelled values add silently: there is no unit to disagree. That is why source columns that are money or distance should carry @ at the mapping, not later.

Currencies convert through a rate you already have

Exchange rates change over time, so they are data rather than fixed catalog conversions. Give the rate its real unit — for example, dollars per euro — and multiply:

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_usd DECIMAL(18,2)@CURRENCY.USD,
        cost_eur DECIMAL(18,2)@CURRENCY.EUR,
        usd_per_eur DECIMAL(18,2)@(CURRENCY.USD / CURRENCY.EUR)
        FROM CSV(
            order_id,revenue_usd,cost_eur,usd_per_eur
            1,125.50,80.00,1.10
            2,84.00,50.00,1.10
        )
    ),
    cost_usd := facts[cost_eur] * facts[usd_per_eur],
    margin_usd := (facts[revenue_usd] - cost_usd)::DECIMAL(18,2)
RETURN
    order_id,
    margin_usd,
    t_margin := TYPEOF(margin_usd)
FOR
    order_id := BIND_VALUES(ARRAY(1, 2))
;
Result
ORDER_ID BIGINT#ORDERSMARGIN_USD DECIMAL(18,2)@CURRENCY.USDT_MARGIN VARCHAR
137.5DECIMAL(18,2)@CURRENCY.USD
229DECIMAL(18,2)@CURRENCY.USD

A rate in the wrong direction leaves the wrong unit after multiplication, so the subtraction fails:

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_usd DECIMAL(18,2)@CURRENCY.USD,
        cost_eur DECIMAL(18,2)@CURRENCY.EUR,
        eur_per_usd DECIMAL(18,4)@(CURRENCY.EUR / CURRENCY.USD)
        FROM CSV(
            order_id,revenue_usd,cost_eur,eur_per_usd
            1,125.50,80.00,0.9090
        )
    ),
    cost_usd := facts[cost_eur] * facts[eur_per_usd],
    margin_usd := facts[revenue_usd] - cost_usd
RETURN
    order_id,
    margin_usd
FOR
    order_id := BIND_VALUES(ARRAY(1))
;
Result
ERROR UE/UNIT-LABEL-MISMATCH

Counting produces a unit too

COUNT(DISTINCT order_id) over a feature annotated with an entity produces a count of that entity. Bare COUNT() counts rows and stays unlabelled. Dividing revenue by a count of orders therefore produces revenue per order:

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_usd DOUBLE@CURRENCY.USD
        FROM CSV(
            order_id,revenue_usd
            1,125.5
            2,84.0
        )
    ),
    total_revenue := SUM(facts[revenue_usd]) OVER (),
    order_count := COUNT(DISTINCT  order_id) OVER (),
    revenue_per_order := total_revenue / order_count
RETURN
    order_id,
    revenue_per_order,
    t_count := TYPEOF(order_count),
    t_rate := TYPEOF(revenue_per_order)
FOR
    order_id := BIND_VALUES(ARRAY(1, 2))
;
Result
ORDER_ID BIGINT#ORDERSREVENUE_PER_ORDER DOUBLE@(CURRENCY.USD / ORDERS)T_COUNT VARCHART_RATE VARCHAR
1104.75BIGINT@ORDERSDOUBLE@(CURRENCY.USD / ORDERS)
2104.75BIGINT@ORDERSDOUBLE@(CURRENCY.USD / ORDERS)

The result has unit CURRENCY.USD / ORDERS. Multiplying it by an order count produces dollars again.

A count without an entity annotation is unlabelled. FeatureQL cannot know whether it counts orders, customers, or something else.

FeatureQL warns with UNIT-CARDINALITY-GRAIN-OVERLAP when a feature is labelled “per order” but is already at order grain. A per-order value attached to one order is usually a modelling mistake.

Units do not decide identity, multiplicity, or additivity

A unit describes dimensional meaning. It does not identify a relationship key, decide how often a value contributes on repeated rows, or prove that a summarized metric can roll up.

Two regional distinct-customer counts can both have unit @CUSTOMERS and still overlap; daily inventory values can all be valid item counts and still be wrong when added across days. See Semantic modeling for the complete model.

Dimensionless is not the same as unlabelled

A number with no unit is unlabelled: FeatureQL does not know what it measures. @DIMENSIONLESS means the number is known to have no unit. These states behave differently.

Discounts, percentages, weights, and multipliers are dimensionless. Label them at the input so a multiplication keeps the measured unit:

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_usd DECIMAL(18,2)@CURRENCY.USD,
        discount_factor DECIMAL(18,4)@DIMENSIONLESS,
        discount_pct DECIMAL(18,2)@DIMENSIONLESS
        FROM CSV(
            order_id,revenue_usd,discount_factor,discount_pct
            1,125.50,0.9000,10.00
            2,84.00,1.0000,0.00
        )
    ),
    net_from_factor := (facts[revenue_usd] * facts[discount_factor])::DECIMAL(18,2),
    factor_from_pct := (1.0 - facts[discount_pct] / 100.0)::DECIMAL(18,4)@DIMENSIONLESS,
    net_from_pct := (facts[revenue_usd] * factor_from_pct)::DECIMAL(18,2)
RETURN
    order_id,
    net_from_factor,
    net_from_pct,
    t_factor := TYPEOF(net_from_factor),
    t_pct := TYPEOF(net_from_pct)
FOR
    order_id := BIND_VALUES(ARRAY(1, 2))
;
Result
ORDER_ID BIGINT#ORDERSNET_FROM_FACTOR DECIMAL(18,2)@CURRENCY.USDNET_FROM_PCT DECIMAL(18,2)@CURRENCY.USDT_FACTOR VARCHART_PCT VARCHAR
1112.95112.95DECIMAL(18,2)@CURRENCY.USDDECIMAL(18,2)@CURRENCY.USD
28484DECIMAL(18,2)@CURRENCY.USDDECIMAL(18,2)@CURRENCY.USD

Numeric literals scale a labelled value under multiply and divide. If an expression builds a named multiplier from source columns, label that multiplier as @DIMENSIONLESS, as the example does.

Unitless functions such as LN() reject a Known measured operand. A ratio of two same-unit quantities is already dimensionless — that is the way into a logarithm. An unlabelled value is allowed; prefer LN(revenue / baseline) over erasing a unit first.

FeatureQL
DEFINE
    orders := ENTITY(),
    order_id := INPUT(BIGINT#orders),
    facts := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        revenue_usd DOUBLE@CURRENCY.USD,
        baseline_usd DOUBLE@CURRENCY.USD
        FROM CSV(
            order_id,revenue_usd,baseline_usd
            1,200.0,100.0
            2,84.0,84.0
        )
    )
RETURN
    order_id,
    log_ratio := LN(facts[revenue_usd] / facts[baseline_usd])
FOR
    order_id := BIND_VALUES(ARRAY(1, 2))
;
Result
ORDER_ID BIGINT#ORDERSLOG_RATIO DOUBLE
10.6931471805599453
20.0

Choose the right operation

You want toWriteNotes
Convert a measured valueCONVERT_UNIT(x, CURRENCY.USD)Changes the value and unit
Label an unlabelled valueCAST(x AS _@CURRENCY.USD)Keeps the datatype
Change datatype, keep the unitCAST(x AS DECIMAL(18,2))Omitting @ keeps the existing unit
Declare a multiplierCAST(x AS _@DIMENSIONLESS)Prefer declaring it on the input
Take a logarithmLN(revenue / baseline)Same-unit ratio, or an authored @DIMENSIONLESS input. Prefer this over erasing a measured unit first
Override a known unitUNSAFE_CAST(x AS _@CURRENCY.EUR)Relabels without changing the value
Remove a known unitUNSAFE_CAST(x AS _@UNDEFINED)Strips the label. LN() then sees Unknown and compiles; prefer a same-unit ratio

Use CONVERT_UNIT() whenever the numeric value must change. CAST() only adds a unit to an unlabelled value. UNSAFE_CAST() changes or removes a label without checking the value.

Bare AS @CURRENCY.USD is not valid. Write _@CURRENCY.USD; _ means “keep the current datatype.”

Publishing and changing units

A persisted feature keeps its unit as part of its type:

FeatureQL
CREATE OR REPLACE FEATURES AS
PERSIST
    fm.doc.units.orders := ENTITY(),
    fm.doc.units.order_id := INPUT(BIGINT#fm.doc.units.orders),
    fm.doc.units.order_facts := INLINE_COLUMNS(
        order_id BIGINT#fm.doc.units.orders BIND TO fm.doc.units.order_id,
        amount DECIMAL(18,2)@CURRENCY.USD,
        speed DOUBLE@(LENGTH.KILOMETER / DURATION.HOUR),
        shipping DECIMAL(18,2)@CURRENCY.USD
        FROM CSV(
            order_id,amount,speed,shipping
            1,100.00,50.0,10.00
        )
    ),
    fm.doc.units.amount := fm.doc.units.order_facts[amount],
    fm.doc.units.speed := fm.doc.units.order_facts[speed],
    fm.doc.units.shipping := fm.doc.units.order_facts[shipping],
    fm.doc.units.total := fm.doc.units.amount + fm.doc.units.shipping
;
Result
feature_name VARCHARstatus VARCHARmessage VARCHAR
FM.DOC.UNITS.ORDERSCREATEDFeature created as not exists
FM.DOC.UNITS.ORDER_IDCREATEDFeature created as not exists
FM.DOC.UNITS.ORDER_FACTSCREATEDFeature created as not exists
FM.DOC.UNITS.AMOUNTCREATEDFeature created as not exists
FM.DOC.UNITS.SPEEDCREATEDFeature created as not exists
FM.DOC.UNITS.SHIPPINGCREATEDFeature created as not exists
FM.DOC.UNITS.TOTALCREATEDFeature created as not exists

Use SHOW FEATURES to inspect the stored unit:

FeatureQL
SHOW FEATURES (COLUMNS (NAME, UNIT))
WHERE NAME LIKE ANY (
    'FM.DOC.UNITS.AMOUNT',
    'FM.DOC.UNITS.SHIPPING',
    'FM.DOC.UNITS.SPEED',
    'FM.DOC.UNITS.TOTAL'
)
ORDER BY NAME;
Result
NAME VARCHARUNIT VARCHAR
FM.DOC.UNITS.AMOUNTCURRENCY.USD
FM.DOC.UNITS.SHIPPINGCURRENCY.USD
FM.DOC.UNITS.SPEEDLENGTH.KILOMETER / DURATION.HOUR
FM.DOC.UNITS.TOTALCURRENCY.USD

To change the unit, replace the feature definition. FeatureQL checks every dependent feature before saving the change:

FeatureQL
CREATE OR REPLACE FEATURES AS
PERSIST
    fm.doc.units.order_facts := INLINE_COLUMNS(
        order_id BIGINT#fm.doc.units.orders BIND TO fm.doc.units.order_id,
        amount DECIMAL(18,2)@CURRENCY.EUR,
        speed DOUBLE@(LENGTH.KILOMETER / DURATION.HOUR),
        shipping DECIMAL(18,2)@CURRENCY.USD
        FROM CSV(
        order_id,amount,speed,shipping
        1,100.00,50.0,10.00
    )
    )
;
Result
ERROR UE/TYPE-FRONTIER-CHANGE-NOT-POSSIBLE

Here TOTAL adds two dollar source columns. Changing the mapped amount column to euros would make that addition invalid, so FeatureQL rejects the replacement and saves nothing.

Preview a unit change

Use EXPLAIN (FIELDS (MUTATION_DRY_RUN)) to preview which dependent features would change or fail without saving anything — EXPLAIN .

Rechecking stops when a dependent feature keeps the same unit. An UNSAFE_CAST() also acts as an intentional boundary because downstream units no longer depend on the original label.

Match the display format

FeatureQL warns when a measured unit is paired with a percentage DISPLAY_FORMAT; dollars and metres are rarely meant to render as percentages.

Next