database-optimization
Use when writing or reviewing database queries, views, indexes, or schema for correctness and performance. Invoke whenever you: add or change ANY query (ORM or raw), touch an EntitySchema/entity/migra
SKILL.mdskills/database-optimization/SKILL.md
GitHubDatabase Optimization
Goal: queries and schema that are correct AND efficient. Don't ship a query you haven't reasoned about — and, wherever a real database is reachable, measured. A query that "looks right" but was never executed against real data is the main source of prod-only failures in this domain.
Workflow
Follow in order — each step gates the next.
1. Read the prior work for this stack
Check LEARNINGS.md beside this skill, when your copy keeps one (and the reference files
below), for the engine, ORM conventions, and known traps before writing anything — most of
this skill's value is already-paid-for lessons. This install's stack: LOCAL.md beside
this skill, when your copy keeps one — Stack.
2. Understand the access pattern before optimizing
Pin down cardinality, filters, joins, sort, expected row counts, and read vs. write frequency. An index or view chosen without this is a guess — you can't rank the levers until you know what the query actually does per request.
3. Write the query set-based and through the ORM
- Use the ORM (EntitySchema + repository/query builder), not raw
manager.query— standing user directive: type-safe, naming-strategy-aware, whitelist-friendly. Drop to parameterized raw SQL only when the ORM genuinely can't express it (say why inline). - Keep it set-based: no N+1; batch; push filtering into SQL, not app code.
- Ownership/scoping checks via
EXISTS (...)subqueries, not join-then-distinct. - Avoid row explosion: a one-to-many join then DISTINCT/GROUP BY multiplies rows
before collapsing them — aggregate in a subquery /
json_agg/ lateral instead. - ORM joins first; a view only to amortize a genuinely heavy query — and if it's that heavy, it's usually a materialized view (see reference file).
- Check the hard rules below — several correct-looking TypeORM constructions fail only at runtime.
4. Measure on realistic data
Run EXPLAIN (ANALYZE, BUFFERS) (or the engine's equivalent) on realistic data; compare
before/after. Look for seq scans on big tables, nested-loop blowups, sorts spilling to
disk, and row-count explosions. Index the columns in filters/joins/sort (composite/
partial for common predicates), don't over-index write-heavy tables, and verify the
planner actually USES the index — an unused index is pure write cost.
Gate: do not claim a performance win without a before/after measurement, and do not
trust a timing taken on a contaminated local DB (zombie queries from timed-out probes
skew everything; this install's perf-testing hygiene notes: LOCAL.md beside this skill,
when your copy keeps one — view-materialization references).
5. Live-verify the query actually runs
Mocked query-builder doubles execute no SQL, so a whole class of TypeORM bugs passes
unit tests and 500s live. Cheap middle ground: a throwaway ts-node DataSource script
running the exact builder against the dev DB (recipe in LEARNINGS.md beside this skill,
when your copy keeps one: "Live-verify a TypeORM query WITHOUT booting the app"). Gate: any
new EntitySchema/relation/query-builder work gets a live execution before you call it done.
Hard rules — TypeORM / Postgres (all live-verified failures)
Each of these passed unit tests and failed against a real database. Details live in
LEARNINGS.md beside this skill, when your copy keeps one, unless noted.
- Pin a canonical projection for UNIONs.
SelectQueryBuilderreorders.addSelect()output (entity-matched columns group ahead of raw expressions), so hand-builtUNION ALLbranches can emit different column orders → type-mismatch errors or silent positional misalignment. Wrap each branch inSELECT <explicit column list> FROM (branch). - Ordering a paginated query by a JOINED column via
getMany/getRawAndEntitiescrashes (column distinctAlias.<col> does not exist). UsegetRawMany()+ explicit select aliases + a raw-row mapper; don't route the order string throughQueryOrderPipe. REFRESH MATERIALIZED VIEW CONCURRENTLYrequires a unique index on the view's grain — if the view can emit duplicate keys, useDISTINCT ON (key) … ORDER BYin the view body to guarantee one row per grain (this install's notes:LOCAL.mdbeside this skill, when your copy keeps one — view-materialization references).- Soft-delete predicate parity: reads must filter
destroyed_at IS NULLwith the same predicate the write path uses — a read that skips it resurrects deleted rows. Exception: byte-parity ports reproduce the SOURCE query's actual WHERE (read it; don't assume). On a LEFT join,child.destroyed_at IS NULLalso keeps join-MISS rows — addchild.id IS NOT NULLor inner-join. createDate/updateDateEntitySchema flags do NOT add a DB default — inserts into Rails-owned tables (no DB default) violate NOT NULL at runtime. SetcreatedAt/updatedAtexplicitly (new Date()) on every write path; a global subscriber covers the general case.- Any schema or view change needs a generated TypeORM migration — publishing the
model package only updates TS types, never the database (this install's notes:
LOCAL.mdbeside this skill, when your copy keeps one — view-materialization references). - Repository/ORM API over raw
manager.query(step 3) — including tiny lookups likepublic_slug → id. - Write migrations against the DEPLOYED Postgres major, not your local one. Local
dev ran PG15+ while staging and prod ran 14, so a
UNIQUE NULLS NOT DISTINCTmigration passed unit tests and a real localmigrate, then failed the staging prestart withsyntax error at or near "NULLS"— and the service auto-reverted, producing a no-op deploy that looked deployed-but-stale. Whenever the deployed major is older than local, ban the newer major's syntax (on 14 that wasMERGE,NULLS NOT DISTINCT, FKSET NULL (col_list), and security-invoker views). When unsure a migration is portable,SELECT version()on the target before trusting a local pass. A failedmigratetask is a release failure, not a skip. - A session timezone mismatch silently zeroes timestamptz-vs-naive comparisons.
Joining
timestamptzvalues against naivedate_trunc(...)results casts the naive side using the session timezone, so they match only under UTC. A local cluster defaulting to America/New_York made a view read ALL-ZERO across three sessions of probing and nearly shipped as a "platform-wide data gap"; underSET timezone='UTC'the same restore held 1,746 real partner-months. On any suspicious all-zero read from a view mixing timestamptz and naive timestamps, checkSHOW timezoneFIRST, run data-validation probes underSET timezone='UTC'for prod parity, andALTER DATABASE <db> SET timezone='UTC'on local prod-restores. - Two ORM configs are one registration list. A TypeORM project typically carries
both a
data-source.ts(what the CLI reads) and the app's DI config, each needing the sameentities/migrationsarrays — and duplicating them guarantees eventual drift. The split hides it: the CLI drives onlydata-source.ts, both setmigrationsRun: false, so a missing app-side entry is 100% silent — no failing test, no boot error — until code relies on it. Export the arrays from ONE shared module both configs import, preserving migration order exactly. - Re-verify a migration timestamp slot at the commit you will actually build on,
immediately before writing the file. A slot
ls-ed during research goes stale the moment you rebase — one collision came from another branch of the same person's own parallel work landing in that exact slot hours later. When briefing a slot to anyone else, add "verify this yourself; do not trust me": that line is what turns a silent divergence into a clean stop.
Designing a constraint
- Read the producer, not just the schema. A
UNIQUE(user_id, cadence, period_start)looked perfectly sensible in isolation, but the workbook parser feeding it keyed duplicates onpayDate + '\0' + source— so two parents on one date from different sources were a shape the source data explicitly permitted and the child table could not store (a real event: an employer change with an overlapping pay date). When reviewing a uniqueness or identity constraint, go find the notion of identity used by whatever validates, parses, or imports the rows. If the two disagree, the schema is usually the one that's wrong — the parser was written against reality. - A constraint that no current data violates is not proven; it is untested. That one
was reachable through the ordinary write path as a raw
QueryFailedError(500) — the back-fill slice merely got there first. - Narrow a wrong constraint rather than dropping it globally — make it partial for
the subtype that genuinely needs it. ⚠️ In Postgres a partial unique cannot be a table
CONSTRAINT; it must be
CREATE UNIQUE INDEX … WHERE …(ADD CONSTRAINT … UNIQUE (…) WHERE …is not valid SQL). TypeORM expresses it as@Index(name, [cols], { unique: true, where: "…" }). A partial unique index violation still populates the error'sconstraintfield with the index name (SQLSTATE 23505), so specs can assert on it exactly as they do for a real constraint. - Prefer a lossy-but-safe key over a correct-but-undeployable constraint when prod data is unknowable — one that fails on deploy against duplicates you cannot prove absent is worse than a design that degrades quietly. Record the reasoning in the commit so the "better" design isn't naively reapplied later.
- Be honest about rollback. A
down()that re-adds a constraint will fail on any database that has since stored the now-legal row. Say so rather than implying the migration is cleanly reversible. - Ask whether the column is read at all before fighting to constrain it. A field that looked like domain state fed nothing; deleting it dissolved a "we must query prod first" blocker rather than answering it.
Common failure modes
- Shipping a query verified only by mocked unit tests — every hard rule above is a bug class mocks cannot see. Live-verify (step 5).
- Trusting
COUNT(*)as a proxy for the real read: the planner prunes unused correlated subselects for COUNT but computes them forSELECT * … LIMIT— a fast count with a hanging page read means materialize, not rewrite (this install's notes:LOCAL.mdbeside this skill, when your copy keeps one — view-materialization references). - "Fixing" a slow view by rewriting joins when the cost is a per-row correlated/ LATERAL subselect — no rewrite saves it; materialize.
- Adding a DB view for join convenience — views are a performance tool; the query builder handles multi-table filter/sort fine.
- Assuming raw rows behave like hydrated entities:
getRawManyskips transformers (numerics arrive as strings — coerce), and node-pg auto-parses jsonb (don'tJSON.parsetwice). - Perf-testing against a dirty local DB: timed-out HTTP probes leave zombie
server-side queries that contaminate every later timing — clear
pg_stat_activityfirst. - A cast or dedup in a view silently defeats the index the planner would otherwise use — the query stays correct and quietly goes seq-scan. Check the plan, not just the result.
- A delete-and-recreate importer silently drops any column absent from its INSERT. Two provenance columns were nulled on every run for weeks, destroying user-built reconciliation links, because they simply weren't listed. The rows looked fine; the data they referenced was gone. Audit the insert object against the entity whenever an importer rebuilds rows.
References
Read the matching file before working in its area:
- This install's view/materialized-view notes (when a view change needs a migration,
build-then-swap, unique grain for CONCURRENT refresh, perf-testing hygiene):
LOCAL.mdbeside this skill, when your copy keeps one — view-materialization references. - postgres-tuning.md — general engine-level Postgres: server/memory
config, diagnostics (
pg_stat_statements, EXPLAIN/BUFFERS), bloat & autovacuum, bulk ops, locks, read-replica scaling, pgvector. Consult when the question is general Postgres rather than project-specific.
Before you call this done
- Access pattern understood (filters, joins, sort, cardinality) — not guessed.
- ORM-first; any raw SQL is parameterized and justified inline.
- Checked the hard-rules list; none of the seven apply unhandled.
- Executed the real query against a real DB (DataSource script, psql, or live app).
- Perf claims backed by before/after
EXPLAIN (ANALYZE, BUFFERS)on clean data. - Schema/view changes carry their generated migration.
Related skills
- tdd-skill: mocked green is necessary, not sufficient; a DB-touching change gets a real-database run.
- deprecation-and-migration: shared-table expand/contract changes, where old and new code hit the DB at once.
- data-analytics: the metrics and dashboards whose queries this skill makes fast.
- code-quality: the hard rules every query, entity and migration diff meets — one responsibility per file, readable by a junior engineer, and one rhythm per kind of work.
Capturing learnings (session-handoff protocol)
Accumulate query/schema tuning knowledge in LEARNINGS.md beside this skill, when your
copy keeps one (project-specific ORM/query-building wins & gotchas), and engine-level
general knowledge in postgres-tuning.md. On handoff /
"update skills" / after DB work: record the optimization, the measured effect, the access
pattern, and the reusable rule under a new ## section in the most relevant file (not this
index). Dedupe/merge, tag by project/engine. If a file outgrows ~250 lines, split it
further. Read the relevant file(s) before DB work. (Where this install files view
learnings: LOCAL.md beside this skill, when your copy keeps one — Capturing
learnings.)