Nothing about the code changed. That’s the confusing part. Same theme, same extensions, same server, and yet the order list takes six seconds to paginate and the sales report gives up before it renders.
The data changed. A plan that was cheap against forty thousand order rows is not cheap against four hundred thousand, and MySQL will keep running that statement badly for as long as you let it.
So this is a diagnosis guide. Plenty of stores get “optimised” by someone bolting on six indexes based on a feeling, which slows every write and leaves the real problem untouched. Find what’s slow. Prove it. Work out which code asks for it. Change one thing.
The Short Answer: Measure Before You Tune
- Switch on the MySQL slow query log and drop long_query_time well below its default, or you’ll only ever catch the disasters.
- Capture during traffic that matters: the report that hangs, a real checkout, an hour of peak.
- Push the log through mysqldumpslow or pt-query-digest. Ranked patterns beat forty thousand raw lines.
- Take the worst few to EXPLAIN, or EXPLAIN ANALYZE if your version has it.
- Work out which controller, model, extension or cron job issues them.
- Change one thing. Measure again.
Steps 1 to 5 touch no store code at all, which is why they come first.
Why Cart and Order Tables Become Slow at Scale
The cart table quietly fills up
Adding to a cart inserts or updates rows in oc_cart. Which identifier those rows hang off depends on whether the shopper is signed in, and the exact columns and indexes are not identical across OpenCart 2.x, 3.x and 4.x. Read your own table before trusting anybody’s description of it, this one included:
SHOW CREATE TABLE oc_cart\G
Guest rows are where it turns awkward. The session dies. The rows it left behind usually don’t, and core OpenCart ships no scheduled job that clears old cart data in the releases most shops run. Confirm that on your install rather than taking it on faith.
Once the pile gets big enough, it stops being a storage question:
- Reads walk more index pages to return the same handful of rows.
- A cleanup DELETE filtered on date_added scans the lot if nothing indexes that column.
- Secondary indexes grow with the table, so less of the hot data stays in memory.
- Every extra index is maintained on every insert and update, and this table takes plenty of both.
- Dumps, restores and schema changes all take longer.
None of which proves the cart table is your problem. It’s a suspect, and it becomes a culprit when queries against it turn up in the slow log.
Orders are spread out, and reports pay for it
A single order touches oc_order plus its children: oc_order_product, oc_order_total, oc_order_history. Sensible design. It’s also why “revenue by month” or “units shipped per product” costs what it does, since answering either means going back out to the children, per order or per group.
The admin sales report is the usual specimen. Depending on your OpenCart version, it can end up shaped roughly like this:
SELECT COUNT(*) AS orders,
SUM((SELECT SUM(op.quantity) FROM `oc_order_product` op
WHERE op.order_id = o.order_id GROUP BY op.order_id)) AS products,
SUM((SELECT SUM(ot.value) FROM `oc_order_total` ot
WHERE ot.order_id = o.order_id AND ot.code = 'tax'
GROUP BY ot.order_id)) AS tax,
SUM(o.total) AS total
FROM `oc_order` o
WHERE o.order_status_id > 0
Fire a subquery for every row the outer query touches and cost tracks order volume directly. On OpenCart issue #6612, a store reported this page producing a query that examined about 119 million rows over roughly 174 seconds. One store’s number from one report, not a figure to expect on yours. Open admin/model/report/sale.php and read what your version builds.
Enable the MySQL Slow Query Log
It’s off unless somebody switched it on, and the shipped long_query_time of 10 seconds is close to useless for a shop. A statement taking three seconds four hundred times an hour will ruin your afternoon without writing a single line to that file.
This is production, so: know your rollback, check free disk before you start, and watch the file size once real traffic hits it.
Permanent version, in my.cnf under [mysqld]:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
If restarting isn’t on the table:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_output = 'FILE';
One second is an investigation threshold, not a permanent home. It’s meant to be noisy so ordinary offenders surface; raise it once you have a decent sample. The variable has session scope too, so anything already connected keeps the value it opened with. Persistent connections, bounce PHP-FPM.
Then there’s this one, which deserves more thought than it gets:
log_queries_not_using_indexes = 1
Fine for a couple of hours while you’re hunting something specific. Bad as a permanent fixture, because OpenCart scans small lookup tables all day for perfectly good reasons and you’ll bury whatever you actually wanted to read. Turn it on, cap it with log_throttle_queries_not_using_indexes, turn it off afterwards.
How to Read a Slow Query Log Entry
# Time: 2026-02-11T09:14:22.418293Z
# User@Host: ocuser[ocuser] @ localhost [] Id: 20431
# Query_time: 12.884301 Lock_time: 0.000142 Rows_sent: 12 Rows_examined: 4183992
SET timestamp=1770801262;
SELECT o.order_id, CONCAT(o.firstname, ' ', o.lastname) AS customer ...
| Field | What it is | What to do with it |
|---|---|---|
| Query_time | How long this run took | Ranks one execution, and nothing beyond that |
| Lock_time | Time spent waiting on locks | If it’s routinely high, you have contention, not a plan problem |
| Rows_sent | Rows handed back | Only meaningful next to Rows_examined |
| Rows_examined | Rows read to produce them | The number that usually points you somewhere |
Read them together. Individually none proves a thing. Four million rows scanned to return twelve is the kind of ratio that says go and look at the execution plan, and at this stage that’s all it says.
This one catches people out: the MySQL reference manual notes a statement is written once it has finished and its locks are released. The file isn’t in execution order. Don’t reconstruct a timeline from it.
Find the Most Expensive Query Patterns
The slowest single run is rarely the biggest bill:
- 100 ms, thirty thousand times an hour. Three thousand seconds of database time.
- 20 seconds, twice an hour. Forty seconds.
One produces the support ticket. The other quietly eats your headroom at peak. You want both, and the raw log won’t separate them for you.
mysqldumpslow is already on the box:
# Ranked by total accumulated execution time
mysqldumpslow -s t -t 15 /var/log/mysql/mysql-slow.log
# Ranked by number of executions
mysqldumpslow -s c -t 15 /var/log/mysql/mysql-slow.log
# Only patterns touching the order tables
mysqldumpslow -g "oc_order" /var/log/mysql/mysql-slow.log
Averages lie here. A query sitting at 200 ms that occasionally lurches to nine seconds averages out much like one that’s steadily mediocre, and those two need very different treatment.
pt-query-digest from Percona Toolkit earns its keep when a ranked list stops being enough, mainly for the latency distribution. It’s another dependency though, and for “show me my five worst patterns” the tool you already have answers in one line.
No shell access? performance_schema and the sys views come at it from a different direction:
SELECT *
FROM sys.statement_analysis
ORDER BY total_latency DESC
LIMIT 10;
They need performance_schema enabled and sys installed, and the digest tables can be truncated or reconfigured underneath you. Keep the three sources straight:
- Slow query log is history: individual executions that crossed your threshold.
- performance_schema digests are running totals since the last server start or reset, across everything, not just the slow stuff.
- SHOW PROCESSLIST is a snapshot of this instant.
All useful. None of them replaces slow-log history.
Use EXPLAIN to Find the Real Bottleneck
So you know which statement to chase. What you don’t know is what MySQL does with it once it lands, and that gap is what EXPLAIN closes.
EXPLAIN SELECT o.order_id, o.total, o.date_added
FROM oc_order o
WHERE o.order_status_id = 5
AND DATE(o.date_added) BETWEEN '2026-01-01' AND '2026-01-31'
ORDER BY o.date_added DESC
LIMIT 20;
| Column | Meaning |
|---|---|
| type | How the table gets accessed: ALL (full scan), index, range, ref, eq_ref, const |
| possible_keys | What the optimizer had to choose from |
| key | What it picked; NULL means nothing |
| rows | Estimated rows read at this step |
| filtered | Estimated share of those rows that survive the condition |
| Extra | Flags like Using where, Using index, Using temporary, Using filesort |
Things that make you look twice: type: ALL, key: NULL, an estimate wildly out of proportion to what comes back, Using temporary, Using filesort. None is a defect on its own. Scanning a forty-row lookup table is correct, and sorting twenty rows in memory is free. Judge them against table size and how often the statement runs.
The example above stacks two problems. If key comes back NULL while rows reads in the hundreds of thousands, nothing indexes order_status_id.
The second one is DATE(o.date_added). Wrap a column in a function and any index on that column stops being usable for a range scan, however well built it is. An index holds stored column values. It does not hold the output of expressions computed over them, so there is nowhere for MySQL to look up “rows whose DATE() result lands in January.” Sargable is the jargon for a predicate an index can range-scan; a function on the column destroys the property. Bound the raw column and the index comes back into play:
WHERE o.order_status_id = 5
AND o.date_added >= '2026-01-01 00:00:00'
AND o.date_added < '2026-02-01 00:00:00'
EXPLAIN ANALYZE arrived in 8.0.18. It runs the query for real, then prints measured timings and row counts beside the estimates, which is how you catch the optimizer guessing wrong rather than just suspecting it. Whichever you reach for, run it on data that resembles production. Two hundred rows on a laptop will cheerfully hand you a plan that means nothing.
Trace Slow SQL Back to OpenCart or an Extension
You have the statement. Now work out what runs it, how often, and on which request.
The candidate list is wider than people expect. Core models and the built-in reports, obviously. Then an extension somebody installed two years ago. An event handler. A cron job nobody monitors. An API call. An OCMOD or vQmod change that never got written down anywhere.
Start with grep. It gets you most of the way:
grep -R "oc_order_product" admin/ catalog/ extension/ --include="*.php"
Paths shift by version, since 4.x rearranged the extension tree, and any query assembled by concatenation won’t match a single search string. Two things worth doing alongside it:
- Line the slow-log timestamp up against your web server’s access log. That’s how you separate “somebody opened a report” from “this fires on every product page.”
- Look at the execution count. Thousands of runs an hour normally means a loop or a hot front-end path, and the fix there is caching or deleting the call, not another index.
Common OpenCart Database Performance Mistakes
- Tuning on a hunch, with no before-and-after to check it against.
- Leaving log_queries_not_using_indexes switched on until the log is unreadable.
- Pasting index definitions out of a blog post (this one included) without checking the schema or the workload.
- Sorting by average latency and never once looking at call counts.
- Trusting documentation written for a different OpenCart version.
- Signing off a fix against a dev database small enough that every plan looks fine.
- Running one enormous DELETE over stale rows mid-trading-day.
Add Indexes Based on Real Query Patterns
Look at what’s already there. A duplicate index is cost with nothing to show for it:
The right index depends on the predicates, the join conditions, the ORDER BY, how selective the columns are, how often the query runs, what’s already defined, and what the plan says before and after you touch it. Putting equality columns ahead of a range column is a sound default, since the range condition ends the part of the prefix anything else could use. A default, not a law. Indexes also get built to satisfy a sort without a filesort, or widened until they cover every column selected. Those aims pull against each other sometimes, and the execution plan referees.
SHOW INDEX FROM oc_order;
SHOW INDEX FROM oc_order_product;
SHOW INDEX FROM oc_order_total;
SHOW INDEX FROM oc_cart;
The right index depends on the predicates, the join conditions, the ORDER BY, how selective the columns are, how often the query runs, what’s already defined, and what the plan says before and after you touch it. Putting equality columns ahead of a range column is a sound default, since the range condition ends the part of the prefix anything else could use. A default, not a law. Indexes also get built to satisfy a sort without a filesort, or widened until they cover every column selected. Those aims pull against each other sometimes, and the execution plan referees.
Where a workload genuinely does filter and sort on these columns over and over, something along these lines may fit. Read them as illustrations of the shape, not as a prescription for your store:
ALTER TABLE oc_order ADD INDEX idx_status_date (order_status_id, date_added);
ALTER TABLE oc_order_product ADD INDEX idx_order_prod (order_id, product_id);
ALTER TABLE oc_order_total ADD INDEX idx_order_code (order_id, code);
ALTER TABLE oc_cart ADD INDEX idx_date_added (date_added);
None is free. Every write touching an indexed column maintains that index, indexes take disk, and they compete for buffer pool space. On oc_cart especially, you want a slow-log entry justifying each one.
ALTER TABLE against a live database: back it up, rehearse on a restored copy so the duration isn’t a surprise, and pick your moment. Modern MySQL adds many indexes in place, though not every case qualifies, and a table rebuild moves serious I/O. If writes never stop, pt-online-schema-change and gh-ost exist for precisely this situation.
Check Whether OpenCart Is Using InnoDB
Engine defaults differ by version, and a store that’s been upgraded in place for years is a lucky dip. Check:
SELECT table_name, engine, table_rows,
ROUND((data_length + index_length) / 1024 / 1024) AS size_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 20;
For anything transactional, you want InnoDB. MyISAM locks at table level and offers neither transactions nor crash-safe recovery, which is how a report scanning oc_order for forty seconds ends up holding back the inserts a checkout is trying to make. InnoDB locks rows and uses MVCC, so a plain non-locking read generally gets its snapshot without queueing behind a writer.
Generally, though. Lock waits don’t vanish on InnoDB. Anything that writes takes row locks, and so does a locking read like SELECT … FOR UPDATE. Sit at REPEATABLE READ and you inherit gap and next-key locks as well. Deadlocks still occur. DDL grabs a metadata lock and stops a table dead for as long as it holds one.
Where you look depends on the engine. Waiting for table level lock in SHOW PROCESSLIST is a MyISAM tell. On InnoDB, go to information_schema.innodb_trx and SHOW ENGINE INNODB STATUS.
Conversion is a genuine operation with a cost attached, not a setting you flip:
ALTER TABLE oc_cart ENGINE=InnoDB;
ALTER TABLE oc_order ENGINE=InnoDB;
Before you convert: back up, rehearse on a copy, confirm there’s disk room for the rebuild, check whether anything leans on MyISAM-specific behaviour such as legacy FULLTEXT usage, use a window, and watch it afterwards.
It clears out a whole category of locking trouble. It does nothing whatsoever for a query that reads four million rows.
Clean Up Stale Cart and Session Data Safely
DELETE FROM oc_cart
WHERE customer_id = 0
AND date_added < DATE_SUB(NOW(), INTERVAL 30 DAY)
LIMIT 5000;
Thirty days is a number chosen to make the example concrete. Yours depends on how long abandoned-cart recovery keeps emailing, what analytics need, and whatever retention or privacy rules bind you. Settle the policy first, write the job second.
Why batch? One unbounded DELETE across millions of rows holds its locks throughout, builds an enormous undo log, hammers redo and I/O, and can drop a replica well behind. Loop until nothing is left, run it while the shop is quiet, and make sure an index covers the predicate so the cleanup isn’t scanning the table it’s meant to shrink.
oc_session (on the database session handler), oc_customer_online and oc_customer_activity may want similar attention. Check what exists, check nothing depends on it, skip the rest. Not every store needs every job.
After a large delete, statistics deserve a thought:
ANALYZE TABLE oc_cart;
Plan selection leans on cardinality estimates, and estimates gathered against a very different distribution can send the optimizer off in the wrong direction. Reach for this when the statistics look stale. It is not a reliable cure for a bad plan.
Rewrite or Cache Expensive Reports
Two ways to spend less: make the query cheaper, or stop running it so often.
Cheaper. Aggregate each child table once and join the results, instead of firing a subquery per order:
SELECT o.order_id, o.date_added, o.total, p.units, t.tax
FROM oc_order o
LEFT JOIN (
SELECT order_id, SUM(quantity) AS units
FROM oc_order_product
GROUP BY order_id
) p ON p.order_id = o.order_id
LEFT JOIN (
SELECT order_id, SUM(value) AS tax
FROM oc_order_total
WHERE code = 'tax'
GROUP BY order_id
) t ON t.order_id = o.order_id
WHERE o.order_status_id > 0
AND o.date_added >= '2026-01-01 00:00:00'
AND o.date_added < '2026-02-01 00:00:00';
Benchmark it against the original on realistic data first. An unbounded derived table rolls up the whole child table even when you only asked for January, so on very large stores push the date filter into the subqueries too. Keep predicates sargable, and prefer keyset pagination to a large OFFSET.
Less often. Cap the default date range so nobody casually requests three years. Cache the output where the numbers needn’t be live, or build a summary table overnight and read one row instead of aggregating on demand.
One deployment note: editing core files makes upgrades miserable, and the change tends to vanish at the next one. Put it in an extension, an override, or an OCMOD modification, and test on staging against realistic data.
Practical OpenCart SQL Performance Checklist
- ☐ Slow query log on, long_query_time temporarily lowered, and a plan to raise it again
- ☐ Sample taken during genuine peak traffic
- ☐ Log aggregated by total time and by execution count
- ☐ Worst patterns run through EXPLAIN on production-sized data
- ☐ Rows_examined compared against Rows_sent for the top offenders
- ☐ No indexed column wrapped in a function inside a WHERE clause
- ☐ Every slow statement traced to the code that issues it
- ☐ SHOW INDEX checked before anything new gets added
- ☐ Engines verified; transactional tables on InnoDB
- ☐ Retention policy decided, enforced by batched cleanup jobs
- ☐ Statistics refreshed after large data changes
- ☐ Expensive reports rewritten, bounded, or cached
- ☐ Schema and engine changes backed up and rehearsed
- ☐ Post-change measurement compared against the baseline
Final Takeaway
Treat this as a cycle rather than a job you finish: measure, identify, confirm, trace, fix, measure again. The slow log says what crossed the line. Aggregation says what’s genuinely costing you. EXPLAIN says what MySQL decided to do. The codebase says who asked.
And keep the numbers. A baseline captured before you touch anything is what lets you claim a change worked, or back it out cleanly when it didn’t. Catalogues grow, extensions land, distributions drift. Run the whole thing again in six months.
For reliable opencart plugins, iOS and Android App development, website custom changes and migrations, store owners can reach out to us at [email protected]. Store owners can also reach out to us at our helpdesk.


