Most early-stage apps are fast, right up until they are not. A dashboard that loaded instantly with 500 records starts to crawl with 500,000. A search box that felt snappy in testing takes seconds in production. Founders often react by paying for a bigger server, and for a while that helps. But in many cases the real cause is a handful of slow queries and missing indexes, which can be fixed in an afternoon at no extra hosting cost.
This guide gives non-specialists and busy developers a practical approach to finding and fixing slow Postgres queries. You will learn how to measure first, how indexes work in plain language, the most common causes of slowness, and a repeatable routine to keep performance healthy as you grow.
Slow pages are not only a technical nuisance. They affect signups, conversions and support load. According to Google's published guidance on web performance, users are more likely to abandon pages that load slowly, and speed is part of how Google evaluates page experience. Even without precise numbers for your product, you can feel the impact: a checkout that hangs, a report that times out and a mobile screen that spins all push people toward competitors.
The database is frequently the hidden bottleneck, because every request that shows data passes through it. Improving a query that runs thousands of times a day delivers far more benefit than polishing something that runs once.
Guessing is the enemy of performance work. Before you touch an index, gather evidence:
Focus on total time, not just single slow executions. A query taking 50 milliseconds but running a million times a day usually matters more than one that takes 5 seconds and runs twice.
Imagine a 500 page book with no index. To find every mention of a topic, you would read each page. That is what Postgres does with a sequential scan: it reads the entire table. An index is like the index at the back of the book. It lets the database jump directly to the right rows.
The default index type, B-tree, handles equality and range checks and sorting very well. Other types exist for special needs: GIN for text search and JSON, GiST for geographic data, and BRIN for huge, naturally ordered tables. Most startups get most of their gains from well-chosen B-tree indexes.
Indexes are not free. Each one takes disk space and must be updated on every insert, update and delete. That is why the goal is the right indexes, not the most indexes.
Put EXPLAIN ANALYZE before a query and Postgres reports how it ran it. You do not need to understand every line. Look for these signals:
Always test on data that resembles production. A query plan on a tiny development database can look completely different from one on a table with millions of rows.
Picture a SaaS product with an orders screen that filters by account, status and date and sorts by newest first. In development it feels instant. In production, as the orders table grows, the page begins to take several seconds.
Running EXPLAIN ANALYZE shows a sequential scan across the whole orders table, followed by a sort. The fix is a composite index covering the columns used together, with the account first (because it is the most selective and always present), then status, then the date in descending order. After adding it, the plan switches to an index scan and the sort disappears. An illustrative result for a case like this is a drop from seconds to tens of milliseconds, though your actual improvement depends on table size and data distribution.
Column order in composite indexes matters. Put equality filters first, then range or sort columns. An index on (account_id, status, created_at) helps queries filtering on account_id alone, or account_id and status, but not queries filtering only on status.
Foreign keys are a classic gap. Postgres does not automatically index the referencing column, so joins and cascading deletes can be slow until you add one.
An ORM loads a list of 50 orders, then runs one extra query per order to fetch its customer. That is 51 queries where two would do. Use eager loading or a join to fetch related data together.
Using SELECT * on wide tables pulls unneeded data across the network. Select only the columns you need, and always paginate lists.
Using OFFSET 100000 forces the database to read and discard 100,000 rows. Keyset pagination, which continues from the last seen value, stays fast on large tables.
A filter like WHERE lower(email) = 'a@b.com' cannot use a plain index on email. Create an expression index or store normalised values.
Heavy updates and deletes leave dead rows. Autovacuum usually handles this, but busy tables may need tuned settings. Soft-delete patterns, covered in our article on soft delete and data recovery in web apps, can also grow tables quickly, so consider partial indexes that only include active rows.
Schema changes in a live system need care. If you are altering large tables, read our guide on zero downtime database migrations for web apps before running anything risky at peak hours.
If you have tuned queries and indexes and still hit limits, consider these next steps in order:
The cheapest time to catch a slow query is before release. Add a few habits: review the SQL for new endpoints, test with a realistically sized dataset, and include a load check before major launches. Our walkthrough of load testing web apps before launch with k6 shows how to expose bottlenecks safely.
If you would like experienced help auditing your database and application layer, our team offers web development services that include performance reviews and fixes.
Database performance does not require mystery or heroics. Measure to find the queries that cost the most, read the plan with EXPLAIN ANALYZE, add targeted indexes, remove wasteful patterns such as N+1 loads and deep offsets, and only then consider caching, replicas or bigger servers. Make it a monthly habit and your app will stay quick as your data grows. Start today by enabling pg_stat_statements and looking at your top five queries: the answer to your next performance problem is probably already in the numbers.