CipherStashDocs
ReferenceEQL

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

This reference is generated and validated against EQL 3.0.4. Running EQL 2.x? See the EQL v2 reference.

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_v3The 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 variantCapability
public.eql_v3_<T>Storage and decryption only.
public.eql_v3_<T>_eqEquality: =, <>, IN, GROUP BY, DISTINCT, equijoins.
public.eql_v3_<T>_ordComparisons (<>=), BETWEEN, ORDER BY, MIN / MAX — plus equality.
public.eql_v3_<T>_ord_opeThe byte-identical twin of _ord, with OPE pinned. See SEM specifiers.
public.eql_v3_<T>_ord_oreAs _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.

VariantMechanismTermExtractor
_ordCLLW OPE (the default)opeql_v3.ord_term(col)
_ord_opeCLLW OPE, pinned. Byte-identical to _ordopeql_v3.ord_term(col)
_ord_oreBlock-ORE, pinnedobeql_v3.ord_term_ore(col)
text_searchCLLW OPE for its ordering termhm, op, bfeq_term / ord_term / match_term
text_search_oreBlock-ORE for its ordering termhm, ob, bfeq_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:

SchemaHoldsDo you call it?
publicThe encrypted domain types — every public.eql_v3_<T> variant you type a column as.Referenced in your table DDL.
eql_v3All 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_internalThe 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:

KeyContentsNotes
vThe EQL version3 — the payload version matches the EQL major version. The domain CHECKs assert it and raise on any other value.
iIdent: {"t": "<table>", "c": "<column>"}Binds the ciphertext to the table and column it was encrypted for. Both keys required.
cCiphertextThe 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:

KeySEM typeWire shapeEnablesReveals
hmeql_v3_internal.hmac_256 (domain over text)Hex string (HMAC-SHA-256)=, <> on _eq and text_search domainsWhether two values are equal — nothing else
opeql_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_searchThe relative order of two values
obeql_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 equalityThe relative order of two values
bfeql_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 domainsProbabilistic 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 envelope
  • hm — equality term: WHERE email = $1 compares this
  • ob — ordering term: ORDER BY and range comparisons walk these blocks
  • bf — 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 under https://schemas.cipherstash.com/eql/v3/), generated from the canonical Rust wire types in the eql-bindings crate. 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 and sv document 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 to 3 alongside 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.

Silently wrong — untyped parameter resolves the native jsonb operator
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_eq

A 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.

On this page