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:
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))
;| ORDER_ID BIGINT#ORDERS | REVENUE_USD DECIMAL(20,2)@CURRENCY.USD | SPEED_KMH DOUBLE@(LENGTH.KILOMETER / DURATION.HOUR) | T_REVENUE VARCHAR | T_SPEED VARCHAR |
|---|---|---|---|---|
| 1 | 125.50 | 14.4 | DECIMAL(20,2)@CURRENCY.USD | DOUBLE@(LENGTH.KILOMETER/DURATION.HOUR) |
| 2 | 84.00 | 7.2 | DECIMAL(20,2)@CURRENCY.USD | DOUBLE@(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, orVOLUME.IMPERIAL_GALLON. - Use the shared names
LENGTH.FOOT,LENGTH.MILE,MASS.POUND, andAREA.ACREfor modern international definitions. - Use
US_SURVEYnames 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
- Add, subtract, or compare: both sides need the same unit. A bare number such as
+ 5is an error; writeCAST(5 AS _@CURRENCY.USD), or use0, which is compatible with any unit. Comparing a labelled value to a bare number warns. - Multiply or divide: units combine and cancel. Metres divided by seconds become metres per second. A literal scale such as
* 2keeps the measured unit. - 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:
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))
;| ORDER_ID BIGINT#ORDERS | MARGIN_USD DECIMAL(18,2)@CURRENCY.USD | T_MARGIN VARCHAR |
|---|---|---|
| 1 | 37.5 | DECIMAL(18,2)@CURRENCY.USD |
| 2 | 29 | DECIMAL(18,2)@CURRENCY.USD |
A rate in the wrong direction leaves the wrong unit after multiplication, so the subtraction fails:
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))
;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:
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))
;| ORDER_ID BIGINT#ORDERS | REVENUE_PER_ORDER DOUBLE@(CURRENCY.USD / ORDERS) | T_COUNT VARCHAR | T_RATE VARCHAR |
|---|---|---|---|
| 1 | 104.75 | BIGINT@ORDERS | DOUBLE@(CURRENCY.USD / ORDERS) |
| 2 | 104.75 | BIGINT@ORDERS | DOUBLE@(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:
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))
;| ORDER_ID BIGINT#ORDERS | NET_FROM_FACTOR DECIMAL(18,2)@CURRENCY.USD | NET_FROM_PCT DECIMAL(18,2)@CURRENCY.USD | T_FACTOR VARCHAR | T_PCT VARCHAR |
|---|---|---|---|---|
| 1 | 112.95 | 112.95 | DECIMAL(18,2)@CURRENCY.USD | DECIMAL(18,2)@CURRENCY.USD |
| 2 | 84 | 84 | DECIMAL(18,2)@CURRENCY.USD | DECIMAL(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.
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))
;| ORDER_ID BIGINT#ORDERS | LOG_RATIO DOUBLE |
|---|---|
| 1 | 0.6931471805599453 |
| 2 | 0.0 |
Choose the right operation
| You want to | Write | Notes |
|---|---|---|
| Convert a measured value | CONVERT_UNIT(x, CURRENCY.USD) | Changes the value and unit |
| Label an unlabelled value | CAST(x AS _@CURRENCY.USD) | Keeps the datatype |
| Change datatype, keep the unit | CAST(x AS DECIMAL(18,2)) | Omitting @ keeps the existing unit |
| Declare a multiplier | CAST(x AS _@DIMENSIONLESS) | Prefer declaring it on the input |
| Take a logarithm | LN(revenue / baseline) | Same-unit ratio, or an authored @DIMENSIONLESS input. Prefer this over erasing a measured unit first |
| Override a known unit | UNSAFE_CAST(x AS _@CURRENCY.EUR) | Relabels without changing the value |
| Remove a known unit | UNSAFE_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:
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
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.DOC.UNITS.ORDERS | CREATED | Feature created as not exists |
| FM.DOC.UNITS.ORDER_ID | CREATED | Feature created as not exists |
| FM.DOC.UNITS.ORDER_FACTS | CREATED | Feature created as not exists |
| FM.DOC.UNITS.AMOUNT | CREATED | Feature created as not exists |
| FM.DOC.UNITS.SPEED | CREATED | Feature created as not exists |
| FM.DOC.UNITS.SHIPPING | CREATED | Feature created as not exists |
| FM.DOC.UNITS.TOTAL | CREATED | Feature created as not exists |
Use SHOW FEATURES to inspect the stored unit:
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;| NAME VARCHAR | UNIT VARCHAR |
|---|---|
| FM.DOC.UNITS.AMOUNT | CURRENCY.USD |
| FM.DOC.UNITS.SHIPPING | CURRENCY.USD |
| FM.DOC.UNITS.SPEED | LENGTH.KILOMETER / DURATION.HOUR |
| FM.DOC.UNITS.TOTAL | CURRENCY.USD |
To change the unit, replace the feature definition. FeatureQL checks every dependent feature before saving the change:
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
)
)
;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
- Semantic modeling — see units alongside entities, multiplicity, and additivity
- All dimensions and units — choose an exact unit name
- Metric additivity — control how summarized values roll up
- Types — datatypes, inference, casts, promotion
- Feature metadata — choose a matching display format