Shopify Fulfillment Constraints: Keep Items Together

A Shopify fulfillment constraints function is a Shopify Function on the cart.fulfillment-constraints.generate.run target that tells checkout which cart lines must ship from the same location, or from a list of locations you name. If no location can satisfy those rules, the buyer sees no shipping options and cannot complete checkout. It is the only API that sets these constraints.

The “only API” part decides most designs. Merchants ask for “keep the gift set together” or “the fridge only ships from the Leeds warehouse” and developers reach for delivery customisation, which can rename, reorder and hide rates but cannot change where stock comes from. Fulfillment constraints work one level down, on allocation, before rates exist. This post was written against the Fulfillment Constraints Function API at version 2026-10.

Fulfillment constraints vs order routing: which one do you need?

Shopify’s order routing overview lists three Function APIs that touch location choice, and they answer different questions.

Order routing location rules rank locations. They say “prefer this warehouse” and Shopify still picks the best match from the ranking. They are also hard to get: the Order Routing Location Rule API is a beta, available by request, and only for Shopify Plus merchants.

Fulfillment constraints are rules, not preferences. A line that must fulfil from location X either gets X or the checkout stops. The reference page does not restrict them to Plus, which makes them the tool most stores can actually use.

The third API, local pickup delivery option generators, creates pickup options and is a separate job.

Ask what a broken rule costs. If breaking the rule should cost you a little efficiency, you want a ranking. If breaking it means a damaged parcel, a split gift box or a hazmat item in a van it should not be in, you want a constraint.

What can the function return?

The result type is CartFulfillmentConstraintsGenerateRunResult, which has one field, operations. There are two operations:

  • deliverableLinesMustFulfillFromSameLocationAdd takes deliverableLineIds and forces those lines into one shared location.
  • deliverableLinesMustFulfillFromAdd takes deliverableLineIds plus a required locationIds list and restricts those lines to the locations you list.

A function that does nothing returns { operations: [] }, and you should return that early and often. Every operation you add narrows the set of locations Shopify is allowed to use, and the API reference is blunt about what happens when you narrow it to nothing: constraints that conflict, or lines with no stock at any allowed location, produce no shipping rates. The buyer gets a message about which items are out of stock and cannot pay.

One naming trap. Shopify’s build tutorial returns deliverableLinesMustFulfillFromAddOperation in its JavaScript sample, which is the type name. The 2026-10 reference examples use deliverableLinesMustFulfillFromAdd as the output key. Run shopify app function typegen and let the generated types settle it for your version.

Writing the input query

The input is the cart plus a few things Shopify knows about the store. The fields that do the work here are:

  • cart.deliverableLines, which is only the lines that need delivery. Each has an id, quantity, merchandise and an attribute(key:) lookup for line properties.
  • locations, every inventory location with id, handle, name, address and metafield, filterable by names or identifiers.
  • fulfillmentConstraintRule.metafield, the configuration your app stored when it activated the rule.

Tags cannot be read as a list inside a Function; use hasAnyTag and alias it. Here is the query for a function with two jobs: keep bundle components together, and send anything tagged warehouse-only to the locations the merchant chose in your app’s settings.

query CartFulfillmentConstraintsGenerateRunInput {
  cart {
    deliverableLines {
      id
      bundle: attribute(key: "_bundle_id") {
        value
      }
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            warehouseOnly: hasAnyTag(tags: ["warehouse-only"])
          }
        }
      }
    }
  }
  fulfillmentConstraintRule {
    metafield(namespace: "$app", key: "configuration") {
      jsonValue
    }
  }
}

The _bundle_id value is a line item property the theme adds when it puts the bundle’s components in the cart. Any shared string works, as long as every component gets the same one.

Reading location IDs from the rule’s metafield, rather than from locations(names: [...]), is a deliberate choice. Names get edited in the admin. Location IDs do not, and the merchant picks them once in your app’s UI.

The function

// @ts-check

/**
 * @typedef {import("../generated/api").CartFulfillmentConstraintsGenerateRunInput} RunInput
 * @typedef {import("../generated/api").CartFulfillmentConstraintsGenerateRunResult} RunResult
 */

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function cartFulfillmentConstraintsGenerateRun(input) {
  const operations = [];

  // 1. Lines that share a _bundle_id attribute ship from one location.
  const bundles = new Map();
  for (const line of input.cart.deliverableLines) {
    const bundleId = line.bundle?.value;
    if (!bundleId) continue;
    const ids = bundles.get(bundleId) ?? [];
    ids.push(line.id);
    bundles.set(bundleId, ids);
  }
  for (const ids of bundles.values()) {
    if (ids.length < 2) continue;
    operations.push({
      deliverableLinesMustFulfillFromSameLocationAdd: {
        deliverableLineIds: ids,
      },
    });
  }

  // 2. Tagged products only ship from the locations the merchant configured.
  /** @type {{ warehouseLocationIds?: string[] }} */
  const config = input.fulfillmentConstraintRule?.metafield?.jsonValue ?? {};
  const warehouseIds = Array.isArray(config.warehouseLocationIds)
    ? config.warehouseLocationIds
    : [];
  const warehouseOnlyLines = input.cart.deliverableLines
    .filter(
      (line) =>
        line.merchandise.__typename === "ProductVariant" &&
        line.merchandise.product.warehouseOnly,
    )
    .map((line) => line.id);

  if (warehouseOnlyLines.length > 0 && warehouseIds.length > 0) {
    operations.push({
      deliverableLinesMustFulfillFromAdd: {
        deliverableLineIds: warehouseOnlyLines,
        locationIds: warehouseIds,
      },
    });
  }

  return { operations };
}

The two guards are there for checkout’s sake. A bundle with one line left in the cart (the buyer removed the rest) gets no constraint, because constraining a single line to “the same location as itself” adds nothing. And an empty location list means no operation at all. locationIds is required, and a missing setting in your app should never become a checkout that cannot ship.

Watch the interaction between the two rules. If a bundle contains a warehouse-only product, every other component in that bundle is now pulled to the warehouse too, because they must share its location. That is usually what the merchant wants. When the warehouse is out of one component, though, the whole bundle is unsellable, and you should tell the merchant that in your settings screen before they find out from a support ticket.

Activating the rule with fulfillmentConstraintRuleCreate

Deploying the function does nothing on its own. Your app has to create a rule on each store with the Admin GraphQL fulfillmentConstraintRuleCreate mutation, which needs the write_fulfillment_constraint_rules scope and must be called by the same app that deployed the function.

mutation CreateFulfillmentConstraintRule($metafields: [MetafieldInput!]) {
  fulfillmentConstraintRuleCreate(
    functionHandle: "keep-together"
    deliveryMethodTypes: [SHIPPING, LOCAL]
    metafields: $metafields
  ) {
    fulfillmentConstraintRule {
      id
      deliveryMethodTypes
    }
    userErrors {
      field
      message
    }
  }
}

Pass the function by functionHandle, the handle from shopify.extension.toml. The tutorial notes that from 2025-10 you no longer need to look up function IDs, and functionId is deprecated on the mutation reference. Send the configuration as a json metafield in the same call, with the key configuration to match the input query.

deliveryMethodTypes is required on create, and it changes what the rule means. The values include SHIPPING, LOCAL, PICK_UP, PICKUP_POINT, RETAIL and NONE. Leave PICK_UP out of a “warehouse only” rule and the rule is not associated with pickup, so a shop holding stock can still offer collection. Include it and pickup has to satisfy the constraint too. Decide that with the merchant, not by copying the list from an example.

A store can have at most 25 active fulfillment constraint functions. One app will not reach that, but two apps can still collide when each adds constraints that, together, leave no valid location.

Testing it before a buyer does

Run shopify app dev against a development store, build a cart that should trigger each rule, then use shopify app function replay to re-run the exact input locally while you change the code. Our guide to testing Shopify Functions locally covers fixtures and replay in more depth.

Three things to check by hand, because none of them shows up in a unit test.

First, caching. Shopify can reuse a result for up to 15 minutes when delivery-related cart data has not changed, so a change to the rule’s metafield may not show in a cart you already have open. Start a fresh cart.

Second, inventory moves. The tutorial warns that changing a product’s inventory location can leave checkout with no shipping rates. If the merchant restocks a warehouse-only product somewhere else, the rule still points at the old location.

Third, surfaces. The reference lists checkout, draft orders, B2B, subscriptions and the Create Order API as supported, POS as partial (shipping to customers only), Order Edit as partial (new items only), and pre-order and try before you buy as unsupported. On an order edited in the admin, only the newly added items are covered, so a constraint the merchant relies on can be bypassed there.

When to use one

Use a fulfillment constraints function when a wrong location produces a real failure: a split bundle, a product that cannot ship from a shop, a cold-chain item. Keep each rule narrow, scope deliveryMethodTypes on purpose, and make the function return no operations whenever its configuration is missing.

Do not use one to express a preference. “Ship from the nearest store where possible” belongs in the store’s order routing settings, because a constraint will turn the occasional stock gap into a checkout that cannot be paid. If the requirement is only about which rates the buyer sees, a delivery customisation function is the smaller tool.

We build Shopify Functions and the admin apps that configure them for merchants who fulfil from more than one location. If your fulfilment rules live in a spreadsheet the warehouse team checks by hand, our Shopify development work usually starts there.

Need this built properly?

Get in touch