EQL supports PostgreSQL B-tree indexes on eql_v2_encrypted columns to improve query performance. This guide explains how to create and use indexes effectively.
- Creating Indexes
- Index Usage Requirements
- Query Patterns That Use Indexes
- Query Patterns That Don't Use Indexes
- Index Limitations
- Best Practices
- GIN Indexes for JSONB Containment
Create a B-tree index on an encrypted column using the eql_v2.encrypted_operator_class:
CREATE INDEX ON table_name (encrypted_column eql_v2.encrypted_operator_class);Named index:
CREATE INDEX idx_users_email ON users (encrypted_email eql_v2.encrypted_operator_class);Create indexes on encrypted columns when:
- The table has a significant number of rows (typically > 1000)
- You frequently query by equality on that column
- Query performance is important
- The column contains searchable index terms (hmac_256, blake3, or ore)
For PostgreSQL to use an index on encrypted columns, all of these conditions must be met:
The encrypted data must contain the index term types that support the operation:
- Equality queries - Require
uniqueindex config (addshmhmac_256 orb3blake3 terms) - Range queries - Require
oreindex config (addsobore_block_u64_8_256 terms) - Pattern matching - Typically scans (bloom filters don't use B-tree indexes)
Example:
-- This data HAS hmac_256 term - index will be used
'{"i":{"t":"users","c":"email"},"v":2,"hm":"abc123..."}'
-- This data has ONLY bloom filter - index WON'T be used for equality
'{"i":{"t":"users","c":"email"},"v":2,"bf":[1,2,3]}'If you:
- Insert data without a search term (e.g., only
bf) - Add the search term later (e.g., add
hm) - Create an index
The index will NOT work until you:
- Recreate the index, OR
- Truncate and repopulate the table
Correct order:
-- 1. Configure the index type FIRST
SELECT eql_v2.add_search_config('users', 'encrypted_email', 'unique', 'text');
-- 2. Insert/update data through CipherStash Proxy (adds index terms)
INSERT INTO users (encrypted_email) VALUES (...);
-- 3. Create the PostgreSQL index
CREATE INDEX ON users (encrypted_email eql_v2.encrypted_operator_class);
ANALYZE users;The query value must be cast to eql_v2_encrypted:
✓ Index will be used:
-- Literal row type
WHERE e = '("{\"hm\": \"abc\"}")';
-- Cast to eql_v2_encrypted
WHERE e = '{"hm": "abc"}'::eql_v2_encrypted;
WHERE e = '{"hm": "abc"}'::text::eql_v2_encrypted;
WHERE e = '{"hm": "abc"}'::jsonb::eql_v2_encrypted;
-- Using helper function
WHERE e = eql_v2.to_encrypted('{"hm": "abc"}'::jsonb);
WHERE e = eql_v2.to_encrypted('{"hm": "abc"}');
-- Using parameterized query with encrypted value
WHERE e = $1::eql_v2_encrypted;✗ Index will NOT be used:
-- Missing type cast
WHERE e = '{"hm": "abc"}'::jsonb;When encrypted column has hm (hmac_256) or b3 (blake3) index terms:
-- These will use the index
SELECT * FROM users
WHERE encrypted_email = $1::eql_v2_encrypted;
SELECT * FROM users
WHERE encrypted_email = '{"hm": "abc123..."}'::eql_v2_encrypted;
SELECT * FROM users
WHERE encrypted_email = eql_v2.to_encrypted('{"hm": "abc123..."}'::jsonb);Expected EXPLAIN output:
Index Only Scan using idx_users_email on users
Index Cond: (encrypted_email = '...'::eql_v2_encrypted)
Or:
Bitmap Heap Scan on users
Recheck Cond: (encrypted_email = '...'::eql_v2_encrypted)
-> Bitmap Index Scan on idx_users_email
Index Cond: (encrypted_email = '...'::eql_v2_encrypted)
When encrypted column has ob (ore_block_u64_8_256) index terms:
SELECT * FROM events
WHERE encrypted_date < $1::eql_v2_encrypted
ORDER BY encrypted_date DESC;Encrypted columns can be used in GROUP BY with indexes:
SELECT encrypted_status, COUNT(*)
FROM orders
GROUP BY encrypted_status;-- ✗ No index usage - missing ::eql_v2_encrypted cast
SELECT * FROM users WHERE encrypted_email = '{"hm": "abc"}'::jsonb;-- ✗ Data only has bloom filter, not hmac_256
-- Index won't be used even if query is correct
SELECT * FROM users
WHERE encrypted_email = $1::eql_v2_encrypted;
-- If column only has: '{"bf":[1,2,3]}'-- ✗ Bloom filter queries typically don't use B-tree indexes
SELECT * FROM users
WHERE encrypted_name ~~ $1::eql_v2_encrypted;-- ✗ Wrong order
CREATE INDEX ON users (encrypted_email eql_v2.encrypted_operator_class);
-- Then add data with hm terms
-- Index won't work until recreatedB-tree indexes only work with:
hm(hmac_256) - for equalityb3(blake3) - for equalityob(ore_block_u64_8_256) - for range queries
They do not work with:
bf(bloom_filter) - pattern matching- Data with
svfield (ste_vec) - JSONB containment uses GIN indexes instead (see GIN Indexes) - Data without any index terms
The index must be created after the data contains the required index terms. If you:
- Add
uniqueconfig to existing column - Re-encrypt data to add
hmterms - Create index
You must create the index after step 2, not before.
If you modify the search configuration (e.g., change from unique to different config), you should:
-- Drop and recreate the index
DROP INDEX idx_users_email;
CREATE INDEX idx_users_email ON users (encrypted_email eql_v2.encrypted_operator_class);
ANALYZE users;Always configure EQL search indexes before creating PostgreSQL indexes:
-- Step 1: Configure searchable encryption
SELECT eql_v2.add_column('users', 'encrypted_email', 'text');
SELECT eql_v2.add_search_config('users', 'encrypted_email', 'unique', 'text');
-- Step 2: Populate data (through CipherStash Proxy)
INSERT INTO users (encrypted_email) VALUES (...);
-- Step 3: Create PostgreSQL index
CREATE INDEX ON users (encrypted_email eql_v2.encrypted_operator_class);
ANALYZE users;Always run ANALYZE after creating an index to update query planner statistics:
CREATE INDEX idx_users_email ON users (encrypted_email eql_v2.encrypted_operator_class);
ANALYZE users;Use EXPLAIN ANALYZE to verify the index is being used:
EXPLAIN ANALYZE
SELECT * FROM users
WHERE encrypted_email = $1::eql_v2_encrypted;Look for:
Index Only Scan using idx_nameBitmap Index Scan on idx_nameBitmap Heap ScanwithBitmap Index Scan
If you see Seq Scan, the index is not being used.
Use descriptive names for easier management:
CREATE INDEX idx_users_encrypted_email
ON users (encrypted_email eql_v2.encrypted_operator_class);
CREATE INDEX idx_events_encrypted_date
ON events (encrypted_date eql_v2.encrypted_operator_class);Indexes on encrypted columns can be large. Monitor index size:
SELECT
indexname,
pg_size_pretty(pg_relation_size(schemaname||'.'||indexname)) AS index_size
FROM pg_indexes
WHERE tablename = 'users';If you remove a search configuration, drop the corresponding PostgreSQL index:
-- After removing search config
SELECT eql_v2.remove_search_config('users', 'encrypted_email', 'unique');
-- Drop the PostgreSQL index
DROP INDEX IF EXISTS idx_users_encrypted_email;While B-tree indexes don't support ste_vec (JSONB containment), you can use PostgreSQL GIN indexes for efficient containment queries on encrypted JSONB columns.
Use GIN indexes when:
- You need to perform JSONB containment queries (
@>,<@) - The table has a significant number of rows (500+ recommended)
- Query performance on containment operations is important
Create a GIN index using the jsonb_array() function, which extracts the encrypted JSONB as a native jsonb[] array:
CREATE INDEX idx_encrypted_jsonb_gin
ON table_name USING GIN (eql_v2.jsonb_array(encrypted_column));
ANALYZE table_name;Important: Always run ANALYZE after creating the index so PostgreSQL's query planner has accurate statistics.
There are two approaches to write containment queries that use GIN indexes:
Convert both sides to jsonb[] and use the native containment operator:
SELECT * FROM table_name
WHERE eql_v2.jsonb_array(encrypted_column) @>
eql_v2.jsonb_array($1::eql_v2_encrypted);Use the convenience function which handles the conversion internally:
SELECT * FROM table_name
WHERE eql_v2.jsonb_contains(encrypted_column, $1::eql_v2_encrypted);Both approaches produce the same result and use the GIN index.
Use EXPLAIN to verify the GIN index is being used:
EXPLAIN SELECT * FROM table_name
WHERE eql_v2.jsonb_array(encrypted_column) @>
eql_v2.jsonb_array($1::eql_v2_encrypted);Expected output:
Bitmap Heap Scan on table_name
Recheck Cond: (jsonb_array(encrypted_column) @> jsonb_array(...))
-> Bitmap Index Scan on idx_encrypted_jsonb_gin
Index Cond: (jsonb_array(encrypted_column) @> jsonb_array(...))
If you see Seq Scan, ensure:
- The index exists
ANALYZEhas been run- The table has enough rows (PostgreSQL may choose sequential scan for very small tables)
| Feature | B-tree Index | GIN Index |
|---|---|---|
| Use case | Equality, range queries | JSONB containment |
| Index terms | hm, b3, ob |
sv (via jsonb_array) |
| Operators | =, <, >, <=, >= |
@>, <@ |
| Function | Direct column reference | eql_v2.jsonb_array() |
Check 1: Verify data has index terms
-- Check if data contains hm (hmac_256) or b3 (blake3) for equality
SELECT encrypted_email::jsonb ? 'hm' AS has_hmac,
encrypted_email::jsonb ? 'b3' AS has_blake3,
encrypted_email::jsonb ? 'ob' AS has_ore
FROM users LIMIT 1;Check 2: Verify query uses correct cast
-- ✓ Correct - will use index
WHERE encrypted_email = $1::eql_v2_encrypted
-- ✗ Wrong - won't use index
WHERE encrypted_email = $1::jsonbCheck 3: Recreate index if needed
DROP INDEX IF EXISTS idx_users_encrypted_email;
CREATE INDEX idx_users_encrypted_email
ON users (encrypted_email eql_v2.encrypted_operator_class);
ANALYZE users;Check 4: Verify index exists
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'users'
AND indexname LIKE '%encrypted%';- Ensure index exists and is being used - Use
EXPLAIN ANALYZE - Check table has been ANALYZEd - Run
ANALYZE table_name - Consider index selectivity - Very small tables might not use indexes
- Check for appropriate search config - Equality needs
unique, ranges needore
- EQL Functions Reference - Complete function API
- Index Configuration - Searchable encryption index types
- Configuration Tutorial - Setting up encrypted columns