
August 19, 2026
8 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Slow queries are the silent killer of web applications, often causing timeouts and frustrated users long before server CPU limits are reached. Proper database indexing for performance is usually the single most effective intervention for a lagging Laravel or WordPress site, reducing execution time from seconds to milliseconds without code refactoring. If your application feels sluggish despite adequate server resources, the bottleneck is almost certainly missing or misconfigured indexes rather than insufficient hardware.
Before adding indexes blindly, audit your current query patterns. On many database-driven website projects in Nepal, I have seen teams add redundant indexes that slow down writes while failing to speed up reads because they misunderstood column order. Effective optimization requires understanding how the storage engine traverses these structures during actual workload execution, not just theoretical knowledge.
How does database indexing for performance actually work internally?
An index is fundamentally a separate data structure maintained alongside your table data. In MySQL (InnoDB) and PostgreSQL, the default index type is the B-Tree (Balanced Tree). This structure keeps data sorted hierarchically, allowing the database to perform binary searches. Instead of reading 1 million rows sequentially to find a specific user email, the engine navigates the tree nodes, typically requiring only 3–4 disk page reads to locate the exact record.
The critical detail many developers miss is that leaf nodes in a secondary index store the primary key value, not the full row data (in InnoDB). When you query a non-covered column, the database performs a "bookmark lookup" or "table access by index rowid": it finds the PK in the index, then jumps back to the clustered index (primary table) to fetch remaining columns. This extra random I/O step is why an index sometimes gets ignored by the optimizer if it estimates too many rows match; scanning the whole table sequentially becomes cheaper than thousands of random pointer chases.
When should you use composite indexes versus single-column indexes?
Single-column indexes are useful for isolated equality checks, but real application queries rarely filter on just one field. Composite indexes cover multiple columns in a specific order, following the Leftmost Prefix Rule. An index on (status, created_at) supports queries filtering by status alone, or status AND created_at, but never created_at alone. Getting this column order wrong is the most common reason MySQL query optimization efforts fail in production Laravel applications.
Determining optimal column order
- Equality first: Place columns used in
=orIN()conditions at the leftmost positions. These narrow the search space most effectively. - Range second: Place columns used in
>,<,BETWEEN, orLIKE 'prefix%'after equality columns. Only one range condition per index can be used efficiently; subsequent columns become unusable for filtering. - Sort/Group last: If your query includes
ORDER BYorGROUP BY, append those columns after filter columns to avoid expensive filesort operations. - Selectivity matters: Among equality columns, place higher-cardinality (more distinct values) columns first when possible, though modern optimizers handle this reasonably well automatically.
-- Bad: Range column blocks usage of subsequent columns
CREATE INDEX idx_bad ON orders (created_at, status, customer_id);
-- Query WHERE status = 'pending' AND created_at > '2026-01-01'
-- Cannot use this index efficiently for status filter
-- Good: Equality first, then range, then sort
CREATE INDEX idx_good ON orders (status, created_at, customer_id);
-- Same query uses index for both filtering AND avoids filesort
-- if ORDER BY customer_id is added On a legal-tech portal I built for marriage registration services, queries filtering by district (equality), application_date (range), and sorting by reference_number dropped from 800ms to 12ms simply by reordering the composite index columns correctly. The data volume was modest (~200k rows), proving that proper indexing matters even before you reach massive scale.
What are covering indexes and how do they eliminate table lookups?
A covering index includes all columns needed by a query within the index structure itself. When the database can satisfy a query entirely from the index without touching the base table, it reports "Using index" in EXPLAIN output. This eliminates the costly bookmark lookup phase entirely, keeping all data access sequential within the index pages rather than jumping randomly between index and heap.
To create a covering index, include SELECT columns after the WHERE/ORDER BY columns. For example, if you frequently run SELECT email, name FROM users WHERE status = 'active', an index on (status, email, name) covers the query completely. Be cautious about index width: including large TEXT or VARCHAR(255+) columns bloats the index size, increases memory pressure, and slows writes. Often, selecting only necessary columns (projection) makes covering feasible where SELECT * would not.
How do you diagnose missing indexes using EXPLAIN ANALYZE?
Never guess which indexes to add. Use EXPLAIN ANALYZE (MySQL 8.0.18+, PostgreSQL 12+) to see actual execution statistics, not just estimated plans. This command runs the query and reports real timing, row counts, and buffer hits for each operation node. Look specifically for these warning signs indicating poor MySQL optimization for SaaS or transactional workloads:
- type: ALL — Full table scan. Acceptable only for tiny config tables (<1000 rows).
- rows examined >> rows returned — Index exists but lacks selectivity or has wrong column order.
- Using filesort — Sorting happens in memory/disk after retrieval. Add index matching ORDER BY.
- Using temporary — GROUP BY or DISTINCT requires temp table. Often fixable with proper composite index.
- Buffer pool hit ratio low — Working set exceeds RAM. Either add RAM or reduce index/table bloat.
-- MySQL 8.0+ / MariaDB 10.11+
EXPLAIN ANALYZE
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'shipped'
AND o.created_at > '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 50;
-- PostgreSQL 16/17
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT ... ; In Laravel, enable the query log or use Debugbar/Laravel Telescope during development to capture slow queries. Copy the exact SQL (with bindings resolved) into your database client's EXPLAIN tool. Do not rely solely on ORM-generated assumptions; Eloquent relationships sometimes produce suboptimal JOINs or N+1 patterns that no index can fix. Always verify the generated SQL matches your intent before investing time in index tuning.
What are the hidden costs of over-indexing production databases?
Indexes are not free. Every INSERT, UPDATE, or DELETE must synchronously update every relevant index. On write-heavy tables like audit logs, order items, or session stores, excessive indexes cause severe throughput degradation. Each additional index adds B-Tree maintenance overhead, increases WAL/binlog volume, and consumes buffer pool memory that could cache hot data.
| Metric | No Extra Index | +3 Targeted Indexes | +10 Redundant Indexes |
|---|---|---|---|
| INSERT latency | ~2ms | ~4ms | ~15-25ms |
| Write TPS (single thread) | ~500 | ~250 | ~40-60 |
| Disk space overhead | Base table size | +30-50% | +150-300% |
| Buffer pool efficiency | High | Moderate | Poor (index thrashing) |
| Schema migration time | Fast | Moderate | Very slow (rebuild all) |
A pattern I've seen repeatedly on eCommerce projects involves developers adding an index for every WHERE clause encountered during debugging, resulting in 15+ indexes on the orders table. Writes during checkout spikes would queue up waiting for index updates, causing payment gateway timeouts. The fix was consolidating to 4 strategic composite indexes that covered 95% of read patterns while restoring write throughput. Always measure write impact after adding indexes, especially on high-velocity tables.
Periodically audit unused indexes using sys.schema_unused_indexes (MySQL) or pg_stat_user_indexes (PostgreSQL). Remove indexes with zero reads since last restart, but verify they aren't seasonal (e.g., year-end reporting). Drop redundant indexes where one is a left-prefix of another: (status) is redundant if (status, created_at) exists. This cleanup reduces storage, improves write performance, and simplifies future schema migrations.
Database indexing for performance: practical next steps
Start by identifying your top 5 slowest queries using slow query logs or APM tools. Run EXPLAIN ANALYZE on each, document current behavior, then design targeted composite indexes following the equality-range-sort ordering principle. Test changes on a staging copy with production-scale data volumes before deploying. Monitor both read latency improvements and write throughput impact post-deployment. Remember that database indexing for performance is iterative: workload patterns shift, and indexes that helped six months ago may now be redundant or insufficient.
If your Laravel, WooCommerce, or custom PHP application continues to struggle despite indexing efforts, the issue may lie in query structure, N+1 problems, or architectural decisions beyond what indexes can solve. Contact me for a focused database performance audit tailored to your production workload and business constraints.

