An expression index stores the result of a computation as an index key. A query comparing lower(email) can search those precomputed keys instead of evaluating lower over every table row. This moves work into index creation and maintenance. It does not change the stored email, guarantee that every query uses the index, or make every textual variation a different expression.

Index the computed meaning

The index on lower(email) contains normalized values associated with rows. An equality predicate using that expression can become an index condition. Ordinary equality on the original email has different semantics and does not automatically gain the same search path. Likewise, applying trim before lower adds another operation; the existing index cannot assume trimming never changes data.

PostgreSQL matches parsed expressions and performs some simplification. Extra parentheses do not inherently defeat matching, so queries need not use identical source text. Conversely, mathematically equivalent expressions are not always recognized as interchangeable. Inspect the planned condition and the actual expression definition rather than relying on a cosmetic string comparison or a universal equivalence promise.

A unique expression index can also enforce a rule such as case-normalized uniqueness. That is a change to allowed data, not just an optimization. Existing conflicting rows will prevent its creation. Lowercasing alone is not a complete definition of email equivalence, and locale or collation behavior can matter for other text. Choose a business normalization rule intentionally before making it a stored constraint.

Keep indexed values stable

Functions used in index expressions must satisfy PostgreSQL’s immutability requirement: their results must depend on their arguments rather than changing external state or the current time. now() cannot be indexed as if it were a stable value forever. Marking a function IMMUTABLE merely to bypass the check does not make the claim true and can leave stored keys inconsistent with later evaluations.

The expression is computed during insertion and applicable updates. Its storage and maintenance cost remain even if the planner never chooses the index. Changing a function’s behavior or normalization policy also requires considering already stored index values; replacing code alone does not recompute them. Plan the rebuild and data validation as part of the semantic change.

When it breaks

An ORM refactor can introduce lower(trim(email)) while the index still stores lower(email). Queries remain correct but may scan more rows. A team can also change collation or a custom function and accidentally rely on old stored keys. Compare the final generated SQL, index definition, relevant function behavior, and target environment when diagnosing the missing path.

Another failure is treating a unique expression index as a harmless performance tweak. Duplicate normalized values can make index creation fail, and future inserts now face a new constraint. Audit and resolve real duplicates without silently deleting business records. For production builds, choose an appropriate index creation procedure and verify its completion; an index name existing in the catalog is not sufficient evidence of a usable finished index.

Check it quickly

Save this as expression.sql and execute psql -X -v ON_ERROR_STOP=1 -f expression.sql in a disposable PostgreSQL 18 database. The ten thousand addresses use the reserved example.test domain, and the table is temporary.

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE accounts (id integer, email text NOT NULL);
INSERT INTO accounts SELECT g, 'User'||g||'@example.test' FROM generate_series(1,10000) AS g;
CREATE UNIQUE INDEX account_email_folded ON accounts (lower(email));
ANALYZE accounts;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM accounts WHERE ((lower(email)))='user42@example.test';
EXPLAIN (COSTS OFF)
SELECT id FROM accounts WHERE lower(trim(email))='user42@example.test';
DO $$ BEGIN
  INSERT INTO accounts VALUES (10001,'USER42@example.test');
  RAISE EXCEPTION 'case-folded duplicate accepted';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'folded duplicate rejected';
END $$;
DO $$ BEGIN
  EXECUTE 'CREATE INDEX invalid_clock ON accounts ((now()))';
  RAISE EXCEPTION 'non-immutable index expression accepted';
EXCEPTION WHEN invalid_object_definition THEN RAISE NOTICE 'non-immutable expression rejected';
END $$;
DO $$ BEGIN
  IF (SELECT id FROM accounts WHERE lower(email)='user42@example.test') IS DISTINCT FROM 42 THEN
    RAISE EXCEPTION 'expression lookup failed';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 used account_email_folded even with redundant parentheses around lower(email). The trim variant used a sequential scan. Inserting an uppercase duplicate of user forty-two failed the unique expression index, and indexing now() failed the immutability requirement. The final assertion confirms the supported lookup still returns id forty-two.

The exception handlers catch only the expected database errors; unexpected success fails the script. Rollback removes all test objects. These results distinguish harmless syntax from changed meaning without claiming the fixture covers Unicode normalization, every collation, production build locking, or arbitrary expression equivalence. Those require their own concrete data and operations.

Sources