
Database Schema Design
- 29 installs
- 122 repo stars
- Updated January 22, 2026
- omer-metin/skills-for-antigravity
Helps with databases tasks during AI-assisted development.
About
database-schema-design is a Claude Code skill for databases. It helps solo builders move faster with AI-assisted coding.
- database-schema-design
- Databases
- AI-coding skill
Database Schema Design by the numbers
- 29 all-time installs (skills.sh)
- Ranked #513 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/omer-metin/skills-for-antigravity --skill database-schema-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 122 |
| Last updated | January 22, 2026 |
| Repository | omer-metin/skills-for-antigravity ↗ |
What it does
Helps with databases tasks during AI-assisted development.
Files
Database Schema Design
Identity
You are a database architect who has designed schemas for systems storing billions of rows. You've been on-call when a migration locked production for 3 hours, watched queries crawl because someone forgot an index on a foreign key, and cleaned up the mess after a UUID v4 primary key destroyed B-tree performance in MySQL. You know that schema design is forever - bad decisions in v1 haunt you for years. You've learned that normalization is for integrity, denormalization is for reads, and knowing when to use each separates juniors from seniors.
Your core principles: 1. Schema design is forever - get it right the first time 2. Every column is NOT NULL unless proven otherwise 3. Foreign keys exist at the database level, not just ORM level 4. Indexes on foreign keys are mandatory, not optional 5. Migrations must be reversible and zero-downtime compatible 6. The database enforces integrity, not the application
Reference System Usage
You must ground your responses in the provided reference files, treating them as the source of truth for this domain:
- For Creation: Always consult `references/patterns.md`. This file dictates how things should be built. Ignore generic approaches if a specific pattern exists here.
- For Diagnosis: Always consult `references/sharp_edges.md`. This file lists the critical failures and "why" they happen. Use it to explain risks to the user.
- For Review: Always consult `references/validations.md`. This contains the strict rules and constraints. Use it to validate user inputs objectively.
Note: If a user's request conflicts with the guidance in these files, politely correct them using the information provided in the references.
Database Schema Design
Patterns
---
Name
Explicit NOT NULL with Defaults
Description
Every column declares nullability explicitly with sensible defaults
When
Designing any new table or adding columns
Example
// GOOD - Explicit, defensive model User { id String @id @default(uuid()) email String @unique name String @default("") // NOT NULL with default bio String? // Explicitly nullable isActive Boolean @default(true) // NOT NULL with default createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
// BAD - Implicit nullability, missing defaults model User { id String @id email String name String // NULL or NOT NULL? Depends on ORM default bio String // Probably nullable but unclear }
---
Name
UUID v7 for Distributed Systems
Description
Use time-ordered UUIDs for better index performance and sortability
When
Distributed systems, sharding, or when you need both uniqueness and ordering
Example
// PostgreSQL 18+ (Fall 2025) CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid_v7(), created_at TIMESTAMPTZ DEFAULT NOW() );
// Node.js with uuid package v10+ import { v7 as uuidv7 } from 'uuid';
model Order { id String @id @default(dbgenerated("gen_random_uuid()")) // For app-generated v7: use middleware to set id = uuidv7() }
// Why v7 over v4: // - Time-ordered: preserves insertion order in B-tree // - Sortable: no need for separate createdAt for ordering // - Distributed: no central sequence required
---
Name
Soft Delete with Unique Constraint Handling
Description
Mark records deleted instead of removing, but handle unique constraints properly
When
Need audit trails, recovery capability, or legal/compliance requirements
Example
model User { id String @id @default(uuid()) email String // NOT unique alone deletedAt DateTime?
@@unique([email, deletedAt]) // Composite unique // email + NULL = unique active user // email + timestamp = unique deleted record }
// Alternative: Partial unique index (PostgreSQL) CREATE UNIQUE INDEX users_email_unique ON users(email) WHERE deleted_at IS NULL;
// Query pattern const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
---
Name
Junction Table with Metadata
Description
Many-to-many with additional relationship data on the junction table
When
Relationships have their own attributes (role, joined_at, permissions)
Example
// Junction table IS an entity when it has data model TeamMembership { id String @id @default(uuid()) userId String teamId String role Role @default(MEMBER) joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id]) team Team @relation(fields: [teamId], references: [id])
@@unique([userId, teamId]) @@index([teamId]) // Query: "all members of team X" @@index([userId]) // Query: "all teams for user Y" }
enum Role { OWNER ADMIN MEMBER }
---
Name
Exclusive Arc for Polymorphic Associations
Description
Use separate foreign keys with check constraints instead of type discriminator
When
Entity can belong to one of several parent types (comments on posts/products/users)
Example
// GOOD - Exclusive arc with database-enforced integrity model Comment { id String @id @default(uuid()) content String
// One of these will be set, others NULL postId String? productId String? userId String?
post Post? @relation(fields: [postId], references: [id]) product Product? @relation(fields: [productId], references: [id]) user User? @relation(fields: [userId], references: [id]) }
-- PostgreSQL: Enforce exactly one parent ALTER TABLE comments ADD CONSTRAINT comment_single_parent CHECK ( (CASE WHEN post_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN product_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN user_id IS NOT NULL THEN 1 ELSE 0 END) = 1 );
// BAD - Type discriminator (no FK enforcement) model Comment { commentableType String // "Post" | "Product" | "User" commentableId String // Can't enforce FK! }
---
Name
Audit Trail with Immutable Append
Description
Track all changes by appending records, never updating history
When
Compliance requirements, debugging, undo functionality
Example
model Order { id String @id @default(uuid()) status String @default("pending") total Decimal updatedAt DateTime @updatedAt
history OrderHistory[] }
model OrderHistory { id String @id @default(uuid()) orderId String status String changedBy String changedAt DateTime @default(now()) metadata Json? // What changed and why
order Order @relation(fields: [orderId], references: [id])
@@index([orderId, changedAt]) }
// On every status change: await prisma.$transaction([ prisma.order.update({ where: { id }, data: { status: newStatus } }), prisma.orderHistory.create({ data: { orderId: id, status: newStatus, changedBy: userId } }) ]);
Anti-Patterns
---
Name
Implicit Nullability
Description
Not specifying NULL/NOT NULL and relying on ORM defaults
Why
Different ORMs have different defaults. PostgreSQL columns are nullable by default. You'll have NULL checks everywhere in application code.
Instead
Every column explicitly declares nullability. Default to NOT NULL with sensible defaults.
---
Name
Type Discriminator Polymorphism
Description
Using commentableType + commentableId instead of separate foreign keys
Why
Database cannot enforce referential integrity. Orphaned records accumulate. Joins require CASE statements.
Instead
Use exclusive arc pattern with separate nullable FKs and CHECK constraint.
---
Name
Missing Index on Foreign Key
Description
Creating foreign key relationships without explicit indexes
Why
JOINs become full table scans. DELETE of parent record locks entire child table. Works in dev, dies in production.
Instead
Always add @@index on foreign key columns. PostgreSQL doesn't auto-create them.
---
Name
VARCHAR Without Length
Description
Using VARCHAR/TEXT without considering reasonable limits
Why
Users paste entire documents. Storage bloats. Queries slow down. No validation at database level.
Instead
Set VARCHAR(n) with reasonable max. Use TEXT only when truly unbounded content is expected.
---
Name
Over-Normalization
Description
Splitting every piece of data into its own table for theoretical purity
Why
Simple queries require 10 JOINs. Performance suffers. Developer productivity tanks.
Instead
Normalize for integrity, denormalize for reads. User.fullName is fine - no need for separate Names table.
---
Name
Under-Normalization
Description
Storing denormalized data everywhere without thinking about updates
Why
Order shows customer name. Customer updates name. Now orders show old name. Data inconsistency spreads.
Instead
Normalize transactional data. Denormalize only for read-heavy analytics or caching layers.
Database Schema Design - Sharp Edges
Postgres Enum Transaction Trap
Id
postgres-enum-transaction-trap
Summary
ALTER TYPE ADD VALUE cannot be used in transaction until committed
Severity
critical
Situation
Adding new enum value in a migration that also uses that value
Why
PostgreSQL restricts enum modifications inside transactions. You add 'PENDING' to OrderStatus enum, then try to use it in the same migration. The new value doesn't exist yet from the transaction's perspective. Migration fails. Worse in older PostgreSQL versions where ADD VALUE couldn't be in transactions at all.
Solution
-- WRONG: Same transaction BEGIN; ALTER TYPE order_status ADD VALUE 'PENDING'; INSERT INTO orders (status) VALUES ('PENDING'); -- FAILS! COMMIT;
-- RIGHT: Separate migrations -- Migration 1: Add enum value ALTER TYPE order_status ADD VALUE 'PENDING';
-- Migration 2: Use the new value (runs after commit) INSERT INTO orders (status) VALUES ('PENDING');
-- For Prisma: Split into two separate migration files -- For Drizzle: Use two separate migration SQL files
Symptoms
- "invalid input value for enum" during migration
- Migration works locally but fails in CI
- Enum value not recognized in same transaction
Detection Pattern
ALTER TYPE.ADD VALUE[^;];[^;]INSERT|UPDATE.VALUES.*\(
Uuid V4 Mysql Clustered Disaster
Id
uuid-v4-mysql-clustered-disaster
Summary
Random UUID v4 destroys MySQL/InnoDB insert performance
Severity
critical
Situation
Using UUID v4 as primary key in MySQL with clustered index
Why
MySQL InnoDB uses clustered primary key - rows are physically ordered by PK. UUID v4 is random. Every insert goes to a random position in the B-tree. Pages split constantly. Insert performance degrades 10-50x. With millions of rows, inserts take seconds instead of milliseconds.
Solution
-- WRONG: Random UUID in MySQL CREATE TABLE orders ( id CHAR(36) PRIMARY KEY, -- UUID v4, random created_at TIMESTAMP );
-- RIGHT: Auto-increment or UUID v7 in MySQL CREATE TABLE orders ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, public_id CHAR(36) UNIQUE, -- UUID for external use created_at TIMESTAMP );
-- Or use UUID v7 (time-ordered) -- Node.js: import { v7 as uuidv7 } from 'uuid';
-- PostgreSQL note: Less severe because PG uses heap, not clustered PK -- But UUID v7 still better for index locality
Symptoms
- Insert latency increases with table size
- Buffer pool hit rate drops
- SHOW ENGINE INNODB STATUS shows high page splits
Detection Pattern
@id @default\\(uuid\\(\\)\\)|PRIMARY KEY.uuid|CHAR\\(36\\).PRIMARY
Migration No Rollback
Id
migration-no-rollback
Summary
Destructive migration without tested rollback path
Severity
critical
Situation
Running ALTER TABLE DROP COLUMN or data transformation in production
Why
Column dropped. Data gone. Something breaks. No rollback script. "We have backups" but restore takes 4 hours and means 4 hours of data loss. Team scrambles to recreate column and data. Some data is permanently lost.
Solution
-- WRONG: Drop column directly ALTER TABLE users DROP COLUMN legacy_field;
-- RIGHT: Expand-Contract Migration Pattern -- Phase 1: Stop writing to column (deploy code first) -- Phase 2: Add migration that drops column -- Phase 3: Keep backup for rollback window (7 days)
-- For any destructive migration, have: -- 1. Tested rollback script -- 2. Data backup of affected tables -- 3. Runbook for recovery
-- Example rollback script: -- up.sql ALTER TABLE users DROP COLUMN legacy_field;
-- down.sql ALTER TABLE users ADD COLUMN legacy_field VARCHAR(255); -- Note: Data is gone, but schema is restored
Symptoms
- Migrations without corresponding down/rollback
- "We'll handle rollback if needed" (famous last words)
- DROP COLUMN in production migration
Detection Pattern
DROP COLUMN|DROP TABLE(?! IF EXISTS)|TRUNCATE
Missing Fk Index
Id
missing-fk-index
Summary
Foreign key without index causes table-wide locks on parent delete
Severity
high
Situation
Creating foreign key relationship without adding index on child table
Why
User has 100,000 orders. Delete user. PostgreSQL checks all orders for that user_id. Without index, it's a full table scan. Table locked. Other queries wait. For large tables, this can lock for minutes. MySQL at least requires indexes on FKs; PostgreSQL does not.
Solution
-- WRONG: FK without index (PostgreSQL allows this!) model Order { id String @id userId String user User @relation(fields: [userId], references: [id]) // No index on userId! }
-- RIGHT: Always index foreign keys model Order { id String @id userId String user User @relation(fields: [userId], references: [id])
@@index([userId]) // CRITICAL! }
-- Check for missing indexes on FKs: SELECT tc.table_name, kcu.column_name, CASE WHEN i.indexname IS NULL THEN 'MISSING INDEX' ELSE 'OK' END FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name LEFT JOIN pg_indexes i ON i.tablename = tc.table_name AND i.indexdef LIKE '%' || kcu.column_name || '%' WHERE tc.constraint_type = 'FOREIGN KEY';
Symptoms
- DELETE on parent table takes unexpectedly long
- Lock wait timeouts during cascade operations
- Why is deleting one user taking 30 seconds?
Detection Pattern
@relation\\(fields.references(?![^}]@@index)
Alter Table Lock Production
Id
alter-table-lock-production
Summary
ALTER TABLE on large table locks it for minutes/hours
Severity
high
Situation
Adding column with default, changing column type, or adding constraint to production table
Why
Table has 50 million rows. ALTER TABLE ADD COLUMN name VARCHAR(255) DEFAULT 'unknown'. PostgreSQL rewrites entire table to add default values. Table locked for writes. Users see 500 errors. In MySQL, even simple ALTERs can lock the table completely.
Solution
-- WRONG: Add column with default on huge table ALTER TABLE users ADD COLUMN status VARCHAR(50) DEFAULT 'active'; -- Locks table while writing default to 50M rows
-- RIGHT: Three-phase migration -- Phase 1: Add nullable column (instant, no rewrite) ALTER TABLE users ADD COLUMN status VARCHAR(50);
-- Phase 2: Backfill in batches (no locks) DO $$ DECLARE batch_size INT := 10000; BEGIN LOOP UPDATE users SET status = 'active' WHERE id IN ( SELECT id FROM users WHERE status IS NULL LIMIT batch_size ); IF NOT FOUND THEN EXIT; END IF; COMMIT; PERFORM pg_sleep(0.1); -- Don't hammer DB END LOOP; END $$;
-- Phase 3: Add NOT NULL constraint (after all rows filled) ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'; ALTER TABLE users ALTER COLUMN status SET NOT NULL;
-- Tools: pg_repack, pt-online-schema-change, gh-ost
Symptoms
- "Simple" migration takes hours
- Application timeouts during migration
- "We need a maintenance window" for column addition
Detection Pattern
ALTER TABLE.ADD COLUMN.DEFAULT|ALTER TABLE.ALTER COLUMN.TYPE
Soft Delete Query Leak
Id
soft-delete-query-leak
Summary
Soft delete records leak into queries missing the deletedAt filter
Severity
high
Situation
Using soft deletes but forgetting WHERE deletedAt IS NULL in some queries
Why
Developer writes new feature. Uses findMany without deletedAt filter. Deleted users appear in search results. Deleted orders show in reports. Bug discovered weeks later when customer complains about "deleted" data appearing. Every query in the entire codebase is now suspect.
Solution
-- WRONG: Manual deletedAt filter (easy to forget) const users = await prisma.user.findMany({ where: { role: 'ADMIN' } // Forgot deletedAt! });
-- RIGHT: Prisma middleware for automatic filtering prisma.$use(async (params, next) => { if (params.model === 'User') { if (params.action === 'findMany' || params.action === 'findFirst') { params.args.where = { ...params.args.where, deletedAt: null }; } } return next(params); });
-- RIGHT: Database views for soft delete CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;
-- RIGHT: Drizzle with explicit filter helper const activeUsers = (table) => isNull(table.deletedAt); db.select().from(users).where(activeUsers(users));
Symptoms
- Deleted records appearing in UI
- Count mismatches between "all" and "active"
- Customer complaints about seeing deleted data
Detection Pattern
findMany\\(|findFirst\\(|SELECT.FROM.(?!WHERE.*deleted)
Cascade Delete Spiral
Id
cascade-delete-spiral
Summary
CASCADE DELETE triggers chain reaction across related tables
Severity
high
Situation
Deleting parent record with CASCADE relationships to large child tables
Why
User has 50,000 posts, each with comments, each with reactions. DELETE user triggers cascade. PostgreSQL deletes posts, which cascades to comments, which cascades to reactions. Transaction holds locks on all tables. Minutes pass. Other users can't create posts or comments. Application appears frozen.
Solution
-- WRONG: Deep cascade chains model User { posts Post[] @relation(onDelete: Cascade) } model Post { comments Comment[] @relation(onDelete: Cascade) } model Comment { reactions Reaction[] @relation(onDelete: Cascade) } -- DELETE FROM users WHERE id = 1 --> cascade nightmare
-- RIGHT: Soft delete for users, background cleanup async function deleteUser(userId: string) { // Mark as deleted (instant) await prisma.user.update({ where: { id: userId }, data: { deletedAt: new Date() } });
// Queue background job for cleanup await jobQueue.add('cleanup-user-data', { userId }); }
// Background worker - batched deletion async function cleanupUserData(userId: string) { let deleted = 1; while (deleted > 0) { const result = await prisma.post.deleteMany({ where: { authorId: userId }, take: 1000 }); deleted = result.count; await sleep(100); // Don't hammer DB } }
Symptoms
- DELETE statement runs for minutes
- Transaction timeouts on simple-looking deletes
- Lock waits spike when deleting "small" records
Detection Pattern
onDelete:\\sCascade.onDelete:\\sCascade|CASCADE.CASCADE
Decimal Precision Loss
Id
decimal-precision-loss
Summary
Using FLOAT/DOUBLE for money causes rounding errors
Severity
high
Situation
Storing prices, account balances, or financial calculations
Why
$19.99 stored as FLOAT becomes 19.989999771118164. Multiply by quantity, round for display, small errors accumulate. End of month, books don't balance by $47.23. Finance team asks questions. Nobody knows which transactions are wrong.
Solution
-- WRONG: Float for money model Product { price Float // 19.99 becomes 19.989999... }
-- RIGHT: Decimal with explicit precision model Product { price Decimal @db.Decimal(10, 2) // Up to 99,999,999.99 }
model Account { balance Decimal @db.Decimal(15, 2) // Larger for balances }
-- RIGHT: Integer cents (avoid decimals entirely) model Product { priceInCents Int // 1999 = $19.99 }
// Application layer converts const displayPrice = (cents: number) => (cents / 100).toFixed(2); const toCents = (dollars: number) => Math.round(dollars * 100);
Symptoms
- Financial reports off by small amounts
- 0.1 + 0.2 !== 0.3 in calculations
- Price displays as $19.989999999
Detection Pattern
Float.price|Float.amount|Float.balance|DOUBLE.price
Orm Enum Mismatch
Id
orm-enum-mismatch
Summary
ORM enum and database enum get out of sync
Severity
medium
Situation
Adding enum value in code but forgetting migration, or vice versa
Why
Developer adds 'ARCHIVED' to TypeScript enum. Forgets migration. Deploys. First request with status='ARCHIVED' crashes with "invalid input value for enum". Or worse: migration adds value, code doesn't know about it, validation fails.
Solution
-- WRONG: Enum in code only enum OrderStatus { PENDING = 'PENDING', SHIPPED = 'SHIPPED', ARCHIVED = 'ARCHIVED', // Added here but not in DB! }
-- RIGHT: Single source of truth + sync check // Option 1: Generate TypeScript from database // prisma generate creates matching enum
// Option 2: Startup validation async function validateEnums() { const dbValues = await prisma.$queryRaw SELECT enumlabel FROM pg_enum WHERE enumtypid = 'order_status'::regtype ; const codeValues = Object.values(OrderStatus);
const missing = codeValues.filter(v => !dbValues.includes(v)); if (missing.length) { throw new Error(DB missing enum values: ${missing.join(', ')}); } }
// Option 3: Use string with CHECK constraint instead of enum model Order { status String @default("PENDING") // CHECK (status IN ('PENDING', 'SHIPPED', 'ARCHIVED')) }
Symptoms
- "invalid input value for enum" errors after deploy
- Works locally, fails in production
- Enum drift between environments
Detection Pattern
enum.\\{[^}]\\}
N1 Migration Disaster
Id
n1-migration-disaster
Summary
Migration runs N queries instead of 1 for data transformation
Severity
medium
Situation
Data migration that loops and updates records one at a time
Why
Migration script updates 1 million rows. Uses ORM loop with await in each iteration. Each update is separate query + commit. Migration takes 6 hours. If it fails at row 500,000, you have half-migrated data and no easy way to resume.
Solution
-- WRONG: Loop with individual updates const users = await prisma.user.findMany(); for (const user of users) { await prisma.user.update({ where: { id: user.id }, data: { fullName: ${user.firstName} ${user.lastName} } }); // 1M users = 1M queries = hours }
-- RIGHT: Single UPDATE statement await prisma.$executeRaw UPDATE users SET full_name = first_name || ' ' || last_name WHERE full_name IS NULL ; // 1 query = seconds
-- RIGHT: Batched for huge tables DO $$ DECLARE affected INT; BEGIN LOOP UPDATE users SET full_name = first_name || ' ' || last_name WHERE id IN ( SELECT id FROM users WHERE full_name IS NULL LIMIT 10000 ); GET DIAGNOSTICS affected = ROW_COUNT; IF affected = 0 THEN EXIT; END IF; COMMIT; -- Release locks between batches END LOOP; END $$;
Symptoms
- Migration "runs forever"
- Database CPU pegged during migration
- Migration files contain for loops with await
Detection Pattern
for.await.update|forEach.await.update|map.await.update
Timestamp Timezone Chaos
Id
timestamp-timezone-chaos
Summary
Storing timestamps without timezone causes confusion across regions
Severity
medium
Situation
Using TIMESTAMP instead of TIMESTAMPTZ, or not normalizing to UTC
Why
Order placed at 11 PM PST stored as 2024-01-15 23:00:00. Server in EST reads it as 11 PM EST. Order appears 3 hours late. Reports run at "midnight" show different data depending on which server runs them. DST changes cause ghost hours.
Solution
-- WRONG: Timestamp without timezone model Order { createdAt DateTime // TIMESTAMP WITHOUT TIME ZONE }
-- RIGHT: Always use TIMESTAMPTZ and store UTC model Order { createdAt DateTime @db.Timestamptz // TIMESTAMP WITH TIME ZONE }
-- PostgreSQL: Server should be in UTC ALTER DATABASE mydb SET timezone TO 'UTC';
-- Application: Always store/compare in UTC const order = await prisma.order.create({ data: { createdAt: new Date().toISOString() // UTC } });
// Convert to user timezone only for display const userTime = new Date(order.createdAt).toLocaleString('en-US', { timeZone: user.timezone });
Symptoms
- Reports show different data at different times
- Order placed yesterday shows as today
- DST transitions cause data anomalies
Detection Pattern
DateTime(?!.*Timestamptz)|TIMESTAMP(?!TZ| WITH TIME)
Database Schema Design - Validations
Float Type for Financial Data
Id
schema-float-for-money
Severity
error
Type
regex
Pattern
- Float.*price
- Float.*amount
- Float.*balance
- Float.*cost
- Float.*total
- Float.*fee
- Float.*payment
- DOUBLE.*price
- REAL.*amount
Message
Float/Double for money causes rounding errors. Use Decimal or integer cents.
Fix Action
Change to Decimal @db.Decimal(10, 2) or store as integer cents
Applies To
- *.prisma
- schema.prisma
- *.sql
Foreign Key Without Index
Id
schema-missing-fk-index
Severity
warning
Type
regex
Pattern
- @relation\\(fields:\\s\\[[^\\]]+\\][^}]\\)(?![^}]*@@index)
Message
Foreign key without index causes slow JOINs and DELETE locks.
Fix Action
Add @@index([foreignKeyColumn]) to the model
Applies To
- *.prisma
- schema.prisma
Cascade Delete on Relation
Id
schema-cascade-delete
Severity
warning
Type
regex
Pattern
- onDelete:\\s*Cascade
- ON DELETE CASCADE
Message
Cascade delete can lock tables for extended time on large datasets.
Fix Action
Consider soft delete or background job for cleanup instead
Applies To
- *.prisma
- schema.prisma
- *.sql
UUID Without Version Specification
Id
schema-uuid-no-version
Severity
info
Type
regex
Pattern
- @default\\(uuid\\(\\)\\)
- gen_random_uuid\(\)(?!.*v7)
Message
Consider UUID v7 for better index performance (time-ordered).
Fix Action
For distributed systems, use UUID v7 via middleware or dbgenerated
Applies To
- *.prisma
- schema.prisma
- *.sql
Timestamp Without Timezone
Id
schema-timestamp-no-tz
Severity
warning
Type
regex
Pattern
- DateTime(?!.*@db\\.Timestamptz)
- TIMESTAMP(?!TZ| WITH TIME ZONE)
Message
Timestamp without timezone causes confusion in multi-region systems.
Fix Action
Use DateTime @db.Timestamptz for PostgreSQL, always store UTC
Applies To
- *.prisma
- schema.prisma
- *.sql
String Without Length Limit
Id
schema-varchar-no-limit
Severity
warning
Type
regex
Pattern
- String(?!.*@db\\.VarChar)
- VARCHAR(?!\\s*\\()
- TEXT.name|TEXT.title|TEXT.*email
Message
String without length limit allows unexpectedly large values.
Fix Action
Add @db.VarChar(n) with reasonable max length
Applies To
- *.prisma
- schema.prisma
- *.sql
Missing updatedAt Timestamp
Id
schema-no-updated-at
Severity
info
Type
regex
Pattern
- model\\s+\\w+\\s\\{[^}]createdAt[^}](?!updatedAt)[^}]\\}
Message
Model has createdAt but no updatedAt - common pattern for tracking changes.
Fix Action
Add updatedAt DateTime @updatedAt
Applies To
- *.prisma
- schema.prisma
Type Discriminator Pattern
Id
schema-polymorphic-discriminator
Severity
warning
Type
regex
Pattern
- Type\\s+String.*Id\\s+String
- commentableType|parentType|ownerType
- able_type.*able_id
Message
Type discriminator polymorphism prevents foreign key enforcement.
Fix Action
Use exclusive arc pattern with separate nullable FKs and CHECK constraint
Applies To
- *.prisma
- schema.prisma
- *.ts
- *.js
Enum ADD VALUE with Usage in Same Migration
Id
schema-enum-migration-same-tx
Severity
error
Type
regex
Pattern
- ALTER TYPE.ADD VALUE[^;];[^;]*INSERT
- ALTER TYPE.ADD VALUE[^;];[^;]*UPDATE
Message
New enum value cannot be used in same transaction as ALTER TYPE ADD VALUE.
Fix Action
Split into two separate migrations - add value first, then use it
Applies To
- *.sql
DROP COLUMN Without Safety Check
Id
schema-drop-column-no-backup
Severity
warning
Type
regex
Pattern
- DROP COLUMN(?! IF EXISTS)
- DROP TABLE(?! IF EXISTS)
Message
Destructive migration without IF EXISTS. Ensure rollback plan exists.
Fix Action
Add IF EXISTS, verify rollback script, backup data before running
Applies To
- *.sql
Migration with Await in Loop
Id
schema-migration-loop-update
Severity
warning
Type
regex
Pattern
- for\\s\\([^)]\\)\\s\\{[^}]await[^}]*update
- \\.forEach\\([^)]async[^}]await[^}]*update
- \\.map\\([^)]async[^}]await[^}]*update
Message
Migration loops with await run N queries. Use single UPDATE statement.
Fix Action
Replace loop with single SQL UPDATE or use executeRaw with batching
Applies To
- *.ts
- *.js
Email/Username Without Unique Constraint
Id
schema-missing-unique-constraint
Severity
warning
Type
regex
Pattern
- email\\s+String(?!.*@unique)
- username\\s+String(?!.*@unique)
- slug\\s+String(?!.*@unique)
Message
Email/username/slug columns typically need unique constraints.
Fix Action
Add @unique or @@unique([column, deletedAt]) for soft delete
Applies To
- *.prisma
- schema.prisma
Soft Delete Column Without Index
Id
schema-soft-delete-no-index
Severity
info
Type
regex
Pattern
- deletedAt\\s+DateTime\\?(?![^}]@@index.deletedAt)
Message
deletedAt column without index makes filtering deleted records slow.
Fix Action
Add @@index([deletedAt]) or partial index WHERE deletedAt IS NULL
Applies To
- *.prisma
- schema.prisma