Unlocking Lightning-Fast Magento 2 Product Listings: A Deep Dive into N+1 Price Query Optimization

Unlocking Lightning-Fast Magento 2 Product Listings: A Deep Dive into N+1 Price Query Optimization

As e-commerce migration experts at Shopping Mover, we constantly monitor the pulse of the Magento ecosystem, especially when it comes to performance. A critical area for any online store is the speed at which product listings load. Slow category or search results pages can severely impact user experience and conversion rates. Recently, a significant GitHub issue (magento/magento2#41532) shed light on a long-standing performance bottleneck related to how Magento 2 loads product prices, and more importantly, presented a robust solution.

The N+1 Query Challenge in Magento 2 Pricing

The core problem identified was an "N+1 query" issue affecting product listing pages. Specifically, rendering a product listing would trigger one catalogrule_product_price query and two catalog_product_entity_tier_price queries for each product displayed. This meant that if you had 24 products on a page, you could be looking at 24-72 unnecessary database queries just for pricing data. This linear scaling of queries directly translated to slower page load times, especially for stores with large catalogs or complex pricing rules.

Interestingly, Magento 2 already possessed the underlying "machinery" to avoid this. Mechanisms like addTierPriceData() and the prepare_catalog_product_collection_prices event, coupled with Magento\CatalogRule\Observer\RulePricesStorage, were designed to fetch prices for an entire collection in a single bulk query. These were already in use for other parts of Magento, such as quote items and bundle products.

Why Bulk Pricing Wasn't Working for Listings

The issue detailed two primary reasons why this bulk pricing mechanism failed for product listings:

  1. The Prefetch Key Mismatch: The observer responsible for storing prefetched rule prices used a key derived from TimezoneInterface::scopeTimeStamp() (including the time component). However, every consumer of this storage (like price rendering observers) looked up prices using TimezoneInterface::scopeDate(), which zeroes out the time component. This fundamental mismatch meant that, except for a brief moment at midnight, the stored prices could never be retrieved, forcing individual product queries.
  2. Missing Collection Preparation: The Magento\Catalog\Block\Product\ListProduct block, responsible for rendering category and search results, simply did not call addTierPriceData() or dispatch the prepare_catalog_product_collection_prices event. Without these calls, the bulk loading process was never initiated for listings. Additionally, CatalogRulePrice::getValue() bypassed the RulePricesStorage entirely, querying the database directly.

The Solution: A Multi-faceted Performance Boost

The proposed solution, outlined in a series of six commits, addresses these issues comprehensively:

  • Key Alignment: The PrepareCatalogProductCollectionPricesObserver now derives its storage key using scopeDate(), ensuring it matches what consumers look up.
  • Storage Consultation: CatalogRulePrice now correctly consults RulePricesStorage before executing a database query.
  • Listing Integration: Magento\Catalog\Block\Product\ListProduct is updated to call addTierPriceData() and dispatch the prepare_catalog_product_collection_prices event once its collection is loaded, enabling bulk price fetching.
  • Configurable Product Support: A new observer in Magento_CatalogRuleConfigurable now prefetches rule prices for configurable product children in bulk.

The impact of these changes is significant. For a category page with simple products, catalogrule_product_price queries dropped from 14 to just 1, and per-product tier price queries went from 14 to 0. On a configurable product page, catalogrule_product_price queries decreased from 24 to 2, and tier price queries from 48 to 24. These improvements translate directly to faster page loads and a smoother shopping experience.


// Example of the key mismatch fix:
// Before: $writer = (new \DateTime())->setTimestamp($localeDate->scopeTimeStamp($store));
// After:  $writer = $localeDate->scopeDate($store->getId()); // Used for both writer and reader

A Known Limitation and Future Outlook

While a major stride, the solution acknowledges a remaining limitation: tier prices for child products of configurable products are still loaded one at a time. This is due to the complexity of selecting the cheapest child across multiple parents in a bulk query, which would require a more fundamental change to how these prices are resolved. This area is earmarked for future development, likely through a new bulk resolver interface.

For Magento users and developers, this issue and its resolution highlight the ongoing commitment to optimizing the platform's core performance. Understanding such deep-seated architectural improvements is crucial for anyone building, maintaining, or migrating a Magento store.

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools