Skip to content

Commit 0f5f0b7

Browse files
committed
[ADR]: How to Ensure Case-insensitive String Comparison in Postgres (#49)
* Add the postgres Case-insensitive String Comparision adr Signed-off-by: Kevin Ullyott <kevin.ullyott@canyongbs.com> * Spell check fix Signed-off-by: Kevin Ullyott <kevin.ullyott@canyongbs.com> * Fix spelling and grammar Signed-off-by: Kevin Ullyott <kevin.ullyott@canyongbs.com> * chore: fix enforcement of copyright on all files --------- Signed-off-by: Kevin Ullyott <kevin.ullyott@canyongbs.com>
1 parent 49047a0 commit 0f5f0b7

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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

Comments
 (0)