If you've ever watched bin/magento setup:upgrade run for 20 minutes on a production database while your deployment pipeline ticks away, you know the pain. On large Magento 2 installations with hundreds of modules and millions of records, setup:upgrade becomes the single biggest bottleneck in your CI/CD pipeline — often taking longer than the actual code deployment, static content generation, and cache warming combined.
The good news? Most of that time is wasted on operations you can optimize, skip, or parallelize. Let's break down exactly what setup:upgrade does, where it spends its time, and how to make it fast.
What Setup:Upgrade Actually Does
setup:upgrade is Magento's schema and data migration tool. When you deploy new code, it:
-
Checks module versions — reads every module's
module.xmlanddb_schema.xml -
Runs schema upgrades — executes
InstallSchema,UpgradeSchema, and declarative schema patches -
Runs data patches — executes
RecurringDataPatchand module-level data patches -
Updates
setup_moduletable — records the new module version -
Runs DI compilation — regenerates
generated/(if in production mode) - Flushes cache types — invalidates configs, layout, block_html, full_page
Steps 2 and 3 are where most of the time goes. If you have modules with poorly written data patches, N+1 queries in upgrade scripts, or schema operations that don't use batch inserts, setup:upgrade can crawl.
Step 1: Profile Before You Optimize
Don't guess — measure. Run setup:upgrade with the verbose flag and capture timestamps:
bin/magento setup:upgrade --verbose 2>&1 | while IFS= read -r line; do
echo "$(date '+%H:%M:%S') $line"
done | tee /tmp/setup-upgrade.log
This gives you a timestamped log showing exactly which modules take the longest. Look for:
- Long gaps between module lines — a single module's upgrade is slow
- Repeating patterns — a third-party module doing per-row inserts in a loop
- "Data upgrade..." messages — data patches running row-by-row instead of in bulk
For a deeper dive, enable MySQL's slow query log during the upgrade:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
Run setup:upgrade, then check mysql.slow_log for queries taking more than a second. You'll often find the same few queries repeated hundreds of times.
Step 2: Eliminate Unnecessary Module Upgrades
Not every deployment changes every module. If only 3 modules changed, why upgrade all 300?
Magento doesn't give you a native --only-module flag, but you can work around it. First, identify which modules actually changed version:
# Compare deployed versions vs new code versions
cd /var/www/html
bin/magento module:status --enabled | while read module; do
code_version=$(grep -oP 'setup_version="[0-9.]+"' app/code/*/etc/module.xml 2>/dev/null | grep -oP '[0-9.]+' | head -1)
db_version=$(bin/magento setup:db:status --module="$module" 2>/dev/null | grep -oP 'version: [0-9.]+' | grep -oP '[0-9.]+')
[ "$code_version" != "$db_version" ] && echo "$module: $db_version → $code_version"
done
If fewer than 10 modules changed, consider running schema patches manually or using a targeted approach. For full automation, build this check into your deploy script and skip setup:upgrade entirely when no modules changed version.
Step 3: Optimize Data Patches
Data patches (Patch/Schema and Patch/Data) are the #1 culprit for slow upgrades. Common anti-patterns:
Anti-pattern 1: Per-row inserts in a loop
// BAD — runs 50,000 queries
foreach ($productIds as $id) {
$this->connection->insert('catalog_product_entity_int', [
'attribute_id' => $attrId,
'entity_id' => $id,
'value' => 1,
]);
}
// GOOD — single batch insert
$rows = array_map(function ($id) use ($attrId) {
return ['attribute_id' => $attrId, 'entity_id' => $id, 'value' => 1];
}, $productIds);
$this->connection->insertMultiple('catalog_product_entity_int', $rows);
Anti-pattern 2: Loading full collections
// BAD — loads 50K products into memory
$products = $this->productCollectionFactory->create()->load();
foreach ($products as $product) { ... }
// GOOD — use direct SQL or iterator
$select = $this->connection->select()->from('catalog_product_entity', ['entity_id']);
$iterator = $this->connection->query($select);
while ($batch = $this->fetchBatch($iterator, 1000)) { ... }
Anti-pattern 3: Missing dependency declarations
If PatchB depends on PatchA, but the dependency isn't declared, Magento may run them in parallel or in the wrong order. The patch system uses getDependencies() to sequence execution. Missing dependencies cause deadlocks on large installs.
public static function getDependencies()
{
return [
\Vendor\Module\Setup\Patch\Data\AddColumnToProducts::class,
];
}
Audit your third-party modules' data patches — you'll be surprised how many have these anti-patterns. If vendor patches are slow, consider wrapping them or running them pre-deployment during off-peak hours.
Step 4: Mark Already-Applied Patches (Skip Redundant Work)
If you know certain data patches have already run and don't need to run again, you can mark them as applied in the database:
INSERT INTO setup_patches SET patch_name = 'Vendor\\Module\\Setup\\Patch\\Data\\SlowPatch',
patch_type = 'data', patch_version = '1.0.0', patch_id = NULL
ON DUPLICATE KEY UPDATE patch_name = patch_name;
This skips the patch on the next setup:upgrade run. Only do this if you're certain the patch has been applied — check with your deployment logs first.
For a safer approach, use Magento's --safe-mode=1 flag during staging deployments to verify patches before running them in production.
Step 5: Declarative Schema Over Upgrade Scripts
Declarative schema (db_schema.xml) is significantly faster than UpgradeSchema.php scripts because Magento generates a single diff and applies it in batch, rather than running arbitrary PHP per module.
If you maintain custom modules, migrate from UpgradeSchema.php to db_schema.xml. The migration is straightforward:
- Create
etc/db_schema.xmlmatching your current schema - Add
<schema>declaration inmodule.xml - Run
bin/magento setup:upgrade --convert-old-scripts=1to auto-generate the schema from existing upgrade scripts - Review and test the generated
db_schema.xml
Magento handles the diff, generates CREATE TABLE, ALTER TABLE, and DROP TABLE statements automatically. No PHP execution needed per upgrade cycle — Magento compares the declarative schema against the database and only runs the delta.
Step 6: Skip DI Compilation During Upgrade
If you generate compiled DI separately (in your CI pipeline), you don't need setup:upgrade to do it. Run:
bin/magento setup:upgrade --keep-generated
This skips the generated/ regeneration step, which can save several minutes on large installs. Just make sure you run setup:di:compile in your CI pipeline before deploying:
# In your CI pipeline
bin/magento setup:di:compile
# Deploy the generated/ directory with the code
# On the server, during deploy hook
bin/magento setup:upgrade --keep-generated
Important: Only use --keep-generated if you've already compiled DI for the exact code version being deployed. Mismatched generated code causes runtime errors.
Step 7: Parallelize Where Possible
Magento's setup:upgrade runs modules sequentially. While you can't natively parallelize it, you can:
- Run schema patches and DI compilation in parallel — schema patches finish first, while DI compilation runs in a separate process
- Pre-warm cache during upgrade — use a background process to warm full page cache while upgrade runs
- Use maintenance mode strategically — put the site in maintenance, run upgrade, then take it out. Don't leave the site live during upgrade.
bin/magento maintenance:enable
bin/magento setup:upgrade --keep-generated &
bin/magento setup:di:compile &
wait
bin/magento cache:clean
bin/magento maintenance:disable
Step 8: Database-Level Optimizations
The setup:upgrade process is I/O-bound on MySQL. These database-side optimizations make a measurable difference:
-
Increase
innodb_buffer_pool_size— ensure the entiresetup_moduleandsetup_patchestables fit in memory -
Tune
innodb_flush_log_at_trx_commit— setting to2during maintenance windows gives a 2-3x write speedup at the cost of ACID guarantees (safe for deployment windows) - Drop unused indexes before upgrade — if a data patch recreates indexes, dropping them first and recreating after is faster than incremental updates
-
Increase
innodb_io_capacity— during maintenance, allow MySQL to use more I/O throughput for faster table operations
-- Temporarily boost InnoDB performance during maintenance
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL innodb_io_capacity = 2000;
SET GLOBAL innodb_io_capacity_max = 4000;
-- After upgrade completes
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL innodb_io_capacity = 200;
SET GLOBAL innodb_io_capacity_max = 2000;
Remember to reset these values after the upgrade completes. Running with relaxed durability settings permanently risks data loss on power failure.
Step 9: Audit Third-Party Modules
Third-party modules often ship with heavy upgrade scripts. Common offenders:
- ERP integrations — creating custom tables with initial data seed
-
Payment gateways — adding columns to
sales_orderwith default values on large tables - Search extensions — rebuilding entire index tables during upgrade
Audit the Setup/Patch/ directories of your installed modules. If you find slow patches, contact the vendor or patch them locally:
# Find all data patches across vendor modules
find vendor/ -path '*/Setup/Patch/Data/*.php' -exec basename {} \;
For the worst offenders, you can often refactor the patch to use batch operations and submit a PR upstream. Many vendors are responsive to performance PRs, especially if you include benchmarks showing the before/after timings.
Step 10: Track Upgrade Duration Over Time
Once you've optimized, keep an eye on regressions. Log setup:upgrade duration on every deployment:
start=$(date +%s)
bin/magento setup:upgrade --keep-generated
end=$(date +%s)
duration=$((end - start))
echo "$(date -Iseconds) setup_upgrade_duration=${duration}s" >> /var/log/magento-deployments.log
Graph this in Grafana, Datadog, or your monitoring tool of choice. When a new deployment suddenly takes 2x longer, you'll know immediately which module introduced a slow patch. Set up alerts for when upgrade duration exceeds your baseline by more than 50%.
Conclusion
setup:upgrade doesn't have to be the bottleneck in your deployment pipeline. By profiling what's slow, optimizing data patches, migrating to declarative schema, skipping unnecessary work, and parallelizing where you can, large Magento 2 installs can go from 20-minute upgrades to under 5 minutes.
The key takeaways:
- Profile first — don't optimize blind, use verbose logging and MySQL slow query log
- Batch everything — per-row operations in data patches are the #1 performance killer
-
Use
--keep-generated— skip DI compilation during upgrade if it's done in CI - Migrate to declarative schema — it's faster, declarative, and auto-generates diffs
- Audit third-party modules — vendor patches are often the worst offenders
- Track duration over time — catch regressions before they become deployment-day emergencies
Fast deployments mean more deployments. More deployments mean smaller changes, faster feedback, and less risk. Every minute you shave off setup:upgrade pays dividends across your entire development workflow.
Top comments (0)