--- title: Filters description: The JSON filter syntax used by discovery requests, collection scopes and merchandising rules — operators, values, grammar and evaluation rules. slug: discovery/filters docKind: reference hub: luigisbox-ai --- Filters are a JSON tree in the request body. One object restricts a result set: ```json { "$and": [ { "availability": { "eq": 1 } }, { "price": { "lt": 50 } }, { "brand": { "in": ["northwear", "atlas"] } } ] } ``` This page is the reference for that syntax. The node grammar and the evaluation rules are the same wherever Luigi's Box AI accepts a filter; what changes between surfaces is which operators the surface accepts. Discovery takes one on three endpoints: | Surface | Field | |---|---| | Search | `filters` | | Collections | `collection_filters` (the page's scope) and `filters` (the shopper's narrowing) | | Recommendations | `filters` | Nothing needs quoting or escaping: a value with spaces, commas or quotes is a JSON string, and a field name with a colon is a JSON key. ## Nodes A filter is a tree of **nodes**. Every node is one of two things. A **condition node** maps field names to operator objects: ```json { "brand": { "eq": "northwear" } } ``` A **group node** holds exactly one of `$and`, `$or` or `$not`: ```json { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] } ``` `$and` and `$or` take an array of nodes. `$not` takes a single node. A node may not mix the two forms — a `$`-key and a field name in the same object is a `422`. ### Everything adjacent is AND Two shorthands reduce `$and` wrappers. Several operators on one field combine as AND: ```json { "price": { "gte": 25, "lte": 75 } } ``` And several fields in one node combine as AND: ```json { "availability": { "eq": 1 }, "color": { "eq": "blue" } } ``` `$and` is needed to place a group beside a condition, or to repeat a field: ```json { "$and": [ { "availability": { "eq": 1 } }, { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] } ] } ``` There is no operator precedence; nesting is explicit. ## Operators | Operator | Means | Value | Example | |---|---|---|---| | `eq` | Equals | Scalar | `{ "color": { "eq": "blue" } }` | | `neq` | Does not equal | Scalar | `{ "color": { "neq": "blue" } }` | | `gt` `gte` `lt` `lte` | Numeric comparison | Scalar | `{ "price": { "gte": 25 } }` | | `in` | Is one of | Non-empty array | `{ "brand": { "in": ["northwear", "atlas"] } }` | | `not_in` | Is none of | Non-empty array | `{ "color": { "not_in": ["black", "white"] } }` | | `exists` | Field is present | `true` / `false` | `{ "image_url": { "exists": true } }` | `in` and `not_in` require at least one entry. There is no empty-set form — when a shopper has selected no brands, omit the condition rather than sending an empty array. ```js const clauses = [{ availability: { eq: 1 } }]; if (brands.length) clauses.push({ brand: { in: brands } }); const filters = clauses.length === 1 ? clauses[0] : { $and: clauses }; ``` `{ "exists": false }` is the same as wrapping `{ "exists": true }` in `$not`. Both mean "objects that do not carry this field at all". ## Fields A field is a catalog attribute name, used verbatim — the same name you indexed it under, as a JSON key. ```json { "color": { "eq": "blue" } } { "price": { "lte": 50 } } { "@category": { "in": ["category/shirts"] } } { "lbx:group_primary": { "eq": true } } { "params.material": { "eq": "cotton" } } { "my odd field": { "eq": 1 } } ``` Namespaced names contain a colon, nested attributes use a dot-path, and a name with spaces or punctuation needs nothing special — a JSON key holds any of them as-is. The only restrictions are that a field name must not begin with `$`, which is reserved for group keys, and must not have leading or trailing whitespace. To see which attributes a catalog holds, list its [attributes and their values](/indexing/catalog-metadata/). ## Values A value is a JSON scalar: a string, a number, a boolean, or `null`. ```json { "in_stock": { "eq": true } } { "price": { "gt": -0.5 } } { "category": { "eq": "musical instrument" } } { "brand": { "eq": "Atlas & Co" } } { "name": { "eq": "salt and pepper" } } ``` Types must match the field's. A number compared against a text field is a `filter.type_mismatch`. ## Size limits A filter tree is bounded by: | Limit | Value | |---|---| | Nesting depth | 10 levels; the root node is level 1 | | Nodes in one `$and` / `$or` array | 50 | | Fields in one condition node | 50 | Exceeding any of them is a `422`. Fifty selected brands are one `in` condition, not fifty nodes. ## Missing attributes Not every object has every attribute. When an attribute is missing: - A **positive** test on a missing attribute is false. `eq`, `in`, `gt`, `gte`, `lt`, `lte` do not match an object that lacks the attribute. - A **negative** test on a missing attribute is true. `neq` and `not_in` *do* match it. For example: ```json { "color": { "neq": "blue" } } ``` matches every object with no `color` at all, alongside the red and green ones. For "has a colour, and it is not blue", add `exists`: ```json { "color": { "neq": "blue", "exists": true } } ``` The same applies to a rule that bans products outside a category: `@category` with `neq` will fire on products with no category. ## Worked examples Everything in stock under €50, from two brands: ```json { "availability": { "eq": 1 }, "price": { "lt": 50 }, "brand": { "in": ["northwear", "atlas"] } } ``` Two categories, excluding clearance: ```json { "@category": { "in": ["category/shirts", "category/knitwear"] }, "clearance": { "neq": true } } ``` A price band, either of two colours: ```json { "$and": [ { "price": { "gte": 25, "lte": 75 } }, { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] } ] } ``` Only the primary product of each variant group: ```json { "lbx:group_primary": { "eq": true } } ``` Everything except what is already in the cart: ```json { "availability": { "eq": 1 }, "@id": { "not_in": ["product/sku-1001", "product/sku-2087"] } } ``` Only products that have an image: ```json { "image_url": { "exists": true } } ``` ## Building a filter from facet selections Rebuild the tree from the selected values on each request rather than mutating one across requests; this keeps the "clear all filters" case correct: ```js function buildFilters({ brands, maxPrice, inStockOnly }) { const clauses = []; if (brands.length) clauses.push({ brand: { in: brands } }); if (maxPrice != null) clauses.push({ price: { lte: maxPrice } }); if (inStockOnly) clauses.push({ availability: { eq: 1 } }); if (clauses.length === 0) return undefined; // omit the field entirely if (clauses.length === 1) return clauses[0]; return { $and: clauses }; } ``` Omit `filters` rather than sending `null` or `{}` when there is nothing to filter on: an empty object is not a valid node. ## When a filter is rejected A filter is checked in two stages, and the two rejections have different shapes. A tree that does not match the node grammar — an unknown operator, an empty `in` array, a node mixing `$and` with a field name, anything over a size limit — is rejected before the request is served, as a [validation error](/api-basics/errors-and-rate-limits/#validation-failures). Each entry's `loc` names the offending path: ```json { "reason": "Request validation failed", "exception_details": { "validation_errors": [ { "loc": ["body", "filters"], "msg": "Value error, filters.$and[2]: unknown group operator '$nor' (allowed: $and, $or, $not)", "type": "value_error" } ] } } ``` A tree that is well-formed but does not fit the catalog is rejected when it is applied, and carries an `error_code` instead: | `exception_details.error_code` | Cause | |---|---| | `filter.unknown_field` | The field is not present in this catalog | | `filter.type_mismatch` | The value's type does not match the field's — a number compared to text | A `filter.type_mismatch` usually means the attribute was indexed as text when you expected a number: `price` arriving as `"29.90"` rather than `29.90` in one feed run types the field as text for good. Check the field's type with [catalog metadata](/indexing/catalog-metadata/) and fix it at the [mapping](/indexing/mapping/), not in the filter. ## Other surfaces that filter Everything above about values, missing attributes and evaluation holds wherever a filter appears. Two things vary by surface: which operators it accepts, and how the tree is written. | Surface | Written as | Operators | |---|---|---| | Discovery — search, collections, recommendations | The map syntax on this page | The [nine above](#operators) | | [Business rules](/merchandising/business-rules/triggers/#condition-syntax) — `match` and `record_match` | `field` / `operator` / `value` leaves in a `conditions` array | Those nine, plus `matches` for substring and `range` for a bounded interval | | [Channel visibility](/concepts/catalogs-and-channels/#visibility-filters) — `visibility_filter` | The same tree as business rules | The same as business rules | ```jsonc // A business rules leaf, for comparison with a condition node above { "field": "brand", "operator": "eq", "value": "northwear" } ``` A surface rejects an operator it does not accept; each surface's own reference lists the operators it takes. For campaigns, the [trigger field metadata](/merchandising/business-rules/triggers/) reports the operators each field accepts. ## See also - [Search](/discovery/search/) · [Collections](/discovery/collections/) · [Recommendations](/discovery/recommendations/) - [Facets](/discovery/facets/) — turning filters into a sidebar - [Catalog metadata](/indexing/catalog-metadata/) — which fields and values exist - [Business rules](/merchandising/business-rules/overview/) — the same semantics, in a different tree shape