I had a category page doing something expensive. For every product in the grid it called ProductRepositoryInterface::getById() to read two custom attributes. That is the textbook N+1: on a category with 300 products, 300 separate product loads. The page felt every one of them.
The fix looks obvious. Stop loading products one at a time and pull them in a single collection query:
$collection = $this->collectionFactory->create()
->addAttributeToSelect(['custom_a', 'custom_b'])
->addIdFilter($productIds);
One query instead of 300. The page got faster. And a few products quietly vanished from the result.
getById() and a collection are not the same query
It is tempting to read the swap as "same rows, fewer queries." In the frontend area, that is not true. The two return different sets of rows.
Magento's CatalogInventory module attaches an in-stock filter to product collections in the frontend when the "Display Out of Stock Products" setting is off. Off is the default. So a plain product collection loaded on the storefront quietly excludes every out-of-stock product before your code ever sees it.
ProductRepositoryInterface::getById() does no such thing. Ask it for an out-of-stock product by id and you get the product. That asymmetry is the whole bug. The repository answers "does this product exist"; the frontend collection answers "is this product buyable right now," and nobody told you the question changed.
Why it is silent
There is no exception. Nothing in the logs. The collection simply returns fewer rows than the ids you passed in.
If your code iterates the collection to build a map keyed by product id, the out-of-stock ids are not wrong values. They are missing keys. Any lookup that expects them falls through to its default branch. An empty label, a skipped badge, a price that never gets set, with no signal that anything went wrong.
That is what makes it dangerous. Every test written with in-stock fixtures passes. The failure only shows up on a real catalog, where some fraction of products is always out of stock, and it usually arrives weeks later as a vague "why is this product missing its custom label" ticket.
The setting behind it
The switch lives at Stores > Configuration > Catalog > Inventory > Stock Options > Display Out of Stock Products. Under the hood it is cataloginventory/options/show_out_of_stock. Most stores leave it off, because most stores do not want to list things nobody can buy. That default is sensible for a category page and surprising inside your custom code.
The filter itself comes from Magento\CatalogInventory\Helper\Stock::addIsInStockFilterToCollection(), applied to storefront product collections. Knowing the method name is enough to grep for it and see exactly where the join gets added.
Three ways out
Need every product regardless of stock? A frontend product collection is the wrong tool, or you opt out of the stock filter deliberately for that one collection. Do it explicitly and leave a comment, because the next person will assume the filter belongs there.
Correctness matters more than the N+1? Keep the repository. An N+1 across 300 products is a real cost, but returning the wrong data quickly is worse than returning the right data slowly. Measure before you assume the collection is the win.
Want both? Load the ids you actually need with a resource model or a raw select you control, instead of a catalog product collection that Magento has been quietly editing. You get the single-query speed without inheriting the storefront's stock semantics.
The general lesson
The trap is not specific to inventory. Any time you replace a per-entity repository call with a collection to kill an N+1, you inherit whatever plugins, observers, and area-specific filters that collection carries. The speed-up is real. The "behaviour-preserving" assumption is the part to check.
A faster query that returns a different answer is not an optimisation. It is a bug with good latency.