I did some testing with all approaches plus the case-sensitive one as a baseline by inserting 1,000,000 entries to each table with random strings of random case of equal length, and here are the results for PostgreSQL 15 in Podman on Macos:
I. Case-sensitive (only for benchmarking baseline) Insert: 1,000,000 rows affected in 11 s 698 ms Select: 0 rows retrieved in 24 ms (execution: 3 ms, fetching: 21 ms)
II. Using case-insensitive collation Insert: 1,000,000 rows affected in 11 s 880 ms Select: 0 rows retrieved in 27 ms (execution: 3 ms, fetching: 24 ms)
III. Lower-index solution by @melkij Insert: 1,000,000 rows affected in 15 s 418 ms Select: 0 rows retrieved in 24 ms (execution: 2 ms, fetching: 22 ms)
IV. Two columns solution Insert: 1,000,000 rows affected in 15 s 627 ms Select: 0 rows retrieved in 23 ms (execution: 1 ms, fetching: 22 ms)
Conclusions: The two columns solution I toyed with is slow, more room for wrong usage in code, and wastes space. The 'index on lowercase' and case-insensitive collation solutions give me comparable results, but collation-based is simpler to use and a little bit faster, so I will use the collation-based solution.
Here is the full code:
--
-- Case-SENSITIVE solution (just as a baseline for benchmarking)
--
DROP TABLE IF EXISTS test_case_sensitive;
CREATE TABLE test_case_sensitive
(
original_email TEXT NOT NULL UNIQUE PRIMARY KEY
);
INSERT INTO test_case_sensitive (original_email)
VALUES ('[email protected]');
-- This entry SUCCEEDS unlike case-insensitive solutions:
INSERT INTO test_case_sensitive (original_email)
VALUES ('[email protected]');
-- Getting the original email provided by the user:
SELECT original_email
FROM test_case_sensitive
WHERE original_email = '[email protected]';
--
-- Case-insensitive solution with collation
--
DROP TABLE IF EXISTS test_collation;
DROP COLLATION IF EXISTS case_ins;
CREATE COLLATION case_ins (PROVIDER = icu, LOCALE = '@colStrength=secondary', DETERMINISTIC = FALSE);
CREATE TABLE test_collation
(
original_email TEXT COLLATE case_ins NOT NULL UNIQUE PRIMARY KEY
);
INSERT INTO test_collation (original_email)
VALUES ('[email protected]');
-- This entry fails as expected:
INSERT INTO test_collation (original_email)
VALUES ('[email protected]');
-- Sending an email to exact email provided by the user:
SELECT original_email
FROM test_collation
WHERE original_email = '[email protected]';
--
-- Case-insensitive solution with an index on 'lowered' original email
--
DROP TABLE IF EXISTS test_lower_index;
DROP INDEX IF EXISTS test_email_uniq;
CREATE TABLE test_lower_index
(
original_email TEXT NOT NULL UNIQUE PRIMARY KEY
);
CREATE UNIQUE INDEX test_email_uniq ON test_lower_index (LOWER(original_email));
INSERT INTO test_lower_index (original_email)
VALUES ('[email protected]');
-- This entry fails as expected as a duplicate:
INSERT INTO test_lower_index (original_email)
VALUES ('[email protected]');
-- Getting the original email provided by the user regardless of case in which it is entered:
SELECT original_email
FROM test_lower_index
WHERE LOWER(original_email) = LOWER('[email protected]');
--
-- Case-insensitive solution with two columns
--
DROP TABLE IF EXISTS test_two_columns;
CREATE TABLE test_two_columns
(
original_email TEXT NOT NULL UNIQUE,
lowered_email TEXT NOT NULL UNIQUE PRIMARY KEY GENERATED ALWAYS AS ( LOWER(original_email) ) STORED
);
INSERT INTO test_two_columns (original_email)
VALUES ('[email protected]');
-- This entry fails as expected:
INSERT INTO test_two_columns (original_email)
VALUES ('[email protected]');
-- Sending an email to exact email provided by the user:
SELECT original_email
FROM test_two_columns
WHERE lowered_email = LOWER('[email protected]');
--
-- Testing
--
CREATE OR REPLACE FUNCTION random_string(length INTEGER) RETURNS TEXT AS
$$
DECLARE
chars TEXT[] := '{A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z}';
result TEXT := '';
BEGIN
IF length < 0 THEN
RAISE EXCEPTION 'Given length cannot be less than 0';
END IF;
FOR _ IN 1..length
LOOP
result := result || chars[1 + RANDOM() * (ARRAY_LENGTH(chars, 1) - 1)];
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
TRUNCATE test_case_sensitive;
INSERT INTO test_case_sensitive(original_email)
SELECT random_string(30)
FROM GENERATE_SERIES(1, 1000000) id;
TRUNCATE test_collation;
INSERT INTO test_collation(original_email)
SELECT random_string(30)
FROM GENERATE_SERIES(1, 1000000) id;
TRUNCATE test_lower_index;
INSERT INTO test_lower_index(original_email)
SELECT random_string(30)
FROM GENERATE_SERIES(1, 1000000) id;
TRUNCATE test_two_columns;
INSERT INTO test_two_columns(original_email)
SELECT random_string(30)
FROM GENERATE_SERIES(1, 1000000) id;
SELECT original_email
FROM test_case_sensitive
WHERE original_email = '[email protected]';
SELECT original_email
FROM test_collation
WHERE original_email = '[email protected]';
SELECT original_email
FROM test_lower_index
WHERE LOWER(original_email) = LOWER('[email protected]');
SELECT original_email
FROM test_two_columns
WHERE lowered_email = LOWER('[email protected]');