Comparison Operators

A comparison is a predicate: it yields true, false, or null, and it is what FILTER and HAVING take. See the Logical Operators page for how predicates combine with AND, OR and NOT, and TEMPORAL for the temporal comparison rules.

NULL never compares equal

Null means unknown, so every comparison with a null operand yields null rather than true or false — including null = null. A FILTER keeps only rows whose predicate is true, so a row with a null operand is dropped by a comparison and by its negation:

FIND orders o
FILTER o.shipped_date < "1998-01-01"
FETCH o.order_id

returns no unshipped order, and neither would the opposite comparison. This is why ISNULL exists: it is the only way to test for null, since = NULL can never match.

FIND orders o
FILTER o.shipped_date ISNULL
FETCH o.order_id

Its negation, "is not null", is written NOT o.shipped_date ISNULL.

Comparing two values that may both be blank

ISNULL tests one value. To compare two columns that may each be empty, use DISTINCT, which counts a missing value as a difference and two missing values as equal — so it always answers true or false, never unknown:

FIND customers c, orders o
FILTER c.country = 'France' AND NOT c.region DISTINCT o.ship_region
FETCH c.customer_id, c.region

This is the same distinctness FETCH DISTINCT applies when it collapses duplicate rows: there too, two blanks count as the same value. So a DISTINCT b asks "are these two genuinely different values", and NOT a DISTINCT b asks "are they the same, blanks included".

Here is an example, French customers have no region recorded, and neither do their orders. Written with <> the query above finds no differences to exclude and = matches nothing, because comparing two missing values is unknown rather than true. With DISTINCT the blanks compare as equal and the orders come back.

Prefer = and <> when the columns cannot be empty. A null-safe comparison is not a plain equality test, so on some engines it can stop an index being used where = would use one; if you are filtering a large table it is worth checking that on your own data before making DISTINCT a habit.

Ordered comparisons need an order

<, <=, >, >= and BETWEEN require operands with a total order. Numbers, text, dates, timestamps and times all have one. Durations only have one within a unit class — clock (h m s ms), days (d), or months (mo q y) — so ordering 1mo against 30d, or using a mixed duration like 1d2h, is rejected: there is no anchor-independent answer. A string-encoded interval is not orderable at all. Compare those with = or <> instead, or store the value as a numeric INTERVAL:<unit> (see TEMPORAL).

Ranges over dates and timestamps

value BETWEEN low AND high is inclusive on both ends — but when high is a DATE or TIMESTAMP literal it is rewritten in SQL to value >= low AND value < high + 1d. That is what makes a range ending on a day include the whole day: a plain SQL BETWEEN … AND '1996-12-31' on a TIMESTAMP column stops at 00:00:00 and silently drops almost all of the last day. The BETWEEN sample below shows the rewrite in its generated SQL.

The rewrite only fires for a literal upper bound. With a computed one — month_end(...), say — no rewrite happens and the half-open form has to be written out by hand (see TEMPORAL).

Membership tests

FILTER c.country IN ('USA', 'Canada')

FILTER NOT c.customer_id IN (
    FIND orders o
    FETCH o.customer_id
)

Beware the second form with NOT: if the subquery yields a single null, the membership test is unknown for every row and the result is empty. NOT EXISTS is immune and is usually what you want.

Pattern matching with LIKE

% matches any run of characters, _ exactly one. Both operands must be TEXT.

Whether LIKE is case-sensitive is not decided by KQL — it follows the column's collation, so the same query can match differently on different databases (and on the same database with a different collation). Where it matters, normalise explicitly with upper() or lower() on both sides rather than relying on the server default. "Does not match" is written NOT x LIKE '…'.

Custom operators

The grammar accepts any lowercase identifier as an infix operator, and one that is not in the catalog is passed through to the generated SQL verbatim. That is a deliberate escape hatch for dialect-specific operators, but it is unchecked: the query works on the database it was written against and may fail on every other one. Transpiling reports it as a warning.

=

left = right → BOOLEAN

True if left equals right.

Operands: any type.

Sample query:

// = : orders shipped to a specific city.
FIND orders o
FILTER o.ship_city = 'London'
FETCH o.order_id, o.ship_city

Generated SQL

all dialects

-- = : orders shipped to a specific city.
SELECT
  o.order_id
, o.ship_city
FROM
 orders o
WHERE
  o.ship_city = 'London'

<>

left <> right → BOOLEAN

True if left does not equal right. NULL on either side yields NULL, not TRUE — a row with a NULL operand is not returned.

Operands: any type.

Sample query:

// <> : orders shipped anywhere but one city.
FIND orders o
FILTER o.ship_city <> 'London'
FETCH o.order_id, o.ship_city

Generated SQL

all dialects

-- <> : orders shipped anywhere but one city.
SELECT
  o.order_id
, o.ship_city
FROM
 orders o
WHERE
  o.ship_city <> 'London'

<

left < right → BOOLEAN

True if left is less than right.

Argument Type Description
left ORDERED the value being tested
right ORDERED the threshold it must stay below

Sample query:

// < : order lines priced below a threshold.
FIND order_details od
FILTER od.unit_price < 10
FETCH od.order_id, od.unit_price

Generated SQL

all dialects

-- < : order lines priced below a threshold.
SELECT
  od.order_id
, od.unit_price
FROM
 order_details od
WHERE
  od.unit_price < 10

<=

left <= right → BOOLEAN

True if left is less than or equal to right.

Argument Type Description
left ORDERED the value being tested
right ORDERED the threshold it must not exceed

Sample query:

// <= : order lines at or below a discount ceiling.
FIND order_details od
FILTER od.discount <= 0.1
FETCH od.order_id, od.discount

Generated SQL

all dialects

-- <= : order lines at or below a discount ceiling.
SELECT
  od.order_id
, od.discount
FROM
 order_details od
WHERE
  od.discount <= 0.1

>

left > right → BOOLEAN

True if left is greater than right.

Argument Type Description
left ORDERED the value being tested
right ORDERED the threshold it must exceed

Sample query:

// > : orders with freight above a threshold.
FIND orders o
FILTER o.freight > 100
FETCH o.order_id, o.freight

Generated SQL

all dialects

-- > : orders with freight above a threshold.
SELECT
  o.order_id
, o.freight
FROM
 orders o
WHERE
  o.freight > 100

>=

left >= right → BOOLEAN

True if left is greater than or equal to right.

Argument Type Description
left ORDERED the value being tested
right ORDERED the threshold it must reach or exceed

Sample query:

// >= : order lines at or above a quantity threshold.
FIND order_details od
FILTER od.quantity >= 10
FETCH od.order_id, od.quantity

Generated SQL

all dialects

-- >= : order lines at or above a quantity threshold.
SELECT
  od.order_id
, od.quantity
FROM
 order_details od
WHERE
  od.quantity >= 10

LIKE

string LIKE pattern → BOOLEAN

True if string matches the SQL LIKE pattern (% and _ wildcards). Both operands must be TEXT.

Argument Type Description
string TEXT the text being matched
pattern TEXT the SQL LIKE pattern with % and _ wildcards

Sample query:

// LIKE : customers whose company name starts with 'A'.
FIND customers c
FILTER c.company_name LIKE 'A%'
FETCH c.customer_id, c.company_name

Generated SQL

all dialects

-- LIKE : customers whose company name starts with 'A'.
SELECT
  c.customer_id
, c.company_name
FROM
 customers c
WHERE
  c.company_name LIKE 'A%'

BETWEEN

value BETWEEN low AND high → BOOLEAN

True if value lies within the inclusive range [low, high]. The AND between the bounds belongs to the range and binds tighter than the logical AND, so a BETWEEN 1 AND 2 AND b is a range test combined with the predicate b. With a DATE or TIMESTAMP literal as the upper bound this is rewritten to the half-open form value >= low AND value < high + 1d, so a range ending on a day includes that whole day rather than stopping at midnight (see docs/TEMPORAL.md).

Argument Type Description
value ORDERED the value being tested
low ORDERED the inclusive lower bound of the range
high ORDERED the inclusive upper bound of the range

Sample query:

// BETWEEN : orders in a date range — a temporal upper bound renders as a half-open interval.
FIND orders o
FILTER o.order_date BETWEEN "1996-07-01" AND "1996-12-31"
FETCH o.order_id, o.order_date

Generated SQL

duckdb · oracle · snowflake · postgresql · mariadb · trino

-- BETWEEN : orders in a date range — a temporal upper bound renders as a half-open interval.
SELECT
  o.order_id
, o.order_date
FROM
 orders o
WHERE
  o.order_date >= DATE '1996-07-01'
 AND
  o.order_date < DATE '1997-01-01'

mssql

-- BETWEEN : orders in a date range — a temporal upper bound renders as a half-open interval.
SELECT
  o.order_id
, o.order_date
FROM
 orders o
WHERE
  o.order_date >= CAST('1996-07-01' AS DATE)
 AND
  o.order_date < CAST('1997-01-01' AS DATE)

sqlite

-- BETWEEN : orders in a date range — a temporal upper bound renders as a half-open interval.
SELECT
  o.order_id
, o.order_date
FROM
 orders o
WHERE
  o.order_date >= '1996-07-01'
 AND
  o.order_date < '1997-01-01'

IN

value IN (items, …) → BOOLEAN

True if value equals any of the listed items.

Operands: any type.

Sample query:

// IN : orders shipped to any of a set of cities.
FIND orders o
FILTER o.ship_city IN ('London', 'Paris')
FETCH o.order_id, o.ship_city

Generated SQL

all dialects

-- IN : orders shipped to any of a set of cities.
SELECT
  o.order_id
, o.ship_city
FROM
 orders o
WHERE
  o.ship_city IN ('London', 'Paris')

DISTINCT

left DISTINCT right → BOOLEAN

True if left and right hold different values, counting a missing value as a difference — and two missing values as equal. Unlike <>, the result is never unknown, so a row is never dropped just because a value is absent.

This is the same distinctness FETCH DISTINCT applies when it collapses duplicate rows: there too, two blanks count as the same value. Write NOT a DISTINCT b for the opposite question — do these match, treating two blanks as matching. Prefer = and <> when the columns cannot be empty: a null-safe comparison is not a plain equality test, so on some engines it can stop an index being used where = would use one — worth checking on your own data before reaching for it by default.

Operands: any type.

Sample query:

// DISTINCT : null-safe difference — a missing value counts as a difference, and two missing values
// count as equal. The same distinctness FETCH DISTINCT uses when it collapses duplicate rows.
// Negated here to ask the opposite: which French orders ship to the customer's own region? Both are
// blank for every French customer, so <> would return nothing — two unknowns are never "different".
FIND customers c, orders o
FILTER c.country = 'France' AND NOT c.region DISTINCT o.ship_region
FETCH c.customer_id, c.region customer_region, o.ship_region order_region

Generated SQL

duckdb · snowflake · mssql · postgresql · sqlite · trino

-- DISTINCT : null-safe difference — a missing value counts as a difference, and two missing values
-- count as equal. The same distinctness FETCH DISTINCT uses when it collapses duplicate rows.
-- Negated here to ask the opposite: which French orders ship to the customer's own region? Both are
-- blank for every French customer, so <> would return nothing — two unknowns are never "different".
SELECT
  c.customer_id
, c.region AS customer_region
, o.ship_region AS order_region
FROM
 customers c
  INNER JOIN orders o ON
   c.customer_id = o.customer_id
WHERE
  c.country = 'France'
 AND
  c.region IS NOT DISTINCT FROM o.ship_region

The remaining dialects differ only in this expression:

Dialect Expression
mariadb c.region <=> o.ship_region
oracle DECODE(c.region, o.ship_region, 0, 1) = 0

ISNULL

value ISNULL → BOOLEAN

True if value is NULL.

Operands: any type.

Sample query:

// ISNULL : orders not yet shipped.
FIND orders o
FILTER o.shipped_date ISNULL
FETCH o.order_id, o.shipped_date

Generated SQL

all dialects

-- ISNULL : orders not yet shipped.
SELECT
  o.order_id
, o.shipped_date
FROM
 orders o
WHERE
  o.shipped_date IS NULL