A brand filter should narrow the catalog to Nike, Adidas, or the house brand, without rejoining the term tables on every click. In WooCommerce a brand is a taxonomy, just like a category, only flat: a product belongs to Nike, not to a child of Nike. InstantFilter treats that brand archive as context. The first HTML is server-side. Every subsequent click on brand, size, or color happens in the browser, in 1.5ms to 5ms, with 0 SQL.
What is filtering by brand in WooCommerce?
There are two places where a visitor expects “brand”, and you must not confuse them.
- The brand archive. The URL is the brand itself, for example
/brand/nike/. The collection is everything from that brand. Inside it the visitor still filters by category, size, color, and price. - The brand facet on a category. The URL is “Men > Sneakers”. Brand is one of the filters next to size and color. The visitor narrows that category to one or more brands.
Both are archive filters, not a sitewide search. Someone who types “Nike” in the header starts a search engine. Someone who clicks the brand archive or the brand facet creates an intersection. That distinction is covered in the guide search vs filtering on category archives. A brand name in the search bar and a brand term in the filter are not the same index.
WooCommerce has its own brand taxonomy: product_brand. Plugins that introduced brands earlier often register their own taxonomy. Perfect Brands for WooCommerce uses pwb-brand. InstantFilter treats both, plus every other public product taxonomy, as archive context. Attributes that start with pa_ — a global attribute “Brand” — are not a taxonomy archive in that picker. Those run as a facet property on the product, not as /brand/nike/, unless you have set them up yourself as a taxonomy archive.
Why do brand filters get slow from term joins?
A product links to a brand via wp_term_relationships. The term itself lives in wp_terms, the taxonomy link in wp_term_taxonomy. An AJAX filter that wants “only Nike, size 42, blue” builds a tax_query with several of those joins, and often also a meta_query on wp_postmeta for price or stock. Every extra facet is an extra join. Every visitor who clicks boots WordPress through admin-ajax.php and occupies a PHP worker.
On a shop with a handful of brands that still works. On a multi-brand catalog with thousands of SKUs and dozens of brands, wp_term_relationships grows with every product. The query then has to find not only “this brand”, but also the intersection with category, size, and color, plus a COUNT for every remaining option. That is the same class of problem as slow attribute filters: the database does work per click that the browser can do once the set is known. At tens of thousands of products that click becomes the spinner of 800ms to 2.5 seconds.
A page cache helps the brand URL on the first load, and fails as soon as the filter becomes a unique AJAX combination. Every brand-plus-size combination is a cache miss. The archive page stays cacheable only when the click no longer needs the server.
How does InstantFilter scope a brand archive?
When the visitor is on the archive of one brand, that brand is the context, just like a category. FilterContext includes every product taxonomy attached to product, including product_brand. WooCommerce category and tag are always included there, even if a request has not registered the taxonomy yet. Via the filter if_context_taxonomies you can add or exclude a taxonomy.
The server query for that first render does not search wp_term_relationships per facet click. It scopes the listing via if_terms_map: which indexed items hang on this term. A hierarchical taxonomy, such as a category, includes child terms. A brand is flat. get_term_children() then returns nothing, and that is the desired behavior: only Nike, not an imaginary child of Nike.
That scoping happens when the set is built, not on every click after that. After hydration those items live in the browser as contextItems. Size, color, and price filter on top of that, with 0 SQL. The counts work as in the guide on dynamic facet counts: an option at 0 disappears, the selected option stays, and a facet’s count ignores that facet’s own selection.
How does a brand facet work inside a category?
On “Sneakers”, brand is not an archive, but a facet. The product carries the brand as a term or as a property in the codebook. A click on Nike filters contextItems of that category down to the items with that brand code. The inverted index maps the option to item IDs when the codebook has that index for the property. Otherwise the engine compares the code on item.props.
The count “Nike (24)” is the number of products in the current intersection, not a pre-baked number from WordPress’s term count. That term count is the number of products with that brand across the whole shop, or at best in the category at load time. As soon as the visitor picks size 42, Nike should only count the sneakers that are both Nike and size 42. A brand that then drops to 0 disappears from the list. The brand that is on stays visible, so the visitor can turn it off.
Checking multiple brands follows the same self-exclude rule. The grid keeps Nike and Adidas. The counts inside the brand block ignore the brand selection and hold size and color fixed. That way you see whether Puma still adds anything before you check it. That number can be higher than the number of cards, because the cards do follow the brand selection and the count inside the brand block ignores it.
Why is a brand not a category with children?
Categories are hierarchical. “Shoes” counts “Sneakers” along, because get_term_children() returns those terms and if_terms_map puts them in the set. Brands are not. A Nike archive must not show products of another brand because someone somewhere created a child term the plugin does not expect. InstantFilter follows the taxonomy: flat stays flat.
That also prevents a class of count errors. A parent category that includes children should show a higher number than the sum of only the parent term. A brand that secretly includes children shows products the URL does not promise. The code does the opposite: no children, so the URL and the set stay the same brand.
If you do use a brand as a hierarchy — a house brand with sub-lines — that taxonomy behaves like a category, including children, as soon as WordPress registers it as hierarchical. The engine does not look at the name “brand”. It looks at whether get_term_children() returns anything.
How do a term join, an attribute filter, and InstantFilter compare?
| AJAX on wp_term_relationships | Brand as pa_ attribute | InstantFilter on the brand taxonomy | |
|---|---|---|---|
| Where the brand lives | Term tables, again on every click | Global attribute, often also in postmeta | Taxonomy, indexed in if_terms_map |
| Archive URL | Only if the taxonomy has an archive | Usually no archive of its own | Yes, if the taxonomy is an archive |
| Child brands | Depends on the query | Not applicable | No, unless the taxonomy is hierarchical |
| SQL per click | Joins plus COUNT per option | meta_query or tax_query | 0 after hydration |
| Count after size 42 | New query | New query | In memory |
| Latency | 800ms – 2,500ms | Same class as AJAX | 1.5ms – 5ms |
The architecture behind that last column is the same as in AJAX vs frontend-first filtering. The brand does not add a second engine. It is a term in the set the browser already has.
How do you set up a brand archive and a brand facet?
You do not replace the brand taxonomy. You put the archive and the facet into the same engine. The layout of the grid, the card, and the brand filter lives in the Layout Builder, not in a page-builder loop.
- Pick the taxonomy the shop already uses. That is
product_brandwhen WooCommerce manages brands, orpwb-brandwhen Perfect Brands manages them. Do not put a second brand list next to it as an attribute, unless you deliberately want to maintain two systems. - Let the archive be respected. On the brand template the listing must follow the WordPress context. Otherwise
/brand/nike/shows the whole catalog and the brand is only a loose facet. - Place it. In Bricks one of the four native elements in the taxonomy template. In Elementor the archive shortcode. On a category you place brand as a facet in the same listing.
- Brand on the card. The card can show
taxonomy.product_brand. That source is in the card presets. The label on the card is display. The filter reads the indexed term, not the text on the card. - Verify. Open a brand archive. The first HTML contains only products of that brand. Click a size. The other sizes that brand does not have in that size disappear, with no request to
admin-ajax.php. Then open a category and click one brand. The counts of the other brands update with it. A brand at 0 disappears. The brand that is on stays.
What does the index store for a brand?
During indexing InstantFilter links every item to its terms in if_terms_map. That table is the trail a filter click would otherwise search in wp_term_relationships. The SSR query of a brand archive joins there once: item_id plus the taxonomy, limited to the term ID of that brand. If the listing uses its own query instead of the archive, it is the same table, but as EXISTS, and only when the listing has the option respect_context turned on.
Without that option a listing ignores the brand in the URL. The page is called Nike and the grid is the whole catalog. The brand is then at best a checkbox the visitor still has to tick. On a real archive — the query context is archive, not a loose listing — that scoping always applies. FilterContext sets the taxonomy query var to the slug of the current term, whether that is product_brand or a custom brand taxonomy.
The card presets can use taxonomy.product_brand as a source. That is the text on the card. The filter reads the same term via the index, not via that visible text. A different label on the card does not change the facet. In the taxonomy picker of the cards, product_brand sits at the top, then pwb-brand, then category and tag. Attributes with pa_ are missing there: those are Woo attributes and belong with the properties, not with the block that can follow an archive URL.
Which mistakes do multi-brand shops make?
You see three mistakes come back, and none of the three is a hosting problem.
- The brand exists twice. The shop has
product_brandand a global attributepa_brand, filled by an import that does not keep them in sync. The archive URL follows the taxonomy. A facet on the attribute follows the other list. The visitor sees Nike on the card and a different Nike in the filter. Pick one source. - The listing on the brand template does not follow the URL.
respect_contextis off. The first HTML then already contains the wrong set, and the browser cannot fix that mistake: it only filters what the server handed over. - The count is WordPress’s term count. That count is the number of products with that term, updated when someone saves a product. It knows nothing about the size the visitor just picked. Suppose the category has 240 Nike sneakers. After size 42 and blue that can be 9. Those 240 and 9 are a worked example, not a measurement. The engine counts the set that remains, not the number WordPress stores on the term.
Does the brand archive stay cacheable?
The first HTML of the brand archive is a normal archive page. A page cache can serve that URL. The click on size or color after that does not hit admin-ajax.php, so that combination does not need its own cache entry. The brand URL is cacheable. The intersection lives in the browser.
A filter that builds a unique AJAX response on every brand click turns every combination into a cache miss. Ten brands, eight sizes, and twelve colors are hundreds of combinations the cache never keeps warm. The click in the browser lets the brand URL cache once. That is the same pattern as with category archives, not a separate cache layer for brands.
What do you see when you test it?
Open the brand archive with the network tab. The document contains the products of that brand in the HTML. Then click a size. There should be no new document and no request to admin-ajax.php. The counts of other facets may change, because that size made the set smaller. The brand itself remains the context of the URL. You do not remove that context by turning a facet off.
On a category it works the other way around. The URL is the category. Brand is a checkbox. Turning Nike off brings the other brands back, inside that category. If the codebook has an inverted index for that property, the option maps to item IDs. Without that index the engine compares the code on item.props. Both paths are memory, not SQL. The grid adapts to that taxonomy archive even when it is not a category or tag: product_brand and a custom brand taxonomy belong to the same check.
Filter on the brand taxonomy when
- Every product has one brand: the brand URL and the set must be the same.
- Visitors narrow a category by brand: the count should be the intersection, not the term count of the whole shop.
- The catalog is large: a join on wp_term_relationships per click does not scale; the set in the browser does, until the codebook grows too large.
Do not expect this route when
- Brand is only a free text field: without a taxonomy or attribute there is no facet to count.
- You also want to find pages by brand: this is a product archive, not a header search bar.
- The catalog sits well above 100,000 SKUs: then the codebook becomes the limit, not the brand term itself.
Frequently asked questions about WooCommerce filtering by brand
Go deeper on brands and archive architecture
A brand is a term in the archive. These guides continue on that architecture:
- Dynamic facet counts without SQL
- Search vs filtering on category archives
- AJAX vs frontend-first filtering
- Slow queries and wp_postmeta
- Filter 50,000 products without server load
- See InstantFilter licenses and pricing
Filter by brand without joining the term tables on every click
Try InstantFilter for 14 days. Open a brand archive and a category, and see whether the brand click lands in milliseconds.