Skip to content
Back to skills

Case Insensitive Uniqueness

ASecurity

Case-Insensitive Uniqueness (Functional Indexes)

  • 17 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 2, 2026
ai-agentsgosqlexpressazure

Security analysis

A100/100

Scanned September 2, 2026

npx -y skills add CarlosCaPe/octorato --skill case-insensitive-uniqueness --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Case Insensitive Uniqueness?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Case Insensitive Uniqueness
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/carloscape-case-insensitive-uniqueness/badge)](https://www.skillsdirectory.com/skills/carloscape-case-insensitive-uniqueness)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: case-insensitive-uniqueness
description: "Case-Insensitive Uniqueness (Functional Indexes)"
metadata:
  short-description: "Case-Insensitive Uniqueness (Functional Indexes)"
  original-index: 31
---

# Case-Insensitive Uniqueness (Functional Indexes)

> Source: [PostgreSQL Best Practices](../DOCUMENTS/PostgreSQL_BestPractices_Azure.md)
> -- Audit Finding #6 and Backlog #3

## What

Creating a case-insensitive unique constraint on text columns (typically email)
using a **functional B-tree index** on `lower(column)` instead of the `citext`
extension.

## Why

Email addresses `Alice@Example.com` and `alice@example.com` are the same
mailbox but different PostgreSQL text values. Without case-insensitive
uniqueness:
- Duplicate accounts can be created with different casing
- Login lookups may fail (searching for `alice@` when stored as `Alice@`)
- Data quality degrades silently

The decision to use `lower()` instead of `citext`:
- `citext` requires an extension (`CREATE EXTENSION citext`) -- adds surface area
- `lower()` + B-tree is native, lightweight, and requires no extension
- Both approaches are equally effective for ASCII email
- Our Best Practices recommend `lower()` until `citext` is justified

## How

### Create the functional unique index
```sql
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_users__email_lower
    ON public."Users" (lower("Email"));
```

### Query using the same expression
```sql
-- MUST use lower() in the query for the index to be used
SELECT "UserId", "Email"
FROM public."Users"
WHERE lower("Email") = lower('Alice@Example.com');
```

### Idempotent pattern for scripts
```sql
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_indexes
        WHERE schemaname = 'public'
          AND indexname = 'uq_users__email_lower'
    ) THEN
        -- Must be outside DO block (CONCURRENTLY restriction)
        RAISE NOTICE '[--] Index uq_users__email_lower needs creation';
    ELSE
        RAISE NOTICE '[OK] Index uq_users__email_lower already exists';
    END IF;
END $$;

-- Outside DO block:
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_users__email_lower
    ON public."Users" (lower("Email"));
```

### Check for existing duplicates BEFORE creating the unique index
```sql
-- This will fail if duplicates exist
SELECT lower("Email"), COUNT(*)
FROM public."Users"
GROUP BY lower("Email")
HAVING COUNT(*) > 1;
```

If duplicates are found, resolve them before creating the unique index.

## lower() vs citext Comparison

| Aspect | `lower()` + B-tree | `citext` |
|--------|-------------------|----------|
| Extension needed | No | Yes (`CREATE EXTENSION citext`) |
| Column type change | No | Yes (`ALTER COLUMN TYPE citext`) |
| Query requirement | Must use `lower()` in WHERE | Transparent (implicit) |
| Index type | Functional B-tree | Standard B-tree |
| Collation awareness | ASCII only | Full locale support |
| Maintenance | Low | Low |

**Our standard**: Use `lower()` unless multiple tables need case-insensitive
behavior AND the `lower()` pattern creates significant code repetition.

## When to Use

- Email columns that must be unique (case-insensitive)
- Username columns with uniqueness requirements
- Any text column where case variants should be treated as equal

## Where We Used It

- ****: Case-insensitive unique index on `Users.Email` (Scribe DB)
- ****: Case-insensitive unique index on `Users.Email` (Feature Flags DB)

## Related Skills

- **Skill #10** (Index CONCURRENTLY) -- creation technique
- **Skill #24** (Orphan Detection) -- check for duplicates before creating unique index
- **Skill #27** (Naming Conventions) -- `uq_<table>__<column>_lower` pattern

## References

- [PostgreSQL Best Practices](../DOCUMENTS/PostgreSQL_BestPractices_Azure.md)
  -- Audit Finding #6 and Backlog #3
- [Feature Flags Audit TDD](../DOCUMENTS/feature_flags_DB_Audit_TDD.md)
  -- "Prevent case-variant duplicates in email identifiers"

## Gotchas

- The query WHERE clause **must** use `lower()` for the index to be selected
  by the planner -- `WHERE "Email" = 'alice@...'` will NOT use the index
- If duplicates exist in the data, `CREATE UNIQUE INDEX` will fail with a
  duplicate key error -- always scan for duplicates first
- `lower()` is ASCII-only collation. For Unicode case folding (e.g., German
  eszett `ss` vs `SS`), consider `citext` or ICU collation
- Application code must consistently apply `lower()` when inserting --
  otherwise different-case values can be stored even though lookups match
- `CONCURRENTLY` is required to avoid locking the table during index build

---

*Category: DDL | Origin: , *

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…