Slow database queries are the most common performance bottleneck in web applications, and the good news is that fixing them is a systematic process rather than a dark art. Most dramatic speedups come from a small number of well-understood causes. The discipline is to measure first, change one thing, and measure again, rather than guessing.
Measure before you touch anything
The first rule is to find the actual slow query instead of optimising the one you assume is slow. Turn on slow-query logging, look at which queries dominate total time, and reproduce the worst offender in isolation. Optimisation without measurement is how teams spend a week speeding up a query that runs twice a day while ignoring the one that runs ten thousand times an hour.
Read the query plan
Every serious database can show you how it intends to execute a query. Learning to read that plan is the single highest-leverage database skill. It tells you whether the engine is scanning an entire table or using an index, where the time is going, and whether your mental model of the query matches reality. Almost every fix starts with looking at the plan and noticing a full scan where there should be an index lookup.
Indexes are the biggest lever
The most common cause of a slow query is a missing or unused index. Index the columns you filter and join on, and use composite indexes when you consistently query several columns together. But indexes are not free: each one adds write overhead and storage, so an over-indexed table is its own problem. The goal is the right indexes for your real query patterns, not an index on everything.
Kill the N+1 problem
The N+1 query problem is the most common performance bug in applications using an ORM. You fetch a list of items, then the code quietly issues one more query per item to load a relation, turning one screen into hundreds of round trips. Eager-load the relations you know you need in a single query instead. This pattern hides especially well behind GraphQL resolvers, where each field can trigger its own query unless you batch.
Select only what you need
Fetching every column when you need three wastes bandwidth, memory, and cache space, and can prevent the database from satisfying a query entirely from an index. Selecting only the required columns is a small change that compounds across every request. The same goes for pulling back thousands of rows to count or paginate them in application code instead of letting the database do it.
Fix the cause, not the symptom
When a query is slow, there is always a tempting shortcut: cache the result, add a read replica, or throw more hardware at it. Sometimes that is the right answer, but reaching for it first often hides a problem that will resurface and grow. A missing index, an N+1 pattern, or a query pulling far more data than it needs is a cause; caching over it treats the symptom and adds a new layer of complexity and potential staleness on top. The disciplined sequence is to understand why the query is slow by reading its plan, fix the underlying issue if there is one, and only then add caching or capacity for the load that genuinely remains. Caching a query that should simply have been indexed is one of the most common ways teams accumulate complexity while leaving the real problem in place.
When the query is already optimal
Sometimes the query is as fast as it can be and the data is simply hot. That is where a cache earns its place: put frequently read, infrequently changed results in Redis and serve them without touching the database at all. Knowing whether you have a query problem or a caching problem is the difference between hours and days of work, and it connects directly to your overall application performance. If you want a stubborn performance issue diagnosed properly, our engineering team can help.