An unindexed email column means every login attempt performs a full table scan. An unindexed FK column means every JOIN on that relationship scans the entire child table. At 10,000 rows this is slow; at 100,000 rows it causes timeouts. Postgres does not automatically index FK columns — unlike MySQL — so every *_id column added by an ORM is unindexed until explicitly declared. ISO 25010 time-behaviour directly captures this: query latency degrades predictably as data volume grows, and the degradation is sharp rather than gradual when no index exists. Missed indexes on status or created_at columns produce full scans on the most common listing and filtering queries in the application.
High because missing indexes cause full table scans on every query of the affected column, producing latency that grows linearly with row count and degrades user experience at scale.
Add @@index in Prisma or CREATE INDEX in SQL for every FK column, high-cardinality filter column (email, slug, external_id), and sort column (created_at) used in listing endpoints.
model Order {
id String @id @default(uuid())
userId String
status String
@@index([userId])
@@index([userId, status])
}
-- Add indexes without locking the table on Postgres
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
CREATE INDEX CONCURRENTLY idx_posts_created_at ON posts(created_at DESC);
For composite queries that filter on two columns together, a composite index beats two single-column indexes — verify query plans with EXPLAIN ANALYZE before and after.
ID: database-design-operations.schema-design.indexes-on-queries
Severity: high
What to look for: First, enumerate every column that appears in a WHERE, JOIN, or ORDER BY clause by reading API route files, service files, and repository classes. Identify the most common query patterns by reading API route files, service files, and repository classes. Note every column that appears in a WHERE clause, JOIN condition, or ORDER BY. Then examine the schema for indexes. In Prisma, look for @@index([...]) and @unique decorators. In Drizzle, look for .index() and .unique() on columns. In raw SQL migrations, look for CREATE INDEX. Cross-reference: columns used frequently in queries should have indexes. Common missed indexes: email column on users table (used in login lookups), created_at on time-ordered tables (used in sorted listing), all foreign key columns (Postgres does NOT automatically index FK columns unlike MySQL), status or state columns used in filtered listings. Also check for missing composite indexes where queries filter on multiple columns together.
Pass criteria: Columns that appear in WHERE/JOIN/ORDER BY clauses in the most common query paths have indexes — at least 90% of FK columns are indexed. All FK columns are indexed. High-cardinality filter columns (email, external_id, slug) are indexed. Indexes are not redundant (e.g., a unique constraint already provides an index).
Fail criteria: Commonly queried columns lack indexes. FK columns are unindexed (a full table scan on every JOIN). Login flow queries a non-indexed email column. Listing endpoints sort by created_at with no index.
Skip (N/A) when: N/A — always applicable for any project with a database (even SQLite benefits from indexes on large-ish tables).
Detail on fail: Specify which columns are missing indexes and the queries that need them. Example: "'users.email' has no index — used in login query WHERE email = $1 without index support." or "'orders.user_id' FK column has no index — JOIN queries will full-scan the orders table." or "'posts.created_at' has no index — listing endpoint ORDER BY created_at DESC will sort without index.".
Remediation: Add indexes for common query patterns:
// Prisma — index on email for login lookup
model User {
id String @id @default(uuid())
email String @unique // @unique implies an index
createdAt DateTime @default(now())
@@index([createdAt]) // for time-ordered listing
}
// Prisma — index on FK column
model Order {
id String @id @default(uuid())
userId String
status String
@@index([userId]) // index the FK
@@index([userId, status]) // composite index if filtering by both
}
-- Raw SQL — add missing indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
CREATE INDEX idx_posts_status_created ON posts(status, created_at DESC);
When adding indexes to an existing production table with many rows, use CREATE INDEX CONCURRENTLY in Postgres to avoid locking: CREATE INDEX CONCURRENTLY idx_users_email ON users(email);