Magento 2's Staging module (Magento_Staging) is one of those Enterprise-only features that sounds great in a sales pitch — schedule product updates, campaign prices, CMS blocks, and category changes to go live at specific times without manual intervention. But under the hood, it introduces a parallel universe of database tables, version-tracking logic, and join conditions that can quietly degrade performance across your entire store.
If you're running Magento Commerce (or Adobe Commerce / Magento Enterprise) and you've ever noticed that product collection queries got slower after setting up a few scheduled updates, or that your admin panel takes ages to load the campaign grid, this post is for you.
How Staging Actually Works (And Why It's Expensive)
The staging system works by maintaining versioned copies of entities. When you schedule an update for a product, Magento doesn't just flag it — it creates a row in the staging_update table, links it to the entity via an intersection table (like catalog_product_entity_datetime_staging, catalog_product_entity_int_staging, etc.), and at query time, it joins these tables to determine which version of each attribute is "current" based on the current timestamp.
This means that every product query on a store with staging enabled potentially joins against the staging intersection tables. The Magento_Staging module modifies collection loading to add these joins automatically. You don't see them in your code — they're injected via plugins on the collection factory — but they show up in your MySQL slow query log.
The Hidden Join Problem
Here's what a typical product collection query looks like without staging:
SELECT e.* FROM catalog_product_entity e
WHERE e.entity_id IN (1, 2, 3, ...)
With staging enabled, the same query gets augmented:
SELECT e.*, staging_table.value AS staged_value
FROM catalog_product_entity e
LEFT JOIN catalog_product_entity_int_staging staging_table
ON e.entity_id = staging_table.entity_id
AND staging_table.attribute_id = 96
AND staging_table.row_id = e.row_id
AND staging_table.created_in <= 1691232000
AND staging_table.updated_in > 1691232000
WHERE e.entity_id IN (1, 2, 3, ...)
Now multiply that by every attribute that supports staging (price, status, visibility, custom attributes...), and you're looking at a query that went from a simple primary-key lookup to a multi-table join with timestamp range conditions on a potentially large intersection table.
Diagnosis: Measuring Staging Overhead
Step 1: Compare With and Without Staging
The cleanest way to measure staging overhead is to temporarily disable the staging plugins and compare query performance. You can do this in a staging environment (not production!) by disabling the Magento_Staging module's plugins:
# In a dev/staging environment only
bin/magento module:status Magento_Staging
Don't actually disable the module in production — it's a core Enterprise dependency. Instead, use this approach:
Step 2: Profile the Queries
Enable MySQL slow query logging with a threshold of 0.5 seconds, and look for queries that reference _staging tables:
-- In my.cnf or at runtime
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
After collecting 24 hours of data, grep for staging-related queries:
grep -i '_staging' /var/log/mysql/slow.log | head -20
Step 3: Check Intersection Table Sizes
The staging intersection tables grow proportionally to the number of scheduled updates you've ever created. Even cancelled or expired updates may leave rows behind.
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE '%_staging%'
ORDER BY table_rows DESC;
If you see tables with hundreds of thousands of rows, you've found your problem.
Optimization Strategies
1. Clean Up Expired Staging Updates
The biggest win is usually just cleaning up old staging data. Magento doesn't automatically purge expired updates — they stay in the intersection tables forever.
Here's a safe cleanup script that removes staging updates older than 90 days that are no longer active:
<?php
// cleanup-staging.php — run via bin/magento or CLI
use Magento\Framework\App\ResourceConnection;
class StagingCleanup
{
private ResourceConnection $resource;
public function __construct(ResourceConnection $resource)
{
$this->resource = $resource;
}
public function execute(): void
{
$connection = $this->resource->getConnection();
$cutoff = time() - (90 * 24 * 60 * 60); // 90 days ago
// Find expired staging updates
$expiredUpdates = $connection->select()
->from('staging_update', ['id'])
->where('updated_in < ?', $cutoff)
->query()
->fetchAll(\Zend_Db::FETCH_COLUMN);
if (empty($expiredUpdates)) {
echo "No expired staging updates found.\n";
return;
}
echo sprintf("Found %d expired updates. Cleaning up...\n", count($expiredUpdates));
// Remove from intersection tables
$stagingTables = $this->getStagingTables();
foreach ($stagingTables as $table) {
$deleted = $connection->delete(
$table,
['created_in IN (?)' => $expiredUpdates]
);
if ($deleted > 0) {
echo sprintf(" %s: %d rows removed\n", $table, $deleted);
}
}
// Remove the staging update records themselves
$connection->delete('staging_update', ['id IN (?)' => $expiredUpdates]);
echo "Cleanup complete.\n";
}
private function getStagingTables(): array
{
$connection = $this->resource->getConnection();
$tables = $connection->select()
->from('information_schema.tables', ['table_name'])
->where('table_schema = ?', $connection->getCurrentDatabase())
->where('table_name LIKE ?', '%_staging')
->query()
->fetchAll(\Zend_Db::FETCH_COLUMN);
return $tables;
}
}
Schedule this as a weekly cron job to keep intersection tables from growing indefinitely.
2. Add Composite Indexes on Intersection Tables
The default schema for staging intersection tables often lacks the composite indexes that would make the timestamp range joins efficient. Check the existing indexes:
SHOW INDEX FROM catalog_product_entity_int_staging;
You'll typically see an index on entity_id but not on the combination of (entity_id, created_in, updated_in). Add it:
ALTER TABLE catalog_product_entity_int_staging
ADD INDEX idx_entity_staging_range (entity_id, created_in, updated_in);
ALTER TABLE catalog_product_entity_decimal_staging
ADD INDEX idx_entity_staging_range (entity_id, created_in, updated_in);
ALTER TABLE catalog_product_entity_datetime_staging
ADD INDEX idx_entity_staging_range (entity_id, created_in, updated_in);
ALTER TABLE catalog_product_entity_text_staging
ADD INDEX idx_entity_staging_range (entity_id, created_in, updated_in);
ALTER TABLE catalog_product_entity_varchar_staging
ADD INDEX idx_entity_staging_range (entity_id, created_in, updated_in);
This index directly supports the join condition that Magento's staging plugin injects, turning full table scans into index range scans.
3. Limit Staged Attributes
Not every attribute needs staging support. By default, Magento stages a broad set of product attributes. If you're scheduling updates for only price and status, you don't need the description, short_description, or custom attributes to participate in staging joins.
You can review which attributes are staged by checking the is_staging column in the catalog_eav_attribute table:
SELECT a.attribute_code, a.frontend_label, cea.is_staging
FROM eav_attribute a
JOIN catalog_eav_attribute cea ON a.attribute_id = cea.attribute_id
WHERE cea.is_staging = 1
ORDER BY a.attribute_code;
For attributes that don't need scheduling, set is_staging = 0:
UPDATE catalog_eav_attribute
SET is_staging = 0
WHERE attribute_id IN (
SELECT attribute_id FROM eav_attribute
WHERE attribute_code IN ('description', 'short_description', 'meta_title', 'meta_description')
);
This reduces the number of joins Magento injects into product collection queries.
4. Optimize the Campaign Grid in Admin
The admin campaign grid (Staging > Content > Campaigns) loads all staging updates with their associated entities. On stores with many campaigns, this grid can take 10+ seconds to load.
The underlying query joins staging_update with entity intersection tables to count how many products, categories, or CMS blocks are assigned to each campaign. On large catalogs, this becomes a massive aggregate query.
Fix: Add an index on staging_update for the is_active and created_in columns:
ALTER TABLE staging_update
ADD INDEX idx_active_created (is_active, created_in);
If you have hundreds of campaigns, also consider archiving old ones. There's no built-in archive feature, but you can use the cleanup script from step 1 with a shorter cutoff (e.g., 30 days for completed campaigns).
5. Batch Scheduled Updates Instead of Creating Individual Ones
Each scheduled update creates a row in staging_update and rows in every relevant intersection table. If you need to update 1,000 products' prices for a weekend sale, don't create 1,000 separate staging updates. Create one update and assign all 1,000 products to it.
Magento's admin UI already supports this — when creating a campaign, you can select multiple products at once. But if you're creating updates programmatically via the StagingInterface or UpdateRepositoryInterface, make sure you're batching:
// BAD — creates 1000 staging updates
foreach ($productIds as $productId) {
$stagingManager->assign(UpdateFactory::create(), [$productId]);
}
// GOOD — creates 1 staging update with 1000 entities
$stagingManager->assign(
UpdateFactory::create()->setId($updateId)->setName('Weekend Sale'),
$productIds
);
6. Disable Staging for Headless/API-Only Stores
If you're running a headless Magento where the storefront doesn't use the staging preview functionality, you can disable the frontend staging plugins while keeping the admin staging module active:
<!-- etc/di.xml in a custom module -->
<type name="Magento\Catalog\Model\ResourceModel\Product\Collection">
<plugin name="stagingProductCollectionPlugin" disabled="true"/>
</type>
This removes the staging joins from storefront product collections while keeping the admin campaign management intact. Test thoroughly before deploying — this only works if your headless frontend doesn't rely on staged attribute values being resolved at query time (i.e., you handle scheduling in your frontend layer instead).
7. Cache Staged Entity Data Aggressively
Since staged entities change at known times (the created_in / updated_in timestamps), you can cache the resolved attribute values with a TTL that expires at the next staging boundary.
For Varnish full-page cache, this works automatically — the cache key includes the current timestamp, and a purge at the staging transition time refreshes the page. But for block-level cache (like the product detail page's price block), you may need custom cache key logic:
// In a custom module, add the next staging time to the cache key
public function getCacheKeyInfo()
{
$keyInfo = parent::getCacheKeyInfo();
$nextStagingTime = $this->stagingData->getNextUpdateTime($this->getProduct()->getId());
$keyInfo['next_staging'] = $nextStagingTime;
return $keyInfo;
}
This ensures that cached blocks are invalidated exactly when a staging update activates, not on every request.
Monitoring: Set Up Alerts for Staging Table Growth
Set up a monitoring check that alerts you when staging intersection tables exceed a threshold:
-- Run weekly via monitoring script
SELECT
CONCAT(table_name, ': ', FORMAT(table_rows, 0), ' rows') AS staging_growth
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE '%_staging%'
AND table_rows > 10000
ORDER BY table_rows DESC;
If any table exceeds 50,000 rows, it's time to run the cleanup script or investigate whether you're creating too many granular staging updates instead of batching them.
Summary
Staging is a powerful feature for merchants who need to schedule campaigns, but it comes with a real performance cost that Magento doesn't document prominently. The key takeaways:
- Clean up expired staging updates regularly — they pile up in intersection tables forever
-
Add composite indexes on
(entity_id, created_in, updated_in)for all staging intersection tables - Limit which attributes support staging — not every attribute needs version tracking
- Batch your scheduled updates — one campaign for 1,000 products, not 1,000 campaigns
- Monitor staging table growth — set up alerts before they become a problem
- Consider disabling frontend staging plugins for headless stores
If you're seeing unexplained slowdowns on product collection queries and you have Magento Commerce with staging enabled, check your intersection table sizes first. It's almost always the culprit.
Need help auditing your Magento 2 staging setup? Get in touch — Magevanta specializes in Magento 2 performance optimization for Enterprise and Commerce installations.
Top comments (0)