← Blog
Problem

How to handle complex discount logic in a Shopify Function

“My discount rules are too complicated to evaluate inside a Shopify Function.”

A merchant describes their promotion and it sounds reasonable. Fifteen percent off one brand. Ten percent more once they buy five. Another five once the cart passes two hundred. VIP customers get a further five. Clearance items are excluded. Never more than forty percent in total.

You start writing the Function, and the thing that stops you is not the logic. It is that the input query will not validate.

Query has complexity of <cost>, which exceeds max complexity of 30

That failure happens before your code runs, so no amount of tuning the Rust helps. It is worth understanding why, because the constraint is not the one most people expect and it changes how you structure the whole extension.

The budget you are actually spending

A Function input query has a maximum calculated cost of 30. Shopify works the cost out from the query document itself, before execution, and each selected field is counted once no matter how many cart lines come back.

The prices are the interesting part:

what you select cost
container fields like cart, lines, product 0
an ordinary leaf like id, quantity, amount 1
hasTags or hasAnyTag 3
inCollections or inAnyCollection 3
any field returning a Metafield object 3
the fields you then select on that object, like value 0
field(key:) on a Metaobject 3

Read that table again with the merchant's promotion in mind. Every arithmetic operation in the rule set is free. Every question of the form "does this thing match" costs 3.

Shopify's own documentation puts the consequence plainly:

Every selection is charged, so aliasing hasTags ten times in one query costs 30, the entire budget, and reading a metafield on the product, the variant, and the shop costs 9.

Ten tag checks. Not ten tag checks plus a cart subtotal and a quantity, just the ten tag checks, and you are done. A promotion that names five brands and two collections and one customer segment has spent 24 before it has read a single price.

It is not a CPU problem, and that is the trap

The execution limits are generous. For carts of up to 200 line items a Function gets 11 million instructions, 128 kB of input and 20 kB of output, and those scale proportionally as the line count grows past 200. The fixed limits are a 256 kB compiled binary and 10,000 kB of linear memory.

Applying a few percentages across a few hundred cart lines does not come close to any of that. So the instinct to optimise the evaluation is aimed at the wrong thing. You have an enormous compute budget and a tiny reading budget, and complex discount logic is almost entirely reading.

The other reason the reading budget binds so hard: an input query is static. It is a fixed document, deployed with the extension. It cannot enumerate the tags a particular merchant invented, or the collection IDs in their store, or the number of brands in this month's promotion. You do not get to generate a query per shop.

The move that works: resolve the matching before checkout

Decide what matches when the merchant saves their rules, not when a shopper reaches checkout. Write the answer onto each product as a metafield. Then the Function reads one number per line and does arithmetic.

The query stops looking like a search and starts looking like a lookup:

query RunInput {
  cart {                                    # container: 0
    lines {                                 # container: 0
      id                                    # leaf: 1
      quantity                              # leaf: 1
      cost { amountPerQuantity { amount } } # leaf: 1
      merchandise {
        __typename                          # 0
        ... on ProductVariant {
          product {
            offer: metafield(namespace: "$app:offer", key: "resolved") {  # 3
              value                         # 0
            }
          }
        }
      }
    }
  }
  shop {
    metafield(namespace: "$app:config", key: "rules") {                   # 3
      value                                 # 0
    }
  }
}

Nine of 30. The brand rules, the collection rules and the exclusions all still exist, but they were evaluated in your app against the Admin API, where there is no cost budget and no 3000 byte limit on the query document. What reaches checkout is the result.

Two details that make this work in practice:

Put the shop-level configuration in one JSON metafield. Thresholds, quantity breaks and the cap are one document, not one metafield each. Shopify recommends this directly: reading one metafield that holds a JSON object costs 3, and every field you select inside it is free. Three separate metafields would cost 9 for the same information.

Budget for the things that genuinely cannot be precomputed. Customer tags and delivery details belong to the checkout, not to the catalog, so they have to be read live. Leaving 21 of the 30 unspent is what makes room for them.

The honest cost of this approach is staleness. A resolved value is a cache, so the catalog changing has to trigger a re-resolve, and you need to decide what happens to a product that was edited while the job was running. That is real work and it is the price of the technique.

The second wall: Functions cannot see each other

Once the rules are split across more than one Function, a cap across all of them stops being possible.

A store can have up to 25 discount functions active. They run concurrently, and Shopify states that they have no knowledge of each other. Their outputs are then combined according to the combination and stacking rules configured on the discount node.

Those combination rules are Shopify's, and they are not the same thing as your own maximum. If the requirement is "never more than forty percent in total, whatever fires", every rule that contributes to that total has to be evaluated inside a single Function, because that is the only place where a single number can be computed and clamped.

A single Discount Function processes one discount, and one is enough for a surprising amount: it can apply savings across the product, order and shipping classes at once.

The cap is where the money actually goes wrong

The failure mode worth designing against is not a Function that errors. It is a Function that succeeds and returns the wrong number, because nothing is watching. Discounts that quietly compound produce order lines at or near zero, and that is generally discovered from the takings rather than from an alert.

Two decisions to make deliberately rather than by default:

Decide what an absent cap means. If the merchant leaves the maximum blank, that can sensibly mean "no ceiling" or "nothing is discounted". Both are defensible and they are opposites. Whichever you pick, pick it on purpose and write a test that states it, because the value will be blank in production on the first store that skips the field.

Decide what a zero result emits. A rule that did not fire and a rule that fired at zero percent are different things, and they may not render the same way at checkout. Check what your checkout actually shows for a zero-value candidate before you emit one.

How to check this before you write the code

The useful property of the cost budget is that it is knowable in advance and costs nothing to check.

  1. Count the query by hand. Container fields are free, ordinary leaves are 1, and matching operations are 3. It takes a minute and it is the cheapest disqualifying check available, so run it before designing anything around a query shape.
  2. Read the calculated limits. Shopify exposes the calculated limits for a Function execution in the Dev Dashboard, and they can be tested with the Shopify CLI.
  3. Do not trust a passing unit test. Query validation happens before your code runs, so a Function whose logic is fully tested can still be rejected on deployment. The two are checked in different places.

If you do not need a Function at all

This is worth saying before anyone builds a Wasm extension for a promotion that Shopify already supports. The Admin API creates automatic and code discounts natively, including percentage, fixed amount, Buy X Get Y and spend-threshold offers. Shopify positions Functions as the tool for "discount functionality that isn't offered out of the box".

If the native discount types cover the promotion, use them. They cost nothing, there is no query budget, and there is no extension to keep deployed against an API version. The reason to reach for a Function is a rule the platform does not model, and a stack with a single ceiling across several interacting offers is a good example of one.

What this covers, and what it does not

Everything above describes the Discount Function API at version 2026-07, which is the current release at the time of writing, read from Shopify's developer documentation. Version 2026-10 is in release candidate.

Three boundaries worth naming, because each one changes the answer:

Cart Transform is a different API with a plan restriction. Rewriting a line's price so the shopper sees a lower amount with no discount line uses the Cart Transform lineUpdate operation, and Shopify's documentation is explicit that only development stores and stores on a Shopify Plus plan can use apps with those operations. A Discount Function carries no such restriction. If you build the quiet version and test it on a development store, it will work there and do nothing on most of the stores that install it.

Functions can now make network calls, but probably not yours. A fetch target exists. For the Discount Function API it is limited to custom apps installed on Shopify Plus and Enterprise stores, it needs network access to be requested separately, and it is not available on development stores. Discount functions with network access are also not supported on draft orders. Treat the run target as having no network unless all of those apply to you.

These are the documented limits, not measurements from a live store. The costs, the budget of 30 and the execution limits are quoted from Shopify's documentation rather than derived by probing a store. The Dev Dashboard figure is the one to trust for your own extension.