Module 7: Useful Extensions

`citext` vs `LOWER()`: case-insensitive text

Email, username, product code, identifiers. All cases where john@example.com and JOHN@EXAMPLE.COM are the same value. PostgreSQL offers two ways to handle case-insensitivity: LOWER() (manual) and citext (extension). This capsule gives you the decision matrix with contrasted code.


The problem without the extension

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(200) NOT NULL,
    name VARCHAR(100)
);

INSERT INTO users (email, name) VALUES ('John@Example.com', 'John');
INSERT INTO users (email, name) VALUES ('JOHN@example.com', 'John2');  -- Duplicate?
INSERT INTO users (email, name) VALUES ('john@example.com', 'John3');  -- Duplicate?

Without a constraint, they all go through. But they should be duplicates.

For queries:

-- Find the user — depends on the case the client sends
SELECT * FROM users WHERE email = 'john@example.com';
-- Returns only 1 row (not the 3)

Typical bug: a user signs up with John@Example.com, then tries to log in with john@example.com, and isn't found.


Approach 1: LOWER() everywhere

-- Insert normalized
INSERT INTO users (email, ...) VALUES (LOWER('John@Example.com'), ...);

-- Query with LOWER on both sides
SELECT * FROM users WHERE email = LOWER('john@example.com');

-- UNIQUE constraint based on LOWER
CREATE UNIQUE INDEX uq_users_email_lower ON users (LOWER(email));

Pros:

  • No extension, vanilla PostgreSQL.
  • Available on ALL providers.

Cons:

  • You have to remember LOWER() in every query. Forget one = bug.
  • ❌ The email is stored in lowercase — you lose the original case (John becomes john).
  • ❌ The index expression is more complex to maintain.
  • ❌ ORM queries may not use the index if they don't include LOWER().

Approach 2: citext

CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email CITEXT NOT NULL UNIQUE,  -- type-aware case-insensitive
    name VARCHAR(100)
);

-- Transparent behavior
INSERT INTO users (email, name) VALUES ('John@Example.com', 'John');
INSERT INTO users (email, name) VALUES ('JOHN@example.com', 'John2');
-- ERROR: duplicate key — works as you'd expect
-- Simple query — automatic case-insensitive
SELECT * FROM users WHERE email = 'JOHN@EXAMPLE.COM';
-- Returns the row even though it was stored as 'John@Example.com'

-- But the SELECT shows the original case
SELECT email FROM users WHERE id = 1;
-- 'John@Example.com' — preserved as it was entered

Pros:

  • Cleaner queries — no LOWER() in the code.
  • Original case preserved on SELECT. Better UX.
  • ✅ Impossible to forget case-insensitivity in queries.
  • ✅ The UNIQUE constraint works naturally.

Cons:

  • ❌ Extension — check availability on your cloud provider (most have it).
  • ❌ Small comparison overhead (internal case folding).

Decision matrix

CaseRecommendation
Emailcitext — always. Case-insensitivity is semantically correct.
Usernamecitext — users don't expect duplicates by case.
Product codecitext if the codes are normalized, plain varchar if not.
Proper namesVARCHAR normal — John Smith and john smith are different people.
URLsVARCHAR normal — case can matter (path component).
Hashes / tokensVARCHAR normal — case matters, they're binary.
Tagscitext generally — Python and python should be the same tag.

Simple rule: if "X" and "x" should be the same value for the business → citext. If not → VARCHAR.


Implementation with SQLAlchemy 2.0

from sqlalchemy.dialects.postgresql import CITEXT
from sqlalchemy.orm import Mapped, mapped_column


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False)
    name: Mapped[str] = mapped_column(String(100))


# Query
user = await session.scalar(
    select(User).where(User.email == "JOHN@EXAMPLE.COM")
)
# Automatic case-insensitive match

Migration:

# alembic/versions/XXX_use_citext_for_email.py
def upgrade() -> None:
    op.execute("CREATE EXTENSION IF NOT EXISTS citext")
    op.alter_column(
        'users', 'email',
        type_=postgresql.CITEXT(),
        existing_type=sa.String(200),
    )


def downgrade() -> None:
    op.alter_column(
        'users', 'email',
        type_=sa.String(200),
        existing_type=postgresql.CITEXT(),
    )

ALTER COLUMN ... TYPE requires rewriting the column — for large tables, consider a zero-downtime migration (module 5 of guide #13).


Comparison: queries before/after

Without citext (with LOWER)

# Search
user = await session.scalar(
    select(User).where(func.lower(User.email) == email_input.lower())
)

# Insert
new_user = User(email=email_input.lower(), name=name)

# Duplicate validation
existing = await session.scalar(
    select(User).where(func.lower(User.email) == new_email.lower())
)
if existing:
    raise EmailAlreadyExists()

Every spot requires lower(). Forget one = bug.

With citext

# Search
user = await session.scalar(
    select(User).where(User.email == email_input)
)

# Insert (no need to normalize — citext does it)
new_user = User(email=email_input, name=name)

# Validation
existing = await session.scalar(
    select(User).where(User.email == new_email)
)
if existing:
    raise EmailAlreadyExists()

Cleaner, with no chance of error.


Real case: refactoring an auth system

Suppose your current app uses:

class User(Base):
    email: Mapped[str] = mapped_column(String(200), unique=True)

And a bug is reported: user Jane@Example.com can't recover their password because the reset email doesn't match (the client sends it in lowercase).

Refactor to citext:

  1. Add the extension:
op.execute("CREATE EXTENSION IF NOT EXISTS citext")
  1. Change the type:
op.alter_column('users', 'email', type_=CITEXT())
  1. Update the model:
email: Mapped[str] = mapped_column(CITEXT, unique=True)
  1. Remove LOWER() from queries — automatic now.

  2. Test:

async def test_email_case_insensitive(session):
    session.add(User(email="Test@Example.com"))
    await session.commit()

    # Search with a different case
    user = await session.scalar(
        select(User).where(User.email == "TEST@EXAMPLE.COM")
    )
    assert user is not None
    assert user.email == "Test@Example.com"  # original case preserved

Bug solved. Cleaner code. Better UX (original case preserved).


Variants and traps

citext with a GIN index for fuzzy

citext doesn't solve fuzzy/partial matching — only case. For LIKE '%john%' case-insensitive, you need pg_trgm (capsule 06).

Performance overhead

citext does case folding on comparison. Small overhead per query. In benchmarks, ~5-10% slower than VARCHAR for pure comparisons. Negligible in most cases.

citext with ORDER BY

SELECT email FROM users ORDER BY email;
-- Order is case-insensitive (alphabetical, case-agnostic)

John and jane sort correctly regardless of case.


Traps and common mistakes

1. citext without installing the extension.

CREATE TABLE with CITEXT fails if the extension isn't active. The migration must install it first.

2. ALTER from VARCHAR to CITEXT on a large table with downtime.

ALTER COLUMN TYPE rewrites the table. For 10M rows, it locks for minutes. Zero-downtime requires expand-contract.

3. Mixing citext comparisons with VARCHAR.

-- email is CITEXT, name is VARCHAR
SELECT * FROM users WHERE email = name;
-- ERROR: incompatible types

Explicit cast: email::text = name.

4. citext with a foreign key.

-- Table A: email CITEXT
-- Table B: email_ref VARCHAR REFERENCES A(email)
-- ❌ Incompatible types

If you reference it, both sides CITEXT.

5. JSON stringification.

{"email": "John@Example.com"}  # Pydantic

Pydantic doesn't know the field is CITEXT. It treats it as a plain string. It only matters when it hits the DB. No issue.

6. Forgetting that CHAR/VARCHAR in a query generates a CAST.

WHERE email = 'john@example.com'  -- 'john@example.com' is text, cast to citext, OK
WHERE email = 'john'::varchar  -- explicit cast, may cause a non-case-insensitive comparison

Generally PG handles it well implicitly. If something's odd, check the casts.

7. Index over LOWER(citext) — redundant.

-- ❌ Not needed — citext is already case-insensitive
CREATE INDEX ON users (LOWER(email));  -- redundant

-- ✅ Normal index
CREATE INDEX ON users (email);

Exercise: compare approaches

Setup: two tables, one with VARCHAR + LOWER(), another with CITEXT.

CREATE TABLE users_lower (
    id SERIAL PRIMARY KEY,
    email VARCHAR(200) NOT NULL,
    UNIQUE (email)
);
-- For case-insensitive UNIQUE:
CREATE UNIQUE INDEX uq_users_lower_email ON users_lower (LOWER(email));


CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users_citext (
    id SERIAL PRIMARY KEY,
    email CITEXT NOT NULL UNIQUE
);


-- Insert the same email with different cases
INSERT INTO users_lower (email) VALUES ('Test@Example.com');
INSERT INTO users_lower (email) VALUES ('TEST@EXAMPLE.COM');
-- Does it fail?

INSERT INTO users_citext (email) VALUES ('Test@Example.com');
INSERT INTO users_citext (email) VALUES ('TEST@EXAMPLE.COM');
-- Does it fail?

Step 1: verify that both prevent duplicates.

Step 2: compare the queries.

-- Lower approach
SELECT * FROM users_lower WHERE LOWER(email) = LOWER('test@example.com');

-- Citext approach
SELECT * FROM users_citext WHERE email = 'test@example.com';

Step 3: verify that the original case is preserved in CITEXT.

SELECT email FROM users_citext WHERE id = 1;
-- Expected: 'Test@Example.com' (not 'test@example.com')

Step 4: measure the overhead.

-- Insert 100k rows in each
-- Compare queries

EXPLAIN ANALYZE SELECT email FROM users_lower WHERE LOWER(email) = LOWER('user@test.com');
EXPLAIN ANALYZE SELECT email FROM users_citext WHERE email = 'user@test.com';

Any significant difference?

See discussion

Step 1: both approaches prevent duplicates correctly.

Step 2: queries with citext are simply cleaner.

Step 3: the original case is preserved in CITEXT. In the LOWER approach, it depends on how you stored it — if you normalized to lowercase, you lost the original.

Step 4: overhead is typically ~5-10% for citext. Negligible at normal scale.

Key takeaways:

  1. Both work. The difference is code clarity and case preservation.
  2. CITEXT wins in UX cases where the original matters (display).
  3. CITEXT wins on maintainability — fewer places to forget LOWER().
  4. The LOWER() approach is the default if you can't install the extension.

Summary and next step

What you learned:

  • citext = a native case-insensitive type. Comparisons automatically case-insensitive. Original case preserved.
  • LOWER() = manual approach. More verbose, error-prone.
  • Decision matrix: emails, usernames, tags → CITEXT. Hashes, URLs, names → VARCHAR.
  • SQLAlchemy: Mapped[str] = mapped_column(CITEXT, unique=True).
  • Migration: CREATE EXTENSION + ALTER COLUMN TYPE.
  • Overhead ~5-10%, negligible.
  • Traps: install the extension, FK with compatible types, don't use redundant LOWER().

In the next capsule we go to UUIDs: gen_random_uuid() (PG 13+) vs uuid-ossp. When to use each and why gen_random_uuid() is the modern answer almost every time.


Resources

  1. PostgreSQL Docs — citext — official reference.
  2. SQLAlchemy — CITEXT type — reference.
  3. Crunchy Data — citext use cases — analysis with examples.

Capsule 03 of 08 — Module 7 — Advanced PostgreSQL for Backend Guide