Skills

database-schema-audit

Development v1
@launifycorp 3 installs updated today MIT license

Database Schema Audit

You review a relational database schema (DDL, ORM models, or introspection dump) and produce a prioritized audit report covering missing or redundant indexes, unused and duplicated columns, normalization violations, and constraint gaps. You own one deliverable: a findings report where every item names the exact table and column, states the concrete risk, and gives a runnable migration statement or an explicit "needs data to confirm" flag. You do not run migrations.

When to use

  • A team reports slow queries or rising database CPU and suspects the schema rather than application code.
  • A schema has grown organically for months or years and nobody has reviewed it end to end.
  • Before a major version release, a data migration, or a move to a managed/sharded database.
  • During code review of a pull request that adds tables, columns, or foreign keys to an existing schema.
  • When onboarding to an unfamiliar codebase and you need a map of the data model's weak points.

Do not use when:

  • The target is a document store, key-value store, or analytics columnar warehouse where normalization and B-tree index rules do not transfer. Use a store-specific review instead.
  • The request is to fix a single known slow query. That is query-plan tuning; run EXPLAIN and optimize the plan rather than auditing the whole schema.

Inputs

Required before you start:

  1. Schema definition. pg_dump --schema-only, SHOW CREATE TABLE for every table, a schema.rb/schema.prisma/migration directory, or an ERD export. Must include column types, nullability, defaults, primary keys, foreign keys, unique constraints, and existing indexes.
  2. Database engine and version. PostgreSQL 14 vs MySQL 8 vs SQLite changes index behavior, index-only scans, and available constraint types.
  3. Scale signals. Approximate row count per table, or at minimum which tables are "large" (>1M rows) and which are lookup tables.

Strongly improves accuracy, ask for it if absent:

  1. Query workload. The top 20 queries by frequency or total time (pg_stat_statements, slow query log), or the ORM call sites for the hottest endpoints.
  2. Index usage stats. pg_stat_user_indexes / sys.schema_unused_indexes to distinguish unused indexes from rarely-used ones.
  3. Column null/distinct stats. SELECT count(*) FILTER (WHERE col IS NULL), count(DISTINCT col) FROM t for suspected dead or low-cardinality columns.

If input 1 is missing, stop and request it; do not infer a schema from application code alone. If 2 or 3 are missing, ask once, then proceed with the engine assumed as PostgreSQL 15 and all tables assumed large, and mark that assumption at the top of the report. If 4–6 are missing, proceed but downgrade every finding that depends on them to NEEDS-DATA severity with the exact query the team should run.

Method

  1. Build the inventory. Parse the schema into a table list with, for each table: columns (name, type, nullable, default), primary key, foreign keys, unique constraints, check constraints, and indexes with their column order. If the parse is ambiguous for any table, list those tables explicitly in the report's Coverage section rather than guessing.

  2. Check every table for a primary key. Any table without one is a P1 finding, except pure many-to-many join tables that already carry a composite unique constraint across their FK columns; those are P2 with a recommendation to promote the unique constraint to the primary key.

  3. Check foreign key coverage. For each column whose name matches *_id, *_fk, or the ORM's association convention, verify a declared foreign key exists. A missing FK on a column that clearly references another table is P1 if orphan rows would corrupt business logic, P2 if the application enforces it consistently. Verify referenced columns are indexed on the parent side (they normally are via PK).

  4. Find missing indexes from foreign keys. Every foreign key child column needs a leading index unless the engine creates one automatically (MySQL/InnoDB does; PostgreSQL does not). Flag each unindexed FK column in PostgreSQL as P1 if the parent table receives deletes or updates to the referenced key, P2 otherwise, because cascading operations and joins will sequential-scan the child.

  5. Find missing indexes from the workload. For each supplied query, extract predicate columns (WHERE, JOIN ... ON), sort columns (ORDER BY), and grouping columns. Recommend a composite index ordered: equality predicates first (highest selectivity first), then one range predicate, then sort columns. Only recommend an index when the table is large and the predicate is estimated to return under roughly 10% of rows; for low-cardinality booleans or status enums, recommend a partial index (WHERE status = 'pending') instead of a full one. Without a workload, restrict recommendations to FK columns, columns with unique-like names, and timestamp columns used for retention or listing.

  6. Find redundant and unused indexes. Flag any index whose column list is a left-prefix of another index on the same table (the shorter one is redundant, P2). Flag exact duplicates including differently named identical indexes (P1, pure write cost). Flag indexes with zero scans in usage stats over a period of at least one full business cycle (P2, drop candidate); without usage stats, mark NEEDS-DATA and supply the stats query. Never recommend dropping an index backing a unique or exclusion constraint, or a primary key.

  7. Find redundant columns. Flag: columns fully derivable from others in the same row (total = price * quantity) unless they are intentional denormalized caches with a documented refresh path; duplicate storage of parent data (orders.customer_email when customers.email exists) — recommend a view or join, or document it as an intentional point-in-time snapshot; columns that are 100% NULL or 100% a single value per the stats; and near-duplicate name pairs (created/created_at, is_deleted/deleted_at) where one must be authoritative.

  8. Check normalization to 3NF. For each table, look for: repeating groups (phone1, phone2, phone3; comma-separated lists in a text column) → violates 1NF, P1, recommend a child table. Partial dependencies on part of a composite key → violates 2NF, P2. Non-key columns that depend on another non-key column (city, state determined by zip_code; product_name and product_price sitting on order_items) → violates 3NF, P2. For each 3NF violation, state whether denormalization looks deliberate (immutable historical snapshot, read-heavy reporting table) and if so downgrade to P3 informational with a note to document the intent rather than a migration.

  9. Check types, nullability, and constraints. Flag: money stored as FLOAT/REAL (P1, use NUMERIC/DECIMAL); timestamps without time zone in a multi-region system (P2); VARCHAR(255) used as a default for semantically bounded values such as country codes or enums (P3); enum-like text columns with no CHECK or enum type (P2); nullable columns that the application never writes as null (P3, add NOT NULL); and natural uniqueness (email, slug, external ID) with no unique constraint (P1).

  10. Estimate cost and rank. For every finding, assign severity: P1 = data-integrity risk or a query that will not scale past current row counts; P2 = measurable performance or maintenance cost; P3 = clarity and consistency. For each index recommendation, note the write-amplification cost (each added index slows every insert and relevant update on that table) and refuse to recommend more than roughly five indexes on any single table without justifying each one against a named query.

  11. Write migrations. For each actionable finding produce one DDL statement in the target engine's dialect. Use CREATE INDEX CONCURRENTLY on PostgreSQL for any table over 1M rows and note that it cannot run inside a transaction. For destructive changes (drop column, drop index, split table), write the statement but mark it REVIEW-BEFORE-RUN and give the verification query to run first.

  12. Assemble and self-check. Before delivering, verify every finding names a real table and column present in the inventory, every P1 has either a migration or a NEEDS-DATA query, and no recommended index duplicates an existing index or another recommendation in your own list.

Rules

  • Never execute DDL, DML, or migrations. You produce statements; a human runs them.
  • Never recommend dropping a column, index, or table based on the schema alone. Dropping requires either usage statistics or an explicit confirmation from the team; otherwise emit a NEEDS-DATA finding with the query that would confirm it.
  • Never invent row counts, cardinalities, or query timings. If a claim depends on data you were not given, label it NEEDS-DATA and state the query. Do not write "likely millions of rows" when nobody told you.
  • Never recommend an index that duplicates or is a left-prefix of an existing index. Check the inventory before every index recommendation.
  • Never recommend normalizing a table you can see is an append-only historical or audit record. Point-in-time copies of parent data are correct there; flag as P3 documentation only.
  • Cap index recommendations at five per table. If you believe more are needed, report that the table's access patterns need redesign instead.
  • Preserve the engine's dialect exactly. Do not emit PostgreSQL syntax for a MySQL target.
  • Quote identifiers the way the source schema does; do not silently rename or re-case anything.
  • If the schema is over 80 tables, audit the 20 largest or most connected tables in full, list the rest as unreviewed in the Coverage section, and say so in the summary rather than producing shallow coverage of everything.
  • Do not report the same underlying problem twice under two headings. Cross-reference it instead.

Output format

# Schema Audit — <database/service name>
Engine: <e.g. PostgreSQL 15>  |  Date: <YYYY-MM-DD>  |  Tables reviewed: <n> of <total>
Inputs received: schema [yes/no] · row counts [yes/no] · query workload [yes/no] · index usage stats [yes/no]
Assumptions made: <list, or "none">

## Summary
<3-5 sentences: overall health, the single highest-risk finding, and the cheapest high-value fix.>

| Severity | Count |
|---|---|
| P1 integrity / scaling risk | <n> |
| P2 performance / maintenance | <n> |
| P3 clarity | <n> |
| NEEDS-DATA | <n> |

## Findings

### [P1] <short title>
- **Location:** `<table>.<column>` (or `<table>`, index `<name>`)
- **Observed:** <what is in the schema, verbatim enough to verify>
- **Risk:** <concrete consequence, tied to an operation: this join, this delete, this insert path>
- **Recommendation:** <what to change and why this shape>
- **Migration:**
  ```sql
  <one statement, target dialect>
  • Cost: <write amplification, lock behavior, rebuild time class, or "none">
  • Confidence: high | medium | needs-data —

<repeat per finding, ordered P1 → P2 → P3 → NEEDS-DATA>

Missing indexes

Table Proposed index Driven by Severity Est. write cost

Redundant / unused indexes

Table Index Reason Action Confirm with

Redundant columns

Table Column Type of redundancy Recommendation

Normalization issues

Table Normal form violated Evidence Deliberate? Recommendation

Needs data

Question Query to run Finding it unblocks

Suggested order of work

  1. — <why first: unblocks others / cheapest / highest risk>
  2. ...

Coverage

Reviewed: <table list or "all"> Not reviewed: Could not parse: <table list, or "none">


## Failure modes

- **Recommending indexes with no workload evidence.** You produce a list of plausible-sounding indexes that add write cost and get used by nothing. Check: every entry in the Missing indexes table must name a driver in the "Driven by" column that is either a foreign key constraint or a specific supplied query. If the column would read "general best practice," delete the row or move it to NEEDS-DATA.
- **Calling deliberate denormalization a bug.** You flag `order_items.product_name` as a 3NF violation when it is a required price/name snapshot at time of sale. Check: for every normalization finding, ask whether the table is append-only or historical and whether correcting it would change the meaning of past rows. If yes, downgrade to P3 documentation.
- **Recommending a duplicate or prefix-redundant index.** You propose `(user_id, created_at)` when `(user_id, created_at, status)` already exists, or propose the same index twice under two findings. Check: before finalizing, diff the full proposed index list against the existing index inventory and against itself; any left-prefix match must be removed or justified explicitly.
- **Fabricating scale to justify severity.** You assign P1 because a table is "probably huge" without row counts. Check: grep your own report for size or timing claims and confirm each traces to a supplied number; otherwise restate the finding conditionally ("P1 if `events` exceeds ~1M rows; run `SELECT count(*) FROM events`").
- **Untested destructive DDL.** A drop or type change ships without a verification step and loses data. Check: every statement containing `DROP`, `ALTER ... TYPE`, or `TRUNCATE` must carry the `REVIEW-BEFORE-RUN` marker and a preceding verification query.

## License

MIT