|
| 1 | +--- |
| 2 | +status: 'accepted' |
| 3 | +date: 2026-01-16 |
| 4 | +decision-makers: Kevin Ullyott, Dan Harrin, Payal Baldaniya |
| 5 | +consulted: Kevin Ullyott, Dan Harrin, Payal Baldaniya |
| 6 | +--- |
| 7 | + |
| 8 | +# How to Ensure Case-Insensitive String Comparison in Postgres |
| 9 | + |
| 10 | +## Context and Problem Statement |
| 11 | + |
| 12 | +By default in Postgres, strings are compared case-sensitive, meaning "ABC" != "abc". |
| 13 | + |
| 14 | +This is problematic when we NEED some comparisons to be case-insensitive. For example, if we need email addresses in the system to be unique and we want to consider "Example@email.com" to violate "example@email.com" in a unique index. |
| 15 | + |
| 16 | +Though there may be areas where this is managed ad hoc, there is a desire for there to be a general strategy for enabling this functionality |
| 17 | + |
| 18 | +## Considered Options |
| 19 | + |
| 20 | +- `citext` Data Type |
| 21 | +- Functional (Expression) Index |
| 22 | +- Manual handling in code/queries |
| 23 | + |
| 24 | +## Decision Outcome |
| 25 | + |
| 26 | +Chosen option: "`citext` data type", because it provides transparent case-insensitive comparison at the database level without requiring application-level handling, works seamlessly with existing queries and unique constraints, and is a well-supported PostgreSQL extension. |
| 27 | + |
| 28 | +### Consequences |
| 29 | + |
| 30 | +- Good, because case-insensitive uniqueness is enforced at the database level, preventing duplicates regardless of how data is inserted |
| 31 | +- Good, because existing queries work without modification—no need to wrap comparisons in `LOWER()` or `ILIKE` |
| 32 | +- Good, because it integrates naturally with Laravel's Eloquent ORM and unique validation rules |
| 33 | +- Good, because it reduces cognitive overhead for developers who don't need to remember to handle case sensitivity manually |
| 34 | +- Bad, because it requires the `citext` extension to be enabled in PostgreSQL (`CREATE EXTENSION citext`) |
| 35 | +- Neutral, because there is a minor performance overhead compared to standard `text`, but it is negligible for most use cases |
| 36 | + |
| 37 | +### Confirmation |
| 38 | + |
| 39 | +Compliance with this ADR can be confirmed through: |
| 40 | + |
| 41 | +1. **Database schema review**: Verify that columns requiring case-insensitive comparison (e.g., email addresses) use the `citext` data type |
| 42 | +2. **Migration audits**: Ensure new migrations for case-insensitive columns specify `citext` rather than `string` or `text` |
| 43 | +3. **Integration tests**: Write tests that attempt to insert duplicate values with different casing to confirm uniqueness constraints work correctly |
| 44 | + |
| 45 | +## Pros and Cons of the Options |
| 46 | + |
| 47 | +### `citext` Data Type |
| 48 | + |
| 49 | +The `citext` extension provides a case-insensitive text data type. Internally, it stores text as-is but performs case-insensitive comparisons using `LOWER()` transparently. |
| 50 | + |
| 51 | +```sql |
| 52 | +CREATE EXTENSION citext; |
| 53 | +ALTER TABLE users ALTER COLUMN email TYPE citext; |
| 54 | +``` |
| 55 | + |
| 56 | +- Good, because comparison is handled transparently at the database level |
| 57 | +- Good, because unique constraints automatically enforce case-insensitive uniqueness |
| 58 | +- Good, because no changes needed to application queries or validation logic |
| 59 | +- Good, because the original case is preserved in storage (e.g., "Example@Email.com" is stored as entered) |
| 60 | +- Neutral, because it requires enabling a PostgreSQL extension (but `citext` is a trusted, built-in extension) |
| 61 | +- Bad, because it is PostgreSQL-specific and reduces portability to other databases |
| 62 | +- Bad, because there is a slight performance cost compared to `text` (though typically negligible) |
| 63 | + |
| 64 | +### Functional (Expression) Index |
| 65 | + |
| 66 | +Create a unique index on a lowercase expression of the column to enforce case-insensitive uniqueness. |
| 67 | + |
| 68 | +```sql |
| 69 | +CREATE UNIQUE INDEX users_email_unique ON users (LOWER(email)); |
| 70 | +``` |
| 71 | + |
| 72 | +- Good, because it doesn't require any extension |
| 73 | +- Good, because the column remains standard `text` or `varchar` |
| 74 | +- Good, because it works well for uniqueness enforcement |
| 75 | +- Bad, because queries must explicitly use `LOWER()` to leverage the index (e.g., `WHERE LOWER(email) = LOWER(?)`) |
| 76 | +- Bad, because developers must remember to apply `LOWER()` consistently in application code |
| 77 | +- Bad, because Laravel's built-in unique validation rules don't automatically use the functional index |
| 78 | +- Bad, because it's easy to accidentally bypass the case-insensitive logic |
| 79 | + |
| 80 | +### Manual Handling in Code/Queries |
| 81 | + |
| 82 | +Handle case-insensitivity entirely in application code by normalizing values before storage and using `LOWER()` or `ILIKE` in queries. |
| 83 | + |
| 84 | +```php |
| 85 | +$user->email = strtolower($request->email); |
| 86 | +``` |
| 87 | + |
| 88 | +- Good, because it doesn't require any database-specific features |
| 89 | +- Good, because it provides maximum control over normalization logic |
| 90 | +- Good, because it is portable across different database systems |
| 91 | +- Bad, because it requires consistent discipline across the entire codebase |
| 92 | +- Bad, because it's error-prone—easy to forget normalization in some code paths |
| 93 | +- Bad, because it doesn't protect against direct database inserts that bypass application logic |
| 94 | +- Bad, because existing data may need migration to normalize case |
| 95 | +- Bad, because the original casing entered by the user is lost |
| 96 | + |
| 97 | +## More Information |
| 98 | + |
| 99 | +### Implementation Notes |
| 100 | + |
| 101 | +1. The `citext` extension should be enabled in a database migration: |
| 102 | + |
| 103 | + ```php |
| 104 | + DB::statement('CREATE EXTENSION IF NOT EXISTS citext'); |
| 105 | + ``` |
| 106 | + |
| 107 | +2. For Laravel migrations, use raw SQL or a custom column type to define `citext` columns: |
| 108 | + |
| 109 | + ```php |
| 110 | + $table->addColumn('citext', 'email'); |
| 111 | + ``` |
| 112 | + |
| 113 | +3. Consider creating a reusable migration helper or custom Blueprint macro for `citext` columns. |
| 114 | + |
| 115 | +### References |
| 116 | + |
| 117 | +- [PostgreSQL `citext` documentation](https://www.postgresql.org/docs/current/citext.html) |
| 118 | +- [PostgreSQL Functional Indexes](https://www.postgresql.org/docs/current/indexes-expressional.html) |
0 commit comments