Core concepts
The model behind every EQL page: domain variants that declare capability, the encrypted payload envelope, the typed-operand rule, and fail-loud blockers.
EQL version
Everything in the EQL reference builds on four ideas: columns are typed as domain variants that declare what they can do, every value is a jsonb payload carrying encrypted index terms, operands must be typed for the encrypted operators to resolve, and anything a column can't do fails loudly instead of returning wrong rows. This page is the canonical home for all four — the per-type and per-query pages link back here rather than restating them.
Variants declare capability
EQL ships its searchable-encryption surface as PostgreSQL domains in the public schema, all backed by jsonb (the functions behind them live in eql_v3 — The three schemas explains the split). Each scalar type generates a family of domain variants, and the variant you type a column as fixes its query capability. Each domain carries a CHECK constraint that validates the encrypted payload on insert, so a malformed or wrong-version value is rejected at write time rather than surfacing at query time.
There is no database-side configuration table. Earlier EQL versions tracked encryption config in the database (config_add_table, config_add_column, and friends) — those are gone in v3. The searchable surface of a column is fixed by the domain variant you type it as, and which index terms travel in a value's payload is decided by the encryption client (the Stack SDK or CipherStash Proxy). The domain makes the matching operators resolve; the term in the payload is what makes them answer.
For any scalar type <T>, the family looks like this:
| Domain variant | Capability |
|---|---|
public.eql_v3_<T> | Storage and decryption only. |
public.eql_v3_<T>_eq | Equality: =, <>, IN, GROUP BY, DISTINCT, equijoins. |
public.eql_v3_<T>_ord | Comparisons (< … >=), BETWEEN, ORDER BY, MIN / MAX — plus equality. |
public.eql_v3_<T>_ord_ope | The byte-identical twin of _ord, with OPE pinned. See SEM specifiers. |
public.eql_v3_<T>_ord_ore | As _ord, with block-ORE pinned. |
public.eql_v3_text_match (text only) | Free-text fuzzy match: @@. |
public.eql_v3_text_search (text only) | Equality + ordering + fuzzy match. |
public.eql_v3_text_search_ore (text only) | As text_search, with block-ORE pinned. |
Every public domain name carries the eql_v3_ prefix. It keeps EQL's types from shadowing built-in Postgres type names such as text and json, and gives each EQL version its own column-type namespace so two versions can coexist. Query-operand domains live in the versioned eql_v3 schema already, so they are unprefixed: eql_v3.query_text_eq, eql_v3.query_json.
Two things worth calling out:
- The bare variant blocks everything.
public.eql_v3_<T>carries no index term. Querying it with any comparison operator raises an "operator not supported" exception. Use it for columns you only ever store and decrypt — Booleans covers this pattern in full. - Which index term backs each capability is an implementation detail of the payload — covered in Anatomy of an encrypted value below.
SEM specifiers
A trailing mechanism suffix — the _ope in _ord_ope — is a SEM specifier: it pins which searchable-encryption mechanism implements the capability, rather than just declaring the capability itself.
| Variant | Mechanism | Term | Extractor |
|---|---|---|---|
_ord | CLLW OPE (the default) | op | eql_v3.ord_term(col) |
_ord_ope | CLLW OPE, pinned. Byte-identical to _ord | op | eql_v3.ord_term(col) |
_ord_ore | Block-ORE, pinned | ob | eql_v3.ord_term_ore(col) |
text_search | CLLW OPE for its ordering term | hm, op, bf | eq_term / ord_term / match_term |
text_search_ore | Block-ORE for its ordering term | hm, ob, bf | eq_term / ord_term_ore / match_term |
The two mechanisms differ in what they demand of the database, not in the capability they declare. eql_v3_internal.ope_cllw is a domain over bytea, so an ordered functional index on eql_v3.ord_term(col) binds bytea_ops, the base type's default operator class. It works anywhere you can CREATE INDEX, with no superuser.
Block-ORE's operator class is hand-written for a composite type and needs superuser to create.
On a database where EQL cannot create that operator class (cloud Supabase and most managed Postgres), the installer disables every ORE-backed domain. The types still exist, but a CHECK constraint rejects the first value written to one, raising feature_not_supported and naming the alternative to use. That is deliberate: installing them anyway leaves < and > running as unindexable sequential scans while CREATE INDEX ... (eql_v3.ord_term_ore(col)) fails with an opaque Postgres error. Failing loudly on the first write beats degrading silently.
Use _ord unless you have a specific reason to pin block-ORE, and pin _ord_ope when you want a column's mechanism frozen against a future default change. Each type page lists its available specifiers under an "SEM specifiers" heading.
Declaring a table is just typing each column as the variant it needs:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email public.eql_v3_text_eq, -- equality only
salary public.eql_v3_integer_ord, -- equality + range + ORDER BY
created_at public.eql_v3_timestamp_ord
);Every scalar type — int2, int4, int8, numeric, float4, float8, date, timestamp, text, and bool in EQL 3.0.3 — ships some subset of this family. The per-category pages list exactly which variants each type has and how to choose between them: Numbers, Dates & times, Text, and Booleans. Encrypted JSON documents use separate domains — public.eql_v3_json_search for searchable documents, public.eql_v3_json for storage-only ones — with their own operator surface; see JSON.
The three schemas
EQL spreads its surface across three PostgreSQL schemas, and the split is what makes EQL upgradable in place:
| Schema | Holds | Do you call it? |
|---|---|---|
public | The encrypted domain types — every public.eql_v3_<T> variant you type a column as. | Referenced in your table DDL. |
eql_v3 | All user-callable functions and operators — the searchable-encryption API (eql_v3.eq_term, eql_v3.jsonb_path_query, the encrypted = / < / @> operators, eql_v3.version()). | Yes — this is the API. |
eql_v3_internal | The implementation functions the domains and operators are built from. | No — never call these directly. |
Why the types live in public, and carry a version prefix. Your columns are typed as public.eql_v3_integer_ord, public.eql_v3_text_eq, and so on — never eql_v3.*. Types stay in public so a column's type resolves without search_path games. The eql_v3_ prefix does two jobs: it stops EQL's types shadowing Postgres built-ins, since text, json, and integer already resolve in public, and it gives each EQL generation its own column-type namespace, so eql_v3_text_eq and a future eql_v4_text_eq can sit side by side in one database while you migrate table by table.
Why eql_v3 is versioned. The schema name encodes the major API version and is itself part of the public contract — a breaking change introduces a new eql_vN schema beside the old one rather than mutating it, so you migrate on your own timeline. Everything in eql_v3 is fair game to call.
Why eql_v3_internal is off-limits. These are the building blocks — term comparators, unsupported-operator blockers, cast helpers — that the operators and domain CHECK constraints wire together. They carry no stability guarantee and can change or disappear between releases. If you're reaching for eql_v3_internal.*, there's a public type or an eql_v3 function that does what you want.
Anatomy of an encrypted value
Every EQL encrypted value is a jsonb payload with a shared envelope plus the index terms that make it queryable. Earlier CipherStash docs called this format the CipherCell — this section is the current definition of the same structure.
Payloads are produced by the encryption clients — the Stack SDK and CipherStash Proxy — and consumed by EQL's operators and functions inside Postgres. EQL never sees plaintext: it validates, stores, and compares these payloads; it cannot produce or decrypt them. The division is strict: the clients never rely on the database for key material.
The envelope
Every payload carries three envelope keys. Each eql_v3 domain's CHECK constraint requires them, so a value missing any of these is rejected at write time:
| Key | Contents | Notes |
|---|---|---|
v | The EQL version | 3 — the payload version matches the EQL major version. The domain CHECKs assert it and raise on any other value. |
i | Ident: {"t": "<table>", "c": "<column>"} | Binds the ciphertext to the table and column it was encrypted for. Both keys required. |
c | Ciphertext | The opaque, non-deterministic encrypted blob (mp_base85-encoded). Never used in comparisons. |
Payloads produced by EQL v2 clients carried v: 2; from 3.0.0 the payload version and the EQL version move together.
A k discriminator ("ct" for a scalar ciphertext, "sv" for a JSON document) also appears on payloads emitted by the clients, distinguishing the two top-level shapes.
Index-term keys
Alongside the envelope, a payload carries the index terms for its column's capability. Each key is backed by a SEM (searchable encrypted metadata) type in the eql_v3_internal schema:
| Key | SEM type | Wire shape | Enables | Reveals |
|---|---|---|---|---|
hm | eql_v3_internal.hmac_256 (domain over text) | Hex string (HMAC-SHA-256) | =, <> on _eq and text_search domains | Whether two values are equal — nothing else |
op | eql_v3_internal.ope_cllw (domain over bytea) | Hex-encoded CLLW OPE ciphertext | <, <=, >, >=, ORDER BY on _ord / _ord_ope domains and text_search, and on String / Number leaves of public.eql_v3_json_search | The relative order of two values |
ob | eql_v3_internal.ore_block_256 (composite: array of bytea block terms) | Array of hex-encoded ORE blocks (block count varies by scalar width) | The same comparisons on the pinned _ord_ore and text_search_ore domains — and = / <>, since ORE comparison collapses to equality | The relative order of two values |
bf | eql_v3_internal.bloom_filter (domain over smallint[]) | Array of set bit positions (signed 16-bit — large filters emit negative positions) | @@ fuzzy match on _match and text_search domains | Probabilistic token overlap between values |
The capability is encoded as required keys: the payload for a public.eql_v3_text_eq column must carry hm; a public.eql_v3_integer_ord payload must carry op (and only op); a text_match payload must carry bf; a text_search payload carries hm, op, and bf. A payload missing its term key fails the domain CHECK — and fails to deserialize in the client bindings.
A scalar payload for a public.eql_v3_text_search column (lookup + ordering + free-text match, so all three terms are required):
{
"v": 3,
"i": { "t": "users", "c": "email" },
"c": "mBbKmsMM%bK#QQOx1yLDBHyD...",
"hm": "9c8ec1d2f9932b979b1bf3f09f8a4e2f6a41f8de2f0c8b7a52e1f5c3d4b6a790",
"ob": ["7a1fd0c2...", "d24c9be1...", "03fa66b8..."],
"bf": [42, 1290, -8113, 30201]
}v,i,c— the envelopehm— equality term:WHERE email = $1compares thisob— ordering term:ORDER BYand range comparisons walk these blocksbf— bloom-filter term:@@fuzzy match tests these bit positions
Encrypted JSON documents use a different payload shape — a document-level key header h and an sv array with one encrypted entry per path, instead of a root ciphertext — defined in JSON.
Machine-readable schemas
The EQL repository publishes the format as JSON Schema in two places:
crates/eql-bindings/schema/— one schema per scalar domain ($ids underhttps://schemas.cipherstash.com/eql/v3/), generated from the canonical Rust wire types in theeql-bindingscrate. TypeScript bindings are generated from the same definitions, so every producer and consumer shares one source of truth.docs/reference/schema/— full-payload schemas covering both the scalar andsvdocument shapes. These files are still named for the v2.x payload releases (eql-payload-v2.2.schema.json,eql-payload-v2.3.schema.json); the v2.3 schema describes the document shape, with the payload version field moving to3alongside the EQL 3.0.0 release.
The typed-operand rule
The eql_v3 domains are backed by jsonb. When an operand has no known type — a bare string literal, an untyped parameter — PostgreSQL reduces the domain to its jsonb base type and resolves the native jsonb operator instead of the encrypted one. The query doesn't fail; it silently returns native jsonb semantics, which are meaningless for encrypted payloads.
SELECT * FROM users WHERE email = $1;-- Typed operand — the encrypted `=` resolves.
SELECT * FROM users WHERE email = $1::public.eql_v3_text_eq;Always type the operand: a typed parameter ($1::public.eql_v3_text_eq) or an explicit cast ('…'::public.eql_v3_integer_ord). The Stack SDK and CipherStash Proxy type bound parameters automatically — raw SQL must do it by hand.
This is the one place where a mistake is silent. Everything else fails loudly:
Unsupported operations fail loudly
Unsupported operators are not silent no-ops. Every operator that a variant doesn't support is still defined — it routes to a blocker function that raises an operator … is not supported exception. A mis-typed query fails loudly instead of silently returning wrong results:
-- salary is public.eql_v3_bigint_eq (equality only)
SELECT * FROM users WHERE salary > $1::public.eql_v3_bigint_eq;
-- ERROR: operator > is not supported for public.eql_v3_bigint_eqA NULL operand still raises — the blockers are deliberately not STRICT, so PostgreSQL can't skip the check. (A SQL NULL column value is not encrypted, so IS NULL / IS NOT NULL themselves always work, on every variant.)
LIKE and ILIKE are blocked on every encrypted variant — pattern matching is meaningless on ciphertext. Encrypted text matching is bloom-filter fuzzy match (@@) instead; Text covers it.
One equality subtlety follows from the term table above, and it splits on whether the column is text.
On the non-text scalars, ordering is equality-lossless: = and <> on an _ord column compare the ordering term (op, or ob on _ord_ore), so those payloads carry no hm term at all and get equality for free. On text, ordering is not equality-lossless, so every orderable text variant carries hm alongside its ordering term and resolves = and <> against it. _eq columns always compare hm.
What the terms reveal
Every index term a value carries is extra material stored in the database, and each term class reveals defined structure to an observer who can read the stored payloads: equality terms reveal value repetition (which rows share a value), ordering terms reveal ordering (which of two values is larger), and bloom terms reveal probabilistic token overlap. None of them reveal the plaintext — but you should only carry the terms you actually query on. The full analysis of what each term does and doesn't leak is in Searchable encryption.
Overview
Encrypt Query Language (EQL) installs encrypted column types and operators into Postgres as plain SQL — encryption itself happens in your client.
Numbers
The complete reference for encrypted numeric columns: the int, float, and numeric domain variants, the ordering term they carry, and range, ORDER BY, and MIN/MAX queries.