Guides

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.

Updated March 10, 2026
24 min read
Editorial note: Cart Transform is not a general-purpose pricing engine. It is a cart presentation and merchandising layer with explicit bundle-oriented operations. The technical challenge is deciding what belongs in Cart Transform, what belongs in Discount Functions, and what should be enforced through validation instead.

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
  • lineUpdate is available only on development stores or Shopify Plus

  • Shopify rejects lineExpand, linesMerge, and lineUpdate when a selling plan is present

  • subscription recurring orders are not supported
  • POS support is partial, and ProductVariant.requiresComponents must be true for 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:

  • lineExpand transforms one parent line into multiple visible component lines

  • linesMerge transforms multiple sibling lines into a single synthetic bundle line

  • lineUpdate overrides 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_prices

Original 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.

  1. Cart Transform owns structure and presentation. It expands or merges lines and marks bundle semantics using attributes or metafields.

  2. Discount Functions own discount policy. They read the bundle markers and decide whether to exclude, target, or partially target bundle lines.

  3. 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_component
  • expanded_items_missing_prices
  • exceeded_maximum_number_of_supported_expanded_cart_items (150)

  • exceeded_maximum_number_of_supported_merged_cart_items
  • price_per_component_feature_not_available
  • title_feature_not_available
  • image_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:

  1. 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.

  2. Stamp transformed semantics into attributes. Bundle role, bundle type, and source parent identifiers are cheap insurance for downstream logic.

  3. Prefer explicit component pricing when money meaning matters. Weight allocation is helpful, but it is not a serious substitute for clear bundle economics.

  4. 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.

  5. Assume line identity is unstable after merge or expand. Anything that targets lines later should reason about transformed topology, not only original topology.

  6. 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.

Sources and further reading

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

Guides

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.

guidesShopify developerCheckout UI Extensions
Guides

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.

guidesShopify developerShopify Functions
Guides

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.

guidesAEOSEO