Search The Query
  • Home
  • Articles
  • Database Optimization: 8 Practices That Actually Matter
Illustration of a database with layers representing indexing, query plans, caching and connection pooling, with a downward latency line graph

Database Optimization: 8 Practices That Actually Matter

A slow database is the bottleneck behind most performance complaints. A query that once returned in 50 milliseconds starts taking 2 seconds, usually because of small oversights: a missing index, a SELECT * that fetches 40 columns when you need 3, a connection opened and closed on every request.

This guide covers eight practices with measurable impact on real-world database performance. They apply across PostgreSQL, MySQL, SQL Server, and other relational databases. Every recommendation is grounded in official database documentation, not vendor benchmarks.

Why Database Optimization Matters

As an application grows, more rows, more concurrent users, and more complex queries all pressure the database. A 10,000-row table survives a missing index. A 10-million-row table with the same gap turns a scan into a multi-second operation that blocks other queries and cascades into slow page loads and timeouts.

The PostgreSQL documentation states the goal plainly: increase the rate at which queries return results. Optimization is a habit of measuring, adjusting, and re-measuring — not a one-time project.

Practice 1: Index Strategically, Not Indiscriminately

Indexes are the single most effective query performance tool. A B-tree index finds rows in O(log n) time instead of scanning every row. But indexes are not free — every INSERT, UPDATE, and DELETE must also update the index.

What to index

  • Columns used frequently in WHERE clauses
  • Columns used in JOIN conditions
  • Columns used in ORDER BY or GROUP BY
  • Columns with high selectivity (many distinct values)
-- Good: index on a frequently filtered column
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- Good: composite index when queries filter on two columns together
CREATE INDEX idx_orders_status_date ON orders(status, created_at);

What to avoid

  • Indexing every column “just in case” — this slows down writes significantly
  • Indexing low-selectivity columns (like a boolean is_active with only two values)
  • Creating redundant indexes (e.g., an index on status when you already have one on status, created_at — the composite index covers leftmost queries on status alone)

The PostgreSQL documentation recommends using EXPLAIN to verify that an index is actually being used before and after creating it. If the planner still chooses a sequential scan, the index is not helping and is just adding write overhead.

Practice 2: Write Selective Queries

The fastest query is the one the database does not have to run. The second fastest is the one that touches the fewest rows and columns. Query optimization is about asking the database for exactly what you need and nothing more.

Stop using SELECT *

SELECT * fetches every column in the table, including large text fields, JSON blobs, and columns the application never uses. When the table has 30 columns and the application needs 4, that is a significant waste of I/O and memory.

-- Bad: fetches all 30 columns
SELECT * FROM orders WHERE customer_id = 99;

-- Good: fetches only what you need
SELECT id, order_date, total, status
FROM orders
WHERE customer_id = 99;

Use LIMIT when you need a sample

If you only need to check whether matching rows exist, or you need the first few results, tell the database to stop scanning after finding enough rows.

-- Good: stops after 10 matches
SELECT id, email FROM users WHERE status = 'active' LIMIT 10;

Filter early, not late

Push filtering into the database rather than pulling rows into the application layer and filtering there. The database can use indexes to skip irrelevant rows; the application cannot.

Practice 3: Normalize First, Denormalize Deliberately

Normalization — organizing data into well-structured tables to eliminate redundancy — is the right starting point for most schemas. A schema in Third Normal Form (3NF) avoids data anomalies and keeps the data model clean. But strict normalization can sometimes require expensive joins on large tables.

The principle: start normalized, and denormalize only when profiling proves a specific query path is too slow.

When denormalization makes sense

  • A report query joins 6+ large tables and runs every few minutes
  • A column is computed from others frequently enough that materializing it saves measurable CPU
  • A read-heavy table benefits from a precomputed summary column

How to denormalize safely

  • Add a redundant column to one table, not a copy of the whole table
  • Update it with a trigger or application logic, never by hand
  • Document the reasoning in the schema so future developers understand the tradeoff

Denormalization responds to a measured problem, not a default design choice. Premature denormalization creates data integrity risks harder to fix than a slow join.

Practice 4: Monitor and Analyze Query Plans

You cannot optimize what you cannot see. Every relational database provides a query plan inspector that shows exactly how the database executes a query — which indexes it uses, whether it scans or seeks, and how many rows it estimates at each step.

PostgreSQL: EXPLAIN ANALYZE

EXPLAIN ANALYZE
SELECT id, order_date, total FROM orders
WHERE customer_id = 99 AND status = 'shipped';

EXPLAIN ANALYZE actually runs the query and reports real timing, not just estimates. Look for:

  • Seq Scan on a large table — this often means a missing index
  • Index Scan or Index Only Scan — the index is being used
  • Nested Loop on large row counts — consider a different join strategy or index
  • Sort with disk sort — the sort is spilling to disk because work_mem is too low

What to do with the plan

A sequential scan on a large table often means a missing index. If the estimated row count is far from actual, run ANALYZE (PostgreSQL) or ANALYZE TABLE (MySQL) to update statistics.

Practice 5: Partition Large Tables

When a single table grows to tens of millions of rows, even indexed queries can slow down because the index itself becomes large. Partitioning splits a large table into smaller physical pieces based on a rule — by date range, by list of values, or by hash — while keeping it logically unified.

Range partitioning by date

The most common pattern: partition a time-series table (orders, logs, events) by month or quarter.

When a query filters on order_date, PostgreSQL scans only the relevant partition instead of the entire table. Dropping old data becomes a fast metadata operation (DROP TABLE orders_2025_01) instead of a slow DELETE.

When to partition

Tables over 10 million rows where queries filter on the partition key — especially log, event, or order tables that grow continuously and need old data archived. PostgreSQL supports declarative partitioning since version 10; MySQL supports it with InnoDB since version 5.7.

Practice 6: Use Connection Pooling

Opening a new database connection is expensive. It involves a TCP handshake, authentication, and session setup. If your application opens a new connection for every query and closes it immediately, a significant chunk of response time is spent on connection overhead — not on the query itself.

How pooling works

A connection pooler maintains a set of open connections and lends them to requests. When a request finishes, the connection goes back to the pool instead of being closed. The next request reuses it instantly.

Popular poolers

Database Pooler Notes
PostgreSQL PgBouncer Lightweight, runs between app and database
PostgreSQL Pgpool-II Pooling plus load balancing and replication
Java apps HikariCP Application-level pool, fastest JVM pool
Python (Django, SQLAlchemy) Built-in pool Configure CONN_MAX_AGE (Django) or pool_size (SQLAlchemy)

Configuration guidance

  • Set the pool size to match your expected concurrent queries, not your total user count
  • Most applications need 10-50 connections, not hundreds — more connections create contention, not parallelism
  • Use PgBouncer in transaction mode for serverless or auto-scaling environments where connections spike and drop

Practice 7: Cache Aggressively

The fastest query is the one you never send. Caching stores expensive query results in memory so subsequent requests skip the database entirely.

What to cache

  • Results of queries that change infrequently (reference data, lookup tables, configuration)
  • Aggregated counts or summaries that are recomputed on every page load
  • API responses assembled from multiple database queries

Cache layers

Layer Tool Best for
In-memory key-value Redis Session data, computed results, query result cache
In-memory key-value Memcached Simple, lightweight caching with no persistence needs
Application-level Language-specific In-process cache for small, rarely-changing data
Database-level Materialized views Precomputed query results stored in the database

Cache invalidation

The hard part is knowing when the cache is stale:

  • Time-based (TTL): expire the cache after a set interval — simplest, good for data that can tolerate being slightly stale
  • Write-through: update the cache whenever the underlying data changes — more accurate but more complex
  • Event-based: invalidate the cache when a specific event occurs (a new order, a status change)

Always set a TTL as a safety net, even if you also use write-through invalidation. A cache entry with no TTL is a cache entry that will eventually serve stale data.

Practice 8: Tune Database Configuration

Databases ship with conservative default settings designed to work on minimal hardware. A production server with 16 GB of RAM running with shared_buffers at its default of 128 MB is leaving significant performance on the table.

Key parameters (PostgreSQL)

Parameter Default Recommended Why
shared_buffers 128 MB 25% of system RAM Primary cache for data pages
work_mem 4 MB 16-64 MB Sort/hash memory per query
effective_cache_size 4 GB 50-75% of RAM Planner cache-hit estimate
maintenance_work_mem 64 MB 256 MB+ VACUUM and reindex speed

Key parameters (MySQL)

Parameter Default Recommended Why
innodb_buffer_pool_size 128 MB 50-70% of RAM InnoDB data and index cache
innodb_log_file_size 48 MB 256 MB+ Fewer checkpoint flushes
max_connections 151 Match pool size Too high wastes memory
query_cache_size 1 MB 0 (MySQL 8.0+) Removed in 8.0 — use app caching

PostgreSQL’s documentation recommends shared_buffers at about 25% of system RAM. The planner uses effective_cache_size to estimate cache hit probability — setting it too low causes the planner to prefer sequential scans over faster index scans.

Important: Always benchmark before and after configuration changes. Default recommendations are starting points, not universal truths. Every workload is different.

Common Mistakes to Avoid

  • Blindly adding indexes without checking the query plan — a useless index costs write performance for zero benefit
  • Using SELECT * everywhere — fetches unnecessary columns and prevents index-only scans
  • Ignoring slow query logs — PostgreSQL and MySQL both flag slow queries automatically
  • Over-allocating connections — more connections create contention, not parallelism
  • Caching everything — cache only what is expensive to compute and stable enough to reuse
  • Tuning parameters blindly — if 100 queries each use 1 GB of work_mem, the database needs 100 GB of RAM

Putting It Together

Start with the highest-impact steps: enable slow query logging, run EXPLAIN ANALYZE on the slowest queries, add the indexes the plan reveals, replace SELECT * with explicit columns, add a connection pooler, and cache repeated results. As data grows, evaluate partitioning, tune configuration based on real metrics, and denormalize only when profiling shows a problem.

Conclusion

A well-optimized database is the product of consistent, measured adjustments — the right index, the right query, the right cache, applied where profiling shows they are needed. Measure before you change, verify after, and document what you did so the next developer does not start from scratch.

If your team is building software that depends on a fast, scalable database — whether for a customer-facing application or an internal tool — Ideativemind builds custom software with performance built in from day one. Contact us to talk about your project.

Releated Posts

What Is Digital Transformation? Beyond the Buzzword

A clear, practical guide to digital transformation — what it really means, its core pillars, why most efforts…

ByByIdeativemind Oct 5, 2026

What Is a REST API? A Beginner-Friendly Explanation

A beginner-friendly guide to REST APIs — what REST means, how HTTP methods work, what a request and…

ByByIdeativemind Oct 3, 2026

Monolith vs Microservices: How to Choose a Software Architecture

A practical guide to monolith vs microservices architecture: what each is, key differences, pros and cons, and when…

ByByIdeativemind Oct 2, 2026

How to Automate Lead Generation: A Practical Playbook

Learn how to automate lead generation step by step — capture, score, route, and nurture leads with the…

ByByIdeativemind Oct 1, 2026

Leave a Reply

Your email address will not be published. Required fields are marked *