I’m using a custom User model with Django’s built-in auth system alongside djangorestframework-simplejwt on PostgreSQL. I’ve run into a case-sensitivity issue with email uniqueness.
Authenticating Registering with test@test.com succeeds. Authenticating Registering again with Test@test.com also succeeds since djangorestframework-simplejwt uses Django’s authenticate() creating a duplicate. Most modern email providers treat these as identical, so this needs to be enforced at the application level.
P.S.: I meant Registering not authenticating, My bad.
What I’ve tried:
Using __iexact for validation — but it causes up to 13 seconds of delay because it generates a UPPER(email) = UPPER(value) query which seq scans the table.
I then added a functional index thinking it would fix it:
constraints = [
models.UniqueConstraint(Lower("email"), name="unique_lower_email"),
]
Still a Seq Scan. The problem is the index is on LOWER but __iexact generates UPPER — they don’t match, so the index is never used.
Solutions I found:
A) Normalize on save
Store all emails as lowercase. Drop the functional constraint, use a plain unique=True, and normalize in save():
def save(self, *args, **kwargs):
self.email = self.email.lower()
super().save(*args, **kwargs)
Queries become a simple filter(email=input.lower()) — hits the btree index, no footguns, still this worries me for the sake of the [RFC 5321 Section 2.4](RFC 5321 - Simple Mail Transfer Protocol.
B) Fix the index to match __iexact
Switch the constraint to Upper to match what __iexact generates:
constraints = [
models.UniqueConstraint(Upper("email"), name="unique_upper_email"),
]
This makes __iexact hit the index. The downside: every lookup must use __iexact — a plain filter(email=value) silently bypasses it and duplicates can slip through or make it filter(email=value.upper()) where it feels more or less like A but with extra hops.
I would really appreciate some insights into this.