No indexes on the columns you actually query
This is the single most common database problem we see, and also the easiest to fix. A table with no indexes works fine when it has 100 rows. Every query, even a bad one, comes back instantly because scanning 100 rows takes no time at all.
The same table at 100,000 rows is a different story. Without an index, the database has to scan every single row to find the ones that match your query. A lookup that took a few milliseconds now takes seconds. Multiply that by every user hitting the page at once, and a slow query becomes a slow app, or a timed-out one.
The fix is usually straightforward. Look at the columns your application filters, sorts, or joins on most often, things like user_id, email, status, or created_at, and add indexes there. Most databases include a query planner or a slow-query log that will point directly at the queries costing you the most time.
- Index foreign key columns used in joins.
- Index columns used in
WHEREclauses on large tables. - Index columns used to sort results, especially with pagination.
- Don't over-index. Every index speeds up reads but slows down writes, so add them where the query patterns actually justify it.
It's worth running EXPLAIN on your slowest or most frequent queries every few months, not just once at launch. Query patterns change as features get added, and a column that never needed an index in month one can easily need one by month six. Composite indexes, covering more than one column, are also worth learning early if your app regularly filters on two or three fields together, since a single-column index won't help much there.
Storing structured data as one big text/JSON blob
Dumping everything into a single flexible JSON column feels fast to build early on. You skip migrations, you skip deciding on a schema, and you can add new fields whenever you want without touching the database. It's a genuinely appealing shortcut in the first few weeks of a product.
The problem shows up the first time you need to actually query that data in a specific way. Filtering by a field buried in a JSON blob is slower and clumsier than filtering by a real column. Reporting on it means writing awkward extraction logic instead of a simple GROUP BY. Enforcing that a field is required, or that it's a number and not a string, becomes something your application has to police instead of something the database guarantees.
None of this matters if the data really is unstructured, like a settings object nobody ever filters on. It matters a lot once that "flexible" field turns out to be something you filter, sort, or report on every single day.
- Structured fields you regularly query belong in real columns.
- JSON columns are fine for genuinely variable, rarely-queried data.
- If you're already writing code to parse a JSON field on every read, that's a strong signal it should be a column instead.
A common middle ground works well in practice: keep the fields you know you'll query, report on, or validate as real columns, and reserve a single JSON column for the genuinely optional, rarely-touched extras. That gives you the flexibility that made JSON appealing in the first place, without giving up the indexing and integrity guarantees on the data that actually drives your product.
No foreign key constraints, "we'll enforce it in the application"
Relying entirely on application code to keep related data consistent works right up until it doesn't. Every insert and update has to remember to check that the related record exists. That's fine when one person is writing the code. It gets fragile fast once there are multiple developers, a background job, an admin script, and someone doing a manual database edit at 2am, all touching the same tables.
It only takes one of those paths skipping the check to create an orphaned record: an order pointing to a customer that no longer exists, a comment attached to a deleted post. Individually these are small bugs. Collectively they're the reason a "simple" data cleanup script turns into a multi-day investigation.
Foreign key constraints don't slow down normal development. They just make the database refuse to do the one thing that would have created an inconsistent record in the first place.
A foreign key constraint moves that check into the database itself, where it can't be forgotten or skipped. The database rejects the invalid insert immediately, with a clear error, instead of silently letting bad data in that someone has to find and fix months later.
The usual pushback is that constraints add friction, especially around migrations or bulk imports. In practice that friction is small and one-time, while the cost of untangling inconsistent production data with real customers attached to it is not. If you're not using constraints today, adding them to your most important relationships, users, accounts, and billing records, is a good place to start.
Not planning for soft deletes or audit history early
Permanently deleting a row feels simpler. There's less to think about, less to store, and the data is just gone. It feels like the right default when you're moving fast and every table is simple.
Then a support request comes in asking what a record looked like last week, before a customer edited it. Or a compliance requirement shows up needing a history of every change made to a record, not just its current state. If rows are actually deleted rather than marked deleted, that information is simply gone. There's no getting it back.
A soft delete marks a row as deleted with a flag or a timestamp, instead of removing it. Your application filters those rows out of normal views, but the data is still there if you need it. Pairing that with a basic audit log, who changed what and when, is a small amount of extra work early on.
- Add a
deleted_attimestamp instead of hard-deleting important records. - Log meaningful changes to sensitive or customer-facing data, even a simple table with old value, new value, and who made the change.
- Retrofitting this after the fact means you have no history for anything that happened before you added it.
You don't need to log every table on day one. Start with the records that carry real business or customer weight, accounts, subscriptions, permissions, anything a support or legal request might eventually touch. Adding a deleted_at column to a table is a five-minute migration when the table is new. Going back and reconstructing history you never captured is not.
Premature sharding or over-engineering for scale you don't have yet
The opposite mistake is just as common, and just as costly. Some teams design their schema for a scale of users the product may never actually reach: sharding a database that comfortably fits on a single instance, building a complex event-sourcing layer for data that would be perfectly served by a few well-indexed tables, or splitting a simple app into a dozen microservices before there's a second engineer to maintain them.
Every layer of that complexity has a cost. It slows down every feature built afterward, because now a simple change has to account for the sharding logic, or the event replay, or the extra service boundary. It's complexity paid for upfront, for a problem that doesn't exist yet, and might never exist.
The better approach is to design a clean, well-indexed, properly normalized schema that can comfortably handle the next couple of orders of magnitude of growth, and revisit it when you have real evidence you need more. That's usually a much bigger lever than any early architectural bet. If you're not sure where that line is for your product, it's often worth getting a second set of eyes from a team that has seen this tradeoff play out before, which is exactly the kind of thing our software development services help startups get right the first time.