I've spent the better part of a decade debugging production databases. The pattern is always the same: a decision that seemed fine during development becomes a bottleneck, a data integrity bug, or a migration nightmare at scale. Here are five mistakes I see over and over — and how to avoid each one.
1. Skipping indexes on foreign keys
This is the most common performance mistake in relational databases. You create a userId column on the orders table, add a foreign key constraint, and move on. The problem? Foreign key constraints don't automatically create indexes in most databases.
Without an index, every JOIN on that foreign key triggers a sequential scan. With 10,000 rows, you won't notice. With 10 million, your API response times go from 50ms to 5 seconds.
-- The table definition
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
total DECIMAL(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- This index is critical — don't skip it
CREATE INDEX idx_orders_user_id ON orders(user_id);How PicaDeck helps: When you draw a relationship between two tables on the canvas, PicaDeck automatically tracks the foreign key column. The export engine includes the appropriate index in every generated migration.
2. Using implicit many-to-many without a join table
Some ORMs let you define many-to-many relationships without explicitly creating a join table. The ORM generates a table like _PostToTag with two foreign key columns and nothing else. This works fine until you need to store metadata on the relationship: when was the tag added? Who added it? What's the sort order?
At that point, you're stuck. You need to replace the implicit join table with an explicit one, rewrite every query that touches the relationship, and migrate the existing data. I've seen this refactor take a team two full sprints.
Rule of thumb
Always create an explicit join table for many-to-many relationships. Even if you don't need metadata today, add at minimum a createdAt timestamp. Future you will be grateful.
3. Storing denormalized data without a sync strategy
Denormalization is a valid performance optimization. Storing a orderCount directly on the users table avoids an expensive COUNT(*) query. The mistake isn't denormalizing — it's denormalizing without a clear strategy for keeping the cached value in sync.
Without triggers, application-level hooks, or a background reconciliation job, your denormalized data will inevitably drift from the source of truth. Users will see "3 orders" in their profile while the orders page shows 5. Support tickets pile up. Trust erodes.
"Every piece of denormalized data is a promise that you'll keep it in sync. Before you make that promise, make sure you can keep it."
4. Not planning for soft deletes from the start
Hard deleting rows seems clean until you realize your application has cascading foreign keys. Deleting a user cascades to their orders, which cascades to order items, which cascades to inventory adjustments. One DELETE statement wipes out an audit trail that compliance needs to retain for seven years.
Soft deletes (an isActive boolean or a deletedAt timestamp) solve this, but retrofitting soft deletes onto an existing schema is painful. Every query needs a WHERE isActive = true filter. Every unique constraint needs to account for soft-deleted rows. Every JOIN needs to exclude inactive records.
-- Add from the start, not as a retrofit
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Partial unique index: email must be unique
-- among active users only
CREATE UNIQUE INDEX idx_users_email_active
ON users(email)
WHERE is_active = true;5. Choosing data types too loosely
Using TEXT for everything is easy during development, but it pushes validation entirely to the application layer. If you store a price as TEXT, nothing prevents "not a number" from ending up in your database. If you use INTEGER for a Unix timestamp instead of TIMESTAMPTZ, you lose timezone handling and every time-based query becomes a conversion exercise.
Loose types (avoid)
TEXTfor prices and amountsVARCHAR(255)for everythingINTEGERfor timestampsTEXTfor enums
Precise types (prefer)
DECIMAL(10,2)for currencyVARCHAR(n)with actual limitsTIMESTAMPTZfor time dataENUMor check constraints
How PicaDeck helps: The column editor shows you every data type available for your target database, with descriptions and constraints. When you switch between PostgreSQL and MySQL, PicaDeck maps types automatically — so TIMESTAMPTZ becomes DATETIME without manual conversion.
Prevention beats remediation
Every one of these mistakes is cheap to prevent and expensive to fix. The common thread is that they're all decisions made quickly during initial development that become structural constraints as the product matures.
The best defense is to make schema design a deliberate, reviewed process — not something that happens implicitly through migration files. Whether you use PicaDeck or a whiteboard, take the time to think through your data model before writing the first line of application code.
Design your schema visually and catch mistakes before they ship. Try PicaDeck free →