Graphql filter multiple values master advanced filtering techniques

Graphql filter multiple values master advanced filtering techniques

To graphql filter multiple values means to request only the records that match any one of several criteria for a single field — all within one query. Instead of making three separate API calls to fetch users with status active, pending, and suspended, you pass an array and get everything back at once.

This pattern is the backbone of real-world filtering interfaces: multi-select dropdowns, faceted search, tag-based content discovery. Done right on the server side, it replaces fragile client-side filtering logic and dramatically reduces payload size. Done wrong, it creates N+1 query nightmares and unmaintainable resolver code.

This guide covers the full picture: schema design, query syntax, resolver implementation, performance pitfalls, and the differences between platforms like Hasura, Apollo, and Prisma — with real code you can adapt immediately.

Key Takeaways

  • Single Request, Multiple Criteria: The in operator lets you match any value from an array in one query, eliminating redundant API calls.
  • Schema-First Design: Defining a typed FilterInput in your schema keeps filtering logic consistent, validated, and self-documented.
  • AND vs OR Logic: Multiple fields in a filter object default to AND in most implementations; OR requires explicit handling with a dedicated field or operator.
  • Backend Indexing Is Non-Negotiable: Multi-value filters without proper database indexes cause full-table scans that will not scale.
  • Platform Syntax Varies: Hasura, Apollo/Prisma, and Contentful each use different filter syntax — always check the schema introspection first.

Who this guide is for

This is for frontend developers consuming GraphQL APIs who need to pass complex filter arguments correctly, and for backend developers who need to design filter input types, write resolver logic, and keep queries performant at scale. Every section includes working code examples you can copy and adapt.

GraphQL Filtering Fundamentals

GraphQL does not have a built-in filter keyword in the spec itself. Filtering is implemented through arguments on query fields, with the shape of those arguments defined entirely in your schema. This means the syntax you use depends heavily on which GraphQL server or platform you are working with.

What is universal is the pattern: you define an input type that describes the available filter conditions, pass it as an argument to a query field, and the resolver translates it into a database query. The declarative nature of GraphQL means clients specify exactly what they want — including the filter criteria — rather than relying on the server to anticipate every filtering scenario.

AspectREST API FilteringGraphQL Filtering
Query StructureMultiple endpoints or long query stringsSingle endpoint, typed arguments
Data OverfetchingCommon — server decides what to returnEliminated — client specifies fields
Filter ComplexityLimited by URL parameter conventionsRich input types with nested conditions
ValidationManual, often inconsistentSchema-enforced, introspectable
Self-DocumentationRequires external docsDiscoverable via introspection

Single Object Retrieval vs. Filtering Collections

These two patterns serve different purposes and should not be conflated in your schema design.

Single object retrieval uses a unique identifier — an ID, slug, or email — to fetch exactly one record. The query is predictable, always returns one result, and is straightforward to cache.

Collection filtering returns zero or more records matching shared criteria. It is the right pattern for search pages, dashboards, and data tables where users discover records by characteristics rather than by a known identifier. Collection queries require more careful performance consideration because the result set is not bounded by definition.

A common mistake is implementing collection-style filtering (using a list of IDs as a filter) when you actually want to batch single-object lookups. For batching multiple known IDs, consider using DataLoader instead of a filter: { id: { in: [...] }} pattern — it is more efficient and composable.

Implementing Multiple Value Filters in GraphQL Queries

The most common pattern for filtering multiple values is the in operator. You define it in your schema as part of a filter input type, then pass an array of values as the argument in your query.

Schema Definition for Multi-Value Filters

Start with the schema. Define a typed input object that describes all the filter options for a given type:

# Schema definition
input StringFilterInput {
  eq: String
  in: [String!]
  contains: String
  notIn: [String!]
}

input UserFilterInput {
  status: StringFilterInput
  role: StringFilterInput
  createdAt: DateRangeInput
}

input DateRangeInput {
  gte: String
  lte: String
}

type Query {
  users(filter: UserFilterInput, limit: Int, offset: Int): [User!]!
}

This typed approach gives you schema-level validation for free. If a client passes an unsupported operator, GraphQL rejects the query before it ever reaches your resolver.

The in Operator: Basic Usage

With the schema above, here is how a client queries for users with multiple statuses:

query GetActiveAndPendingUsers($filter: UserFilterInput!) {
  users(filter: $filter) {
    id
    name
    status
    email
  }
}

Variables passed with this query:

{
  "filter": {
    "status": {
      "in": ["active", "pending"]
    }
  }
}

Always use variables rather than hardcoding values into the query string. Variables enable query caching (the query document stays the same, only the variables change), prevent injection issues, and make client code cleaner.

The in operator translates directly to a SQL WHERE status IN ('active', 'pending') clause on the backend. Make sure the status column is indexed — an unindexed IN filter on a large table is a full-table scan waiting to cause an incident.

The filter Keyword Across Platforms

The filter argument name is a convention, not a GraphQL spec requirement. Here is how the major platforms implement it:

PlatformMulti-Value Filter SyntaxNotes
Hasurawhere: { status: { _in: ["active", "pending"] } }Uses _in with underscore prefix
Prismawhere: { status: { in: ["active", "pending"] } }Standard in, no prefix
Apollo/CustomDefined by your schemaYou control naming conventions
Contentfulwhere: { status_in: ["active", "pending"] }Field name + operator suffix
Gatsby GraphQLfilter: { contentType: { in: ["post", "page"] } }Standard in inside filter object

Always check your platform’s schema via introspection before writing filter queries. Tools like GraphiQL and Apollo Sandbox show you exactly which operators are available on each field.

“OK, I see it is possible with a comma: filter: { contentType: { in: ["post", "page"] }, draft: { eq: false } } — this should be documented somewhere.”
Gatsby GitHub Issue #10320, Dec 2018
Source link

The in and containsAny Operators

These two operators look similar but target different data shapes:

in matches records where a scalar field equals any value in the provided array. Use it when the field holds a single value (like status: "active") and you want to match several possible values.

containsAny matches records where an array field contains at least one value from the provided array. Use it when the field itself holds multiple values (like tags: ["graphql", "api", "backend"]) and you want records that have any of the tags you specify.

# in: scalar field matches any of the values
query {
  products(filter: { categoryId: { in: [1, 4, 7] } }) {
    id
    name
  }
}

# containsAny: array field contains at least one match
query {
  articles(filter: { tags: { containsAny: ["graphql", "api"] } }) {
    id
    title
    tags
  }
}

Performance note: in on an indexed scalar field is fast. containsAny on an array field requires a GIN index in PostgreSQL (or equivalent in your database) to avoid sequential scans. Without it, containsAny queries degrade badly at scale.

These operators build directly on GraphQL where clause syntax — understanding the base predicate model makes advanced multi-value conditions easier to reason about.

Array Filtering Techniques

When the field you are filtering is itself an array type, you have several operators to choose from depending on whether you want partial or complete matches:

OperatorExample SyntaxMatches WhenDB Performance
containstags: { contains: ["graphql"] }Array includes the valueGood with GIN index
containsAlltags: { containsAll: ["graphql", "java"] }Array includes ALL valuesModerate
containsAnytags: { containsAny: ["graphql", "rest"] }Array includes ANY valueGood with GIN index
isEmptytags: { isEmpty: true }Array is empty or nullExcellent

Use containsAny for user-facing tag filters (“show me articles with any of these tags”). Use containsAll for strict requirement scenarios (“show me products that support all of these protocols”). The choice between them directly affects how broad or narrow your result set will be.

After filtering by array values, use GraphQL distinct patterns to ensure your results contain only unique entities — array filters can produce duplicate records when joined data is involved.

Filtering with the between Keyword (Range Filters)

Range filtering uses gte/lte (or a dedicated between input type) to match records within a numerical or date boundary. This is the correct pattern for price ranges, date windows, and rating thresholds.

query GetProductsInPriceRange($filter: ProductFilterInput!) {
  products(filter: $filter) {
    id
    name
    price
  }
}
{
  "filter": {
    "price": {
      "gte": 10.00,
      "lte": 50.00
    },
    "categoryId": {
      "in": [2, 5, 9]
    }
  }
}

This combines a range filter with a multi-value filter in one query. On the backend, this should generate a single SQL query with both conditions — not two separate lookups.

Timezone handling is the most common source of bugs with date range filters. Always store and filter dates in UTC. If users expect local-time filtering, convert on the client before sending the query variable, not inside the resolver.

Filtering Non-Null Fields with has / exists

The has or exists operator (naming varies by platform) filters out records where a field is null or missing. This is essential for partial data scenarios — user profiles where optional fields may be empty, products with incomplete attribute sets, or draft content without a publish date.

query GetUsersWithAvatar {
  users(filter: { avatar: { exists: true } }) {
    id
    name
    avatar
  }
}

In Prisma, the equivalent is avatar: { not: null }. In Hasura it is avatar: { _is_null: false }. The concept is the same across platforms — always check your schema’s null-check operator name.

Advanced Multiple Field Filtering: AND, OR, NOT

Real-world filtering requirements involve combining conditions across multiple fields. The behavior when you specify multiple fields in a filter object depends on your GraphQL implementation — most default to AND logic implicitly.

Combining Filters with AND, OR, NOT

Here is how the same multi-condition filter looks across different platforms:

# Hasura: explicit AND with _and
query {
  orders(
    where: {
      _and: [
        { status: { _in: ["processing", "shipped"] } },
        { total: { _gte: 150 } },
        { customerId: { _eq: 42 } }
      ]
    }
  ) {
    id
    status
    total
  }
}
# Prisma-style: implicit AND (multiple fields = AND)
query {
  orders(
    where: {
      status: { in: ["processing", "shipped"] }
      total: { gte: 150 }
      customerId: { equals: 42 }
    }
  ) {
    id
    status
    total
  }
}
# OR logic in Hasura
query {
  products(
    where: {
      _or: [
        { categoryId: { _in: [1, 2] } },
        { featured: { _eq: true } }
      ]
    }
  ) {
    id
    name
  }
}
“To create a multi-parameter filter, you can use the and keyword to concatenate the filter requirements onto your query. In the query below, we are looking at a list of completed carts where the total purchase order is over $150.00.”
Tabnine Blog
Source link
  • Default behavior for multiple fields in a filter object is AND in Apollo/Prisma and Hasura — do not assume OR
  • For OR conditions, check whether your platform supports an explicit or / _or field or requires separate queries
  • NOT filters (notIn, _nin, not) are useful for exclusion lists but can be slow without proper indexing
  • Combining AND and OR in the same query can create complex boolean logic — test with real data to confirm expected results

Nested Filters: Filtering on Related Types

Nested filtering means applying filter conditions to fields of a related (joined) type. For example: find all blog posts where at least one comment was written by a specific author.

# Hasura: filter posts by a property of their comments
query {
  posts(
    where: {
      comments: {
        authorId: { _eq: 99 }
      }
    }
  ) {
    id
    title
    comments(where: { authorId: { _eq: 99 } }) {
      body
      createdAt
    }
  }
}

This is powerful but carries a serious performance risk: the N+1 problem. Without DataLoader or query batching, a nested filter can result in one database query per parent record. Always implement DataLoader for nested resolvers, and check your ORM’s eager loading options for common nested filter patterns.

Nested filtering connects to several related topics: the GraphQL filter on nested field guide covers join strategies and resolver optimization for relational data, while GraphQL nested queries explains how nested resolution works under the hood — which helps you predict the database operations your nested filters will produce.

Dynamic Query Building on the Client

In practice, filter arguments are constructed dynamically based on user input. Here is a typical JavaScript pattern for building a filter object from form state:

function buildProductFilter(formState) {
  const filter = {};

  if (formState.selectedCategories.length > 0) {
    filter.categoryId = { in: formState.selectedCategories };
  }

  if (formState.minPrice !== null || formState.maxPrice !== null) {
    filter.price = {};
    if (formState.minPrice !== null) filter.price.gte = formState.minPrice;
    if (formState.maxPrice !== null) filter.price.lte = formState.maxPrice;
  }

  if (formState.selectedTags.length > 0) {
    filter.tags = { containsAny: formState.selectedTags };
  }

  return filter;
}

// Usage with Apollo Client
const { data } = useQuery(GET_PRODUCTS, {
  variables: { filter: buildProductFilter(formState) },
});

Using GraphQL variables (not string interpolation) is critical here. It keeps the query document static so Apollo Client’s normalized cache can work correctly, and it prevents any form of injection.

Backend Implementation: Resolver Patterns

The resolver receives the filter input object and must translate it into a database query. Here is a pattern for a Node.js resolver using Prisma that handles the in operator and range conditions:

// resolver.js (Apollo Server + Prisma)
const resolvers = {
  Query: {
    users: async (_, { filter = {}, limit = 20, offset = 0 }, { prisma }) => {
      const where = buildPrismaWhere(filter);
      return prisma.user.findMany({
        where,
        take: limit,
        skip: offset,
      });
    },
  },
};

function buildPrismaWhere(filter) {
  const where = {};

  if (filter.status?.in) {
    where.status = { in: filter.status.in };
  }

  if (filter.role?.in) {
    where.role = { in: filter.role.in };
  }

  if (filter.createdAt) {
    where.createdAt = {};
    if (filter.createdAt.gte) where.createdAt.gte = new Date(filter.createdAt.gte);
    if (filter.createdAt.lte) where.createdAt.lte = new Date(filter.createdAt.lte);
  }

  return where;
}

Keep the filter-to-database translation in a dedicated function (buildPrismaWhere above). This makes it testable in isolation, reusable across resolvers, and much easier to debug when a filter produces unexpected results.

Input Validation in Resolvers

Never pass filter inputs directly to the database without validation. Common issues to guard against:

  • Unbounded arrays: A client could pass thousands of values in an in filter. Cap array length (e.g., max 100 values) to prevent runaway database queries.
  • Deep nesting: Recursively nested filter objects can create extremely complex queries. Limit nesting depth at the schema or resolver level.
  • Type coercion bugs: GraphQL strings are not automatically dates or numbers. Convert types explicitly in the resolver before passing to the ORM.
  • Empty filter arrays: An empty in: [] should return zero results, but some ORMs treat it differently — test this case explicitly.

Transforming Filter Inputs for Different Databases

The same GraphQL filter input needs different shapes depending on your database. Here is the same filter expressed for three backends:

// Input: { status: { in: ["active", "pending"] }, price: { gte: 10, lte: 50 } }

// → Prisma (PostgreSQL)
{ status: { in: ["active", "pending"] }, price: { gte: 10, lte: 50 } }

// → Raw SQL (pg)
WHERE status IN ('active', 'pending') AND price BETWEEN 10 AND 50

// → MongoDB (Mongoose)
{ status: { $in: ["active", "pending"] }, price: { $gte: 10, $lte: 50 } }

Build the transformation layer once, test it thoroughly, and never bypass it by constructing raw query strings from user input.

Performance Optimization for Multi-Value Filtering

OptimizationDataset: 1M RecordsTypical Query TimeNotes
No optimizationFull table scan2–5sUnusable at scale
Single-column indexIndex scan100–500msGood for single-field filters
Compound indexIndex-only scan20–100msEssential for multi-field filters
Query result cacheCache hit<10msBest for repeated filter combos

Database Indexing Strategies

Create indexes that match your actual filter patterns — not theoretical ones. The most useful indexes for multi-value filtering:

  • Single-column index on any field used in in, eq, or not filters
  • Compound index for fields that are filtered together frequently (e.g., status + createdAt)
  • GIN index (PostgreSQL) for array fields used with containsAny or containsAll
  • Partial index for filtering a known subset — e.g., an index WHERE status = 'active' if most queries filter for active records
  • Monitor index usage with pg_stat_user_indexes (Postgres) and remove unused indexes — they slow down writes without helping reads

The most common performance mistake is adding indexes after a problem appears in production. Add them during schema design, based on the filter patterns you know your application will use.

Caching Strategies for Filtered Queries

Filter result caching requires careful cache key design. The cache key must include the complete filter state — not just the query name. Two queries with the same name but different filter variables must cache separately.

Apollo Client handles this automatically for queries using variables. Its normalized cache stores results keyed by the full query + variable combination, so filtered queries are cached independently.

Server-side caching with Redis is appropriate for expensive filter combinations that many users run with the same parameters (e.g., homepage product listings with default filters). Use a TTL that matches your data’s update frequency — a 60-second cache is reasonable for product catalogs, but wrong for real-time inventory.

Cache invalidation is the hard part. If you cache filtered query results and a record that matches those filters is updated, the cache must be invalidated. Event-driven invalidation (invalidate on write) is more reliable than time-based expiry for data consistency.

For queries combining filtering with aggregation, see the GraphQL count guide — it covers how to return total result counts alongside filtered data without a separate query.

Combining Pagination with Multi-Value Filtering

Filtered collections almost always need pagination. The interaction between filter conditions and pagination strategy matters for both performance and UX consistency.

Offset-based pagination (limit/offset) is simpler to implement and supports jumping to specific page numbers. Its weakness with filtered queries: if records are added or removed between pages, users see duplicates or skipped results. It also requires a COUNT(*) query to calculate total pages, which can be expensive on large filtered sets.

Cursor-based pagination is more robust for filtered results. The cursor points to a specific record in the ordered set, so pagination is consistent even if records are added. It does not require a count query, making it faster for large datasets.

query GetFilteredProducts($filter: ProductFilterInput!, $first: Int!, $after: String) {
  products(filter: $filter, first: $first, after: $after) {
    edges {
      node {
        id
        name
        price
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

After applying multi-value filters, control result set size with GraphQL limit and pagination patterns — this guide covers both offset and cursor-based approaches with implementation examples.

Applying Sort Order to Filtered Results

Sorting is almost always needed alongside filtering. Users filtering products by category also want to sort by price or rating. Design your schema to accept both arguments on the same query field:

type Query {
  products(
    filter: ProductFilterInput
    orderBy: ProductOrderByInput
    limit: Int
    offset: Int
  ): ProductConnection!
}

input ProductOrderByInput {
  field: ProductSortField!
  direction: SortDirection!
}

enum ProductSortField {
  PRICE
  RATING
  CREATED_AT
  NAME
}

enum SortDirection {
  ASC
  DESC
}

For full details on ordering filtered results, see GraphQL sorting and GraphQL orderBy — both cover schema design and resolver implementation for sort arguments.

Common Pitfalls and How to Avoid Them

  • No index on filtered fields: The single most common cause of slow GraphQL filter queries. Always index fields used in in, eq, and range filters.
  • Unbounded in arrays: A client passing 10,000 IDs in a filter can cause database timeouts. Validate and cap array sizes in your resolver.
  • Client-side filtering of large datasets: Fetching all records and filtering in JavaScript destroys performance and UX. Push all filter logic to the server.
  • Empty in: [] edge case: Some ORMs treat an empty array as “no filter” (returning all records) rather than “match nothing” (returning zero). Test this and handle it explicitly.
  • N+1 in nested filters: Filtering on related types without DataLoader causes one database query per parent record. Implement DataLoader for all relationship resolvers.
  • Confusing AND and OR defaults: Multiple fields in a filter object are AND in most implementations. Assuming OR leads to unexpectedly broad or narrow results.
  • Skipping input validation: Never pass filter inputs to the database without validating types, lengths, and allowed values.

Don’t Over-Engineer: Finding the Right Balance

The most sophisticated filtering system is not always the right one. Implementing dozens of filter operators, complex logical combinations, and deeply nested conditions creates maintenance overhead and makes it harder to optimize database queries.

Simple FilteringComplex Filtering
Fast, predictable query executionHandles any user requirement
Easy to index and optimizeMore flexible schema design
Easier to test and debugBetter for power users and analytics
Limited to predefined filter patternsHigher maintenance burden
May require multiple queries for edge casesHarder to optimize across all combinations

Start with the filter capabilities that cover 90% of your use cases. Add complexity incrementally, driven by actual usage data rather than theoretical requirements. Track which filter combinations users actually run — you will often find that 5-6 specific combinations account for the vast majority of queries, and those are the ones worth optimizing first.

Custom Directives for Context-Aware Filtering

GraphQL directives let you modify query behavior at the field level. For filtering, the most practical use case is permission-based filtering — automatically restricting filter results based on the authenticated user’s role without requiring the client to specify those restrictions.

# Schema
directive @filterByOwner on FIELD_DEFINITION

type Query {
  orders(filter: OrderFilterInput): [Order!]! @filterByOwner
}
// Directive implementation
const filterByOwnerDirective = {
  filterByOwner: {
    resolve(next, source, args, context) {
      // Inject owner filter based on authenticated user
      args.filter = {
        ...args.filter,
        ownerId: { eq: context.userId },
      };
      return next(source, args, context);
    },
  },
};

This pattern ensures that even if a client omits the owner filter, the directive injects it automatically. It is cleaner than duplicating ownership checks in every resolver.

Real-World Applications

These three scenarios illustrate how the patterns in this guide come together in production:

  1. E-commerce product catalog: Users filter by multiple categories (in), price range (gte/lte), availability (eq), and tags (containsAny) in one query. The backend uses compound indexes on (categoryId, price, inStock). Apollo Client caches results per filter combination. Default sorting by relevance with user-selectable price/rating sort.
  2. Content management system: Editors filter articles by publication status (in: ["draft", "review"]), author (in for team views), tag combinations (containsAll for strict tagging workflows), and date ranges. Permission directives ensure editors only see content in their assigned sections.
  3. Analytics dashboard: Users filter events by time range (gte/lte), geographic region (in), user segment (in), and event type (in). Pre-aggregated materialized views handle common time-range + segment combinations. Cursor-based pagination handles result sets of hundreds of thousands of rows.

Best Practices Checklist

  • Define typed FilterInput objects in your schema — never accept raw JSON or untyped maps
  • Use GraphQL variables for all filter values — never interpolate user input into query strings
  • Validate array lengths and field types in resolvers before hitting the database
  • Create database indexes based on actual filter patterns, starting with the most frequently used combinations
  • Implement DataLoader for all resolvers that handle nested filters to prevent N+1 problems
  • Handle the empty in: [] case explicitly — decide whether it means “match nothing” or “no filter applied”
  • Document which logical operator is the default (AND vs OR) in your schema’s filter objects
  • Test filter combinations with production-sized datasets before deploying — performance problems often don’t appear until data volume is realistic
  • Use query complexity limits to prevent clients from constructing deeply nested filters that generate expensive queries

More GraphQL Guides

GuideWhat it covers
GraphQL Where ClauseThe foundation of server-side filtering: how to design and implement where arguments
GraphQL Filter on Nested FieldFiltering parent records based on properties of related child types
GraphQL Nested QueryStructuring queries with multiple levels of related data
GraphQL SortingAdding orderBy arguments to filtered queries
GraphQL OrderBySchema design and resolver implementation for sort arguments
GraphQL Limit Number of ResultsPagination patterns for filtered collections
GraphQL DistinctDeduplicating results in multi-value filter queries
GraphQL CountReturning total result counts alongside filtered data
GraphQL JoinsHow relational data joins work with nested filters

Frequently Asked Questions

Use the in operator in your filter argument: filter: { status: { in: ["active", "pending"] } }. This matches any record where the field equals any value in the array. Define the filter as a typed input object in your schema, and always pass the values as a GraphQL variable rather than hardcoding them in the query string. The exact operator name (in, _in) varies by platform — check your schema via introspection.

in applies to scalar fields: it matches records where a single-value field (like status) equals any value in the array. containsAny applies to array fields: it matches records where an array field (like tags) contains at least one of the specified values. If your field holds one value, use in. If your field holds a list of values, use containsAny. Both require proper database indexing to perform well at scale.

Most GraphQL implementations (Apollo/Prisma, Hasura) treat multiple fields in a filter object as AND by default. If you specify filter: { status: { in: ["active"] }, role: { in: ["admin"] } }, it returns records where BOTH conditions are true. For OR logic, you need to use an explicit or or _or field — check your platform’s documentation, as the syntax varies. Never assume OR behavior without verifying it against your schema.

Nested filters let you filter parent records based on properties of related types. In Hasura: where: { comments: { authorId: { _eq: 99 } } } returns posts that have at least one comment by that author. The critical implementation detail is that nested filters almost always require DataLoader to avoid N+1 database queries — without it, each parent record triggers a separate database lookup. Always test nested filter performance with realistic data volumes before shipping to production.

The most common cause is a missing database index on the filtered field. Without an index, every filter query does a full table scan regardless of how efficient the GraphQL layer is. Check your query execution plan (EXPLAIN ANALYZE in PostgreSQL) to confirm. Other causes: unbounded in arrays with thousands of values, nested filters without DataLoader causing N+1 queries, or filtering on a computed/transformed field that cannot use an index. Fix indexes first — it typically reduces query time by 10–50x.

Pass both conditions in the same filter object. For example: filter: { categoryId: { in: [1, 4, 7] }, price: { gte: 10, lte: 50 } }. Since multiple fields default to AND, this returns records in one of those categories AND within the price range. On the backend, this should translate to a single database query with both conditions — not two separate lookups. Use a compound index on (categoryId, price) for best performance.

Behavior varies by ORM and database driver. Some treat in: [] as “match nothing” (returning zero results, which is mathematically correct — no value can be in an empty set). Others treat it as “no filter applied” (returning all records). This inconsistency is a common source of bugs. Always test the empty array case explicitly in your resolver, handle it explicitly in code (if (filter.status?.in?.length === 0) return []), and document the behavior for your API consumers.