Modern Database Optimization: Practical Tips for MySQL and PostgreSQL
At TCCB Solutions, we spend a lot of our time helping clients squeeze more performance out of their databases. A slow query that takes 800ms might feel harmless in development, but multiply it across thousands of concurrent users and it quickly becomes the bottleneck that drags an entire application down. The good news is that most database performance problems come from a handful of recurring patterns, and once you know what to look for, the fixes are usually straightforward. Here are the techniques we reach for most often when tuning MySQL and PostgreSQL.
Start With the Query Plan, Not a Guess
Before we change a single line, we always look at how the database actually executes a query. Both engines give us a window into this with EXPLAIN. In PostgreSQL we lean on EXPLAIN ANALYZE, which runs the query and reports real timings rather than estimates:
EXPLAIN ANALYZE
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;
When we see a Seq Scan on a large table, or a row estimate that is wildly different from the actual count, that is our signal that an index is missing or the planner's statistics are stale. Guessing at optimizations without reading the plan first is how teams waste days chasing the wrong problem.
Index With Intent
Indexes are the single biggest lever we have, but more is not better. Each index speeds up reads while adding overhead to every write. We focus on a few high-value patterns:
- Composite indexes that match your actual filter and sort columns, in the right order. An index on
(status, created_at)serves the query above far better than two separate single-column indexes. - Covering indexes so the database can answer a query from the index alone. In PostgreSQL we use
INCLUDEto bundle extra columns without bloating the index key. - Partial indexes for queries that only ever touch a subset of rows, such as active records.
-- Only index the rows we actually query
CREATE INDEX idx_orders_pending
ON orders (created_at DESC)
WHERE status = 'pending';
A partial index like this stays small, fits in memory, and is dramatically faster than indexing every row in the table.
Kill the N+1 Query
One of the most common issues we find in application code has nothing to do with the database engine at all. The N+1 query problem happens when an app fetches a list of records, then fires a separate query for each one. Loading 100 blog posts and then running 100 follow-up queries for authors turns a single round trip into 101. We solve this by fetching related data in one go with a join or a batched IN clause:
SELECT p.id, p.title, a.name AS author
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.published = true;
Most ORMs support eager loading for exactly this reason. Turning it on is often the cheapest performance win available.
Keep Statistics and Maintenance Current
The query planner can only make good decisions if it has accurate statistics. In PostgreSQL we make sure autovacuum is tuned for busy tables, and we run ANALYZE after large bulk loads so the planner is not working from outdated assumptions. In MySQL with InnoDB, ANALYZE TABLE serves a similar purpose. Neglected maintenance is a quiet killer: a query that was fast last month can degrade simply because the statistics drifted as the data grew.
Pool Your Connections
Opening a database connection is expensive, and PostgreSQL in particular allocates a full backend process per connection. Under load, hundreds of short-lived connections will exhaust memory long before they exhaust the CPU. We put a pooler such as PgBouncer in front of PostgreSQL, or use the built-in pool in our application framework, so a small set of warm connections is reused instead of constantly torn down and rebuilt. This single change has rescued more than one client from mysterious slowdowns during traffic spikes.
Cache the Expensive, Repeatable Work
Finally, the fastest query is the one you never run. When the same expensive aggregation is requested over and over, we cache the result, either in the application layer with something like Redis or, for reporting workloads, by using PostgreSQL materialized views that we refresh on a schedule:
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT date_trunc('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
GROUP BY 1;
-- Refresh on a schedule, off the hot path
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;
Bringing It Together
Database optimization is rarely about one magic setting. It is a steady discipline of measuring before changing, indexing with purpose, eliminating wasteful query patterns, and giving the engine the resources and statistics it needs to make smart choices. Applied together, these techniques routinely take page loads from sluggish to instant.
If your application is feeling slow and you suspect the database is the culprit, we would love to take a look. A quick conversation is often all it takes to spot the bottleneck and map out a plan.