24 KiB
Querying Data in Medusa
Medusa's Query API (query.graph()) is the primary way to retrieve data, especially across modules. It provides a flexible, performant way to query entities with relations and filters.
Contents
- When to Use Query vs Module Services
- Basic Query Structure
- In Workflows vs Outside Workflows
- Field Selection
- Filtering
- Important Filtering Limitation
- Pagination
- Querying Linked Data
- Validation with throwIfKeyNotFound
- Performance Best Practices
When to Use Query vs Module Services
⚠️ USE QUERY FOR:
- ✅ Retrieving data across modules (products with linked brands, orders with customers)
- ✅ Reading data with linked entities
- ✅ Complex queries with multiple relations
- ✅ Storefront and admin data retrieval
⚠️ USE MODULE SERVICES FOR:
- ✅ Retrieving data within a single module (products with variants - same module)
- ✅ Using
listAndCountfor pagination within one module - ✅ Mutations (always use module services or workflows)
Examples:
// ✅ GOOD: Query for cross-module data
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"], // brand is in different module
})
// ✅ GOOD: Module service for single module
const [products, count] = await productService.listAndCountProducts(
{ status: "active" },
{ take: 10, skip: 0 }
)
Basic Query Structure
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "entity_name", // The entity to query
fields: ["id", "name"], // Fields to retrieve
filters: { status: "active" }, // Filter conditions
pagination: { // Optional pagination
take: 10,
skip: 0,
},
})
In Workflows vs Outside Workflows
Outside Workflows (API Routes, Subscribers, Scheduled Jobs)
// In API routes
const query = req.scope.resolve("query")
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title"],
})
// In subscribers/scheduled jobs
const query = container.resolve("query")
const { data: customers } = await query.graph({
entity: "customer",
fields: ["id", "email"],
})
In Workflows
Use useQueryGraphStep within workflow composition functions:
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
const myWorkflow = createWorkflow(
"my-workflow",
function (input) {
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id", "title"],
filters: {
id: input.product_id,
},
})
return new WorkflowResponse({ products })
}
)
Field Selection
Basic Fields
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "description"],
})
Nested Relations
Use dot notation to include related entities:
const { data } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"variants.*", // All fields from variants
"variants.sku", // Specific variant field
"category.id",
"category.name",
],
})
Performance Tip
⚠️ IMPORTANT: Only retrieve fields and relations you'll actually use. Avoid using * to select all fields or retrieving all fields of a relation unnecessarily.
// ❌ BAD: Retrieves all fields (inefficient)
fields: ["*"]
// ❌ BAD: Retrieves all product fields (might be many)
fields: ["product.*"]
// ✅ GOOD: Only retrieves needed fields
fields: ["id", "title", "product.id", "product.title"]
Filtering
Exact Match
filters: {
email: "user@example.com"
}
Multiple Values (IN operator)
filters: {
id: ["id1", "id2", "id3"]
}
Range Queries
filters: {
created_at: {
$gte: startDate, // Greater than or equal
$lte: endDate, // Less than or equal
}
}
Text Search (LIKE)
filters: {
name: {
$like: "%search%" // Contains "search"
}
}
// Starts with
filters: {
name: {
$like: "search%"
}
}
// Ends with
filters: {
name: {
$like: "%search"
}
}
Not Equal
filters: {
status: {
$ne: "deleted"
}
}
Multiple Conditions
filters: {
status: "active",
created_at: {
$gte: new Date("2024-01-01"),
},
price: {
$gte: 10,
$lte: 100,
},
}
Filtering Nested Relations (Same Module)
To filter by fields in nested relations within the same module, use object notation:
// Product and ProductVariant are in the same module (Product Module)
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
sku: "ABC1234" // ✅ Works: variants are in same module as product
}
}
})
Important Filtering Limitation
⚠️ CRITICAL: With query.graph(), you CANNOT filter by fields from linked data models in different modules. The query.graph() method only supports filters on data models within the same module.
What This Means
- Same Module (✅ Can filter with
query.graph()): Product and ProductVariant, Order and LineItem, Cart and CartItem - Different Modules (❌ Cannot filter with
query.graph()): Product and Brand (custom), Product and Customer, Review and Product - Different Modules (✅ Can filter with
query.index()): Any linked modules when using the Index Module
Example: Cannot Filter Products by Linked Brand with query.graph()
// ❌ THIS DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
"brand.name": "Nike" // ❌ Cannot filter by linked module field
}
})
// ❌ THIS ALSO DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
brand: {
name: "Nike" // ❌ Still doesn't work - brand is in different module
}
}
})
Solution 1: Use query.index() with Index Module (Recommended)
✅ BEST APPROACH: Use the Index Module to filter across linked modules efficiently at the database level:
// ✅ CORRECT: Use query.index() to filter products by linked brand
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: "Nike" // ✅ Works with Index Module!
}
}
})
Why this is best:
- Database-level filtering (most efficient)
- Supports pagination properly
- Only retrieves the data you need
- Designed specifically for cross-module filtering
Requirements:
- Index Module must be installed and configured
- Link must have
filterableproperties defined - See Querying Linked Data section for setup details
Solution 2: Query from Other Side
✅ GOOD ALTERNATIVE: Query the linked module and filter on it directly using query.graph():
// ✅ CORRECT: Query brands and get their products
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
filters: {
name: "Nike" // ✅ Filter on brand directly
}
})
// Access Nike products
const nikeProducts = brands[0]?.products || []
Use this when:
- You don't have the Index Module set up
- The "other side" of the link makes sense as the primary entity
- You need a quick solution without additional setup
Solution 3: Filter After Query (Least Efficient)
⚠️ LAST RESORT: Query all data with query.graph(), then filter in JavaScript:
// Get all products with brands
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
})
// Filter in JavaScript after query
const nikeProducts = products.filter(p => p.brand?.name === "Nike")
Only use this when:
- Dataset is very small (< 100 records)
- Index Module is not available
- Querying from the other side doesn't make sense
- You need a temporary solution
Avoid because:
- Fetches unnecessary data from database
- Inefficient for large datasets
- No pagination support at database level
- Uses more memory and network bandwidth
More Examples
Example: Approved Reviews for a Specific Product
When you need to filter linked data by its own properties, you have multiple options:
// ❌ WRONG: Cannot filter linked reviews from product query with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "reviews.*"],
filters: {
id: productId,
reviews: {
status: "approved" // ❌ Doesn't work - reviews is linked module
}
}
})
// ❌ ALSO WRONG: Filtering in JavaScript is inefficient
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "reviews.*"],
filters: { id: productId }
})
const approvedReviews = products[0].reviews.filter(r => r.status === "approved") // ❌ Client-side filter
// ✅ OPTION 1 (BEST): Use Index Module to filter cross-module
const { data: products } = await query.index({
entity: "product",
fields: ["*", "reviews.*"],
filters: {
id: productId,
reviews: {
status: "approved" // ✅ Works with Index Module!
}
}
})
// ✅ OPTION 2 (GOOD): Query reviews directly with filters
const { data: reviews } = await query.graph({
entity: "review",
fields: ["id", "rating", "comment", "product.*"],
filters: {
product_id: productId, // Filter by product
status: "approved" // Filter by review status - both in same query!
}
})
Why Option 1 (Index Module) is best:
- Database-level filtering across modules
- Returns data in the structure you expect (product with reviews)
- Supports pagination properly
- Only retrieves the data you need
Why Option 2 (query from other side) is good:
- No Index Module setup required
- Still uses database filtering
- Works well when the "other side" is the logical primary entity
Example: Reviews for Active Products (Cross-Module)
// ❌ WRONG: Cannot filter by linked module with query.graph()
const { data } = await query.graph({
entity: "review",
fields: ["id", "rating", "product.*"],
filters: {
product: {
status: "active" // Doesn't work - product is linked module
}
}
})
// ✅ OPTION 1 (BEST): Use Index Module
const { data: reviews } = await query.index({
entity: "review",
fields: ["*", "product.*"],
filters: {
product: {
status: "active" // ✅ Works with Index Module!
}
}
})
// ✅ OPTION 2 (GOOD): Query from the other side
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: { status: "active" }
})
// Flatten reviews if needed
const reviews = products.flatMap(p => p.reviews)
Example: Products with Variants (Same Module - Works!)
// ✅ CORRECT: Product and variants are in same module (Product Module)
// Use query.graph() - no need for Index Module
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
inventory_quantity: {
$gte: 10 // ✅ Works: both in Product Module
}
}
}
})
Pagination
Basic Pagination
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id", "title"],
pagination: {
skip: 0, // Offset
take: 10, // Limit
},
})
// metadata.count contains total count
console.log(`Total: ${metadata.count}`)
With Ordering
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "created_at"],
pagination: {
skip: 0,
take: 10,
order: {
created_at: "DESC", // Newest first
},
},
})
Multiple Order Fields
pagination: {
order: {
status: "ASC",
created_at: "DESC",
}
}
Querying Linked Data
When entities are linked via module links, you have two options depending on your filtering needs:
Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters
Use query.graph() when:
- ✅ Retrieving linked data without filtering by linked module properties
- ✅ Filtering only by properties in the primary entity's module
- ✅ You want to include related data in the response
Limitations:
- ❌ CANNOT filter by properties of linked modules (data models in separate modules)
- ✅ CAN filter by properties of relations in the same module (e.g., product.variants)
// ✅ WORKS: Get products with their linked brands (no cross-module filtering)
const { data: products } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"brand.*", // All brand fields
],
filters: {
id: "prod_123", // ✅ Filter by product property (same module)
},
})
// Access linked data
console.log(products[0].brand.name)
// ✅ WORKS: Filter by same-module relation (product and variants are in Product Module)
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
sku: "ABC1234" // ✅ Works: variants are in same module as product
}
}
})
// ❌ DOES NOT WORK: Cannot filter products by linked brand name
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
brand: {
name: "Nike" // ❌ Fails: brand is in a different module
}
}
})
Reverse Query (From Link to Original):
// Get brands with their linked products
const { data: brands } = await query.graph({
entity: "brand",
fields: [
"id",
"name",
"products.*", // All linked products
],
})
// Access linked products
brands[0].products.forEach(product => {
console.log(product.title)
})
Option 2: query.index() - Filter Across Linked Modules (Index Module)
Use query.index() when:
- ✅ You need to filter data by properties of linked modules (separate modules with module links)
- ✅ Filtering by custom data model properties linked to Commerce Module entities
- ✅ Complex cross-module queries requiring efficient database-level filtering
Key Distinction:
- Same module relations (e.g., Product → ProductVariant): Use
query.graph()✅ - Different module links (e.g., Product → Brand, Product → Review): Use
query.index()✅
When to Use query.index()
The Index Module solves the fundamental limitation of query.graph(): you cannot filter one module's data by another module's linked properties using query.graph().
Examples of when you need query.index():
- Filter products by brand name (Product Module → Brand Module)
- Filter products by review ratings (Product Module → Review Module)
- Filter customers by custom loyalty tier (Customer Module → Loyalty Module)
- Any scenario where you need to filter by properties of a linked data model in a different module
Setup Requirements
Before using query.index(), ensure the Index Module is configured:
-
Install the Index Module:
npm install @medusajs/index -
Add to
medusa-config.ts:module.exports = defineConfig({ modules: [ { resolve: "@medusajs/index", }, ], }) -
Enable the feature flag in
.env:MEDUSA_FF_INDEX_ENGINE=true -
Run migrations:
npx medusa db:migrate -
Mark linked properties as filterable in your link definition:
// src/links/product-brand.ts defineLink( { linkable: ProductModule.linkable.product, isList: true }, { linkable: BrandModule.linkable.brand, filterable: ["id", "name"] } )The
filterableproperty marks which fields can be queried across modules. -
Start the application to trigger data ingestion into the Index Module.
Using query.index()
const query = req.scope.resolve("query")
// ✅ CORRECT: Filter products by linked brand name using Index Module
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: "Nike", // ✅ Works with Index Module!
},
},
})
// ✅ CORRECT: Filter products by review ratings
const { data: products } = await query.index({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: {
reviews: {
rating: {
$gte: 4, // Products with reviews rated 4 or higher
},
},
},
})
query.index() Features
Pagination:
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: { name: "Nike" },
},
pagination: {
take: 20,
skip: 0,
},
})
Advanced Filters:
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: {
$like: "%Acme%", // LIKE operator
},
},
status: {
$ne: "deleted", // Not equal
},
},
})
query.graph() vs query.index() Decision Tree
Need to filter by linked module properties?
├─ No → Use query.graph()
│ └─ Faster, simpler, works for most queries
│
└─ Yes → Are the entities in the same module or different modules?
├─ Same module (e.g., product.variants) → Use query.graph()
│ └─ Example: Product and ProductVariant both in Product Module
│
└─ Different modules (e.g., product → brand) → Use query.index()
└─ Example: Product (Product Module) → Brand (Custom Module)
└─ Requires Index Module setup and filterable properties
Important Notes
- Performance: The Index Module pre-ingests data on application startup, enabling efficient cross-module filtering
- Data Freshness: Data is synced automatically, but there may be a brief delay after mutations
- Fallback: If you don't need filtering,
query.graph()is sufficient and more straightforward - Module Relations: Always use
query.graph()for same-module relations (product → variants, order → line items)
Validation with throwIfKeyNotFound
Use throwIfKeyNotFound to validate that a record exists before performing operations:
// Outside workflows
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "product",
fields: ["id", "title"],
filters: {
id: productId,
},
}, {
throwIfKeyNotFound: true, // Throws if product doesn't exist
})
// If we get here, product exists
const product = data[0]
// In workflows
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id", "title"],
filters: {
id: input.product_id,
},
options: {
throwIfKeyNotFound: true, // Throws if product doesn't exist
},
})
When to use:
- ✅ Before updating or deleting a record
- ✅ When the record MUST exist for the operation to continue
- ✅ To avoid manual existence checks
// ❌ BAD: Manual check
const { data } = await query.graph({ /* ... */ })
if (!data || data.length === 0) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Product not found")
}
// ✅ GOOD: Let query handle it
const { data } = await query.graph(
{ /* ... */ },
{ throwIfKeyNotFound: true }
)
Performance Best Practices
1. Only Query What You Need
⚠️ CRITICAL: Always specify only the fields you'll use. Avoid using * or querying unnecessary relations.
// ❌ BAD: Retrieves everything (slow, wasteful)
fields: ["*"]
// ✅ GOOD: Only needed fields (fast)
fields: ["id", "title", "price"]
2. Limit Relation Depth
There's no hard limit on relation depth, but deeper queries are slower. Only include relations you'll actually use.
// ❌ BAD: Unnecessary depth
fields: [
"id",
"title",
"variants.*",
"variants.product.*", // Circular, unnecessary
"variants.prices.*",
"variants.prices.currency.*", // Probably don't need all currency fields
]
// ✅ GOOD: Appropriate depth
fields: [
"id",
"title",
"variants.id",
"variants.sku",
"variants.prices.amount",
"variants.prices.currency_code",
]
3. Use Pagination for Large Result Sets
// ✅ GOOD: Paginated query
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id", "title"],
pagination: {
take: 50, // Don't retrieve thousands of records at once
skip: 0,
},
})
4. Filter Early
Apply filters to reduce the data set before retrieving fields and relations:
// ✅ GOOD: Filters reduce result set first
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
status: "published",
created_at: {
$gte: lastWeek,
},
},
})
5. Use Specific Queries for Different Use Cases
// ✅ For listings (minimal fields)
const { data: listings } = await query.graph({
entity: "product",
fields: ["id", "title", "thumbnail", "price"],
})
// ✅ For detail pages (more fields)
const { data: details } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"description",
"thumbnail",
"images.*",
"variants.*",
"variants.prices.*",
],
filters: { id: productId },
})
Common Patterns
Pattern: List with Search
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { q } = req.validatedQuery
const filters: any = {}
if (q) {
filters.title = { $like: `%${q}%` }
}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "thumbnail"],
filters,
...req.queryConfig, // Uses request query config
})
return res.json({ products })
}
Pattern: Retrieve with Validation
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { id } = req.params
// Throws 404 if product doesn't exist
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "description", "variants.*"],
filters: { id },
}, {
throwIfKeyNotFound: true,
})
return res.json({ product: data[0] })
}
Pattern: Query with Relations and Filters
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { category_id } = req.validatedQuery
const { data: products } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"thumbnail",
"variants.id",
"variants.prices.amount",
"category.name",
],
filters: {
category_id,
status: "published",
},
pagination: {
take: 20,
skip: 0,
},
})
return res.json({ products })
}
Pattern: Count Records
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id"], // Minimal fields for counting
filters: {
status: "published",
},
})
return res.json({
count: metadata.count,
})
}
Pattern: Recent Items
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data: recentProducts } = await query.graph({
entity: "product",
fields: ["id", "title", "created_at"],
pagination: {
take: 10,
skip: 0,
order: {
created_at: "DESC", // Newest first
},
},
})
return res.json({ products: recentProducts })
}