Developer guide
Cart Transform Function deep dive: bundles, line expansion, merge logic, and when it conflicts with discounts
A technical Shopify developer guide to Cart Transform Functions covering lineExpand, linesMerge, lineUpdate, bundle pricing, metafield-driven configuration, discount conflicts, limits, and production design patterns.
What Cart Transform actually owns
Cart Transform is Shopify’s cart-side merchandising surface. It is the Function API that owns bundle expansion, bundle merging, and line-level presentation updates in the cart. If the requirement is to change how lines are grouped, shown, or priced as a cart structure problem, Cart Transform is the right layer.
That scope matters because teams often treat Cart Transform as a generic “do something complicated in checkout” API. It is not. Shopify’s Cart Transform docs define its job much more narrowly: change the pricing and presentation of cart line items, especially for bundles, add-ons, and structured merchandising behavior.
“To modify the appearance of cart items, such as updating titles and images, changing prices, and bundling items, you can only use the Cart Transform API.”
Shopify’s bundle-building docs reinforce the same split. For customized bundles, the
platform points developers to the cartTransform object plus app-owned
metafields and metaobjects. This is not theme code, not checkout DOM manipulation,
and not a discount script in new clothes.
“Customized bundles: Uses the cartTransform object to convert a group of products added to the cart into a bundle.”
The clean mental model
Cart Transform is the cart structure layer. Discount Functions are the discount policy layer. Validation is the rule-enforcement layer. Most broken bundle systems come from mixing those responsibilities.
If you are untangling old Script logic at the same time, the
Shopify Scripts to Shopify Functions migration guide
is the right companion piece. For broader legacy checkout surface changes, use the
Shopify Checkout UI Extensions migration guide
.
Hard constraints to design around first
The first architecture discussion should not be about bundle UX. It should be about platform constraints. Shopify’s current Cart Transform docs expose several limits that heavily shape production design.
- a store can install a maximum of one cart transform function
lineUpdateis available only on development stores or Shopify PlusShopify rejects
lineExpand,linesMerge, andlineUpdatewhen a selling plan is present- subscription recurring orders are not supported
POS support is partial, and
ProductVariant.requiresComponentsmust betruefor full POS support
“You can install a maximum of one cart transform function on each store.”
That single-transform rule is much more important than it looks. It means bundle apps, custom merchandising logic, and line-update logic are all competing for the same transform slot. If a merchant already has a transform-based app installed, your design cannot assume an empty slot exists.
The selling plan restriction is equally important. If your merchant sells subscriptions, prepaid bundles, or recurring products, you should assume Cart Transform is not a universal answer. Shopify’s docs are explicit that these operations are rejected when a selling plan is present.
Do not discover constraints halfway through
Before building any transform, answer three questions: is there already a cart transform installed, do bundle candidates ever carry selling plans, and does the merchant need lineUpdate on a non-Plus store? Those answers often decide the whole implementation path.
The three operations and how they differ
Cart Transform exposes three operations:
lineExpandtransforms one parent line into multiple visible component lineslinesMergetransforms multiple sibling lines into a single synthetic bundle linelineUpdateoverrides the price, title, or image of a single cart line
That seems simple, but the data model differences are not minor. Expand preserves a parent line as the conceptual source and emits child components. Merge starts from a set of existing cart lines and replaces their presentation with a grouped parent representation. Update does not change topology. It changes the visible presentation of a line that already exists.
The wrong operation usually creates downstream discount bugs. If your discount logic expects a stable parent line and you instead merge several sibling lines into a new synthetic line, targeting and exclusion rules can drift immediately. Likewise, if you use expand when the real business concept is “group these independently added items into one bundle,” you create a parent-child model that did not exist in the original cart behavior.
Building a lineExpand bundle with per-component pricing
Use lineExpand when the shopper adds a parent bundle product and you want
Shopify to display the underlying component lines. This is often the cleanest fit for
configurable kits, add-on bundles, or parent SKUs that exist mainly as a commercial
wrapper for child merchandise.
A strong pattern is to store the bundle recipe on the parent product as JSON in a
metafield, and store app-level defaults or helper IDs on the cartTransform
object itself.
query RunInput {
presentmentCurrencyRate
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
id
title
product {
bundledComponentData: metafield(
namespace: "$app:bundles"
key: "components"
) {
value
}
}
}
}
}
}
cartTransform {
configuration: metafield(
namespace: "$app:bundles"
key: "function-configuration"
) {
jsonValue
}
}
}A JavaScript implementation can then parse the parent bundle definition and return a
lineExpand operation:
/**
* @typedef {import("../generated/api").RunInput} RunInput
* @typedef {import("../generated/api").CartTransformRunResult} CartTransformRunResult
* @typedef {import("../generated/api").Operation} Operation
*/
const NO_CHANGES = { operations: [] };
/**
* @param {RunInput} input
* @returns {CartTransformRunResult}
*/
export function cartTransformRun(input) {
const operations = input.cart.lines.reduce(
/** @param {Operation[]} acc */
(acc, cartLine) => {
const expandOperation = buildExpandOperation(cartLine, input.presentmentCurrencyRate);
return expandOperation ? [...acc, { lineExpand: expandOperation }] : acc;
},
[]
);
return operations.length ? { operations } : NO_CHANGES;
}
function buildExpandOperation(cartLine, presentmentCurrencyRate) {
const merchandise = cartLine.merchandise;
if (merchandise?.__typename !== "ProductVariant") return null;
const raw = merchandise.product?.bundledComponentData?.value;
if (!raw) return null;
const components = JSON.parse(raw);
if (!Array.isArray(components) || components.length === 0) return null;
return {
cartLineId: cartLine.id,
title: merchandise.title,
expandedCartItems: components.map((component) => ({
merchandiseId: component.variantId,
quantity: component.quantity ?? 1,
attributes: [
{ key: "_bundle_parent_variant_id", value: merchandise.id },
{ key: "_bundle_role", value: "component" },
],
price: component.price == null
? undefined
: {
adjustment: {
fixedPricePerUnit: {
amount: (component.price * presentmentCurrencyRate).toFixed(2),
},
},
},
})),
};
}Two technical details matter here. First, fixed component pricing should be converted
with presentmentCurrencyRate if your source prices are stored in shop
currency. Second, you should stamp bundle semantics into line attributes if later
discount logic, analytics logic, or order-processing logic needs to distinguish
bundle components from normal lines.
Pricing semantics, presentment currency, and weight allocation
Bundle pricing is where Cart Transform becomes deceptively tricky. Shopify supports two different patterns for expanded pricing:
- set per-component fixed prices
- let Shopify allocate the parent price using the weight price algorithm
“When you don't provide a fixed price per unit on lineExpand operations, Shopify allocates the bundle price to its component lines based on weight.”
The weight price algorithm uses unit price * quantity to allocate the
parent bundle value across components. That can be acceptable when you want a
proportional distribution. It is a bad fit when:
- duty and tax depend on exact component pricing
- one component is a free add-on and should stay visibly free
- you need stable per-component economics for reporting or downstream systems
- discount logic must exclude or target specific components cleanly
If your bundle economics matter at the component level, specify
fixedPricePerUnit explicitly. Shopify’s current docs also expose the
corresponding failure modes. You cannot combine an overall parent price adjustment
with per-component prices, and if one expanded item has a price then all expanded
items must have prices.
// Good: every expanded item has an explicit price
expandedCartItems: [
{
merchandiseId: "gid://shopify/ProductVariant/111",
quantity: 1,
price: {
adjustment: {
fixedPricePerUnit: { amount: "49.00" }
}
}
},
{
merchandiseId: "gid://shopify/ProductVariant/222",
quantity: 1,
price: {
adjustment: {
fixedPricePerUnit: { amount: "0.00" }
}
}
}
]
// Bad: one component has fixed pricing and another relies on implicit allocation
// This can trigger expanded_items_missing_pricesOriginal insight
If finance, tax, or discount policy cares about bundle internals, use explicit component prices. The weight allocation model is a presentation convenience, not a robust economic contract.
Building a linesMerge bundle from sibling lines
Use linesMerge when the cart already contains sibling lines that should
collapse into one bundle representation. This is the better fit for “build your own
kit” logic where shoppers add components independently and the app groups them once a
valid set exists.
A common pattern is to tag products with bundle role metafields, then group matching lines into a merge candidate.
query Input {
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
product {
comboMealComponent: metafield(
namespace: "custom"
key: "combo-meal-component-type"
) {
value
}
}
}
}
}
}
}Then group the lines and emit one or more merge operations:
const NO_CHANGES = { operations: [] };
const REQUIRED_COMPONENTS = [
"combo-meal-component-burger",
"combo-meal-component-fries",
"combo-meal-component-drink",
];
/**
* @param {RunInput} input
* @returns {CartTransformRunResult}
*/
export function cartTransformRun(input) {
const scopedLines = input.cart.lines.filter(
(line) => line.merchandise?.__typename === "ProductVariant"
);
if (scopedLines.length === 0) return NO_CHANGES;
const buckets = new Map();
for (const line of scopedLines) {
const componentType = line.merchandise.product?.comboMealComponent?.value;
if (!componentType) continue;
const existing = buckets.get(componentType) ?? [];
existing.push(line);
buckets.set(componentType, existing);
}
const mergeLines = [];
for (const requiredType of REQUIRED_COMPONENTS) {
const bucket = buckets.get(requiredType);
if (!bucket || bucket.length === 0) return NO_CHANGES;
mergeLines.push({
cartLineId: bucket[0].id,
quantity: 1,
});
}
return {
operations: [
{
linesMerge: {
cartLines: mergeLines,
title: "Combo Meal",
attributes: [
{ key: "_bundle_role", value: "parent" },
{ key: "_bundle_type", value: "combo-meal" },
],
price: {
percentageDecrease: {
value: 10,
},
},
},
},
],
};
}This matches the core merge behavior Shopify documents in its examples. Notice the two important details:
- merge decisions should be deterministic and quantity-aware
- the leftover line problem is normal and must be handled deliberately
Shopify’s own examples make that leftover behavior explicit. If a cart contains two burgers, one fries, and one drink, only one burger participates in the merged bundle and the extra burger remains as a separate line. That is correct behavior, but teams often forget to design the UX around it.
Using lineUpdate for presentation and targeted repricing
lineUpdate is the narrowest and easiest operation to misuse. It does not
build a bundle. It overrides the price, title, or image of a single existing line.
Shopify’s current docs note that apps with update operations can be used only on
development stores or stores on Shopify Plus.
“Only development stores or stores on a Shopify Plus plan can use apps with lineUpdate operations.”
This makes lineUpdate a poor foundation for mass-market bundle features.
It is best used when the merchant specifically needs presentation or repricing
overrides on eligible Plus stores.
query Input {
cart {
buyerIdentity {
customer {
hasAnyTag(tags: ["VIP"])
}
}
lines {
id
merchandise {
__typename
... on ProductVariant {
title
product {
metafield(namespace: "$app:vip", key: "eligible") {
value
}
}
}
}
}
}
}const NO_CHANGES = { operations: [] };
/**
* @param {Input} input
* @returns {CartTransformRunResult}
*/
export function cartTransformRun(input) {
const isVip = input.cart.buyerIdentity?.customer?.hasAnyTag ?? false;
if (!isVip) return NO_CHANGES;
const operations = input.cart.lines.reduce((acc, line) => {
const variant = line.merchandise;
if (variant?.__typename !== "ProductVariant") return acc;
const eligible = variant.product?.metafield?.value === "true";
if (!eligible) return acc;
return [
...acc,
{
lineUpdate: {
cartLineId: line.id,
title: `${variant.title} (VIP)`,
price: {
adjustment: {
fixedPricePerUnit: {
amount: "69.95",
},
},
},
},
},
];
}, []);
return operations.length ? { operations } : NO_CHANGES;
}Also note a subtle limitation from Shopify’s docs: the updated image is visible in checkout only and is not persisted to orders. That is another reason to treat lineUpdate as a presentation layer, not a durable product-data rewrite.
Activation, blockOnFailure, and configuration design
Cart Transform activation happens through the Admin GraphQL API. The current
cartTransformCreate mutation uses a function handle plus optional
metafields and a blockOnFailure flag.
“Once created, the cart transform function becomes active for the shop.”
Example activation mutation:
mutation CartTransformCreate(
$functionHandle: String
$blockOnFailure: Boolean
$metafields: [MetafieldInput!]
) {
cartTransformCreate(
functionHandle: $functionHandle
blockOnFailure: $blockOnFailure
metafields: $metafields
) {
cartTransform {
id
blockOnFailure
}
userErrors {
field
message
}
}
}{
"functionHandle": "bundle-cart-transform",
"blockOnFailure": true,
"metafields": [
{
"namespace": "$app:bundles",
"key": "function-configuration",
"type": "json",
"value": "{\"version\":1,\"bundleTypes\":[\"meal\",\"gift-box\"]}"
}
]
}Configuration design is a real engineering decision here. Small, stable app-level
config belongs on cartTransform metafields. Per-product bundle recipes
often belong on product or variant metafields. Huge bundle graphs should usually not
be shoved into one giant JSON blob just because the function can read metafields.
Original insight
Treat cartTransform metafields as routing and defaults, not as your entire bundle database. Product-level recipes scale better, debug better, and reduce accidental coupling between unrelated bundle types.
When Cart Transform conflicts with discounts
The conflict with discounts is not that Shopify forbids both layers from existing. The conflict is that they solve different problems but often touch the same lines. Shopify’s discount docs say discount functions run concurrently and “have no knowledge of each other.” The same practical independence applies when Cart Transform restructures lines before discount logic is evaluated in the checkout flow.
“All discount functions run concurrently, and have no knowledge of each other.”
In practice, Cart Transform conflicts with discounts in four recurring ways:
double-discounting, where Cart Transform encodes bundle savings and Discount Functions also apply savings to the same economic surface
wrong target lines, where a discount still targets pre-merge components even though the shopper now sees a merged parent bundle
bad allocation, where weighted component pricing makes downstream discount economics hard to reason about
stacking surprises, where a merchant expects bundle pricing to act like a non-stackable discount even though it is not governed by discount node combination settings
The most common production mistake is this: developers use Cart Transform to reduce bundle price, then later add a Discount Function that gives 10% off “all eligible product lines,” and the transformed bundle receives that discount too. Technically, nothing is broken. Architecturally, the store is discounting a price that was already transformed to include bundle savings.
Another subtle conflict appears when line identity changes. If discounts target specific original cart lines but your merge logic creates one synthetic bundle line, your discount-targeting assumptions can fail unless you design around the transformed topology.
A discount-safe bundle architecture
The safest architecture is to decide explicitly where the bundle’s economic benefit lives.
Cart Transform owns structure and presentation. It expands or merges lines and marks bundle semantics using attributes or metafields.
Discount Functions own discount policy. They read the bundle markers and decide whether to exclude, target, or partially target bundle lines.
Validation owns rule enforcement. If a bundle should block checkout under certain conditions, do not fake that with pricing side effects.
A practical exclusion pattern is to stamp the transformed bundle parent with a machine-readable attribute, then have Discount Functions ignore those lines unless the discount is explicitly bundle-aware.
query CartLinesDiscountsGenerateRunInput {
cart {
lines {
id
bundleRole: attribute(key: "_bundle_role") {
value
}
merchandise {
__typename
... on ProductVariant {
id
}
}
}
}
discount {
discountClasses
}
}/**
* Illustrative Discount Function example
* Skip transformed bundle parents unless this discount is intentionally bundle-aware.
*/
export function cartLinesDiscountsGenerateRun(input) {
const eligibleTargets = input.cart.lines
.filter((line) => line.bundleRole?.value !== "parent")
.map((line) => ({
cartLine: { id: line.id },
}));
if (eligibleTargets.length === 0) {
return { operations: [] };
}
return {
operations: [
{
productDiscountsAdd: {
candidates: [
{
message: "Spring promo",
targets: eligibleTargets,
value: {
percentage: { value: 10.0 },
},
},
],
selectionStrategy: "ALL",
},
},
],
};
}This is not the only design, but it illustrates the principle: if bundle structure is synthetic, mark it explicitly so downstream pricing layers can reason about it. The bundle marker becomes part of your contract between Function APIs.
Limits, errors, and debugging reality
Cart Transform sits inside the general Shopify Functions resource model, so the normal Function limits still matter. Shopify’s current Function docs list an 11 million instruction limit for carts with up to 200 line items, a 128 kB function input limit, and a 20 kB function output limit.
“This limit doesn't support bulk price transformations across all line items.”
That note is easy to miss and very relevant here. Cart Transform is not meant to be a whole-cart repricing engine. If you find yourself trying to recalculate pricing for every line in the cart, you are probably in the wrong layer.
The Cart Transform docs also expose concrete operation-level errors that are worth engineering against early:
cannot_combine_price_adjustment_and_price_per_componentexpanded_items_missing_pricesexceeded_maximum_number_of_supported_expanded_cart_items(150)exceeded_maximum_number_of_supported_merged_cart_itemsprice_per_component_feature_not_availabletitle_feature_not_availableimage_feature_not_available
In production, the highest-value debugging move is to log and preserve your exact bundle recipe inputs at the application layer, because the function itself does not give you traditional request-response debugging ergonomics. You should be able to reconstruct the transform decision from:
- cart line IDs and quantities at decision time
- the metafield bundle recipe used
- the chosen operation and pricing path
- whether the store had a selling plan on the affected line
- whether another pricing layer also targeted the same lines
What Cart Transform is not for
Cart Transform gets abused when developers want one API to solve everything near the cart. It is usually the wrong tool when the real requirement is:
- discount policy with code handling and combination rules
- shipping discount logic
- payment method control
- checkout blocking and error messaging
- subscription and selling-plan aware bundle transformations
- large-scale repricing of arbitrary cart contents
Those problems map more cleanly to Discount Functions, Delivery Customization, Payment Customization, Validation, or a different product model altogether.
The fastest way to build a brittle bundle system is to make Cart Transform own structure, pricing, discount policy, enforcement, and storefront assumptions all at once.
Original insights that save real engineering time
These are the patterns I would optimize for early:
Pick one economic owner for bundle savings. Either the bundle savings live in Cart Transform pricing or they live in Discount Functions. Do not leave that ambiguous.
Stamp transformed semantics into attributes. Bundle role, bundle type, and source parent identifiers are cheap insurance for downstream logic.
Prefer explicit component pricing when money meaning matters. Weight allocation is helpful, but it is not a serious substitute for clear bundle economics.
Design for the one-transform rule upfront. If your merchant may use multiple merchandising apps, the transform slot is a product decision, not just an implementation detail.
Assume line identity is unstable after merge or expand. Anything that targets lines later should reason about transformed topology, not only original topology.
Treat selling plans as a hard branch, not an edge case. If subscriptions matter, you need an explicit alternative path.
The deepest lesson
Cart Transform is easiest to use when you stop thinking of it as a discount engine and start thinking of it as a controlled cart topology engine with pricing side-effects.
Best internal links
These are the best follow-on reads when Cart Transform is only one part of a larger checkout modernization project.
Shopify Scripts to Shopify Functions migration guide
for mapping legacy Ruby logic into Discount, Delivery Customization, Payment Customization, Validation, and Cart Transform targets.
Shopify Checkout UI Extensions migration guide
for understanding where cart structure work ends and checkout-step, Thank you page, and pixel migrations begin.
Shopify analytics playbook for operators
for validating reporting when transformed lines and bundle pricing affect event payloads and order interpretation.
Sources and further reading
Shopify Dev, Cart Transform Function API
Shopify Dev, Start building bundles
Shopify Dev, Discount Function API
Shopify Dev, Function APIs
Shopify Developer Changelog, Cart Transform Function API
Shopify Developer Changelog, New and updated operations for the Cart Transform API
Shopify Developer Changelog, Pricing bundles per component and additional customizations
Shopify Dev, cartTransformCreate
Shopify Dev, cartTransformDelete
Shopify Dev, cartTransforms query
Shopify Dev, Build a Discount Function
Shopify Dev, Cart and Checkout Validation Function API
FAQ
When should I use lineExpand instead of linesMerge?
Use lineExpand when a single parent line should break into visible component lines, such as a configured bundle or add-on package. Use linesMerge when separate cart lines should become one synthetic bundle line. Expand starts from a parent line. Merge starts from sibling lines already in the cart.
Can Cart Transform replace Discount Functions?
Not cleanly. Cart Transform can change bundle pricing and line presentation, but Discount Functions remain the right layer for discount policy, code handling, subtotal discounts, shipping discounts, and stacking behavior. Treating Cart Transform as your whole discount engine usually creates brittle results.
Why do Cart Transform bundles sometimes conflict with discounts?
The conflict is usually architectural, not accidental. Cart Transform changes line structure and can also adjust pricing, while Discount Functions run concurrently and independently. If you encode bundle savings in Cart Transform and also allow discounts to target those same lines, you can double-discount or discount the wrong surface.
What are the most important limitations to remember?
Only one cart transform can be installed per store, selling plans cause expand, merge, and update operations to be rejected, and lineUpdate is limited to development stores or Shopify Plus. Those three constraints should shape the design before you write the first line of code.
Related resources
Keep exploring the playbook
Shopify Checkout UI Extensions migration guide
A practical Shopify developer guide to moving from checkout.liquid, Scripts, additional scripts, and script tags to Checkout UI Extensions, Functions, web pixels, and blocks.
Shopify Scripts to Shopify Functions migration guide
A technical Shopify developer guide to migrating line item, shipping, and payment Scripts to Shopify Functions, unified Discount API, delivery and payment customizations, validation, and modern deployment workflows.
Shopify AEO guide
A practical Shopify guide to answer engine optimization covering AI search visibility, product citability, structured data, Shopify Catalog, entity signals, and measurement.