Module 7: Useful Extensions
Mini-project: refactoring a user system with extensions
You close out the module by applying 3 extensions (citext, gen_random_uuid, pg_trgm) to a real user system. You'll take a typical "before" schema and refactor it "after" using the right extensions. By the end you'll have a reusable pattern for production-ready user systems.
The "before" — typical schema without extensions
# app/models/user.py — initial version (improvable)
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
full_name: Mapped[str] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
Problems:
idis SERIAL — predictable, exposes count, not globally unique.emailis VARCHAR — case-sensitive. Bug: a user signs up withJohn@Example.com, tries to log in withjohn@example.com, and isn't found.usernameis VARCHAR — same problem.- No search — finding users by a similar name (typo) doesn't work.
The "after" — refactor with extensions
# app/models/user.py — refactor
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, text
from sqlalchemy.dialects.postgresql import UUID, CITEXT
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
# 1. UUID — globally unique, doesn't expose count
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
# 2. CITEXT — automatic case-insensitive
email: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False)
username: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False)
# 3. full_name plain VARCHAR — case matters for names
full_name: Mapped[str] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
Plus an index for pg_trgm search (in the migration):
-- For fuzzy search of full_name
CREATE INDEX idx_users_fullname_trgm ON users USING gin (full_name gin_trgm_ops);
Complete migration
# alembic/versions/XXX_refactor_users.py
"""Refactor users with citext + UUID + pg_trgm
Changes:
- id: SERIAL → UUID with gen_random_uuid()
- email: VARCHAR → CITEXT
- username: VARCHAR → CITEXT
- Add GIN trigram index on full_name
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# Install extensions
op.execute("CREATE EXTENSION IF NOT EXISTS citext")
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# gen_random_uuid is built-in in PG 13+. For older PG: CREATE EXTENSION pgcrypto.
# === For a new project: new table ===
op.create_table(
'users',
sa.Column('id', postgresql.UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
primary_key=True),
sa.Column('email', postgresql.CITEXT(), unique=True, nullable=False),
sa.Column('username', postgresql.CITEXT(), unique=True, nullable=False),
sa.Column('full_name', sa.String(200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
)
# GIN trigram index
op.execute("""
CREATE INDEX idx_users_fullname_trgm
ON users USING gin (full_name gin_trgm_ops)
""")
def downgrade() -> None:
op.drop_index('idx_users_fullname_trgm')
op.drop_table('users')
# Don't drop extensions — they may be used by other tables
Search endpoint with "did you mean"
# app/routers/users.py
from typing import Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.deps import get_db
from app.models import User
router = APIRouter()
class UserSearchResult(BaseModel):
id: uuid.UUID
email: str
username: str
full_name: str
similarity: Optional[float] = None
class SearchResponse(BaseModel):
results: list[UserSearchResult]
did_you_mean: list[str] = []
@router.get("/users/search", response_model=SearchResponse)
async def search_users(
q: str,
db: AsyncSession = Depends(get_db),
):
"""Search users by full_name with smart suggestions."""
if not q or len(q) < 2:
raise HTTPException(400, "Query must be at least 2 chars")
# Exact + prefix matches first
exact_result = await db.execute(text("""
SELECT id, email, username, full_name, 1.0 AS sim
FROM users
WHERE full_name ILIKE :pattern
ORDER BY full_name
LIMIT 10
"""), {"pattern": f"%{q}%"})
exact_users = [
UserSearchResult(
id=r.id, email=r.email, username=r.username,
full_name=r.full_name, similarity=r.sim
)
for r in exact_result.mappings()
]
# If few exact matches, add fuzzy
fuzzy_users = []
if len(exact_users) < 3:
fuzzy_result = await db.execute(text("""
SELECT id, email, username, full_name,
similarity(full_name, :q) AS sim
FROM users
WHERE full_name % :q
AND full_name NOT ILIKE :pattern
ORDER BY sim DESC
LIMIT 5
"""), {"q": q, "pattern": f"%{q}%"})
fuzzy_users = [
UserSearchResult(
id=r.id, email=r.email, username=r.username,
full_name=r.full_name, similarity=r.sim
)
for r in fuzzy_result.mappings()
]
return SearchResponse(
results=exact_users + fuzzy_users,
did_you_mean=[u.full_name for u in fuzzy_users[:3]],
)
@router.post("/users", response_model=UserSearchResult)
async def create_user(
email: str,
username: str,
full_name: str,
db: AsyncSession = Depends(get_db),
):
"""Create a user. citext handles case automatically."""
user = User(email=email, username=username, full_name=full_name)
db.add(user)
try:
await db.commit()
await db.refresh(user)
except Exception as e:
await db.rollback()
raise HTTPException(409, f"User already exists: {e}")
return UserSearchResult(
id=user.id, email=user.email, username=user.username,
full_name=user.full_name
)
@router.get("/users/by-email/{email}", response_model=UserSearchResult)
async def get_user_by_email(
email: str,
db: AsyncSession = Depends(get_db),
):
"""Search by email. citext: automatic case-insensitive."""
user = await db.scalar(select(User).where(User.email == email))
if not user:
raise HTTPException(404, "User not found")
return UserSearchResult(
id=user.id, email=user.email, username=user.username,
full_name=user.full_name
)
Tests for the refactor
# tests/test_users_refactored.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_email_case_insensitive(client: AsyncClient):
"""citext: emails case-insensitive."""
# Create with one case
r1 = await client.post("/users", json={
"email": "John@Example.com",
"username": "John",
"full_name": "John Doe"
})
assert r1.status_code == 200
# Search with a different case
r2 = await client.get("/users/by-email/JOHN@example.com")
assert r2.status_code == 200
assert r2.json()["email"] == "John@Example.com" # original case preserved
@pytest.mark.asyncio
async def test_email_case_insensitive_unique(client: AsyncClient):
"""citext: inserting a duplicate with a different case fails."""
r1 = await client.post("/users", json={
"email": "test@example.com",
"username": "test",
"full_name": "Test User"
})
assert r1.status_code == 200
# Duplicate with a different case
r2 = await client.post("/users", json={
"email": "TEST@EXAMPLE.COM",
"username": "test2",
"full_name": "Test 2"
})
assert r2.status_code == 409 # citext detects duplicate
@pytest.mark.asyncio
async def test_search_with_typo_suggestions(client: AsyncClient):
"""pg_trgm: search with a typo brings suggestions."""
# Create users
await client.post("/users", json={
"email": "john@example.com", "username": "john",
"full_name": "John Smith"
})
await client.post("/users", json={
"email": "jane@example.com", "username": "jane",
"full_name": "Jane Doe"
})
# Search with a typo
response = await client.get("/users/search?q=Jhon")
body = response.json()
# No exact match for "Jhon"
assert len(body["results"]) > 0 # but fuzzy matches
assert "John Smith" in body["did_you_mean"]
@pytest.mark.asyncio
async def test_uuid_format(client: AsyncClient):
"""gen_random_uuid: IDs are valid UUIDs."""
import uuid as uuid_lib
response = await client.post("/users", json={
"email": "uuid@example.com", "username": "uuiduser",
"full_name": "UUID Test"
})
user_id = response.json()["id"]
# Verify UUID format
parsed = uuid_lib.UUID(user_id)
assert parsed.version == 4 # gen_random_uuid produces v4
@pytest.mark.asyncio
async def test_uuid_unpredictable(client: AsyncClient):
"""UUIDs are not predictable (not incremental)."""
ids = []
for i in range(5):
r = await client.post("/users", json={
"email": f"user{i}@example.com", "username": f"user{i}",
"full_name": f"User {i}"
})
ids.append(r.json()["id"])
# IDs are not sequential
# (technically possible but astronomically unlikely)
assert len(set(ids)) == 5 # all distinct
The final BENCHMARKS.md
# Users Refactor — Comparison Before/After
## Setup
- PostgreSQL 16
- Extensions: citext, pg_trgm, pgcrypto (built-in)
- 100,000 users seeded with realistic full_names (Faker)
## Comparison: queries by email
| Approach | Query | Plan | Time |
|----------|-------|------|------|
| Before (VARCHAR + LOWER) | `WHERE LOWER(email) = LOWER(:e)` | Index Scan on uq_users_email_lower | 12ms |
| After (CITEXT) | `WHERE email = :e` | Index Scan on uq_users_email | 11ms |
Similar performance. CITEXT is cleaner in code.
## Comparison: fuzzy search
| Approach | Query | Plan | Time |
|----------|-------|------|------|
| Before (no pg_trgm) | `WHERE full_name ILIKE '%john%'` | Seq Scan + Filter | 145ms (100k rows) |
| After (with GIN trigram) | `WHERE full_name % 'jhon'` | Bitmap Index Scan | 18ms |
8x faster with GIN trigram, plus typo tolerance.
## Comparison: PK size
| Type | Index Size (100k rows) |
|------|----------------------|
| SERIAL (BIGINT 8 bytes) | 1.5 MB |
| UUID (16 bytes) | 4.2 MB |
UUID 3x larger, but a gain in globally unique + not exposing count.
## Takeaways
1. **CITEXT** eliminates LOWER() boilerplate without significant overhead.
2. **gen_random_uuid()** trade-off: 3x size for security/distribution wins.
3. **pg_trgm** GIN index transforms fuzzy search from 145ms to 18ms.
4. **3 extensions, 0 extra dependencies** — all in PostgreSQL.
Module wrap-up
What you learned across the 8 capsules:
- Capsule 01: Module introduction — decision matrix-first.
- Capsule 02: Installation + cloud providers — operational gotchas.
- Capsule 03:
citextvsLOWER()— decision matrix. - Capsule 04: UUIDs —
gen_random_uuidvsuuid-ossp. - Capsule 05:
hstorevs JSONB — simple rule. - Capsule 06:
pg_trgmadvanced cases — dedup + did-you-mean. - Capsule 07: Large extensions — awareness without going deep.
- Capsule 08: Integrating mini-project.
Your next step:
- Apply the decisions to your real project.
- Check the availability of extensions on your cloud provider.
- Incremental refactor — start with
citextfor emails.
We start in the next module
Module 8 closes out the guide. It covers Recursive CTEs (recursive Common Table Expressions) — the SQL technique for hierarchical queries and graph traversal. And then the final capstone project: a refactor of the Blog API applying JSONB + FTS + partitioning + MVs + advisory locks + extensions + CTEs in a single codebase.
It's the close of the guide. Your portfolio ends up complete with a project that demonstrates mastery of PostgreSQL's advanced features.
Resources
- PostgreSQL Docs — Extensions — reference.
- SQLAlchemy — PostgreSQL types — all the custom types.
- GitHub — Awesome Postgres — curated resources.
- Crunchy Data Blog — analysis of extensions.
Capsule 08 of 08 — Module 7 — Advanced PostgreSQL for Backend Guide
End of module 7. Continue with module 8 (Recursive CTEs + Final Project).