Every Magento 2 store has a silent performance killer hiding in its database: the quote and quote_item tables. Every time a customer adds a product to their cart — whether they complete checkout or abandon it — Magento creates a quote record. Over months and years, these tables grow to millions of rows, and nobody notices until checkout starts timing out.
I've seen stores where quote had 15 million rows and quote_item had 40 million. Checkout page load went from 1.2 seconds to 8 seconds. The cart page took longer than the product page. And the merchant had no idea why — because the frontend was cached, the database looked fine on paper, and the slow query log wasn't catching the right queries.
In this post, I'll show you exactly how to diagnose quote table bloat, clean it up safely, and prevent it from coming back.
Why Quote Tables Are a Problem
The quote table is central to Magento's checkout flow. Every cart — active or abandoned — is a quote. The quote_item table stores each line item. When a customer proceeds to checkout, Magento loads the quote, joins it with quote items, calculates totals, checks inventory, applies rules, and renders the cart/checkout page.
Here's what happens when these tables grow unbounded:
-
Cart page load increases — Loading a quote with 20 items requires joining
quote,quote_item,quote_item_option,quote_address,quote_address_item,quote_payment, andquote_shipping_rate. Every one of these tables is filtered byquote_id, and if the quote table is 10M+ rows, even indexed queries slow down due to buffer pool pressure. - Checkout AJAX calls slow down — Totals calculation, shipping estimation, and payment method evaluation all query the quote tables. These are uncached backend calls.
- Admin order creation slows down — When a merchant creates an order from the admin, Magento queries the quote tables to find existing customer carts.
-
Database backup and recovery times increase — A 15M row quote table adds significant time to
mysqldumpand point-in-time recovery. - Memory pressure on PHP-FPM — Magento loads quote data into PHP objects. With a bloated quote_item table, the collection loading consumes more memory per request.
Step 1: Diagnose the Problem
Before fixing anything, measure the current state:
-- Check row counts
SELECT
(SELECT COUNT(*) FROM quote) AS quote_count,
(SELECT COUNT(*) FROM quote_item) AS quote_item_count,
(SELECT COUNT(*) FROM quote_address) AS quote_address_count,
(SELECT COUNT(*) FROM quote_item_option) AS quote_item_option_count;
-- Check table sizes
SELECT
table_name,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'quote%'
ORDER BY data_length DESC;
-- Check active vs abandoned quotes
SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) AS inactive,
SUM(CASE WHEN is_active = 0 AND updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) AS stale_inactive
FROM quote;
The stale_inactive count is your problem. These are abandoned carts older than 30 days that serve no purpose but still occupy space and slow down queries.
Also check for slow queries related to quotes:
SELECT * FROM mysql.slow_log
WHERE sql_text LIKE '%quote%'
ORDER BY query_time DESC
LIMIT 20;
Or if you're using Percona or have the performance schema enabled:
SELECT digest_text, count_star, avg_timer_wait/1000000000 AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
WHERE digest_text LIKE '%quote%'
ORDER BY avg_timer_wait DESC
LIMIT 20;
Step 2: Clean Up Abandoned Quotes
Magento has a built-in mechanism for this, but it's conservative and often disabled. Here's how to do it properly.
The Safe Approach: Archive First
Before deleting anything, archive old quotes to a separate table:
-- Create archive tables
CREATE TABLE quote_archive LIKE quote;
CREATE TABLE quote_item_archive LIKE quote_item;
CREATE TABLE quote_address_archive LIKE quote_address;
CREATE TABLE quote_item_option_archive LIKE quote_item_option;
-- Move inactive quotes older than 90 days
INSERT INTO quote_archive
SELECT * FROM quote
WHERE is_active = 0
AND updated_at < DATE_SUB(NOW(), INTERVAL 90 DAY);
INSERT INTO quote_item_archive
SELECT qi.* FROM quote_item qi
INNER JOIN quote_archive qa ON qi.quote_id = qa.entity_id;
INSERT INTO quote_address_archive
SELECT qa.* FROM quote_address qa
INNER JOIN quote_archive q ON qa.quote_id = q.entity_id;
INSERT INTO quote_item_option_archive
SELECT qio.* FROM quote_item_option qio
INNER JOIN quote_item_archive qia ON qio.item_id = qia.item_id;
Verify the archive counts match:
SELECT
(SELECT COUNT(*) FROM quote_archive) AS archived_quotes,
(SELECT COUNT(*) FROM quote_item_archive) AS archived_items;
Delete in Batches (Never DELETE All at Once)
Deleting millions of rows in one transaction will lock the table and potentially crash MySQL. Use batched deletes:
-- Delete in batches of 10,000
SET @batch_size = 10000;
SET @continue = 1;
WHILE @continue > 0 DO
DELETE FROM quote_item_option
WHERE item_id IN (
SELECT qio.item_id
FROM quote_item_option qio
INNER JOIN quote_item qi ON qio.item_id = qi.item_id
INNER JOIN quote q ON qi.quote_id = q.entity_id
WHERE q.is_active = 0
AND q.updated_at < DATE_SUB(NOW(), INTERVAL 90 DAY)
LIMIT @batch_size
);
SET @continue = ROW_COUNT();
-- Sleep to reduce replication lag
DO SLEEP(0.5);
END WHILE;
Repeat this pattern for quote_address_item, quote_shipping_rate, quote_payment, quote_address, quote_item, and finally quote — in that order, to respect foreign key relationships.
If your tables use InnoDB with foreign key constraints (they should), you may need to temporarily disable constraint checking or delete child tables first.
Use Magento's Built-in Sales Cleaning
Magento includes a CLI command for cleaning up expired quotes:
bin/magento sales:cleanQuotes
By default, this removes quotes older than the quote/lifetime configuration value (typically 30 days). However, it only removes quotes that have no related orders. You can configure the lifetime in:
Stores → Configuration → Sales → Sales → Order Cleaning
Set "Quote Lifetime" to 30 or 60 days. But be aware — this command can be slow on very large tables and may timeout if run during peak hours.
Step 3: Optimize the Remaining Quotes
After cleanup, optimize the table to reclaim space:
OPTIMIZE TABLE quote;
OPTIMIZE TABLE quote_item;
OPTIMIZE TABLE quote_address;
OPTIMIZE TABLE quote_item_option;
On a 15M row table, this can take 5–15 minutes. Run it during a maintenance window.
Add Missing Indexes
Magento's default schema includes indexes on quote_id, customer_id, and is_active. But depending on your MySQL version and table size, you may benefit from composite indexes:
-- Composite index for the most common admin query
ALTER TABLE quote ADD INDEX idx_active_updated (is_active, updated_at);
-- Covering index for cart loading
ALTER TABLE quote_item ADD INDEX idx_quote_id_product_id (quote_id, product_id);
Always test new indexes on a staging environment first. Use EXPLAIN to verify the index is being used:
EXPLAIN SELECT * FROM quote WHERE is_active = 0 AND updated_at < '2026-04-15';
Step 4: Prevent Future Bloat
Cleaning up once isn't enough. You need a strategy to keep quote tables small going forward.
Configure a Cron Job for Regular Cleanup
Create a cron job that runs the cleanup weekly during low-traffic hours:
# crontab -e
0 3 * * 0 /usr/bin/php /var/www/magento/bin/magento sales:cleanQuotes >> /var/log/magento/quote-cleanup.log 2>&1
Set Up a Custom Cleanup Script
If Magento's built-in cleanup isn't aggressive enough, create a custom script:
<?php
// cleanup-quotes.php
use Magento\Framework\App\Bootstrap;
require __DIR__ . '/app/bootstrap.php';
$bootstrap = Bootstrap::create(BP, $_SERVER);
$objectManager = $bootstrap->getObjectManager();
$resource = $objectManager->get(\Magento\Framework\App\ResourceConnection::class);
$connection = $resource->getConnection();
$batchSize = 5000;
$threshold = date('Y-m-d H:i:s', strtotime('-60 days'));
// Get stale inactive quote IDs
$quoteIds = $connection->fetchCol(
"SELECT entity_id FROM quote
WHERE is_active = 0
AND updated_at < ?
AND entity_id NOT IN (SELECT quote_id FROM sales_order)
LIMIT $batchSize",
[$threshold]
);
if (empty($quoteIds)) {
echo "No stale quotes found.\n";
exit;
}
$connection->beginTransaction();
try {
// Delete child tables first
foreach (['quote_item_option', 'quote_address_item', 'quote_shipping_rate', 'quote_payment'] as $table) {
$connection->delete($table, ['item_id IN (SELECT item_id FROM quote_item WHERE quote_id IN (?))' => $quoteIds]);
}
$connection->delete('quote_item', ['quote_id IN (?)' => $quoteIds]);
$connection->delete('quote_address', ['quote_id IN (?)' => $quoteIds]);
$connection->delete('quote', ['entity_id IN (?)' => $quoteIds]);
$connection->commit();
echo "Deleted " . count($quoteIds) . " stale quotes.\n";
} catch (\Exception $e) {
$connection->rollBack();
echo "Error: " . $e->getMessage() . "\n";
}
Reduce Guest Quote Lifetime
Guest carts are the biggest source of quote bloat. Every visitor who adds a product to their cart and leaves creates a permanent quote record. You can reduce the lifetime of guest quotes specifically:
In etc/di.xml or via a custom module, override the quote lifetime for guest quotes:
<type name="Magento\Quote\Model\Quote\GarbageCollection">
<arguments>
<argument name="lifetime" xsi:type="number">7</argument>
</arguments>
</type>
This limits guest quotes to 7 days instead of the default 30. For B2B stores with logged-in customers only, you can set this even lower.
Step 5: Monitor Quote Table Health
Set up monitoring so you catch bloat before it becomes a problem:
-- Create a monitoring query for your dashboard
SELECT
'quote_health' AS metric,
COUNT(*) AS total_quotes,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active_quotes,
SUM(CASE WHEN is_active = 0 AND updated_at > DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END) AS recent_abandoned,
SUM(CASE WHEN is_active = 0 AND updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) AS stale_abandoned
FROM quote;
You can wrap this in a simple shell script that alerts when stale_abandoned exceeds a threshold:
#!/bin/bash
# check-quote-bloat.sh
THRESHOLD=50000
RESULT=$(mysql -u $DB_USER -p$DB_PASS $DB_NAME -N -e "
SELECT COUNT(*) FROM quote
WHERE is_active = 0
AND updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
")
if [ "$RESULT" -gt "$THRESHOLD" ]; then
echo "WARNING: $RESULT stale quotes (threshold: $THRESHOLD)"
# Send alert via Slack, email, etc.
fi
Real-World Case Study
A mid-size B2C fashion retailer came to us with checkout pages taking 6–8 seconds to load. The frontend was fully cached with Varnish, so catalog and product pages were fast. But checkout — being dynamic — was uncached and suffering.
Diagnosis:
-
quotetable: 8.2M rows (7.9M inactive) -
quote_itemtable: 22M rows -
quote_item_optiontable: 45M rows - Total quote-related storage: 12GB
Actions taken:
- Archived all inactive quotes older than 60 days (7.1M quotes)
- Batched deletes over 4 hours during a maintenance window
- Ran
OPTIMIZE TABLEon all quote tables (reclaimed 9GB) - Added composite index on
(is_active, updated_at) - Set up weekly cleanup cron with 30-day threshold
- Reduced guest quote lifetime to 7 days
Results:
- Checkout page load: 6.5s → 1.4s
- Cart page load: 4.2s → 0.9s
- Database size reduced by 9GB
- No recurrence after 6 months (quote table stays under 200K rows)
Common Pitfalls
Don't Delete Quotes with Orders
Always check that a quote hasn't been converted to an order before deleting:
-- Safe delete check
SELECT COUNT(*) FROM quote q
LEFT JOIN sales_order so ON q.entity_id = so.quote_id
WHERE q.is_active = 0
AND q.updated_at < DATE_SUB(NOW(), INTERVAL 90 DAY)
AND so.entity_id IS NULL;
Magento stores quote_id in sales_order, so this join is reliable.
Don't Forget the Search Temp Table
Magento creates temporary search tables during quote loading. If these are large, they can cause issues:
-- Check for orphaned search temp tables
SHOW TABLES LIKE 'search\_tmp\_%';
Don't Run OPTIMIZE TABLE on a Primary During Peak Hours
OPTIMIZE TABLE on InnoDB creates a temporary copy of the table. On large tables, this can consume significant disk space and I/O. Run it during maintenance windows or on a replica first.
Conclusion
Quote table bloat is one of the most overlooked performance issues in Magento 2. It doesn't show up in frontend cache hit rates or Lighthouse scores — it shows up in checkout conversion rates, which is where your revenue lives.
The fix is straightforward: audit, archive, delete in batches, optimize, and automate. A weekly cleanup cron plus a reduced guest quote lifetime will keep your quote tables lean and your checkout fast.
TL;DR checklist:
- ✅ Check
quoteandquote_itemrow counts - ✅ Archive inactive quotes older than 60–90 days
- ✅ Delete in batches to avoid table locks
- ✅ Run
OPTIMIZE TABLEto reclaim space - ✅ Add composite indexes for common queries
- ✅ Set up weekly cleanup cron
- ✅ Reduce guest quote lifetime to 7 days
- ✅ Monitor stale quote count with alerting
Your checkout page — and your conversion rate — will thank you.