
# Database Query Verification

When the experiment loop optimizes a database query -- raw SQL rewrite, CTE restructuring, index addition, or ORM query refactor -- the standard verification steps (lint + unit tests) are insufficient. Mocked tests verify Python logic around the query, not that the query returns correct data.

This guide defines verification tiers for DB query optimizations, ordered cheapest to most thorough. **Tier 1 is mandatory for any raw SQL change. Tiers 2-3 are strongly recommended when infrastructure allows.**

## Tier 1: EXPLAIN Plan Comparison (mandatory for raw SQL changes)

Compare query plans before and after the optimization. This catches structural mistakes (wrong JOINs, missing WHERE clauses, accidentally broader/narrower result sets).

**Use `EXPLAIN` (not `EXPLAIN ANALYZE`) by default** -- `EXPLAIN ANALYZE` actually executes the query, which is unsafe for mutations and may affect production data.

### Django

```python
# Safe: plan only, no execution
before_plan = MyModel.objects.filter(status="active").explain()
after_plan = MyModel.objects.filter(status="active").select_related("org").explain()
print(before_plan)
print(after_plan)
```

For raw SQL:

```python
from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("EXPLAIN SELECT * FROM myapp_order WHERE status = %s", ["active"])
    plan = cursor.fetchall()
    for row in plan:
        print(row)
```

**If you need actual row counts** (not just estimates), wrap in a transaction and roll back:

```python
from django.db import connection, transaction

try:
    with transaction.atomic():
        with connection.cursor() as cursor:
            cursor.execute("EXPLAIN ANALYZE SELECT * FROM myapp_order WHERE status = %s", ["active"])
            plan = cursor.fetchall()
            for row in plan:
                print(row)
        raise RuntimeError("ROLLBACK")  # force rollback -- nothing persists
except RuntimeError:
    pass
```

### SQLAlchemy

```python
from sqlalchemy import create_engine, text

engine = create_engine("postgresql://...", echo=True)  # echo=True logs all SQL

# Compile a query to see the SQL without executing
from sqlalchemy.dialects import postgresql
compiled = query.statement.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})
print(str(compiled))

# EXPLAIN via raw execution
with engine.connect() as conn:
    result = conn.execute(text(f"EXPLAIN {str(compiled)}"))
    for row in result:
        print(row[0])
```

For safe EXPLAIN ANALYZE with rollback:

```python
with engine.begin() as conn:
    result = conn.execute(text(f"EXPLAIN ANALYZE {str(compiled)}"))
    for row in result:
        print(row[0])
    conn.rollback()  # discard all effects
```

### What to check in the plan

| Check | Before to After | Problem if different |
|-------|-----------------|----------------------|
| Estimated rows | Should be ~same | Query may return different result set |
| Scan type | Seq Scan to Index Scan = good | If reversed, the "optimization" made it worse |
| Join type/order | Should match or improve | Different join order can change semantics with outer joins |
| Filter conditions | All original conditions preserved | Missing WHERE clause = data leak |
| Sort node | Should match or disappear (index covers it) | Missing sort = different ordering behavior |

### When to escalate to Tier 2

- EXPLAIN shows different estimated row counts between before and after
- The query involves outer joins, UNIONs, or subqueries (plan comparison alone can miss semantic drift)
- The rewrite changes the query structure significantly (e.g., 3 UNIONs to single WHERE...OR)

## Tier 2: Result Diffing Against Dev/Staging

Run both the original and optimized queries against a real database and compare results.

### Prerequisites

- Access to a dev or staging database with representative data
- Both queries can be executed as SELECT (or wrapped to be read-only)

### Django

```python
import json

# Run both queries against the same database state
before_results = list(MyModel.objects.raw("SELECT * FROM myapp_order WHERE status = %s", ["active"]))
after_results = list(MyModel.objects.filter(status="active").values())

# Compare row counts
print(f"Before: {len(before_results)} rows")
print(f"After: {len(after_results)} rows")

# Compare actual data (sort both for deterministic comparison)
before_sorted = sorted(before_results, key=lambda r: r.id if hasattr(r, 'id') else str(r))
after_sorted = sorted(after_results, key=lambda r: r.get("id", str(r)))

# For Django model instances, serialize to dicts
from django.forms.model_utils import model_to_dict
before_dicts = [model_to_dict(r) for r in before_sorted]
after_dicts = list(after_sorted)

match = json.dumps(before_dicts, default=str, sort_keys=True) == json.dumps(after_dicts, default=str, sort_keys=True)
print(f"Results match: {match}")
```

### SQLAlchemy

```python
import json

with engine.connect() as conn:
    before_results = conn.execute(text(original_sql)).mappings().all()
    after_results = conn.execute(text(optimized_sql)).mappings().all()

print(f"Before: {len(before_results)} rows")
print(f"After: {len(after_results)} rows")

# Sort and compare
before_sorted = sorted(before_results, key=lambda r: r["id"])
after_sorted = sorted(after_results, key=lambda r: r["id"])

match = json.dumps([dict(r) for r in before_sorted], default=str, sort_keys=True) == \
        json.dumps([dict(r) for r in after_sorted], default=str, sort_keys=True)
print(f"Results match: {match}")

if not match:
    before_ids = {r["id"] for r in before_results}
    after_ids = {r["id"] for r in after_results}
    missing = before_ids - after_ids
    extra = after_ids - before_ids
    print(f"Missing from optimized: {len(missing)}")
    print(f"Extra in optimized: {len(extra)}")
```

### What to check

- **Row count**: Must be identical. Any difference = correctness regression -> DISCARD.
- **Row content**: Sample 10-20 rows and verify field values match.
- **Ordering**: If the original query has ORDER BY, verify the optimized result preserves it.
- **NULL handling**: CTE rewrites and WHERE...OR consolidations often change NULL behavior. Check that rows with NULL values in filter columns appear/disappear consistently.
- **Aggregates**: If the query aggregates (COUNT, SUM, AVG), verify the numbers match exactly.

### If no dev/staging database is available

- Check if the project has a Docker Compose or similar local DB setup
- Check for seed scripts (`manage.py loaddata`, `fixtures/`, `alembic/seed.py`)
- If none exists, escalate to Tier 3 (generate a seeded test)
- If no DB access at all, **flag the optimization as unverified** in results.tsv and HANDOFF.md

## Tier 3: Generated Integration Test

Create a test with a seeded database that exercises both the original and optimized queries, then assert equivalence. This persists as a regression guard for future changes.

### Django TestCase with setUpTestData

```python
from django.test import TestCase

class DashboardQueryTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        """Runs once for the entire test class -- faster than setUp per test."""
        cls.org = Organization.objects.create(name="test-org")
        cls.user = User.objects.create(username="testuser", org=cls.org)
        cls.empty_user = User.objects.create(username="empty", org=cls.org)

        # Rows that match all filter conditions (should appear)
        cls.active_orders = Order.objects.bulk_create([
            Order(user=cls.user, status="active", total=100),
            Order(user=cls.user, status="active", total=250),
        ])

        # Rows that match some but not all conditions (should NOT appear)
        Order.objects.create(user=cls.user, status="cancelled", total=50)

        # Rows with NULL in filter/join columns
        Order.objects.create(user=cls.user, status=None, total=0)

    def test_dashboard_returns_correct_results(self):
        result = get_dashboard_stats(self.user.id)
        self.assertEqual(result["total_orders"], 2)
        self.assertEqual(result["total_revenue"], 350)

    def test_dashboard_handles_empty_user(self):
        result = get_dashboard_stats(self.empty_user.id)
        self.assertEqual(result["total_orders"], 0)

    def test_dashboard_handles_null_status(self):
        """Verify NULL status rows are excluded from active counts."""
        result = get_dashboard_stats(self.user.id)
        # NULL status should not count as active
        self.assertNotIn(None, [o.status for o in result.get("orders", [])])
```

Run with `--keepdb` to reuse the test database across runs:

```bash
$RUNNER -m pytest tests/test_dashboard.py --keepdb -v
```

### SQLAlchemy with create_all + seed

```python
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

from myapp.models import Base, User, Order

@pytest.fixture(scope="module")
def engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)

@pytest.fixture(scope="module")
def seeded_session(engine):
    with Session(engine) as session:
        user = User(username="testuser")
        empty_user = User(username="empty")
        session.add_all([user, empty_user])
        session.flush()

        session.add_all([
            Order(user_id=user.id, status="active", total=100),
            Order(user_id=user.id, status="active", total=250),
            Order(user_id=user.id, status="cancelled", total=50),
            Order(user_id=user.id, status=None, total=0),  # NULL edge case
        ])
        session.commit()
        yield session

def test_dashboard_query(seeded_session):
    result = get_dashboard_stats(seeded_session, user_id=1)
    assert result["total_orders"] == 2
    assert result["total_revenue"] == 350

def test_empty_user(seeded_session):
    result = get_dashboard_stats(seeded_session, user_id=2)
    assert result["total_orders"] == 0
```

### When to generate integration tests

- The optimization rewrites raw SQL (CTEs, UNIONs, complex JOINs)
- The query is a critical path (dashboard, billing, auth)
- No existing integration tests cover the query
- The project already has integration test infrastructure (test DB, seed scripts, fixtures)

## ORM Antipatterns

### Django

#### N+1: missing select_related / prefetch_related

The most common Django performance problem. Each access to a related object triggers a separate query.

```python
# BAD: N+1 -- one query per order to fetch the user
orders = Order.objects.filter(status="active")
for order in orders:
    print(order.user.username)  # SELECT * FROM auth_user WHERE id = %s (per iteration)

# GOOD: select_related for ForeignKey/OneToOne (SQL JOIN)
orders = Order.objects.filter(status="active").select_related("user")
for order in orders:
    print(order.user.username)  # no extra query -- joined in original SELECT

# GOOD: prefetch_related for ManyToMany/reverse ForeignKey (separate query + Python join)
users = User.objects.prefetch_related("orders")
for user in users:
    print(user.orders.count())  # no extra query -- prefetched
```

When to use which:
- `select_related`: ForeignKey, OneToOneField (does a SQL JOIN -- one query total)
- `prefetch_related`: ManyToManyField, reverse ForeignKey (two queries -- one for parents, one for related objects, joined in Python)
- Nested: `select_related("order__customer__org")` chains across multiple ForeignKeys
- Mixed: `prefetch_related(Prefetch("items", queryset=Item.objects.select_related("product")))`

#### Over-fetching: no .only() or .defer()

```python
# BAD: fetches all 30 columns when you need 3
users = User.objects.filter(is_active=True)

# GOOD: fetch only needed columns
users = User.objects.filter(is_active=True).only("id", "username", "email")

# GOOD: exclude heavy columns
users = User.objects.filter(is_active=True).defer("bio", "avatar_data")
```

#### QuerySet evaluation in loops

```python
# BAD: .count() executes a SELECT COUNT(*) query each iteration
for category in categories:
    if category.products.count() > 0:  # query per category
        process(category)

# GOOD: annotate to compute counts in a single query
from django.db.models import Count
categories = Category.objects.annotate(product_count=Count("products"))
for category in categories:
    if category.product_count > 0:  # no extra query -- already computed
        process(category)

# BAD: len() on a queryset fetches all rows into memory to count them
total = len(Order.objects.filter(status="active"))

# GOOD: .count() for just the count (SELECT COUNT(*))
total = Order.objects.filter(status="active").count()
```

#### F() expressions vs Python-side computation

```python
# BAD: fetches every row, computes in Python, saves back
for product in Product.objects.all():
    product.price = product.price * 1.1
    product.save()  # one UPDATE per row

# GOOD: single UPDATE at the database level
from django.db.models import F
Product.objects.update(price=F("price") * 1.1)
```

`F()` expressions push computation to the database. This avoids fetching rows and eliminates race conditions from concurrent updates.

#### Missing DB indexes on filtered/ordered fields

```python
# If you frequently filter or order by status + created_at, add indexes:
class Order(models.Model):
    status = models.CharField(max_length=20, db_index=True)
    created_at = models.DateTimeField(db_index=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE)  # auto-indexed

    class Meta:
        indexes = [
            # Composite index for queries that filter on both
            models.Index(fields=["status", "created_at"]),
            # Partial index for common filter (PostgreSQL only)
            models.Index(
                fields=["created_at"],
                condition=models.Q(status="active"),
                name="idx_active_orders_created",
            ),
        ]
```

#### Bulk operations vs loops

```python
# BAD: one INSERT per object
for item in items:
    Order.objects.create(user=user, product=item, status="pending")

# GOOD: single INSERT with all rows
Order.objects.bulk_create([
    Order(user=user, product=item, status="pending")
    for item in items
])

# BAD: one UPDATE per object
for order in orders:
    order.status = "shipped"
    order.save()

# GOOD: single UPDATE
Order.objects.filter(id__in=[o.id for o in orders]).update(status="shipped")

# Or if you need per-object different values:
for order in orders:
    order.status = compute_status(order)
Order.objects.bulk_update(orders, ["status"])
```

`bulk_create` accepts `batch_size` for very large inserts (default sends all at once). `bulk_update` also accepts `batch_size`. Both skip signals and custom `save()` methods -- use only when that's acceptable.

### SQLAlchemy

#### N+1: missing eager loading

```python
# BAD: N+1 -- lazy loading triggers a query per access
users = session.query(User).all()
for user in users:
    print(user.orders)  # SELECT * FROM orders WHERE user_id = ? (per user)

# GOOD: joinedload -- single query with JOIN
from sqlalchemy.orm import joinedload
users = session.query(User).options(joinedload(User.orders)).all()

# GOOD: selectinload -- two queries (one for users, one for all their orders)
from sqlalchemy.orm import selectinload
users = session.query(User).options(selectinload(User.orders)).all()

# GOOD: subqueryload -- two queries using a subquery
from sqlalchemy.orm import subqueryload
users = session.query(User).options(subqueryload(User.orders)).all()
```

When to use which:
- `joinedload`: small related sets, one-to-one, or when you need a single round-trip. Beware of cartesian products with multiple joinedloads.
- `selectinload`: large related sets, many-to-many. Uses `WHERE id IN (...)` -- efficient and avoids cartesian product.
- `subqueryload`: similar to selectinload but uses a correlated subquery. Better when the parent query has LIMIT/OFFSET (selectinload would re-execute the parent query).

#### Session lifecycle: expire_on_commit for read-heavy code

```python
# BAD: after commit, accessing any attribute triggers a SELECT to refresh it
session = Session(engine)
user = session.query(User).first()
session.commit()
print(user.username)  # triggers a SELECT to re-load -- wasteful if you just read

# GOOD: disable expire_on_commit for read-heavy patterns
session = Session(engine, expire_on_commit=False)
user = session.query(User).first()
session.commit()
print(user.username)  # uses cached value -- no extra query
```

Use `expire_on_commit=False` when you read data, commit (to release locks), then continue reading. Avoid it when other processes may modify the data between your commit and subsequent reads.

#### Connection pool sizing

```python
from sqlalchemy import create_engine

# Defaults: pool_size=5, max_overflow=10 (total 15 connections max)
engine = create_engine(
    "postgresql://...",
    pool_size=20,        # persistent connections
    max_overflow=10,     # temporary connections above pool_size
    pool_timeout=30,     # seconds to wait for a connection before error
    pool_recycle=1800,   # recycle connections after 30 min (prevents stale connections)
    pool_pre_ping=True,  # test connection liveness before checkout
)
```

If you see `TimeoutError: QueuePool limit` errors, the pool is exhausted. Common causes:
- Long-running transactions holding connections (fix: shorter transactions, streaming with `yield_per`)
- Connection leaks from missing `session.close()` or `engine.dispose()` (fix: use context managers)
- Pool too small for concurrency level (fix: increase `pool_size` + `max_overflow`)

#### yield_per() for large result sets

```python
# BAD: loads all 10M rows into memory at once
results = session.query(LogEntry).all()

# GOOD: stream results in batches of 1000
for entry in session.query(LogEntry).yield_per(1000):
    process(entry)

# SQLAlchemy 2.0 style
from sqlalchemy import select
stmt = select(LogEntry)
with session.execute(stmt, execution_options={"yield_per": 1000}) as result:
    for entry in result.scalars():
        process(entry)
```

`yield_per` uses server-side cursors (PostgreSQL) or client-side batching (MySQL/SQLite). For PostgreSQL, ensure `stream_results=True` is set on the execution options or connection for true server-side streaming.

#### Compiled cache (query_cache_size)

```python
# SQLAlchemy 1.4+ caches compiled SQL statements
engine = create_engine(
    "postgresql://...",
    query_cache_size=500,  # default is 500; increase for apps with many distinct queries
)
```

The compiled cache avoids re-compiling the same ORM query into SQL on every execution. If you see `SAWarning: Compiled cache is full`, increase `query_cache_size` or audit for queries constructed with string formatting (these produce unique SQL each time and defeat the cache).

#### Core vs ORM performance

```python
# ORM: full object construction, identity map tracking, event hooks
users = session.query(User).filter(User.is_active == True).all()

# Core: raw rows, no object overhead -- 2-5x faster for read-heavy bulk operations
from sqlalchemy import select
stmt = select(User.__table__).where(User.__table__.c.is_active == True)
with engine.connect() as conn:
    rows = conn.execute(stmt).fetchall()

# Hybrid: ORM query but return lightweight tuples instead of mapped objects
users = session.query(User.id, User.username).filter(User.is_active == True).all()
```

Use Core for bulk reads, reports, and data export. Use ORM when you need relationship traversal, change tracking, or business logic on model instances.

### General Patterns

#### Connection pooling

| Context | Solution |
|---------|----------|
| Django | `CONN_MAX_AGE` in `DATABASES` settings (0 = new connection per request, `None` = persistent) |
| SQLAlchemy | Built-in `QueuePool` (default), configure via engine parameters |
| External pooler | pgbouncer (PostgreSQL), ProxySQL (MySQL) -- sits between app and DB |

```python
# Django: persistent connections (avoid connection overhead per request)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "CONN_MAX_AGE": 600,  # reuse connections for 10 minutes
        # ...
    }
}
```

With `CONN_MAX_AGE=None` (persistent), connections live until the worker process dies. This can exhaust DB connection limits if you have many workers. Use pgbouncer in production to cap total connections.

#### Missing indexes

Common cases where indexes are missing:

```python
# Foreign keys: Django auto-indexes ForeignKey fields, but SQLAlchemy does NOT
class Order(Base):
    __tablename__ = "orders"
    user_id = Column(Integer, ForeignKey("users.id"), index=True)  # add index=True explicitly

# Filtered columns: any column in WHERE clauses needs an index
# ORDER BY columns: any column in ORDER BY without a matching index causes a filesort
# Composite indexes: for queries filtering on multiple columns
Index("idx_orders_status_created", Order.status, Order.created_at)
```

#### Soft deletes without partial indexes

```python
# BAD: soft-deleted rows bloat every query
class Order(Base):
    is_deleted = Column(Boolean, default=False)

# Every query must add .filter(is_deleted=False), and the index scans ALL rows

# GOOD: partial index excludes deleted rows (PostgreSQL)
Index("idx_orders_active", Order.status, Order.created_at,
      postgresql_where=Order.is_deleted == False)

# Django equivalent
class Meta:
    indexes = [
        models.Index(
            fields=["status", "created_at"],
            condition=models.Q(is_deleted=False),
            name="idx_orders_active",
        ),
    ]
```

#### JSON columns: when to normalize

Keep as JSON when:
- The structure varies per row (user preferences, feature flags)
- You rarely query by individual fields within the JSON
- The data is read-mostly and written as a whole

Normalize when:
- You frequently filter, join, or aggregate on fields inside the JSON
- Multiple rows share the same nested structure (many-to-many through JSON array = missing join table)
- You need referential integrity on values inside the JSON

```python
# BAD: filtering on JSON field -- no index, full table scan
Order.objects.filter(metadata__shipping_method="express")

# If this query is frequent, extract to a real column:
class Order(models.Model):
    shipping_method = models.CharField(max_length=50, db_index=True)
    metadata = models.JSONField(default=dict)  # keep for remaining unstructured data
```

PostgreSQL supports GIN indexes on JSONB columns, which can help for containment queries (`@>`, `?`), but they do not help with arbitrary key-value lookups or range queries.

#### Raw SQL for complex aggregations

ORMs generate verbose SQL for complex aggregations. When the generated SQL is measurably slower, drop to raw SQL:

```python
# Django: complex aggregation the ORM handles poorly
from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("""
        SELECT
            date_trunc('month', created_at) AS month,
            status,
            COUNT(*) AS order_count,
            SUM(total) AS revenue,
            AVG(total) FILTER (WHERE total > 0) AS avg_nonzero
        FROM myapp_order
        WHERE created_at >= %s
        GROUP BY 1, 2
        ORDER BY 1 DESC, 2
    """, [cutoff_date])
    rows = cursor.fetchall()

# SQLAlchemy: use text() for raw SQL
from sqlalchemy import text

with engine.connect() as conn:
    result = conn.execute(text("""
        SELECT date_trunc('month', created_at) AS month,
               status,
               COUNT(*) AS order_count,
               SUM(total) AS revenue
        FROM orders
        WHERE created_at >= :cutoff
        GROUP BY 1, 2
        ORDER BY 1 DESC, 2
    """), {"cutoff": cutoff_date})
    rows = result.fetchall()
```

Always verify raw SQL with Tier 1 (EXPLAIN comparison) -- there is no type system to catch mistakes.

## Experiment Loop Integration

When the experiment loop detects a DB query optimization, extend the standard verification:

### After step 6 (Implement) -- detect DB changes

Check if the diff modifies:
- `connection.cursor()` / `cursor.execute()` calls (Django raw SQL)
- `session.execute(text(...))` / `engine.execute()` calls (SQLAlchemy raw SQL)
- Complex ORM queries (nested filters, multiple joins, aggregations, `.raw()`)
- Migration files (`migrations/`, `alembic/versions/`)
- Model `Meta.indexes` or `Index()` definitions

### After step 8 (Verify output equivalence) -- escalate verification

If a DB change is detected:

1. **Always run Tier 1** (EXPLAIN comparison) for raw SQL changes
2. **Run Tier 2** (result diffing) if dev/staging DB is accessible
3. **Generate Tier 3** (integration test) if the query is on a critical path and no integration test exists

### Keep/Discard additions

```
DB query change detected?
+-- YES -> EXPLAIN plans compared?
|   +-- NO -> Run Tier 1 before proceeding
|   +-- YES -> Row estimates match?
|       +-- NO -> DISCARD (correctness risk)
|       +-- YES -> Scan type improved or maintained?
|           +-- Improved -> Continue to standard keep/discard
|           +-- Degraded -> DISCARD (performance regression at DB level)
+-- NO -> Standard verification (compile + tests + guard)
```

### Recording in results.tsv

For DB query optimizations, add verification tier to the notes column:

```
experiment 5 | views.py:get_dashboard_stats | KEEP | 340ms->45ms | db-verified:tier1+tier2
experiment 6 | services.py:find_user | KEEP | filter->get (unique field) | db-verified:tier1 (type-safe)
experiment 7 | reports.py:generate_report | DISCARD | CTE rewrite | db-unverified (no staging DB)
```

## Safe ORM Query Patterns (verification shortcuts)

Some ORM query changes are inherently safe due to framework constraints:

| Change | Safe? | Why |
|--------|-------|-----|
| Adding `select_related` / `prefetch_related` | Yes | Same data, fewer queries -- cannot change result set |
| Adding `.only()` / `.defer()` to narrow fields | Yes | Subset of same data -- cannot return wrong rows |
| `.filter().first()` to `.get()` on unique field | Yes | Django raises `DoesNotExist` / `MultipleObjectsReturned` if constraint is wrong |
| `session.query(Model).first()` to `.one()` | Mostly | `.one()` raises if not exactly one result -- safer but may raise on edge cases |
| Adding `db_index=True` or `Index()` | Yes | Does not change query semantics, only execution speed |
| `bulk_create` instead of loop of `.create()` | Mostly | Same rows inserted, but skips signals and custom `save()` |
| Raw SQL rewrite of ORM query | **Tier 1+ required** | No ORM safety net -- must verify plan and results |
| CTE restructuring | **Tier 2+ recommended** | Complex semantic changes -- plan comparison alone may miss edge cases |
| UNION consolidation | **Tier 2+ recommended** | NULL handling and deduplication behavior can change |

## Cross-Domain Patterns

Database optimizations frequently overlap with other performance domains:

| DB Pattern | Overlapping Domain | Why |
|------------|-------------------|-----|
| N+1 query elimination | CPU + Async + Memory | N extra queries = N network round-trips (async), N result parsing cycles (CPU), N result set allocations (memory) |
| Connection pool exhaustion | Async | Long-running queries or leaked connections block the pool, starving async handlers waiting for a connection |
| Large result set without streaming | Memory | Loading 10M rows at once via `.all()` causes GC pressure. Use `.iterator()` (Django) or `yield_per()` (SQLAlchemy) |
| Missing index causing full table scan | CPU | Database does the heavy lifting, but the application waits longer -- shows up as increased wall-clock time |
| Excessive serialization of ORM objects | CPU | Converting 100k model instances to dicts/JSON is CPU-bound. Use `.values()` / `.values_list()` or Core queries |
