Skip to content

Query Operators

All standard Feathers query operators are supported, plus the following:

Comparison Operators

OperatorSQLDescription
$lt<Less than
$lte<=Less than or equal
$gt>Greater than
$gte>=Greater than or equal
$inINIn a list of values (handles null)
$ninNOT INNot in a list of values (handles null)
$eq= / ISEqual (handles null)
$ne!= / IS NOTNot equal (handles null)

null inside $in / $nin

A null in the array is a value you can match, as it is in a Feathers/Mongo query — not SQL's "never equal to anything". The null is lifted out of the list and compiled into an explicit IS NULL / IS NOT NULL:

ts
// age IN (1) OR age IS NULL  — matches the 1s and the NULL rows
await app.service("users").find({ query: { age: { $in: [null, 1] } } });

// age IS NULL
await app.service("users").find({ query: { age: { $in: [null] } } });

// age NOT IN (2) AND age IS NOT NULL — everything that is neither 2 nor NULL
await app.service("users").find({ query: { age: { $nin: [null, 2] } } });

Without that lifting a plain age IN (null, 1) would silently skip every NULL row, and age NOT IN (null, 2) would be UNKNOWN for every row and match nothing at all.

An array without a null compiles to an untouched IN / NOT IN, so NULL rows stay excluded from { age: { $nin: [1] } }age <> 1 is unknown for them. Add the null explicitly ($nin: [null, 1]) to include them.

An empty array keeps its boolean identity: $in: [] matches nothing, $nin: [] matches everything.

Pattern Matching

OperatorSQLDescription
$likeLIKEPattern matching
$notLikeNOT LIKENegated pattern matching
$iLikeILIKE / LIKECase-insensitive pattern matching (Postgres uses ILIKE; MySQL/SQLite fall back to LIKE, which is case-insensitive for ASCII)
$notILikeNOT ILIKE / NOT LIKENegated case-insensitive pattern matching (same dialect fallback as $iLike)

With $like and friends you supply the wildcards (%, _) yourself:

ts
await app.service("users").find({ query: { name: { $like: "A%" } } });
await app.service("users").find({ query: { email: { $notILike: "%@spam.com" } } });

$startsWith / $endsWith

Convenience prefix/suffix matching. The value is matched literally — any %, _, or \ in it is escaped — and a single wildcard is appended/prepended. Compiles to LIKE 'value%' ESCAPE '\' / LIKE '%value' ESCAPE '\'. Available on all dialects (case-sensitive, except SQLite's LIKE is ASCII-case-insensitive).

ts
// name LIKE 'Jo%'
await app.service("users").find({ query: { name: { $startsWith: "Jo" } } });

// name LIKE '%son' — a value of "10%" matches the literal "10%", not a wildcard
await app.service("users").find({ query: { name: { $endsWith: "son" } } });

Range Operators

OperatorSQLDescription
$betweenBETWEENValue is within [min, max] (inclusive)
$notBetweenNOT BETWEENValue is outside [min, max]

The value must be a [min, max] tuple; both bounds are inclusive. Available on all dialects.

ts
// age BETWEEN 18 AND 65
await app.service("users").find({ query: { age: { $between: [18, 65] } } });

await app.service("users").find({
  query: { createdAt: { $notBetween: ["2026-01-01", "2026-02-01"] } },
});

Regular Expressions

OperatorSQL (Postgres)SQL (MySQL)Description
$regex~REGEXPMatches a regex
$notRegex!~NOT REGEXPDoes not match

Dialect support

$regex / $notRegex are available on PostgreSQL and MySQL only. SQLite has no built-in REGEXP, so these operators are rejected there with a BadRequest rather than emitting SQL that fails at runtime.

ts
// PostgreSQL: name ~ '^Jo.*n$'
await app.service("users").find({ query: { name: { $regex: "^Jo.*n$" } } });

Array Operators (PostgreSQL)

OperatorSQLDescription
$contains@>Array contains
$contained<@Array contained by
$overlap&&Array overlap

These work on native array columns (text[], integer[], …) and on json / jsonb array columns. For non-text[]/integer[] element types, declare the column's array type (e.g. "x-db-type": "varchar[]") so the literal is cast to the column's exact element type — see Declaring column types.

ts
await app.service("posts").find({ query: { tags: { $contains: ["news"] } } });

JSON Key Existence (PostgreSQL)

For json / jsonb columns, test whether top-level keys (or array string elements) exist. PostgreSQL only — rejected with a BadRequest on other dialects.

OperatorSQLDescription
$hasKeyjsonb_existsThe key exists
$hasKeyAnyjsonb_exists_anyAny of the listed keys exists
$hasKeyAlljsonb_exists_allAll of the listed keys exist
ts
// payload ? 'userId'
await app.service("events").find({ query: { payload: { $hasKey: "userId" } } });

// payload ?| array['a','b']
await app.service("events").find({
  query: { payload: { $hasKeyAny: ["a", "b"] } },
});

Relation Operators

$some, $none, and $every filter parent records by conditions on their hasMany children (EXISTS / NOT EXISTS subqueries). See Querying Relations → hasMany.

Logical Operators

$and and $or are supported for combining conditions:

ts
// Users named Alice who are at least 18
await app.service("users").find({
  query: {
    $and: [{ name: "Alice" }, { age: { $gte: 18 } }],
  },
});

// Users named Alice or Bob
await app.service("users").find({
  query: {
    $or: [{ name: "Alice" }, { name: "Bob" }],
  },
});

$not

$not negates an entire condition object at the database level — it compiles to NOT (...) around whatever the inner query produces. It is operator-agnostic: the inner condition can use any operator, nested $and / $or, or multiple keys.

ts
// NOT (age = 20)
await app.service("users").find({ query: { $not: { age: 20 } } });

// NOT (age > 15) — works with any operator, not just equality
await app.service("users").find({ query: { $not: { age: { $gt: 15 } } } });

// De Morgan: NOT (age = 10 OR age = 20) === age != 10 AND age != 20
await app.service("users").find({
  query: { $not: { $or: [{ age: 10 }, { age: 20 }] } },
});

Because the whole object is negated as a unit, a multi-key condition negates the conjunction — $not: { age: 20, name: "b" } is NOT (age = 20 AND name = "b"), not a per-property inversion. An empty $not: {} is a no-op.

Querying JSON Columns

Once a column is declared as json or jsonb, you can query into it with dot notation. Each segment after the column name is a key in the JSON document, and all comparison operators work on the resolved value:

ts
const service = new KyselyService({
  Model: db,
  name: "events",
  properties: {
    payload: { type: "object", "x-db-type": "jsonb" },
  },
});

// Nested path: payload -> a -> b -> c
await service.find({ query: { "payload.a.b.c": { $gte: 2 } } });

// Top-level key
await service.find({ query: { "payload.name": "John" } });

Key segments are always parameterized, so they are safe against injection.

Querying Dates & Timestamps

By default a date/timestamp query value is passed straight to the database driver, so what "works" depends on the dialect, the column type, and the value's JavaScript type. The combinations are inconsistent and some fail outright — for example an epoch-millisecond number throws on Postgres (no implicit cast), a Date instance throws on SQLite (better-sqlite3 cannot bind it), and a number silently matches the wrong rows on SQLite/MySQL.

Opt in to type-aware date coercion by declaring a column's temporal type — either with an x-db-type annotation in properties or with getPropertyType. The adapter then normalizes any of a Date, an ISO-8601 string, an epoch-millisecond number, or a "YYYY-MM-DD" string into the representation every supported driver compares correctly — a full ISO string for timestamp / timestamptz / datetime columns, and a "YYYY-MM-DD" string for date columns. With it enabled, all four formats return the same rows on every dialect:

ts
const service = new KyselyService({
  Model: db,
  name: "events",
  properties: {
    startsAt: { "x-db-type": "timestamptz" },
    day: { "x-db-type": "date" },
  },
});

// All of these are equivalent now:
await service.find({ query: { startsAt: { $gt: new Date("2026-01-15T10:30:00Z") } } });
await service.find({ query: { startsAt: { $gt: "2026-01-15T10:30:00.000Z" } } });
await service.find({ query: { startsAt: { $gt: 1768473000000 } } });

// A "YYYY-MM-DD" value is the right format for a `date` column:
await service.find({ query: { day: { $gte: "2026-01-15" } } });

Notes

  • Normalization is done in UTC. A "YYYY-MM-DD" value against a timestamp column is interpreted as that day's UTC midnight.
  • Coercion is applied to $lt, $lte, $gt, $gte, $eq, $ne, $in, and $nin values; null and pattern operators are left untouched.
  • It only affects query values. Stored values must already be in a comparable format (e.g. ISO strings for SQLite text columns).