This page summarises which SQL operators and language features work against EQL-encrypted columns, and which encrypted-domain type each one requires.
EQL ships its searchable-encryption surface as PostgreSQL domains in the public schema:
- per-scalar encrypted-domain types —
public.eql_v3_integer,public.eql_v3_text,public.eql_v3_timestamp, … — one family of domain variants per scalar; and - an encrypted-JSON document type —
public.eql_v3_json— for structured-encryption (ste_vec) JSONB.
The capability of a column is fixed by the domain variant you type it as. There is no database-side add_search_config / add_column step: which index terms travel in a value's payload is decided by the encryption client (CipherStash Proxy / CipherStash Stack), and the column's domain variant is what makes the matching operators resolve. Unsupported operators are not silent no-ops — they route to blocker functions that RAISE an "operator not supported" exception (a NULL operand still raises; the blockers are deliberately not STRICT).
Each scalar type <T> is a family of jsonb-backed domains in public. The catalog scalar tokens that ship today are:
smallint, integer, bigint, numeric, real, double, date, timestamp, text, boolean.
(See Adding a Scalar Encrypted-Domain Type for how the family is generated.) The domains live in the public schema, so they survive DROP SCHEMA eql_v3 CASCADE — dropping eql_v3 removes the operators, extractors, and aggregates but leaves the public-typed columns and their data intact. Their extracted index-term types are the self-contained eql_v3_internal SEM types (eql_v3_internal.hmac_256, eql_v3_internal.ore_block_256, eql_v3_internal.bloom_filter).
Every scalar generates a storage-only variant plus the query variants its capabilities allow:
| Domain variant | Index term carried | Extractor (for indexing) | = <> |
< <= > >= |
MIN / MAX |
@> <@ |
|---|---|---|---|---|---|---|
public.<T> |
none (storage only) | — | ❌ | ❌ | ❌ | ❌ |
public.<T>_eq |
hm (hmac_256) |
eql_v3.eq_term(col) |
✅ | ❌ | ❌ | ❌ |
public.<T>_ord / _ord_ope |
op (ope_cllw) |
eql_v3.ord_term(col) |
✅ | ✅ | ✅ | ❌ |
public.<T>_ord_ore |
ob (ore_block_256) |
eql_v3.ord_term_ore(col) |
✅ | ✅ | ✅ | ❌ |
public.eql_v3_text_match |
bf (bloom_filter) |
eql_v3.match_term(col) |
❌ | ❌ | ❌ | ✅* |
public.eql_v3_text_search |
hm + op + bf |
all three extractors | ✅ | ✅ | ✅ | ✅* |
public.eql_v3_text_search_ore |
hm + ob + bf |
all three extractors | ✅ | ✅ | ✅ | ✅* |
* On text_match / text_search / text_search_ore, @> / <@ are bloom-filter token containment (probabilistic ngram match), not JSONB containment and not SQL LIKE. See Indexing.
Notes:
- The bare
public.<T>variant carries no index term and blocks every comparison operator — it is storage / decryption only. Type the column as_eqor_ord(or cast at the call site, e.g.col::public.eql_v3_integer_ord) when you need to query. _ordand_ord_opeare twins: byte-identical surfaces backed by the CLLW-OPE term.opis a hex-encoded, order-preserving ciphertext compared by native bytea ordering after hex-decode (no custom comparison protocol, andeql_v3_internal.ope_cllwis a domain overbytea, so a functional btree oneql_v3.ord_term(col)uses the default operator class and needs no superuser)._ordis the recommended name;_ord_opedocuments the scheme explicitly._ord_oreexposes the same ordered surface backed by the block-ORE term (ob) instead, compared by the custom N-block protocol. Use it when you specifically need block-ORE. Caveat: its btree operator class is created by a superuser-onlyDOblock that is silently skipped without that privilege. When it is missing,CREATE INDEX … btree (eql_v3.ord_term_ore(col))still succeeds — PostgreSQL falls back torecord_opson the composite — but that opfamily does not contain the ORE comparison operators, so the index never engages and the ordering it stores is not the ORE ordering. Verify with\d+that the index opclass isore_block_256_operator_class, notrecord_ops.- On
text_ord/text_ord_ope/text_ord_ore,=/<>route throughhm(exact HMAC) — ordering terms over text are not equality-lossless. text_ordaccepts the empty string (itsopterm is well-formed and sorts first).text_ord_orerejects it: encrypting""yields an empty ORE term (ob: []) that the domain CHECK refuses.=/<>is the only searchable surface for_eq. On_ordvariants the equality operators are available too (alongside the ordered ones).booleanis storage-only by design — a two-value column has too little cardinality for any searchable index to be safe, so it ships onlypublic.eql_v3_boolean(no_eq/_ord).LIKE/ILIKE(~~/~~*) and the native JSONB operators are blocked on every scalar domain variant — they are meaningless on a scalar payload. Text matching is the bloom-filter@>ontext_match, notLIKE.MIN/MAXare exposed only on the ordered variants, aseql_v3.min(public.<T>_ord)/eql_v3.max(...)(and likewise on_ord_ope/_ord_ore) — see EQL Functions Reference.
A ✅ means the operator resolves on a column typed as that domain variant. A ❌ means the operator is blocked (it raises) for that variant.
| SQL operator | Meaning | public.<T> |
_eq |
_ord / _ord_ore / _ord_ope |
text_match |
text_search |
|---|---|---|---|---|---|---|
= |
Equality | ❌ | ✅ | ✅ | ❌ | ✅ |
<> / != |
Inequality | ❌ | ✅ | ✅ | ❌ | ✅ |
< <= > >= |
Ordered comparison | ❌ | ❌ | ✅ | ❌ | ✅ |
@> / <@ |
Bloom-filter token containment | ❌ | ❌ | ❌ | ✅ | ✅ |
LIKE ILIKE (~~/~~*) |
SQL pattern match | ❌ | ❌ | ❌ | ❌ | ❌ |
IS NULL / IS NOT NULL |
Null check | ✅ | ✅ | ✅ | ✅ | ✅ |
Notes:
- A SQL
NULLcolumn value is not encrypted, soIS NULL/IS NOT NULLalways work regardless of variant. @>/<@ontext_match/text_searchtest whether the encrypted text contains the (encrypted) search terms via the bloom filter. This replaces the oldLIKE/ILIKE-on-match-index recipe: there is noLIKEon encrypted text — use@>.
This matrix covers higher-level SQL constructs. As above, ✅ requires the column to be typed as a variant that carries the necessary term.
| SQL feature | Notes | Required variant |
|---|---|---|
WHERE col = … / <> |
_eq, _ord, text_search |
|
WHERE col < / <= / > / >= |
_ord, text_search |
|
WHERE col BETWEEN … AND … |
desugars to >= and <= |
_ord, text_search |
WHERE col @> … |
bloom-filter token containment (text), or document containment (public.eql_v3_json) |
text_match, text_search, public.eql_v3_json |
WHERE col IN (…) |
desugars to = |
_eq, _ord, text_search |
ORDER BY col |
meaningful only with an ordering term | _ord, text_search |
GROUP BY col / DISTINCT |
needs an equality term | _eq, _ord, text_search |
MIN(col) / MAX(col) |
eql_v3.min(public.<T>_ord) / max — type the column as _ord or cast at the call site (eql_v3.min(col::public.eql_v3_integer_ord)) |
_ord |
COUNT(col) / COUNT(DISTINCT col) |
plain COUNT(col) needs no term; DISTINCT needs an equality term |
any / _eq for DISTINCT |
JOIN … ON lhs.col = rhs.col |
both sides must share the same keyset and a matching variant | _eq, _ord, text_search |
Notes:
- Cross-column / cross-table comparisons (joins,
IN (subquery), set-operation dedup) require both sides to have been encrypted with the same keyset and a matching variant. ORDER BYwithout an ordering term will not produce a meaningful order — type the column as an_ordvariant when ordering matters.- Aggregates beyond
MIN/MAX(SUM,AVG, …) are not supported on encrypted values — decrypt at the application boundary and aggregate client-side. - Parameter binding: CipherStash Proxy rewrites bound parameters so the encrypted operator and any functional indexes are selected. When bypassing the proxy, type the parameter (
$1::public.eql_v3_integer_ord) so the encrypted operator resolves rather than the nativejsonbone.
eql_v3 indexes through a functional index on the term extractor, never an operator class on a column. The extractor's return type carries a default opclass, and the extractors are inlinable, so bare-form queries (WHERE col = $1, ORDER BY col) engage the index:
-- Equality (hash index on eq_term)
CREATE INDEX users_email_eq ON users USING hash (eql_v3.eq_term(encrypted_email));
-- Ordering / range (btree index on ord_term; use ord_term_ore for an _ord_ore column)
CREATE INDEX events_at_ord ON events USING btree (eql_v3.ord_term(encrypted_at));
-- Text match (bloom containment — GIN on match_term)
CREATE INDEX users_name_match ON users USING gin (eql_v3.match_term(encrypted_name));See Database Indexes for Encrypted Columns for the full recipes, GIN containment, and performance guidance.
public.eql_v3_json is the encrypted-JSON document domain (built on the structured-encryption "ste_vec" model). A JSONB document is encrypted into a searchable vector (sv) of terms — one element per path inside the document — each carrying:
s— a deterministic selector hash for the JSON path (always present); and- one or more value terms depending on the JSON type of the leaf at that path.
Selectors locate a path; value terms let EQL compare the value at that path.
The search capabilities available on a value extracted via -> or eql_v3.jsonb_path_query are determined by the terms emitted for that node type.
| JSON node type | Value terms (alongside s) |
Equality (=, <>, GROUP BY) |
Ordering (< … >=, ORDER BY, MIN/MAX) |
|---|---|---|---|
Object { … } |
hm |
✅ | ❌ |
Array [ … ] |
hm on the container; each element also appears as its own sv entry with its own leaf terms |
✅ | ❌ |
String "…" |
hm, op (variable-width CLLW OPE) |
✅ | ✅ |
| Number (integer/numeric) | hm, op (CLLW OPE) |
✅ | ✅ |
| Boolean / JSON null | hm |
✅ | ❌ |
hm supports equality only; op is a CLLW OPE term that preserves order and collapses to equality on matching keys. JSON null here refers to a null literal inside the document — a SQL NULL column is not encrypted at all.
| SQL form | Resolves to | Returns / notes |
|---|---|---|
doc @> needle / needle <@ doc |
eql_v3."@>" / eql_v3."<@" |
document containment; GIN-indexable via eql_v3.to_ste_vec_query(doc)::jsonb — see GIN Indexes for JSONB Containment. needle must be typed ($1::eql_v3.query_jsonb, another public.eql_v3_json, or an public.eql_v3_jsonb_entry). |
doc -> 'sel'::text / doc -> N |
eql_v3."->" |
field / 0-based array-element access; returns public.eql_v3_jsonb_entry. |
doc ->> 'sel'::text |
eql_v3."->>" |
the matching entry serialized as text (ciphertext JSON, not decrypted plaintext). |
extracted-leaf = <> |
eql_v3.eq_term(public.eql_v3_jsonb_entry) |
equality on a value extracted via -> (e.g. doc -> 'sel'::text = $1). |
extracted-leaf < <= > >= |
eql_v3.ord_term(public.eql_v3_jsonb_entry) |
ordered comparison on an extracted String / Number leaf. |
MIN / MAX of extracted leaf |
eql_v3.min(public.eql_v3_jsonb_entry) / max |
over an extracted ordered leaf. |
eql_v3.jsonb_path_query(doc, sel) |
path query | set-returning; yields encrypted entries. Also jsonb_path_query_first, jsonb_path_exists. |
eql_v3.jsonb_array_length/elements/elements_text(doc) |
array helpers | length / set-returning elements / element text. |
Typed operands (important). The selector / needle operand must carry a known type — a typed parameter (
$1, which the Proxy supplies) or an explicit cast (doc -> 'sel'::text,$1::eql_v3.query_jsonb). A bare untyped literal (doc -> 'sel') resolves to the nativejsonboperator (PostgreSQL reduces thepublic.eql_v3_jsondomain to itsjsonbbase type for an unknown-typed RHS) and silently returns native jsonb semantics instead of the encrypted operator.
These native PostgreSQL JSONB operators are blocked on public.eql_v3_json (they RAISE, rather than falling through to native whole-document semantics): root-document = <> < <= > >=, and ?, ?|, ?&, @?, @@, #>, #>>, -, #-, ||. Use containment (@>), field access (-> / ->>), or the eql_v3.jsonb_path_* functions instead.
See EQL with JSON and JSONB for worked examples.
- EQL Functions Reference — full list of functions and operators.
- Database Indexes for Encrypted Columns — functional-index and GIN recipes, plus performance guidance.
- EQL with JSON and JSONB — end-to-end
public.eql_v3_jsonexamples. - Client-side searchable-encryption configuration — CipherStash Stack schema reference and CipherStash Proxy.