Shopify Functions

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the Shopify Function APIs in version 2026-10 (the latest stable version, released on 2026-10-01), Shopify CLI 5.0.0 (published on 2026-10-27, with the function-runner 9.2.2 that it bundles), the npm package @shopify/shopify_function 2.0.1, the Rust crate shopify_function 2.3.0 (published on 2026-10-26) and the extension templates of the Shopify/extensions-templates repository as of 2026-10-27, which still scaffold api_version = "2026-07". It was checked on 2026-10-27. Shopify Functions are available on every plan when they ship inside a public app from the Shopify App Store; a custom app that contains Functions can be installed only on a Shopify Plus store, and a few operations are Plus-only even for public apps, as listed in the section on Availability and plans.

A Shopify Function is a small piece of backend logic that Shopify runs for you inside the cart and checkout flows. You do not host it, you never call it by URL, and you cannot make network calls from it: you compile it to WebAssembly (Wasm), upload it with your app, and Shopify calls it at a named point (a target) with the result of a GraphQL query that you wrote (the input). The function answers with a list of operations (give this line 10 percent off, hide this payment method, expand this line into its components, reject this checkout) and Shopify applies them. Functions replaced the older Shopify Scripts, which stopped executing on 2026-06-30, so this page is also the migration target for every Script.

The page sits after the pages on apps and scaffolding, access scopes and the Admin GraphQL API, whose mutations activate a Function, and it complements checkout extensibility, which covers the UI side of the same checkout. The language chapters of this site cover the languages involved: Rust, Rust and WebAssembly and JavaScript.

The running example is the Pocket Coffee shop. Five Functions are built for it, all in one app with five extensions:

Pocket Coffee rule Function API Extension handle

Coffee Club members get 10 percent off beans, and free shipping from 40 EUR

Discount (two targets)

coffee-club-discount

The Starter kit line is split into the hand grinder and the 250 g beans it contains

Cart Transform

kit-bundle

The "Same-day Madrid" option is hidden outside Madrid, and "Standard" is renamed

Delivery Customization

delivery-rules

"Cash on Delivery" is hidden outside Spain, and "Bank Transfer" is renamed

Payment Customization

payment-rules

A cart heavier than the 5 kg parcel limit cannot be checked out

Cart and Checkout Validation

parcel-limit

This page was prepared without a Shopify account: nothing was deployed, activated or run by Shopify. What did run locally is the whole build and test loop. The five extensions were compiled to Wasm with shopify app build, executed with shopify app function run on sample carts (including carts of 1, 50, 200 and 1,000 lines to measure the instruction count), covered by Wasm integration tests (nine tests, all passing), and the six Admin GraphQL documents that activate them were validated against the downloaded 2026-10 schema. The Rust Function was not compiled, because no Rust toolchain was installed. The list of what was and was not run is in What was run and what was not.

How a Function works

A Function has three parts, and Shopify calls the same sequence every time it needs the Function’s answer.

  • The input query is a GraphQL query against the schema of the Function API, written in a .graphql file next to the code. Shopify runs it before calling your code and hands the JSON result to the module. You receive only the fields you asked for, which keeps the input small and cheap to read.

  • The function logic is a Wasm module with one exported function per target. Shopify’s own templates and client libraries exist for Rust and JavaScript, but any language that compiles to Wasm and meets the Wasm API can be used.

  • The output is a JSON document of declarative operations, shaped by the same API schema. Your code never changes the cart: it describes the change and Shopify applies it, after validating the document.

How a JavaScript or Rust Function becomes a Wasm module: source and input query, bundling or cargo build, Wasm file, shopify app deploy and the limits that apply at run time

The lifecycle involves four actors, and a Function never runs unless a merchant has created a function owner for it. The owner is the Shopify object that the Function belongs to: a discount for the Discount API, a cart transform, a delivery customization, a payment customization or a validation for the others. The merchant (or your app, through the Admin GraphQL API) creates it, configures it, and can turn it on and off.

sequenceDiagram participant Dev as App developer participant Shopify participant Merchant participant Buyer Dev->>Shopify: shopify app deploy (Wasm modules plus configuration) Merchant->>Shopify: install the app and create the function owner Merchant->>Shopify: save configuration as metafields Buyer->>Shopify: change the cart or open checkout Shopify->>Shopify: run the input query and call the Wasm export Shopify-->>Buyer: apply the returned operations

Four properties of the model explain most of the design rules in the rest of this page.

  • Functions are deterministic. Randomness and clocks are not allowed, so the same module and the same input always give the same output and the same instruction count. That is what makes local testing exact. The input offers the shop’s local time through shop.localTime when a rule depends on the hour of the day.

  • Functions are isolated. A Function cannot call a service, read a database or write a file. Everything it knows comes from the input query: Shopify data, the metafields and app-owned metaobjects that hold its configuration, and the cart attributes the storefront set. The only exception is the fetch target, which is limited to custom apps installed on Enterprise stores (and, for the Discount API, Plus stores) and which also needs a request to Shopify for network access.

  • Functions are fast or they fail. They run in the buyer’s path, with hard limits on size, memory and instructions (see Limits and performance). A Function that breaks a limit does not slow checkout, it errors, and what happens next depends on the API.

  • Functions are versioned with the API. Each extension declares an api_version, and the schema file it ships is a copy of that version’s schema. Updating the version, regenerating the schema and regenerating the JavaScript types are three separate steps.

The Function APIs and their targets

Every Function API has one or more targets, named identifiers such as cart.transform.run. The shopify.extension.toml of an extension maps each target to an input query and a Wasm export. Names follow the pattern context.object.action.mode, where the mode is run (the logic) or fetch (the optional network step that runs first).

Function API Target Operations returned Function owner and activation Limit per store

Discount

cart.lines.discounts.generate.run, cart.delivery-options.discounts.generate.run

productDiscountsAdd, orderDiscountsAdd, deliveryDiscountsAdd, enteredDiscountCodesAccept, enteredDiscountCodesReject

An automatic or code discount: discountAutomaticAppCreate, discountCodeAppCreate

25 active discount functions

Cart Transform

cart.transform.run

lineExpand, linesMerge, lineUpdate

cartTransformCreate

One cart transform function per app

Delivery Customization

cart.delivery-options.transform.run

deliveryOptionHide, deliveryOptionMove, deliveryOptionRename

deliveryCustomizationCreate

25 active

Payment Customization

cart.payment-methods.transform.run

paymentMethodHide, paymentMethodMove, paymentMethodRename, paymentTermsSet, orderReviewAdd

paymentCustomizationCreate

25 active

Cart and Checkout Validation

cart.validations.generate.run

validationAdd

validationCreate

25 active

Fulfillment Constraints

cart.fulfillment-constraints.generate.run

deliverableLinesMustFulfillFromAdd, deliverableLinesMustFulfillFromSameLocationAdd

A fulfillment constraint rule

25 active

Order Routing Location Rule

cart.fulfillment-groups.location-rankings.generate.run

fulfillmentGroupLocationRankingAdd

A location rule (by request, Plus only)

not stated

Source: Shopify: Function APIs and the reference page of each API linked in the references. The target names and operation names were read from the schema files of the extension templates; the limits per store come from the API pages and from the changelog entry on 25 functions in a single batch (2025-05-21).

Three more APIs exist in the template catalogue but are not part of the stable 2026-10 set: the fulfillment option generator (cart.fulfillment-options.generate.run), the local pickup option generator (purchase.local-pickup-delivery-option-generator.run) and the pickup point option generator (purchase.pickup-point-delivery-option-generator.run) are templates with api_version = "unstable", which means they are available only in the unstable API version and may change. The Discounts Allocator API (purchase.discounts-allocator.run) was a developer preview that ended on 2026-09-10 and was removed; an app that still declares that target fails shopify app deploy for the whole app, with the error text API not found for target: purchase.discounts-allocator.run, until the extension is removed. The Product Discount, Order Discount and Shipping Discount APIs are deprecated since API version 2025-04 and must be replaced by the unified Discount API, as described in Migrating from the old discount APIs.

Execution order in checkout

Functions do not run all at once. Each API runs at a fixed point of the cart and checkout flow and reads what the earlier points produced, which is why a validation cannot see a cart that has not been discounted yet and why a discount sees the lines after a cart transform changed them.

Seven stages of the checkout flow in which Shopify Functions run: cart transform, line discounts, fulfillment groups, delivery methods, delivery discounts, payment methods and validation

The stages of the figure, in order, are the cart lines (Cart Transform), the cart line discounts (Discount), the fulfillment groups (Fulfillment Constraints and Order Routing), the delivery methods (the pickup generators, then Delivery Customization), the delivery discounts (Discount again, for the shipping class), the payment methods (Payment Customization) and finally the verification (Cart and Checkout Validation). Two practical consequences follow. A delivery customization runs before the delivery discounts of stage 5, and a payment customization runs after every discount has been calculated. When the order of two Functions matters, check the stage rather than assuming that the one installed first runs first.

Where each Function API works

Each API page lists the Shopify surfaces on which it runs. The entries that decide whether a rule will apply are worth knowing before designing a Function. "Partly" means the API page states a restriction, summarised below the table.

Surface Discount Cart Transform Delivery Customization Payment Customization Validation

Cart page of a theme

Yes

Yes

No

No

Yes

Checkout

Yes

Partly

Yes

Yes

Yes

Accelerated checkout (Shop Pay, Apple Pay, Google Pay)

Yes

Yes

Yes

Partly

Yes

Storefront API carts

Yes

Yes

No

No

Yes

Draft order created in checkout

Partly

Yes

Yes

Yes

Yes

Draft order created in the admin

Partly

Yes

No

No

Yes

Order edit in checkout

Partly

No

No

No

No

POS

Yes

Partly

Partly

No

No

Subscriptions (recurring orders)

Partly

No

No

No

No

Pre-order and Try Before You Buy

No

No

No

Yes

No

Create Order API

No

No

No

No

No

The restrictions behind "Partly" are the following. A Discount function is not supported with network access on draft orders, is not re-run when an order is edited in checkout, and is not re-run when recurring orders are created (a shipping discount on the original order becomes a 100 percent shipping discount on the recurring orders, whatever its value). Cart Transform in checkout rejects lineExpand, linesMerge and lineUpdate when a selling plan is present, and on POS it needs ProductVariant.requiresComponents to be true for full support. Delivery Customization works on POS only when shipping to a store. Payment Customization on accelerated checkout is limited as described in the payment section.

For Pocket Coffee the table has one immediate effect: the Coffee Club discount works at a POS counter (a market stall is a realistic extension of the shop), while the parcel-weight validation does not, so a weight check for POS orders would need another mechanism. It also means that nothing in delivery-rules or payment-rules can influence the cart page, where the buyer has not yet chosen a destination.

Availability and plans

Situation What applies

A public app distributed through the Shopify App Store that contains Functions

Installable on a store of any plan. Individual capabilities can still be Plus-only, as listed below.

A custom app that contains Functions

Installable only on a Shopify Plus store. See plans and Shopify Plus for how custom apps are distributed.

Development stores

Can use apps with Functions for testing, including the Cart Transform lineUpdate operation. Network access (the fetch target) is not available there.

Cart Transform lineUpdate, and the update price and title overrides

Only development stores and Plus stores.

Payment Customization: paymentTermsSet

Only on stores with the Plus plan.

Payment Customization: orderReviewAdd

Only for B2B purchases on stores with the Plus plan.

Payment Customization: hiding specific placements

Only on Shopify Plus stores; without placements a hidden method is hidden everywhere.

Fetch target (network access)

Custom apps installed on Enterprise stores, after Shopify approves a request; for Discount, on Plus and Enterprise stores. Not available on development stores or in a feature preview.

Order Routing Location Rule

Available by request, for merchants on Plus enrolled in the Partners program.

The extension: files and configuration

A Function lives in an extension folder of the app, scaffolded with the CLI. With a store connection the command is interactive; in a script the template and flavor are given as flags.

# one extension per Function; --template selects the API, --flavor the language
shopify app generate extension --template discount --flavor vanilla-js --name "Coffee Club discount"
shopify app generate extension --template cart_transform --flavor rust --name "Starter kit bundle"

Source: Shopify CLI: app generate extension. This command needs a Shopify login, so it was not run here; the extensions below were written by hand from the template files, using the same layout.

The template ids that produce Functions in the catalogue (templates.json of Shopify/extensions-templates) are discount, cart_transform, delivery_customization, payment_customization, cart_checkout_validation, fulfillment_constraints, order_routing_location_rule, the deprecated order_discounts, product_discounts and shipping_discounts, and the unstable fulfillment_option_generator, local_pickup_delivery_option_generator, pickup_point_delivery_option_generator and discounts_allocator. The --flavor option accepts vanilla-js, typescript, rust and wasm for Functions (the react flavors are for UI extensions). Some Functions have a companion admin UI extension that gives the merchant a configuration screen; discount_details_function_settings, validation_settings_ui and order_routing_location_rule_ui are the templates for them.

A JavaScript Function extension, as built for this page, has this layout.

extensions/coffee-club-discount/
  shopify.extension.toml        configuration: api_version, targets, build
  package.json                  dependencies and the "codegen" key used by typegen
  schema.graphql                copy of the Function API schema for api_version
  src/
    index.js                    re-exports one function per target
    cart_lines_discounts_generate_run.graphql   input query of the first target
    cart_lines_discounts_generate_run.js        logic of the first target
    cart_delivery_options_discounts_generate_run.graphql
    cart_delivery_options_discounts_generate_run.js
  generated/api.ts              types written by "shopify app function typegen"
  dist/function.wasm            build output
  tests/default.test.js         integration test (Vitest)
  tests/fixtures/*.json         one fixture per scenario

Source: Shopify/extensions-templates, folder functions-discount-js

shopify.extension.toml

The configuration of the first extension, exactly as it was built.

api_version = "2026-07"

[[extensions]]
name = "Coffee Club discount"
handle = "coffee-club-discount"
type = "function"
description = "10 percent off beans for Coffee Club members, free shipping from 40 EUR"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/cart_lines_discounts_generate_run.graphql"
  input_variables = { namespace = "$app:coffee-club", key = "variables" }
  export = "cart-lines-discounts-generate-run"

  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/cart_delivery_options_discounts_generate_run.graphql"
  input_variables = { namespace = "$app:coffee-club", key = "variables" }
  export = "cart-delivery-options-discounts-generate-run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"

Every key that the file accepts is listed below.

Key Required Meaning

api_version

Yes

The Function API version used by the extension. It may also appear inside , where it overrides the root value. The current templates use 2026-07; Shopify recommends the latest supported version, which is 2026-10.

Yes

The array that holds every extension of the file.

type

Yes

function for Functions.

name

Yes

The merchant-facing name that appears in the Shopify admin. Templates use a translation key such as t:name, resolved from the locales folder.

handle

Yes

The internal identifier. It cannot be changed after a draft version or a deploy exists. It is also the functionHandle that Admin API mutations use to find the Function.

uid

Yes, written by the CLI

An app-scoped identifier that shopify app deploy uses to decide whether an extension is created, updated or deleted. The CLI generates it when it scaffolds the extension; it must not be edited. The hand-written extensions of this page omit it, and the offline build accepted them.

description

No

The merchant-facing description.

Yes

One entry per target the module implements. Contains target, input_query, input_variables and export.

target

Yes

The target identifier, for example cart.lines.discounts.generate.run.

input_query

No

Path to the GraphQL input query. When omitted, the Function receives no input.

input_variables

No

A JSON metafield (namespace and key) that supplies values for the variables the input query declares. It requires a query that declares variables. It cannot be combined with the deprecated extension-level [extensions.input.variables].

export

No

The name of the Wasm export that runs the target. Defaults to _start. Functions do not use the old module setting.

[extensions.build]

No

Build settings.

command

Yes for builds

The build command. For JavaScript the CLI builds without one, so it is set to an empty string. For Rust it is cargo build --target=wasm32-unknown-unknown --release.

path

No

Relative path of the Wasm module. Defaults to dist/index.wasm; the templates set dist/function.wasm for JavaScript and target/wasm32-unknown-unknown/release/NAME.wasm for Rust.

watch

No

Globs that shopify app dev watches to rebuild. Defaults to src/**/*.js and src/**/*.ts for JavaScript and TypeScript. Paths outside the function folder are not allowed, and input queries are always watched.

wasm_opt

No

Whether the CLI optimises the module with wasm-opt before upload. Defaults to true.

[extensions.ui]

No

handle of an admin UI extension used as the merchant interface, and enable_create, which lets you stop merchants from creating owners themselves.

[extensions.ui.paths]

No

create and details routes of an App Home page used as the merchant interface. details may contain :id, which Shopify replaces with the id of the function owner. It is mutually exclusive with [extensions.ui].

The export value is a kebab-case string in the JavaScript templates (cart-lines-discounts-generate-run) and the exported JavaScript function has the camelCase spelling (cartLinesDiscountsGenerateRun); the CLI generates the Wasm exports from the targets of the configuration. In Rust the export is the name of the function marked with #[shopify_function], in snake case (cart_lines_discounts_generate_run), so the export values differ between the two languages. For API versions up to 2023-07 the CLI still compiles JavaScript to a single _start export for backward compatibility.

package.json and typegen

The codegen key of the extension package.json tells shopify app function typegen which schema and queries to read and where to write the TypeScript types, which JavaScript projects use through JSDoc.

{
  "name": "coffee-club-discount",
  "version": "0.0.1",
  "license": "UNLICENSED",
  "type": "module",
  "scripts": {
    "typegen": "shopify app function typegen",
    "build": "shopify app function build"
  },
  "codegen": {
    "schema": "schema.graphql",
    "documents": "src/*.graphql",
    "generates": {
      "./generated/api.ts": {
        "plugins": [
          "typescript",
          "typescript-operations"
        ]
      }
    },
    "config": {
      "omitOperationSuffix": true
    }
  },
  "dependencies": {
    "@shopify/shopify_function": "^2.0.1"
  },
  "devDependencies": {
    "@shopify/shopify-function-test-helpers": "^1.1.0",
    "typescript": "^5.9.3",
    "vitest": "^3.2.7"
  }
}

Source: Shopify/extensions-templates, folder functions-discount-js

shopify app function typegen generated generated/api.ts (3,488 lines for the Discount schema) from the schema and from the queries in src/. It names every operation after the query: the query CartLinesInput produced the type CartLinesInput, whose field discount.metafield.jsonValue is typed unknown, because the content of a JSON metafield is not known to GraphQL. The generated file also declares enums such as DiscountClass and ProductDiscountSelectionStrategy, which the examples below import. The build runs typegen itself, so running it by hand is only needed to refresh editor types.

The app configuration around the extensions

The extensions sit in an app whose shopify.app.toml declares the scopes needed by the mutations that activate them.

client_id = "00000000000000000000000000000000"
name = "pocket-coffee-functions"
application_url = "https://example.invalid"
embedded = true

[access_scopes]
scopes = "read_products,write_discounts,write_cart_transforms,write_delivery_customizations,write_payment_customizations,write_validations"

[auth]
redirect_urls = ["https://example.invalid/api/auth"]

[webhooks]
api_version = "2026-10"

  [[webhooks.subscriptions]]
  uri = "/webhooks/app/uninstalled"
  topics = ["app/uninstalled"]

  [[webhooks.subscriptions]]
  uri = "/webhooks/customers/data_request"
  compliance_topics = ["customers/data_request"]

  [[webhooks.subscriptions]]
  uri = "/webhooks/customers/redact"
  compliance_topics = ["customers/redact"]

  [[webhooks.subscriptions]]
  uri = "/webhooks/shop/redact"
  compliance_topics = ["shop/redact"]

The client id is a placeholder of 32 zeros and the URLs use the reserved .invalid top-level domain, so the file can be built but not deployed. The scopes follow the access scopes reference: write_discounts for the discount mutations, write_cart_transforms, write_delivery_customizations, write_payment_customizations and write_validations for the four owners of the other APIs.

Writing the input query

The input query is where most of the cost of a Function is decided, so it deserves the same care as the logic.

What the input contains

Each API exposes an Input type. The parts that recur are cart (lines, merchandise, buyer identity, delivery groups, cost, attributes), localization (country, language), shop (local time, metafields, app-owned metaobjects), presentmentCurrencyRate (to convert amounts from the shop currency), fetchResult (the answer of the fetch target when there is one), and the function owner under a name that depends on the API: discount, cartTransform, deliveryCustomization, paymentCustomization or validation. Each owner exposes metafield(namespace:, key:), which is how a merchant’s configuration reaches the code. Cart lines are a union: the merchandise is a ProductVariant or a CustomProduct, so queries select __typename and use inline fragments, and JavaScript tests it before reading variant fields.

The Discount API adds enteredDiscountCodes and triggeringDiscountCode; Payment Customization adds the list paymentMethods (with id, name and placements); Cart and Checkout Validation adds buyerJourney.step, so that a rule can be applied only at, for example, the completion of checkout.

Cost and size limits

Shopify computes the cost of an input query from its text, before running the Function, and rejects the query when the cost is above 30 or its text is longer than 3,000 bytes (comments are not counted).

Field Example Cost

__typename

0

Any field that returns a Metafield object

metafield on a Product

3

Any field of a Metafield object

value, jsonValue

0

The metaobject root field

metaobject(handle: "...")

1

field(key:) on a Metaobject

field(key: "price-tier")

3

hasAnyTag, hasTags

on a customer or a product

3

inAnyCollection, inCollections

on a product or a variant

3

Any field of a HasTagResponse or CollectionMembership

hasTag, isMember

0

Any other leaf

id, sku, quantity

1

Container fields (such as cart or lines) cost nothing and each selected field is counted once, however many lines the cart has. Aliasing a costly field several times charges it each time, so ten aliases of hasAnyTag cost 30, the whole budget. List arguments and variables are limited to 100 elements and metafield values larger than 10,000 bytes are not returned. A query over budget fails validation with Query has complexity of <cost>, which exceeds max complexity of 30.

Because the check happens at deploy time, a small script is useful during development. This one applies the table with graphql 16 to any query file and a schema; it reproduces the cost of 8 that Shopify documents for its own example.

// Computes the Functions input-query cost with the rules of the "Input query limits" table.
import {parse, buildSchema, visit, TypeInfo, visitWithTypeInfo, getNamedType, isLeafType} from 'graphql16';
import fs from 'fs';
const [,, schemaFile, ...queries] = process.argv;
const schema = buildSchema(fs.readFileSync(schemaFile, 'utf8'));
const THREE = new Set(['hasAnyTag', 'hasTags', 'inAnyCollection', 'inCollections']);
for (const q of queries) {
  const doc = parse(fs.readFileSync(q, 'utf8'));
  const ti = new TypeInfo(schema);
  let cost = 0;
  visit(doc, visitWithTypeInfo(ti, {
    Field(node) {
      const name = node.name.value;
      const parent = ti.getParentType()?.name;
      const type = getNamedType(ti.getType());
      if (name === '__typename') return;
      if (parent === 'Metafield' || parent === 'HasTagResponse' || parent === 'CollectionMembership') return;
      if (type?.name === 'Metafield') cost += 3;
      else if (THREE.has(name)) cost += 3;
      else if (name === 'metaobject') cost += 1;
      else if (isLeafType(type)) cost += 1;
    },
  }));
  console.log(`${q}: cost ${cost} of 30`);
}

Source: Shopify: Function APIs, input query limits. The script is original; it implements the cost table above and nothing else.

Run on the queries of this page, the script printed these costs, and it printed 8 for the documented example and 33 for a query with eleven aliased hasAnyTag selections.

Input query Cost of 30

Documented example (lines, hasTags, one metafield)

8

coffee-club-discount, lines target

11

coffee-club-discount, delivery target

9

kit-bundle

6

delivery-rules

4

payment-rules

3

parcel-limit

3

Eleven aliased hasAnyTag selections

33

The local tools do not enforce the limit: shopify app build accepted the query with a cost of 33, and app function run does not read the query at all. The error comes from Shopify’s own validation, so the script above is the way to see it before a release.

Configuration through metafields and variables

Hard-coding a percentage in the code means a new release for every promotion. The documented pattern is to keep the configuration in a metafield on the function owner and to read it in the input query.

  • Use a JSON metafield for any structure with more than one field, and read it with jsonValue, so that the code does not parse a string. One JSON metafield costs 3 however many keys it has, while three separate metafields cost 9.

  • Put the metafield in a reserved namespace with the $app: prefix (for example $app:coffee-club), so that no other app can write to it.

  • Let the merchant edit the values in an admin UI extension or an App Home page of your app, referenced from [extensions.ui] or [extensions.ui.paths]. Metafields are written with the Admin API, for instance in the metafields argument of the create mutation shown later.

  • Change a configuration format with the parallel change pattern: read the old and the new metafield, prefer the new, migrate the data, then remove the old code. An invalid metafield for input variables raises InvalidVariableValueError when the Function runs (since 2025-01), so malformed data fails loudly.

Input query variables go one step further: values that are arguments of fields, such as the tag in hasAnyTag(tags: [$memberTag]), cannot come from the jsonValue of a metafield read in the same query, because the argument must be known before the query runs. A query declares variables with defaults, and the target’s input_variables points at the JSON metafield that overrides them. Pocket Coffee uses this so that the tag names are configuration instead of constants:

query CartLinesInput($memberTag: String! = "coffee-club", $beansTag: String! = "beans") {
  cart {
    buyerIdentity {
      customer {
        isMember: hasAnyTag(tags: [$memberTag])
      }
    }
    lines {
      id
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            isBeans: hasAnyTag(tags: [$beansTag])
          }
        }
      }
    }
  }
  discount {
    discountClasses
    metafield(namespace: "$app:coffee-club", key: "function-configuration") {
      jsonValue
    }
  }
}

The aliases isMember and isBeans keep the JavaScript readable, and each hasAnyTag costs 3, which is part of the 11 points counted above. hasAnyTag returns one boolean, which is cheaper to process than receiving all the tags and searching them in the module. Other fields that do the filtering inside Shopify are hasTags, inAnyCollection and inCollections.

Scopes and protected data

The input query reads Shopify data on behalf of the app, so the fields it selects are bound to access scopes. Customer details and addresses additionally need protected customer data approval. Both matter in production: the Dev Dashboard shows the input and output of a Function run only when the app has the scopes that the input query requires (since 2026-05-13 these logs are visible automatically instead of through merchant-shared logs), and lists the missing scopes when a run cannot be shown.

Discount Functions

The Discount Function API (introduced on 2025-05-21) is the single entry point for custom discounts. One function processes one discount, either an automatic discount or a discount code, and may apply savings in three discount classes at once: PRODUCT (specific cart lines), ORDER (the order subtotal) and SHIPPING (delivery). The classes a discount may use are set by the merchant or the app when the discount is created, and the function receives them in discount.discountClasses, so it can return nothing for a class that was not enabled.

Up to 25 discount functions can be active on a store. They all run concurrently and know nothing about each other; what Shopify does with their results is decided by the combination rules (combinesWith) and the stacking rules stored on the discount node, not by the code.

Targets and operations

Target Class Operations (one of each at most per result)

cart.lines.discounts.generate.run

Product, order

productDiscountsAdd (candidates that target cart lines), orderDiscountsAdd (candidates that target the order subtotal), enteredDiscountCodesAccept and enteredDiscountCodesReject

cart.delivery-options.discounts.generate.run

Shipping

deliveryDiscountsAdd (candidates that target a delivery group or a delivery option), enteredDiscountCodesAccept and enteredDiscountCodesReject

The operation groups share a selection strategy that tells Shopify how to pick among the candidates.

Operation Strategies Meaning

productDiscountsAdd

FIRST, MAXIMUM, ALL

Apply the first candidate whose conditions hold, the candidate worth most, or every candidate to its eligible lines.

orderDiscountsAdd

FIRST, MAXIMUM

As above; an order candidate targets orderSubtotal and can exclude cart lines with excludedCartLineIds.

deliveryDiscountsAdd

ALL

Every eligible delivery discount is applied.

Source: Shopify: Discount Function API and the schema file of the discount template

A candidate carries a message (shown to the buyer), targets, a value and optional fields. The value is either a percentage or a fixedAmount (a product candidate’s fixed amount has appliesToEachItem). Order candidates take conditions (cartLineMinimumQuantity, cartLineMinimumSubtotal or orderMinimumSubtotal), and product candidates take prerequisites, a list of cartLine ids with the required quantity, which is how a "buy X, get Y" discount is expressed (added to the API in 2026-04). associatedDiscountCode ties a candidate to an entered code and is meant for functions with network access.

Discount codes entered by the buyer

For a code discount, triggeringDiscountCode holds the code that started the Function, and enteredDiscountCodes lists every code the buyer typed. A function with network access may validate external codes with enteredDiscountCodesAccept, which must contain a subset of the entered codes. Since 2025-12-17, any Discount function that is backed by an automatic discount can reject codes with enteredDiscountCodesReject, giving a custom message, to stop double discounting on sale prices or to disqualify products; only codes whose rejectable flag is true can be rejected. Shopify validates a result and raises an error when it breaks one of these rules: more than one operation of the same kind, an accepted code that was not entered, a rejected code that was not entered or is not rejectable, or an associatedDiscountCode that was not entered.

The Coffee Club discount, lines target

The Pocket Coffee rule is: a customer of the Coffee Club segment gets 10 percent off the beans. The merchant tags the members coffee-club and tags the bean products beans; the percentage lives in a JSON metafield. The input query is the one shown before; the logic returns early whenever there is nothing to do, because each instruction costs checkout time.

import { DiscountClass, ProductDiscountSelectionStrategy } from "../generated/api";

/**
 * @typedef {import("../generated/api").CartLinesInput} CartLinesInput
 * @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
 */

const NO_CHANGES = { operations: [] };

/**
 * @param {CartLinesInput} input
 * @returns {CartLinesDiscountsGenerateRunResult}
 */
export function cartLinesDiscountsGenerateRun(input) {
  // The discount only produces a PRODUCT class result when the merchant enabled that class.
  if (!input.discount.discountClasses.includes(DiscountClass.Product)) return NO_CHANGES;
  // Not a Coffee Club member (or a guest): nothing to do, return before any other work.
  if (!input.cart.buyerIdentity?.customer?.isMember) return NO_CHANGES;

  const percentage = Number(input.discount.metafield?.jsonValue?.percentage ?? 10);

  const targets = input.cart.lines
    .filter(
      (line) =>
        line.merchandise.__typename === "ProductVariant" &&
        line.merchandise.product.isBeans,
    )
    .map((line) => ({ cartLine: { id: line.id } }));

  if (targets.length === 0) return NO_CHANGES;

  return {
    operations: [
      {
        productDiscountsAdd: {
          selectionStrategy: ProductDiscountSelectionStrategy.First,
          candidates: [
            {
              message: `COFFEE CLUB ${percentage}% OFF BEANS`,
              targets,
              value: { percentage: { value: percentage } },
            },
          ],
        },
      },
    ],
  };
}

Source: Shopify: Build a discount function and the JavaScript discount template of Shopify/extensions-templates

Three details of this code are worth reading closely. The discountClasses test returns early when the merchant did not enable the product class. The test on merchandise.__typename guards the union before the variant’s product is read. And the message is what the buyer sees next to the discounted line, so it is written for the buyer, not for the log.

The Coffee Club discount, delivery target

The same extension implements the shipping side: members get free shipping when the subtotal reaches 40 EUR.

query CartDeliveryOptionsInput($memberTag: String! = "coffee-club") {
  cart {
    buyerIdentity {
      customer {
        isMember: hasAnyTag(tags: [$memberTag])
      }
    }
    cost {
      subtotalAmount {
        amount
      }
    }
    deliveryGroups {
      id
    }
  }
  discount {
    discountClasses
    metafield(namespace: "$app:coffee-club", key: "function-configuration") {
      jsonValue
    }
  }
}
import { DeliveryDiscountSelectionStrategy, DiscountClass } from "../generated/api";

/**
 * @typedef {import("../generated/api").CartDeliveryOptionsInput} CartDeliveryOptionsInput
 * @typedef {import("../generated/api").CartDeliveryOptionsDiscountsGenerateRunResult} CartDeliveryOptionsDiscountsGenerateRunResult
 */

const NO_CHANGES = { operations: [] };

/**
 * @param {CartDeliveryOptionsInput} input
 * @returns {CartDeliveryOptionsDiscountsGenerateRunResult}
 */
export function cartDeliveryOptionsDiscountsGenerateRun(input) {
  if (!input.discount.discountClasses.includes(DiscountClass.Shipping)) return NO_CHANGES;
  if (!input.cart.buyerIdentity?.customer?.isMember) return NO_CHANGES;

  const threshold = Number(input.discount.metafield?.jsonValue?.freeShippingFrom ?? 40);
  const subtotal = Number(input.cart.cost.subtotalAmount.amount);
  if (subtotal < threshold) return NO_CHANGES;

  return {
    operations: [
      {
        deliveryDiscountsAdd: {
          selectionStrategy: DeliveryDiscountSelectionStrategy.All,
          candidates: input.cart.deliveryGroups.map((group) => ({
            message: "COFFEE CLUB FREE SHIPPING",
            targets: [{ deliveryGroup: { id: group.id } }],
            value: { percentage: { value: 100 } },
          })),
        },
      },
    ],
  };
}

The subtotal arrives as a decimal string ("45.00"), which the code converts with Number. Money is exposed as a Decimal scalar so that no precision is lost in transit; converting to a number is acceptable for a threshold comparison, and an amount that must be exact in the output is better returned as it was received. A checkout can hold several delivery groups (shipping and pickup, for example), so the candidates are created for every group instead of assuming one.

src/index.js re-exports both functions, which is how one module serves two targets.

export * from "./cart_lines_discounts_generate_run";
export * from "./cart_delivery_options_discounts_generate_run";

Source: Shopify/extensions-templates, folder functions-discount-js

Running the discount locally

shopify app function run executes the compiled module with an input that you supply, with the same runner (function-runner 9.2.2) that the CLI bundles. The first input is a member whose cart holds one bean line and one equipment line.

{
  "cart": {
    "buyerIdentity": { "customer": { "isMember": true } },
    "lines": [
      { "id": "gid://shopify/CartLine/1", "merchandise": { "__typename": "ProductVariant", "product": { "isBeans": true } } },
      { "id": "gid://shopify/CartLine/2", "merchandise": { "__typename": "ProductVariant", "product": { "isBeans": false } } }
    ]
  },
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": { "jsonValue": { "percentage": 10, "freeShippingFrom": 40 } }
  }
}
export SHOPIFY_CLI_NO_ANALYTICS=1 CI=1
shopify app build --no-input
shopify app function run --no-input --path extensions/coffee-club-discount \
  --input "$PWD/extensions/coffee-club-discount/tests/lines-member.json" \
  --export cart-lines-discounts-generate-run

The --input path must be absolute (relative paths are resolved from the extension folder), and --export is needed when the module has more than one target. The command printed the input, the logs (empty) and this output, followed by the measurements:

{
  "operations": [
    {
      "productDiscountsAdd": {
        "selectionStrategy": "FIRST",
        "candidates": [
          {
            "message": "COFFEE CLUB 10% OFF BEANS",
            "targets": [
              {
                "cartLine": {
                  "id": "gid://shopify/CartLine/1"
                }
              }
            ],
            "value": {
              "percentage": {
                "value": 10
              }
            }
          }
        ]
      }
    }
  ]
}

Source: Shopify: Test and debug Shopify Functions. Output of the local run on 2026-10-27; only the bean line (id 1) is discounted.

Run Instructions Linear memory Module size

Member, lines target (one bean line, one equipment line)

328,602

1,280 kB

3,345 bytes

Guest, lines target (returns early)

198,487

1,280 kB

3,345 bytes

Member with 45.00 EUR, delivery target

293,914

1,280 kB

3,345 bytes

The early return is visible in the numbers: the guest run executes about 130,000 fewer instructions than the member run. Even the guest run costs 198,000 instructions, which is the cost of starting the JavaScript engine and reading the input; that fixed cost is what a Rust Function does not pay, as discussed in the performance section.

Activating the discount

The function owner is created with an Admin API mutation. Since the 2025-10 API version the mutations identify the function by the handle that you defined in shopify.extension.toml (functionHandle), which is stable across environments, instead of the functionId, which changes at every deploy. The mutation also sets the discount classes, the combination rules and the two metafields that the input queries read: the configuration of the Function and the variables of the input queries.

mutation CreateCoffeeClubDiscount {
  discountAutomaticAppCreate(
    automaticAppDiscount: {
      title: "Coffee Club"
      functionHandle: "coffee-club-discount"
      startsAt: "2026-11-01T00:00:00Z"
      discountClasses: [PRODUCT, SHIPPING]
      combinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }
      metafields: [
        {
          namespace: "$app:coffee-club"
          key: "function-configuration"
          type: "json"
          value: "{\"percentage\":10,\"freeShippingFrom\":40}"
        }
        {
          namespace: "$app:coffee-club"
          key: "variables"
          type: "json"
          value: "{\"memberTag\":\"coffee-club\",\"beansTag\":\"beans\"}"
        }
      ]
    }
  ) {
    automaticAppDiscount {
      discountId
    }
    userErrors {
      field
      message
    }
  }
}

Source: Shopify: discountAutomaticAppCreate. Validated against the Admin GraphQL 2026-10 schema; not executed, because it needs a store.

A merchant can create the same discount in the admin, in the Discounts page, by choosing the discount type that the app provides; the [extensions.ui] or [extensions.ui.paths] configuration decides which screen the merchant sees, and discountCodeAppCreate creates the code variant. The app can list its own Functions, with their ids, titles and API types, with the shopifyFunctions query.

The same function in Rust

Rust is the language Shopify recommends for Functions that run on large carts or that are computationally complex. The current template produces this code for the lines target (unmodified from the functions-discount-rs template; it applies the 10 percent order discount and the 20 percent product discount of the template, not the Pocket Coffee rule).

use super::schema;
use shopify_function::prelude::*;
use shopify_function::Result;

#[shopify_function]
fn cart_lines_discounts_generate_run(
    input: schema::cart_lines_discounts_generate_run::Input,
) -> Result<schema::CartLinesDiscountsGenerateRunResult> {
    let has_order_discount_class = input
        .discount()
        .discount_classes()
        .contains(&schema::DiscountClass::Order);
    let has_product_discount_class = input
        .discount()
        .discount_classes()
        .contains(&schema::DiscountClass::Product);

    if !has_order_discount_class && !has_product_discount_class {
        return Ok(schema::CartLinesDiscountsGenerateRunResult { operations: vec![] });
    }

    let mut operations = vec![];

    // Check if the discount has the ORDER class
    if has_order_discount_class {
        operations.push(schema::CartOperation::OrderDiscountsAdd(
            schema::OrderDiscountsAddOperation {
                selection_strategy: schema::OrderDiscountSelectionStrategy::First,
                candidates: vec![schema::OrderDiscountCandidate {
                    targets: vec![schema::OrderDiscountCandidateTarget::OrderSubtotal(
                        schema::OrderSubtotalTarget {
                            excluded_cart_line_ids: vec![],
                        },
                    )],
                    message: Some("10% OFF ORDER".to_string()),
                    value: schema::OrderDiscountCandidateValue::Percentage(schema::Percentage {
                        value: Decimal(10.0),
                    }),
                    conditions: None,
                    associated_discount_code: None,
                }],
            },
        ));
    }

    Ok(schema::CartLinesDiscountsGenerateRunResult { operations })
}

Source: Shopify/extensions-templates, folder functions-discount-rs (the product class branch of the template is omitted here for brevity). This code was not compiled, because no Rust toolchain was installed for this page.

The code is typed against the schema through the schema module, whose types the #[typegen] macro in src/main.rs generates from schema.graphql and the input query.

use shopify_function::prelude::*;
use std::process;

pub mod cart_delivery_options_discounts_generate_run;
pub mod cart_lines_discounts_generate_run;

#[typegen("schema.graphql")]
pub mod schema {
    #[query("src/cart_lines_discounts_generate_run.graphql")]
    pub mod cart_lines_discounts_generate_run {}

    #[query("src/cart_delivery_options_discounts_generate_run.graphql")]
    pub mod cart_delivery_options_discounts_generate_run {}
}

fn main() {
    log!("Please invoke a named export.");
    process::abort();
}

Source: Shopify/extensions-templates, folder functions-discount-rs

The build configuration changes from the JavaScript one in the export values and in [extensions.build].

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/cart_lines_discounts_generate_run.graphql"
  export = "cart_lines_discounts_generate_run"

  [extensions.build]
  command = "cargo build --target=wasm32-unknown-unknown --release"
  path = "target/wasm32-unknown-unknown/release/coffee-club-discount.wasm"
  watch = [ "src/**/*.rs" ]

Migrating from the old discount APIs

The Product Discount, Order Discount and Shipping Discount APIs are deprecated since API version 2025-04 and Shopify asked developers to move before API version 2026-04. Their targets (purchase.product-discount.run, purchase.order-discount.run, purchase.shipping-discount.run) are replaced by the two targets above. The changes the migration guide lists are these.

Before Now Notes

discountNode in the input

discount

The discount that owns the function and its metafields.

(not available)

enteredDiscountCodes, triggeringDiscountCode

New input fields on both targets.

discounts in the output

operations

A list of operations instead of a list of discounts.

discountApplicationStrategy

selectionStrategy of the operation (ProductDiscountSelectionStrategy or OrderDiscountSelectionStrategy)

FIRST, MAXIMUM or (product only) ALL.

productVariant target of the shipping API

cartLineIds and delivery targets

Delivery candidates target a delivery group or option.

(not available)

candidates, associatedDiscountCode, enteredDiscountCodesAccept, enteredDiscountCodesReject

New in the unified API.

Source: Shopify: Discount Function API, migrate from deprecated Discount Function APIs and Shopify changelog: deprecation of Product, Order and Shipping Discount Function APIs (2025-05-21). The old target names are those of the deprecated templates in the catalogue (order_discounts still uses purchase.order-discount.run).

Cart Transform Functions

The Cart Transform API changes the presentation and pricing of lines in the cart, which a discount cannot do: expand one line into its components, merge several lines into one bundle, or override a line’s title, image or price. Shopify runs it first, so everything after it (discounts, delivery, payment, validation) sees the transformed cart.

A store can install at most one cart transform function per app, and when several apps each have one, all of them run. Two operations from different apps that touch the same line can collide, and Shopify resolves them in the order in which the apps activated their transform with cartTransformCreate.

Operations

Operation What it does Constraints

lineExpand

Replaces one cart line by expandedCartItems, each with a merchandiseId (a variant id), a quantity and optional price (fixedPricePerUnit) and attributes; optional bundle title, image and a bundle-wide price adjustment (percentageDecrease).

Component prices must be given for every component or for none. A price adjustment and per-component prices cannot be combined. Up to 150 expanded items.

linesMerge

Merges cartLines (line id and quantity) into one line of a parentVariantId, with optional title, image, attributes and a percentageDecrease price adjustment.

The quantities must be available in the cart; the parent variant must exist.

lineUpdate

Changes one line’s title, image or price (fixedPricePerUnit).

Only development stores and Plus stores. The price cannot be negative.

Collisions and rules

When operations target the same line, Shopify orders them by activation time and applies these rules.

  • Two lineExpand operations on one line: the first is executed, the others are discarded. The same holds for two linesMerge and for two lineUpdate operations.

  • A lineExpand and a linesMerge on one line: the expand wins and the merge is discarded.

  • A lineExpand, a linesMerge and a lineUpdate on one line: the expand wins and the other two are discarded.

  • A fixed lineExpand and a customised lineExpand on one line: the fixed one wins.

Shopify rejects lineExpand, linesMerge and lineUpdate for a cart that has a selling plan, which is a rejection, not an error. Other invalid cases are a component quantity below zero, a line that does not exist, a variant id that does not exist, a negative price, a merge quantity larger than the line’s quantity, and an update that targets a line that is being expanded or merged.

Pricing a bundle

For a linesMerge the adjustment is applied to the sum of the component prices; for a lineExpand it is applied to the price of the bundle product. When a lineExpand gives no fixed price per unit, Shopify allocates the bundle price to the components by weight, where the weight of a component is its unit price multiplied by its quantity. For a bundle price of 100 and components of 10 times 1, 20 times 2 and 30 times 3, the weights are 10, 40 and 90 (total 140) and the shares are 7.14, 28.57 and 64.29, which is the worked example in the API reference. The amounts the input shows are in the presentment currency, and output prices are interpreted in that currency too; to start from a shop-currency amount, select presentmentCurrencyRate in the input and convert before returning the price.

Title and image overrides must use images uploaded to the Shopify CDN (an image URL from another host fails with invalid_image_url) and without a size suffix; the override shows in the cart, checkout and order notification emails, but a theme must print item.title (cart) or line.title (notification) to display the new title.

Errors

The API reference lists error codes per operation. Among them, exceeded_maximum_number_of_supported_expanded_cart_items (more than 150), expanded_items_missing_prices (some components priced, some not), cannot_combine_price_adjustment_and_price_per_component, component_merchandise_not_found, insufficient_component_quantity_to_merge, parent_variant_not_found, invalid_image_url, image_not_found, fixed_price_adjustment_cannot_be_negative and update_feature_not_available (the shop is not on Plus or a development store) are the ones that appear while building. A runtime failure of a cart transform is exposed by the Storefront API, since API version 2026-04, as the cart error code MERCHANDISE_LINE_TRANSFORMERS_RUN_ERROR instead of the generic INVALID error with the message "An error occurred in your cart". blockOnFailure of cartTransformCreate decides whether a failed run blocks cart and checkout operations.

The Starter kit expansion

The Pocket Coffee Starter kit is one variant (SKU PC-KIT-001) that contains the hand grinder and 250 g of beans. The merchant sells it as one line, and the cart transform expands it into its two components, so that stock is decremented for the real variants and the cart shows what the buyer gets. The components are configuration, stored in a JSON metafield on the cart transform, not in the code.

query CartTransformRunInput {
  cart {
    lines {
      id
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          sku
        }
      }
    }
  }
  cartTransform {
    metafield(namespace: "$app:kits", key: "components") {
      jsonValue
    }
  }
}
/**
 * @typedef {import("../generated/api").CartTransformRunInput} CartTransformRunInput
 * @typedef {import("../generated/api").CartTransformRunResult} CartTransformRunResult
 */

/** @type {CartTransformRunResult} */
const NO_CHANGES = { operations: [] };

/**
 * @param {CartTransformRunInput} input
 * @returns {CartTransformRunResult}
 */
export function cartTransformRun(input) {
  // { "PC-KIT-001": [{ "merchandiseId": "gid://shopify/ProductVariant/...", "quantity": 1 }, ...] }
  const kits = input.cartTransform.metafield?.jsonValue ?? {};

  const operations = [];
  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;
    const components = kits[line.merchandise.sku ?? ""];
    if (!components) continue;

    operations.push({
      lineExpand: {
        cartLineId: line.id,
        title: "Starter kit",
        expandedCartItems: components.map((component) => ({
          merchandiseId: component.merchandiseId,
          quantity: component.quantity,
        })),
      },
    });
  }

  return operations.length > 0 ? { operations } : NO_CHANGES;
}

The expansion gives no component prices, so Shopify allocates the bundle’s price by weight as described above. The title Starter kit keeps the bundle’s name on the expanded line. The input of the local run holds the kit and two bags of beans:

{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 1, "merchandise": { "__typename": "ProductVariant", "sku": "PC-KIT-001" } },
      { "id": "gid://shopify/CartLine/2", "quantity": 2, "merchandise": { "__typename": "ProductVariant", "sku": "PC-ETH-WHL-250" } }
    ]
  },
  "cartTransform": {
    "metafield": {
      "jsonValue": {
        "PC-KIT-001": [
          { "merchandiseId": "gid://shopify/ProductVariant/1001", "quantity": 1 },
          { "merchandiseId": "gid://shopify/ProductVariant/1002", "quantity": 1 }
        ]
      }
    }
  }
}

and the output is a single lineExpand for the kit line; the bags are left alone.

{
  "operations": [
    {
      "lineExpand": {
        "cartLineId": "gid://shopify/CartLine/1",
        "title": "Starter kit",
        "expandedCartItems": [
          {
            "merchandiseId": "gid://shopify/ProductVariant/1001",
            "quantity": 1
          },
          {
            "merchandiseId": "gid://shopify/ProductVariant/1002",
            "quantity": 1
          }
        ]
      }
    }
  ]
}

Source: Shopify: Test and debug Shopify Functions. Output of the local run on 2026-10-27: 254,531 instructions, 1,995 bytes of Wasm.

Creating the owner, with the configuration metafield, needs one mutation; the two variant ids are placeholders.

mutation CreateKitTransform {
  cartTransformCreate(
    functionHandle: "kit-bundle"
    blockOnFailure: false
    metafields: [
      {
        namespace: "$app:kits"
        key: "components"
        type: "json"
        value: "{\"PC-KIT-001\":[{\"merchandiseId\":\"gid://shopify/ProductVariant/1001\",\"quantity\":1},{\"merchandiseId\":\"gid://shopify/ProductVariant/1002\",\"quantity\":1}]}"
      }
    ]
  ) {
    cartTransform {
      id
    }
    userErrors {
      field
      message
    }
  }
}

Source: Shopify: cartTransformCreate. Validated against the Admin GraphQL 2026-10 schema; not executed.

Delivery Customization Functions

A delivery customization renames, reorders and hides the delivery options that a buyer sees at checkout (carrier rates, local delivery, pickup). Up to 25 can be active on a store. Typical uses are hiding an option for certain products or customers, ordering options by preference, hiding options for PO box addresses and adding messaging to titles. Hiding for PO box addresses works only for options that come from carrier services or third-party shipping apps; with Shopify’s built-in rates the address data may not be available to the function.

The target is cart.delivery-options.transform.run and the operations are deliveryOptionHide, deliveryOptionMove (with the option’s deliveryOptionHandle and the new index) and deliveryOptionRename (with a new title). The input holds cart.deliveryGroups, and each group holds its deliveryAddress, its deliveryOptions (with handle, title, code, description, cost and deliveryMethodType), its cartLines and its groupType (one-time purchase or subscription). A checkout and an order can contain several delivery methods, for example shipping and pickup, so a function iterates over every delivery group and never assumes one method per order.

Matching an option

An operation names an option by its handle. Matching on the title is tempting and is what the example below does for readability, but it is brittle: a merchant can rename an option, a translation changes the title, and two options can share a pattern. Since API version 2026-10 a delivery option also exposes its metafields (metafield(namespace:, key:)), which lets a function that generates options, such as a carrier app, write a typed value that a delivery customization reads by name instead of parsing the title. Metafields of a delivery option are not persisted after checkout.

The Pocket Coffee delivery rules

Pocket Coffee offers a same-day courier called "Same-day Madrid", only valid for postal codes that begin with 28, and a "Standard" option for everything else. The function hides the same-day option elsewhere and renames "Standard" to state the delivery time.

query CartDeliveryOptionsTransformRunInput {
  cart {
    deliveryGroups {
      deliveryAddress {
        countryCode
        zip
      }
      deliveryOptions {
        handle
        title
      }
    }
  }
}
/**
 * @typedef {import("../generated/api").CartDeliveryOptionsTransformRunInput} CartDeliveryOptionsTransformRunInput
 * @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
 */

/** @type {CartDeliveryOptionsTransformRunResult} */
const NO_CHANGES = { operations: [] };

/**
 * @param {CartDeliveryOptionsTransformRunInput} input
 * @returns {CartDeliveryOptionsTransformRunResult}
 */
export function cartDeliveryOptionsTransformRun(input) {
  const operations = [];

  // A checkout can hold several delivery groups (shipping and pickup), so look at every one.
  for (const group of input.cart.deliveryGroups) {
    const address = group.deliveryAddress;
    const inMadrid = address?.countryCode === "ES" && (address.zip ?? "").startsWith("28");

    for (const option of group.deliveryOptions) {
      if (option.title === "Same-day Madrid" && !inMadrid) {
        operations.push({ deliveryOptionHide: { deliveryOptionHandle: option.handle } });
      } else if (option.title === "Standard") {
        operations.push({
          deliveryOptionRename: {
            deliveryOptionHandle: option.handle,
            title: "Standard (2-3 working days)",
          },
        });
      }
    }
  }

  return operations.length > 0 ? { operations } : NO_CHANGES;
}

Run with a Barcelona address (08001), the function renames the standard option and hides the Madrid one:

{
  "operations": [
    {
      "deliveryOptionRename": {
        "deliveryOptionHandle": "std-es",
        "title": "Standard (2-3 working days)"
      }
    },
    {
      "deliveryOptionHide": {
        "deliveryOptionHandle": "same-day-md"
      }
    }
  ]
}

Source: Shopify: Test and debug Shopify Functions. Output of the local run on 2026-10-27: 199,405 instructions, 2,146 bytes of Wasm. The input is extensions/delivery-rules/tests/barcelona.json.

Activation uses deliveryCustomizationCreate:

mutation CreateDeliveryRules {
  deliveryCustomizationCreate(
    deliveryCustomization: { title: "Pocket Coffee delivery rules", functionHandle: "delivery-rules", enabled: true }
  ) {
    deliveryCustomization {
      id
    }
    userErrors {
      field
      message
    }
  }
}

Source: Shopify: deliveryCustomizationCreate. Validated against the Admin GraphQL 2026-10 schema; not executed.

Payment Customization Functions

A payment customization renames, reorders and hides the payment methods of the checkout, sets payment terms and can add a review requirement to an order. Up to 25 can be active on a store. The target is cart.payment-methods.transform.run; its input lists paymentMethods (each with id, name and placements) next to the cart and localization.

Operation Behaviour

paymentMethodHide

Removes a method from the options. placements (optional) limits the hiding to some placements; that is a Plus feature.

paymentMethodMove

Moves a method to an index.

paymentMethodRename

Renames a method.

paymentTermsSet

Sets, changes or removes payment terms (net, fixed date or event-based, with an optional deposit). Plus only; not supported in accelerated checkouts; incompatible with carts that contain subscription selling plans.

orderReviewAdd

Submits the checkout as a draft that the merchant must review and approve, with a reason. B2B purchases on Plus only.

Five rules of the API decide what a function can touch.

  • Methods whose name is a logo, such as Shop Pay, Apple Pay and Google Pay, cannot be renamed. That includes all wallets and Shopify’s native gift card field.

  • Wallets can be removed from the express or payment section but cannot be reordered.

  • In Shop Pay, payment customization functions apply operations only to the native gift card field, not to other methods.

  • Plan and geographical restrictions apply to payment method customizations. When usage is restricted, the input still contains every payment method, but the operations that target restricted methods have no effect at checkout, so a function can seem to work in tests and not in a store.

  • Matching on name works for the text a method shows in a given language; keep the comparison in one place and re-check it when the checkout languages change.

The Pocket Coffee payment rules

Pocket Coffee sells cash on delivery only in Spain (the courier handles the cash), and wants the bank transfer method to say that the parcel ships after the payment arrives.

query CartPaymentMethodsTransformRunInput {
  localization {
    country {
      isoCode
    }
  }
  paymentMethods {
    id
    name
  }
}
/**
 * @typedef {import("../generated/api").CartPaymentMethodsTransformRunInput} CartPaymentMethodsTransformRunInput
 * @typedef {import("../generated/api").CartPaymentMethodsTransformRunResult} CartPaymentMethodsTransformRunResult
 */

/** @type {CartPaymentMethodsTransformRunResult} */
const NO_CHANGES = { operations: [] };

/**
 * @param {CartPaymentMethodsTransformRunInput} input
 * @returns {CartPaymentMethodsTransformRunResult}
 */
export function cartPaymentMethodsTransformRun(input) {
  const operations = [];

  for (const method of input.paymentMethods) {
    if (method.name === "Cash on Delivery" && input.localization.country.isoCode !== "ES") {
      operations.push({ paymentMethodHide: { paymentMethodId: method.id } });
    } else if (method.name === "Bank Transfer") {
      operations.push({
        paymentMethodRename: {
          paymentMethodId: method.id,
          name: "Bank Transfer (order ships after payment)",
        },
      });
    }
  }

  return operations.length > 0 ? { operations } : NO_CHANGES;
}

For a German buyer (DE) the local run hides the cash method and renames the bank transfer, and leaves the card alone:

{
  "operations": [
    {
      "paymentMethodHide": {
        "paymentMethodId": "gid://shopify/PaymentCustomizationPaymentMethod/1"
      }
    },
    {
      "paymentMethodRename": {
        "paymentMethodId": "gid://shopify/PaymentCustomizationPaymentMethod/2",
        "name": "Bank Transfer (order ships after payment)"
      }
    }
  ]
}

Source: Shopify: Test and debug Shopify Functions. Output of the local run on 2026-10-27: 185,909 instructions, 1,907 bytes of Wasm. The input is extensions/payment-rules/tests/germany.json.

The owner is created with paymentCustomizationCreate.

mutation CreatePaymentRules {
  paymentCustomizationCreate(
    paymentCustomization: { title: "Pocket Coffee payment rules", functionHandle: "payment-rules", enabled: true }
  ) {
    paymentCustomization {
      id
    }
    userErrors {
      field
      message
    }
  }
}

Source: Shopify: paymentCustomizationCreate. Validated against the Admin GraphQL 2026-10 schema; not executed.

Cart and Checkout Validation Functions

A validation function checks the cart and the checkout on the server, before the buyer can proceed, including express checkouts such as Shop Pay, PayPal, Google Pay and Apple Pay. It replaces what was previously done with scripts in the theme and with client-side checks that a buyer could bypass. Up to 25 can be active on a store. Typical uses are quantity limits for a flash sale, B2B minimums, maximums and multiples, order minimums or credit limits per company location, age or membership checks, purchase order number validation and billing address restrictions.

The target is cart.validations.generate.run, the only operation is validationAdd with a list of errors, and each error has a message and a target, a path that tells the checkout where to show the message. An empty list means the cart is valid. Errors from validation functions are exposed in the Storefront API Cart object (as user errors with the code VALIDATION_CUSTOM), on the theme cart page and during checkout.

Field Target value

The cart as a whole

$.cart

Email, phone

$.cart.buyerIdentity.email, $.cart.buyerIdentity.phone

Delivery address fields

$.cart.deliveryGroups[0].deliveryAddress.FIELD with FIELD one of address1, address2, city, company, countryCode, firstName, lastName, phone, provinceCode, zip

Billing address fields

$.cart.billingAddress.FIELD with the same fields

PO number

$.cart.poNumber

Localized fields

$.cart.localizedField.KEY

The billing address and PO number targets were added with API version 2026-04. The input has buyerJourney.step, so a rule can run only at the completion of checkout, and the validation owner holds the metafields of the configuration. When the function itself fails with a runtime error (not a validation error), blockOnFailure of validationCreate decides whether the buyer is blocked: with blockOnFailure: true a failing function blocks checkout, and with false the failure is tolerated. That setting is the difference between a safety net and an outage, and it should be chosen deliberately: a flash-sale quantity limit should usually block, a soft recommendation should not.

The parcel weight limit

Pocket Coffee ships in parcels of at most 5 kg. The function sums the weight of the lines (each variant has a weight and a unit) and adds an error on the cart when the total is above the limit.

query CartValidationsGenerateRunInput {
  cart {
    lines {
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          weight
          weightUnit
        }
      }
    }
  }
}
/**
 * @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
 * @typedef {import("../generated/api").CartValidationsGenerateRunResult} CartValidationsGenerateRunResult
 */

const MAX_PARCEL_KG = 5;
const KG_PER_UNIT = { KILOGRAMS: 1, GRAMS: 0.001, POUNDS: 0.45359237, OUNCES: 0.0283495231 };

/**
 * @param {CartValidationsGenerateRunInput} input
 * @returns {CartValidationsGenerateRunResult}
 */
export function cartValidationsGenerateRun(input) {
  let totalKg = 0;
  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;
    const perUnit = KG_PER_UNIT[line.merchandise.weightUnit] ?? 1;
    totalKg += (line.merchandise.weight ?? 0) * perUnit * line.quantity;
  }

  const errors =
    totalKg > MAX_PARCEL_KG
      ? [
          {
            message: `One parcel can hold ${MAX_PARCEL_KG} kg at most; split your order into two checkouts.`,
            target: "$.cart",
          },
        ]
      : [];

  return { operations: [{ validationAdd: { errors } }] };
}

A cart with four 1 kg bags and five 250 g bags weighs 5.25 kg:

{
  "operations": [
    {
      "validationAdd": {
        "errors": [
          {
            "message": "One parcel can hold 5 kg at most; split your order into two checkouts.",
            "target": "$.cart"
          }
        ]
      }
    }
  ]
}

Source: Shopify: Test and debug Shopify Functions. Output of the local run on 2026-10-27 for extensions/parcel-limit/tests/heavy.json: 193,127 instructions, 2,123 bytes of Wasm. A cart of two 1 kg bags (light.json) returned {"validationAdd": {"errors": []}}.

The first version of the fixture used three 250 g bags (4.75 kg), which is under the limit, and correctly produced no error; the test case was changed to five bags to cross the limit. A boundary of exactly 5 kg passes, because the check is a strict greater-than.

mutation CreateParcelLimit {
  validationCreate(
    validation: { title: "Parcel weight limit", functionHandle: "parcel-limit", enable: true, blockOnFailure: true }
  ) {
    validation {
      id
    }
    userErrors {
      field
      message
    }
  }
}

Source: Shopify: validationCreate. Validated against the Admin GraphQL 2026-10 schema; not executed.

Other Function APIs

Three more stable APIs follow the same model and are only summarised here.

  • Fulfillment Constraints (cart.fulfillment-constraints.generate.run) limits where cart items can be fulfilled from. Its operations are deliverableLinesMustFulfillFromAdd (items must come from one of a list of locations) and deliverableLinesMustFulfillFromSameLocationAdd (items must come from the same location). When the constraints cannot be met, the buyer sees no shipping options at checkout. Up to 25 can be active on a store.

  • Order Routing Location Rule (cart.fulfillment-groups.location-rankings.generate.run) ranks the locations that can fulfil each item with fulfillmentGroupLocationRankingAdd; Shopify picks the highest-ranked location. It applies to checkout, draft orders, order editing and order imports, and is available only by request to Plus merchants enrolled in the Partners program.

  • Fetch targets add a network step before a run target for the APIs that support them (Discount, Cart and Checkout Validation and others). Shopify makes the HTTPS call on your behalf and passes the result to the run target as fetchResult. It is limited to custom apps on Enterprise stores (Plus and Enterprise for Discount), needs network access approved by Shopify, and is not available on development stores. The page on plans and Shopify Plus places it among the Plus and Enterprise capabilities.

Choosing a language

Any language that compiles to a Wasm module meeting the Shopify Wasm API can be used, and the documentation names Rust, Zig and TinyGo as examples. Two languages have first-party templates and libraries: Rust and JavaScript (with TypeScript).

JavaScript or TypeScript Rust

Library

npm @shopify/shopify_function 2.0.1 (2.0.0 or newer required for small binaries and the current runner)

crate shopify_function 2.3.0 (2.0.0 or newer, which needs Shopify CLI 3.85.0 or newer)

Compiler

esbuild bundles the sources and npm imports; Javy compiles the bundle

cargo build --target=wasm32-unknown-unknown --release, then wasm-opt run by the CLI

Build command in the toml

Empty string

The cargo command

Binary size observed

1.9 to 3.3 kB (the JavaScript engine is linked at run time and is not in the module)

Under 256 kB is required; the template profile (lto, opt-level = "z", strip) keeps it small

Instructions for the same job

Hundreds of thousands for an almost empty run, millions for 50 to 200 lines (measured below)

Lower; Shopify calls Rust "the most performant language choice" (not measured here)

Local debugging

console.log

log!

Source: Shopify: Language considerations, JavaScript for Functions and Rust for Functions. The binary sizes and instruction counts were measured with the five JavaScript extensions of this page.

Shopify’s guidance is to use JavaScript for prototypes and for small carts, and Rust for public apps, for functions that expect many lines and for anything computationally heavy. The migration path is deliberately cheap: Rust code can be added next to the JavaScript in the same extension and the toml selects which one is built, so merchants never see the switch.

JavaScript and Javy

Shopify CLI compiles JavaScript with Javy, a build tool and a runtime engine. The module produced contains your code but not a JavaScript engine; it links to the engine at run time. That is why the five extensions of this page compile to modules between 1,907 and 3,345 bytes.

What the runtime provides and what it does not:

  • The language is ECMAScript 2020, plus TextEncoder and TextDecoder (UTF-8 only; no TextEncoderStream, no encodeInto, no streaming decode).

  • The event loop is disabled. async and await and promises compile, but fail when the function executes.

  • Web APIs such as setTimeout, fetch, crypto and URL, and Node.js globals such as process or node:http, are not available.

  • npm packages are fine: esbuild bundles them before compilation. Every bundled dependency adds code that the engine must load, so keep the bundle small.

Both limits were reproduced. A function declared async produced the log Pending jobs in the event queue. Scheduling events is not supported when the event-loop runtime config is not enabled, a Wasm backtrace and the output {}. A call to fetch produced Error: fetch is not defined with a stack trace and the local runner’s Invalid Output error. Both cases are covered by the pitfalls list at the end of this page.

TypeScript works through the same esbuild step; the generated api.ts declares the input and result types, and the operation enums are imported from it as the templates do. The generated jsonValue type is unknown, so a TypeScript function casts the configuration to its own type. The Decimal scalar is typed as a string in the generated types, while the templates return numbers for percentages, which the schema accepts at run time.

Rust

The requirements are the latest stable Rust with the wasm32-unknown-unknown target installed (rustup target add wasm32-unknown-unknown), Shopify CLI 3.85.0 or newer and version 2.0.0 or newer of the shopify_function crate. The older wasm32-wasi and wasm32-wasip1 targets and the cargo-wasi tool are obsolete: Rust 1.84 removed wasm32-wasi, and crate 2.0.0 and newer builds with the unknown-unknown target. Functions generated by the CLI use the latest crate.

The crate has five parts.

Component Description

typegen

A macro that generates Rust types from the GraphQL schema and each input query: #[typegen("schema.graphql")] on a module with one #[query("src/NAME.graphql")] submodule per target.

shopify_function

An attribute macro that marks the entry point: it exports a Wasm function with the Rust function’s name (which must match the target’s export) and reads the input and writes the output through the Wasm API.

Deserialize

A derive macro that deserialises the function input into your own structs, such as a JSON metafield configuration, with rename_all, rename and default attributes of #[shopify_function(...)].

log!

Writes to the function’s logs; use it instead of eprintln!.

run_function_with_input

A helper for unit tests that runs a function on a JSON input string.

A typical Cargo.toml keeps the release profile that makes the module small.

[package]
name = "coffee-club-discount"
version = "1.0.0"
edition = "2021"

[dependencies]
shopify_function = "2.3.0"

[profile.release]
lto = true
opt-level = "z"
strip = true

Source: Shopify/extensions-templates, folder functions-discount-rs; the package name is changed to the extension handle. A rust-toolchain.toml with channel = "stable", the components rustfmt and clippy and the target wasm32-unknown-unknown is part of the template.

Configuration read from a JSON metafield is typed with custom_scalar_overrides, which links the jsonValue field to your own struct, so that no JSON parsing appears in the code. The key is the path to the field, starting with the query’s operation name.

use shopify_function::prelude::*;

#[derive(Deserialize)]
#[shopify_function(rename_all = "camelCase")]
pub struct Configuration {
    pub discount_percentage: f64,
    #[shopify_function(default)]
    pub excluded_product_ids: Vec<String>,
}

#[typegen("schema.graphql")]
pub mod schema {
    #[query(
        "src/cart_lines_discounts_generate_run.graphql",
        custom_scalar_overrides = {
            "CartInput.discount.metafield.jsonValue" => super::cart_lines_discounts_generate_run::Configuration,
        }
    )]
    pub mod cart_lines_discounts_generate_run {}
}

Source: Shopify: Rust for Functions, migrating from JavaScript. The two code fragments of the reference are combined here. Not compiled.

To keep a Rust module under 256 kB, the documentation recommends keeping the template profile, leaving wasm_opt enabled, using regex_lite instead of regex, using to_ascii_uppercase and to_ascii_lowercase to avoid Unicode tables, querying only the data that is needed (typegen produces code for every field of every query) and keeping JSON metafields small (deserialisation code grows with the struct). cargo doc --open and cargo expand show the generated types.

To move an existing JavaScript function to Rust, add the Rust files to the same extension: update the JavaScript to @shopify/shopify_function 2.0.0 or newer (the CLI picks its local runner from this version), add wasm32-unknown-unknown, generate a temporary Rust extension to copy Cargo.toml and the .rs files, delete the temporary folder, add target/ to .gitignore, add __typename to fragments on interfaces and unions in the input queries (this does not affect the JavaScript), port the logic, then switch the export values and the [extensions.build] section of the toml, keeping the JavaScript values as comments. Integration tests can run both builds by passing targeting[fixture.target].export to the runner, and the swap is reversible until the JavaScript source is removed.

The Wasm API for other languages

The Shopify Wasm API is a small set of imported functions that a module uses to read input and write output lazily, so that no JSON parser needs to be embedded in the binary. Values are 64-bit NaN-boxed numbers whose tag says whether they are a null, a boolean, a number, a string, an object, an array or an error. A module must export, for each target, a function of type (func) with no parameters and no results, and it logs through the imported shopify_function_log_new_utf8_str; writes to standard error are ignored. Read errors (decode error, not an object, byte array out of bounds, read error, not an array, index out of bounds, not indexable) and write status codes (success, I/O error, expected key, object length error, value already written, not an object, value not finished, array length error, not an array) are numeric and may grow, so new codes must be handled. Moving from version 1 to version 2 of the Wasm API means importing from shopify_function_v2, no longer passing a context pointer, and removing the context creation and output finalisation calls.

(module
 (func $run (export "run")
   ...
 )
)

Source: Shopify: WebAssembly for Functions. The WAT module is the reference’s skeleton of an export that matches the toml export = "run".

The Rust chapter WebAssembly with Rust explains the compiler targets and the Wasm model that Functions build on; Functions themselves use the first-party crate and not wasm-bindgen.

Building, testing and debugging

The command line

Command Purpose

shopify app build --no-input

Validates shopify.app.toml and builds every extension. Works offline with placeholder values for client_id and URLs.

shopify app function build --path DIR

Compiles one Function to Wasm.

shopify app function typegen --path DIR

Regenerates the TypeScript types (JavaScript flavors only); prints GraphQL types generated successfully.

shopify app function run --path DIR --input FILE --export NAME

Runs the compiled module once on an input and prints input, logs, output, resource limits and a benchmark. --json prints one JSON document, --profile writes a .perf profile for Speedscope. Without --input the JSON is read from standard input.

shopify app function replay --log ID

Re-runs a logged execution of a function (from app dev), optionally with --watch to repeat on code changes.

shopify app function info --json

Prints the handle, name, API version, targets (input query path and export), the paths of the schema, the Wasm file and the function runner.

shopify app function schema --stdout

Fetches the latest schema of the Function API into schema.graphql or to standard output. It needs a Shopify login; without one it stops with "To run this command, log in to Shopify".

shopify app dev

Previews the app on a development store and streams function logs to the terminal; each run is saved under .shopify/logs as a log file whose six-character suffix is the identifier for replay.

shopify app deploy

Builds, uploads and releases a new app version, including the Wasm modules. Needs a login (or a token in CI).

Source: Shopify CLI: app function run, app function replay and Shopify: Test and debug Shopify Functions. The first five rows and info were run; schema was run only to see its login message; dev, deploy and replay need a Shopify identity or a log file and were not run.

shopify app function info --json returned, for the discount extension:

{
  "function": {
    "handle": "coffee-club-discount",
    "name": "Coffee Club discount",
    "apiVersion": "2026-07",
    "directory": "/path/to/pocket-coffee-functions/extensions/coffee-club-discount",
    "targets": [
      {
        "target": "cart.lines.discounts.generate.run",
        "inputQueryPath": "/path/to/.../src/cart_lines_discounts_generate_run.graphql",
        "export": "cart-lines-discounts-generate-run"
      },
      {
        "target": "cart.delivery-options.discounts.generate.run",
        "inputQueryPath": "/path/to/.../src/cart_delivery_options_discounts_generate_run.graphql",
        "export": "cart-delivery-options-discounts-generate-run"
      }
    ],
    "schemaPath": "/path/to/.../schema.graphql",
    "wasmPath": "/path/to/.../dist/function.wasm",
    "functionRunnerPath": "/path/to/node_modules/@shopify/cli/bin/function-runner-9.2.2"
  }
}

Source: Shopify CLI: app function info. Output shortened; the absolute paths are replaced by placeholders.

Shopify CLI 5.0.0 removed shopify app logs and shopify app logs sources. Running the command now stops with this message:

`shopify app logs` and `shopify app logs sources` were removed in Shopify CLI 5.0. To see Function logs, run `shopify app dev`.

Source: Shopify CLI: app commands. Message printed by Shopify CLI 5.0.0 on 2026-10-27.

The current Shopify guide on testing and debugging still describes the command with its --source option, so for CLI 5.0.0 and newer app dev and the Dev Dashboard are the sources of function logs.

Logging

console.log in JavaScript, log! in Rust and the imported shopify_function_log_new_utf8_str in other languages write to the function log, which is truncated after 1 kB and is shown by app function run under "Logs". Printing to standard output or standard error does not work as a debugging tool. Logging costs instructions even when the text is truncated, so temporary logs must be removed before deploy, and logs must never contain customer data. Because runs are deterministic, a detailed log can be added to a local build and a production run reproduced exactly with its input.

Integration tests on the Wasm binary

Every Function template ships tests/default.test.js and tests/fixtures/, powered by the package @shopify/shopify-function-test-helpers (1.1.0 at the check date, which also returns the instruction count of each run). The test builds the module, reads the function information from the CLI, validates the input query against the schema, validates each fixture’s input and expected output against the schema and the query, runs the compiled Wasm and compares the result with the fixture. A fixture is a JSON file with a payload that has export, target, input and output, and every .json file in the folder becomes a test.

{
  "payload": {
    "export": "cart-lines-discounts-generate-run",
    "target": "cart.lines.discounts.generate.run",
    "input": {
      "cart": {
        "buyerIdentity": {
          "customer": {
            "isMember": false
          }
        },
        "lines": [
          {
            "id": "gid://shopify/CartLine/1",
            "merchandise": {
              "__typename": "ProductVariant",
              "product": {
                "isBeans": true
              }
            }
          }
        ]
      },
      "discount": {
        "discountClasses": [
          "PRODUCT"
        ],
        "metafield": null
      }
    },
    "output": {
      "operations": []
    }
  }
}

The suites of the five extensions (nine tests: four for the discount, one each for the bundle, the delivery rules and the payment rules, two for the parcel limit) all passed with Vitest 3.2.7:

cd extensions/coffee-club-discount
npm install --save-dev vitest@^3.2.4 @shopify/shopify-function-test-helpers@^1.0.0
npx vitest run

The unmodified template test fails against Shopify CLI 5.0.0. The helper expects shopify app function info --json to return schemaPath, wasmPath, functionRunnerPath and an object targeting at the top level, but CLI 5.0.0 (which introduced typed JSON output for every command) returns the same information inside a function key and lists the targets as an array called targets. The first failure was Failed to build schema from undefined, and after unwrapping the key the next one was Cannot read properties of undefined (reading 'cart.lines.discounts.generate.run'). Until the helper or the templates are updated, the beforeAll of the test can adapt the shape.

  beforeAll(async () => {
    functionDir = path.dirname(__dirname);
    await buildFunction(functionDir);
    functionInfo = await getFunctionInfo(functionDir);
    // Shopify CLI 5.0.0 wraps the result in a "function" key and lists "targets" as an array.
    const info = functionInfo.function ?? functionInfo;
    ({ schemaPath, functionRunnerPath, wasmPath } = info);
    targeting = info.targeting ?? Object.fromEntries(info.targets.map((t) => [t.target, t]));
    schema = await loadSchema(schemaPath);
  }, 45000);

Source: Shopify/shopify-function-test-helpers and the default.test.js of the functions-discount-js template. The adaptation is original and was run against Shopify CLI 5.0.0.

A pure unit test (Vitest for JavaScript, run_function_with_input and cargo test for Rust) is faster, can be step-debugged, and does not need the compiled module, but it does not execute in Wasm, so a rare difference between the test and production is possible. The Wasm integration test and app function run are the two checks that run the real binary.

Limits and performance

Fixed limit (every Function) Value

Compiled binary

256 kB

Runtime linear memory

10,000 kB

Runtime stack

512 kB

Runtime table elements, across all tables

10,000

Runtime tables

4

Logs written

1 kB, truncated

Dynamic limit Up to 200 cart lines Above 200 lines

Instructions

11 million

Scales proportionally

Function input

128 kB

Scales proportionally

Function output

20 kB

Scales proportionally

Source: Shopify: Function APIs, limitations. One kilobyte is 1,000 bytes. The 20 kB output limit does not support bulk price transformations across all lines; use discount functions, B2B catalogs or target specific products instead.

Beyond the table, apps can reference only their own Functions in Admin mutations (another app’s function gives Function not found), a function that declares a table larger than the limits cannot be deployed, and the App Store does not permit apps that edit and execute function code dynamically. The input query limits (3,000 bytes, cost 30, lists of at most 100 elements, metafield values up to 10,000 bytes) are described in the section on the input query.

Measured scaling

The first extension was run on carts of 1, 50, 200 and 1,000 bean lines, all discounted by a member. The table shows the instructions of the JavaScript module and the limits that app function run printed for each input.

Lines Instructions Instruction limit Input size Output size Linear memory

1

308,514

11M

222 B

180 B

1,280 kB

50

2,107,267

11M

4.37 kB

2.08 kB

1,344 kB

200

7,597,751

11M

17.21 kB

8.04 kB

1,472 kB

1,000

36,664,857

55M

85.96 kB

40.07 kB

2,112 kB

Source: Shopify: Optimize instruction counts. Measured on 2026-10-27 with shopify app function run; the input size limit printed was 125 kB for up to 200 lines and 625 kB for 1,000 lines, the output limit 19.53 kB and 97.66 kB.

Bar chart of the instructions used by the JavaScript Coffee Club discount on carts of 1, 50, 200 and 1,000 lines against the allowance for each cart size

A 200-line cart of beans, with the function doing one boolean check per line, used 69 percent of the 11 million allowed instructions, which is why Shopify warns that JavaScript functions meet the limit sooner. The cost is roughly linear: about 36,000 instructions per line in this function, on top of a fixed cost of about 270,000 instructions. A real function with a richer rule per line, or with a nested loop, would exceed the limit well before 200 lines. The chart also shows the shape of the limit: it grows at the same rate as the cart above 200 lines, so a function that is linear in the number of lines stays inside the allowance, and a quadratic one does not.

What happens when a limit is broken

The local runner reports; it does not enforce. A test function with a busy loop reported 2,295,222,693 instructions against an allowance of 11 million and printed the output without any error, so a local run must be compared with the printed limit by hand (or by --json, whose instructions field is meant for scripts). In production, Shopify stops a function that exceeds the instruction limit and reports an InstructionCountLimitExceededError. Other failures observed locally:

Cause Local result

An async function in JavaScript

Log: Pending jobs in the event queue. Scheduling events is not supported when the event-loop runtime config is not enabled. plus a Wasm backtrace; the output is {}.

A call to fetch

Error: fetch is not defined with the JavaScript stack, then Invalid Output and IO error while reading marker: failed to fill whole buffer.

An input query over the cost limit

Built and ran locally without complaint; the limit is checked by Shopify’s own validation.

Output that breaks an API rule (two operations of the same kind, unknown operation)

Rejected by Shopify when the result is validated; a Wasm integration test with the schema catches shape errors earlier.

Source: Shopify: JavaScript for Functions and Optimize instruction counts. The first two rows were produced on 2026-10-27 with modified copies of the discount extension that were deleted afterwards.

What a failed Function means for the buyer depends on the API: a Cart Transform run failure surfaces as the Storefront error code MERCHANDISE_LINE_TRANSFORMERS_RUN_ERROR and blocks the operation when blockOnFailure is set, and a Validation function with blockOnFailure set stops checkout. These are decisions to take at activation time.

Reducing instructions

Shopify lists the improvements in order of effect: migrate to Rust, request only the input the function needs, profile the function and optimise the hot paths, and track instruction counts in the tests so that they do not regress. For input, query only what is read, let Shopify evaluate conditions through hasAnyTag, inAnyCollection and similar fields, read JSON metafields with jsonValue, and precompute configuration when the merchant saves it so that the function receives the shape it needs (a lookup of ids rather than rules to evaluate). For logic, return early, avoid nested loops over lines (build a map or a set in one pass), avoid repeated work, avoid string work such as formatting and case conversion, and remove debug logging. For Rust, use the latest crate, borrow instead of cloning, avoid allocating strings inside loops, and try opt-level values other than "z" while watching the 256 kB limit.

To profile, build a Rust module with strip = false in [profile.release] and wasm_opt = false in [extensions.build], then run shopify app function run --input input.json --profile and open the .perf file in Speedscope. A JavaScript function shows every frame as unknown, because Javy modules carry no function names.

Deploying and operating

shopify app deploy builds the app and creates an app version, a snapshot of the app configuration and all extensions (the Wasm modules included), which is then released. After the release the owner of each Function must exist: the merchant creates it in the Shopify admin or the app creates it with the mutations shown above. Deleting a Function means removing the extension folder and deploying again; that permanently deletes the Function and all its owners, and the Function stops running on every store.

A deployed app can list the Functions it owns, with their ids and titles, to check that a release contains what was expected.

query FunctionsOfThisApp {
  shopifyFunctions(first: 10) {
    nodes {
      id
      title
      apiType
      app {
        title
      }
    }
  }
}

Source: Shopify: shopifyFunctions. Validated against the Admin GraphQL 2026-10 schema; not executed.

Some operational rules follow from the model.

  • Create owners with functionHandle, not functionId. The id changes between environments; the handle does not and is scoped to your app. Formal removal dates for functionId had not been announced at the check date.

  • Keep a function owner’s configuration backward compatible, because stores run the owner that was last saved, not the newest code: use the parallel change pattern described earlier.

  • Choose blockOnFailure consciously for Cart Transform and Validation.

  • Use the Dev Dashboard to inspect every run of a development store, with input, output, logs and the instruction count against its limit; for production runs the app needs the access scopes of the input query.

  • Pin the extension api_version and update it deliberately: a new version means a new schema, regenerated types and a re-test of every fixture.

  • In CI, shopify app deploy is authenticated with the app automation token, as described in billing, App Store and deployment. Run the Wasm integration tests before the deploy step so that a regression stops the pipeline.

Source: Shopify: About Shopify Functions, deleting functions, Shopify changelog: functionHandle (2025-10-01) and Test and debug Shopify Functions. shopify app deploy and the Dev Dashboard were not used for this page.

Migrating from Shopify Scripts

Shopify Scripts were small Ruby programs, written in the Script Editor app, that customised the cart and checkout. There were three kinds: line item scripts (prices and discounts of cart lines, run whenever the cart changed), shipping scripts (rename, hide, reorder and discount shipping rates, run when the buyer reached the shipping step) and payment scripts (rename, hide and reorder payment gateways, run when the buyer reached the payment step). The Script Editor offered templates such as percentage or amount off a product, bulk discounts, buy one get one free, and modify, hide or reorder shipping rates and payment gateways.

Shopify announced the deprecation on 2026-04-09. Editing and publishing new Scripts stopped on 2026-04-15, and all Scripts stopped executing on 2026-06-30: any Script still published was deactivated, so by the check date of this page every Script customisation has already disappeared from checkout and the migration is overdue rather than upcoming.

Date Event

2024-11-21

The limit of active automatic app discounts is raised from 5 to 25, with the explicit goal of letting developers migrate multiple promotions from Scripts.

2025-04

The Product, Order and Shipping Discount APIs are deprecated in favour of the unified Discount API (announced 2025-05-21).

2025-05-21

The new Discount API is introduced; up to 25 active functions per API for payment, delivery, validation and fulfillment constraints (from 5).

2026-04-09

Deprecation of Scripts announced; the Scripts customizations report is recommended for planning.

2026-04-15

Editing and publishing new Scripts becomes impossible.

2026-06-30

All Scripts stop executing.

Where each Script goes

The Help Center describes the Script types and templates, and the Function API pages describe the use cases of each API; combining the two gives this mapping. A Script that has no row has no documented Function equivalent.

Script behaviour Function API How

Percentage or amount off products, by tag, collection, customer tag or quantity

Discount, product class

productDiscountsAdd with percentage or fixedAmount; select with hasAnyTag, inAnyCollection or hasTags.

Order-level discount, tiered or threshold ("buy four, get 20 percent off")

Discount, order class

orderDiscountsAdd with conditions (orderMinimumSubtotal, cartLineMinimumQuantity) and tiers kept in a JSON metafield.

Buy one get one free

Discount, product class

productDiscountsAdd with prerequisites that name the cart line and the quantity required (API 2026-04).

Bundle price, dynamic pricing of a line, title or image of a line

Cart Transform

lineExpand and linesMerge for bundles; lineUpdate (Plus and development stores only) for a price or title override.

Discount on a shipping rate

Discount, shipping class

deliveryDiscountsAdd with percentage or fixedAmount; a discount reduces a rate and cannot raise it.

Rename, hide or reorder shipping rates

Delivery Customization

deliveryOptionRename, deliveryOptionHide, deliveryOptionMove.

Rename, hide or reorder payment gateways

Payment Customization

paymentMethodRename, paymentMethodHide, paymentMethodMove (with the wallet and plan restrictions above).

Modify a shipping rate price (raise it)

none documented

The Delivery Customization operations are rename, hide and move. Use a shipping profile or a carrier service for the rate.

Block a purchase (a check that Scripts could not do cleanly)

Cart and Checkout Validation

validationAdd with a target path.

Source: Shopify Help Center: Shopify Scripts and the Script Editor app and the Function API references linked above

Steps of a migration

flowchart TD A["Scripts customizations report"] --> B["List every published Script and its type"] B --> C{"What does the Script do?"} C -->|"Line item price or discount"| D["Discount API, or Cart Transform for bundles and overrides"] C -->|"Shipping rates"| E["Delivery Customization, plus Discount shipping class for price"] C -->|"Payment gateways"| F["Payment Customization"] D --> G["Move constants to a JSON metafield and an admin screen"] E --> G F --> G G --> H["Write fixtures from real carts, test the Wasm"] H --> I["Deploy, create the owner on a development store, compare"] I --> J["Activate on the live store and retire the Script"]

Source: Shopify changelog: Shopify Scripts will be deprecated on June 30, 2026. The flowchart is original, built from the mapping above.

A Script and its replacement differ in how they are made, tested and operated. The Script was edited and tested in the Script Editor, with its debugger, and published from the admin. A Function is source code in an app repository, built with the CLI, tested with fixtures and released with an app version; its configuration is data (metafields), not code; it can be written in several languages; and it is installed through an app, which makes the plan rules of the availability section the first thing to settle for a merchant that used to write its own Scripts.

The Coffee Club line item rule, as a Script and as a Function, shows the difference. The Script below is written in the style of the Script Editor templates and can no longer be run anywhere; it is shown only for comparison.

CAMPAIGN_TAG = "coffee-club"
PRODUCT_TAG = "beans"

customer = Input.cart.customer
if customer && customer.tags.include?(CAMPAIGN_TAG)
  Input.cart.line_items.each do |line_item|
    next unless line_item.variant.product.tags.include?(PRODUCT_TAG)
    line_item.change_line_price(line_item.line_price * Decimal.new(0.9), message: "Coffee Club 10% off")
  end
end

Output.cart = Input.cart

Source: Shopify Help Center: Shopify Scripts and the Script Editor app. Illustrative only: the Scripts API is retired, the snippet was not executed and its method names are reproduced from the Script Editor templates, whose reference could not be read for this page.

The Function differs in four visible ways. The Script changed prices itself (change_line_price); the Function asks Shopify to apply a discount, so reports and combination rules treat it as a discount. The tags and the percentage are constants in the Script and configuration in the Function. The Script ran in the cart object; the Function receives only the fields in its query. And the Script was a single file; the Function has an input query, an output type, a configuration and a test.

What changed

Date or version Change

2024-12-11

The input limit was raised to 128 kB.

2025-01-06

Input query variables metafields are validated: an invalid one raises InvalidVariableValueError instead of being treated as empty.

2025-01-08

Templates and CLI changed to support Rust 1.84, which removed wasm32-wasi.

2025-05-14

Network access for Discount functions.

2025-05-21

The unified Discount API; deprecation of the Product, Order and Shipping Discount APIs; 25 functions per API; POS-specific function logic; new reference documentation.

2025-07

Standardised target and operation names in Cart Transform, Delivery Customization, Fulfillment Constraints, Order Routing, Payment Customization and Cart and Checkout Validation. Validation returns operations like the other APIs.

2025-10-01

functionHandle replaces functionId in Admin mutations (API 2025-10).

2025-12-10

@shopify/shopify-function-test-helpers and Wasm integration tests in every template.

2025-12-17

Discount codes can be rejected by Discount functions.

2026-04

Metaobject access (app-owned) in every function target; prerequisites for product discount candidates; billing address and PO number targets for validation; the Storefront error code for cart transform failures; the removal of the old discount APIs was due before this version.

2026-05-13

Function run log details appear in the Dev Dashboard with the right access scopes.

2026-06-30

Shopify Scripts stop executing.

2026-09-10

The Discounts Allocator developer preview ends and the API is removed.

2026-09-15

Delivery options expose their metafields to functions (API 2026-10).

2026-10-27

Shopify CLI 5.0.0: typed JSON output, app logs removed, app function info --json wrapped in function.

Common mistakes

  • A JavaScript extension needs command = "" and an entry file at src/index.js, and the extension package.json needs the codegen key; without them the build stops. --input of app function run must be an absolute path.

  • The export of the toml must match the Wasm export: kebab-case in JavaScript, snake case in Rust. A mismatch fails at run time, not at build time.

  • The async keyword in a JavaScript function and any use of fetch, setTimeout, crypto or URL compile and fail on execution.

  • An input query over a cost of 30 is accepted by the local build and rejected by Shopify’s validation (the cost script of this page shows it in advance); the 3,000 byte size limit is applied by the same validation.

  • Reading a JSON metafield as a string and parsing it in the function wastes instructions; select jsonValue.

  • Decimal values arrive as strings, and a strict TypeScript project types the percentage as a string while the templates return a number; keep money arithmetic out of the output.

  • Matching delivery options and payment methods by display name breaks when a merchant renames them or the checkout language changes; use handles, ids or metafields.

  • Plus-only operations (lineUpdate, paymentTermsSet, orderReviewAdd, placement-specific hiding) and restricted payment methods give no error when they are not allowed: the output is returned and has no effect.

  • A cart transform that returns operations on a cart with a selling plan is rejected, not applied.

  • Several cart transforms on one line collide, and the earliest activated function wins.

  • A discount function that is not given a discount class returns nothing; test with each class enabled.

  • Debugging with console.log in production costs instructions and is truncated at 1 kB.

  • shopify app logs no longer exists in Shopify CLI 5.0.0 and newer.

  • The default template integration test fails with Shopify CLI 5.0.0 until its beforeAll is adapted.

  • A custom app with a Function cannot be installed on a non-Plus store; a public app can.

Pocket Coffee: putting it together

Extension Target Behaviour checked locally Instructions

coffee-club-discount

cart.lines.discounts.generate.run

10 percent off the bean line of a member; nothing for a guest

328,602 and 198,487

coffee-club-discount

cart.delivery-options.discounts.generate.run

100 percent off the delivery group of a member above 40 EUR

293,914

kit-bundle

cart.transform.run

lineExpand of the Starter kit line into two variants

254,531

delivery-rules

cart.delivery-options.transform.run

Rename "Standard"; hide "Same-day Madrid" for 08001

199,405

payment-rules

cart.payment-methods.transform.run

Hide "Cash on Delivery" and rename "Bank Transfer" for Germany

185,909

parcel-limit

cart.validations.generate.run

One error on $.cart for 5.25 kg; none for 2 kg

193,127

Source: Shopify: Test and debug Shopify Functions. Measured on 2026-10-27 with Shopify CLI 5.0.0.

Created in this order on a development store (not done for this page), the five owners reproduce the shop’s rules without changing the theme: the Coffee Club discount, the cart transform for the kit, the delivery and payment customizations and the validation. The shipping rates of the shop, the Coffee Club segment and the bean products with their tags are store data, described in the pages on shipping, taxes, payments and checkout settings and store setup. The metafields that hold the configuration are defined as described in metafields and metaobjects.

What was run and what was not

Run locally on 2026-10-27 (macOS, Node.js 24.21.0, Shopify CLI 5.0.0, no login, no store, placeholder values only):

  • shopify app build --no-input on an app with a placeholder client_id and the five Function extensions, which built every extension with the message "pocket-coffee-functions built!" (repeated after each change; Javy produced modules of 1,907 to 3,345 bytes).

  • shopify app function run on every sample input shown above, and on carts of 1, 50, 200 and 1,000 lines for the instruction counts and the scaled limits, plus three deliberately wrong variants of the discount (a busy loop of three million iterations, an async function and a call to fetch) and one with eleven aliased tag selections.

  • shopify app function typegen and shopify app function info --json; shopify app function schema only to see its login prompt; shopify app logs only to see its removal message.

  • Nine Wasm integration tests with Vitest 3.2.7 and @shopify/shopify-function-test-helpers 1.1.0, after adapting the helper call for CLI 5.0.0.

  • The input query cost script on eight queries.

  • The six Admin GraphQL documents (discountAutomaticAppCreate, cartTransformCreate, deliveryCustomizationCreate, paymentCustomizationCreate, validationCreate and the shopifyFunctions query), validated against the Admin GraphQL 2026-10 schema downloaded anonymously (all valid).

  • The offline build accepted input_variables on a target and the write_* access scopes of the app configuration.

Not run, because they need a Shopify account, a store or an app registered with Shopify: shopify app generate extension, shopify app dev, shopify app function replay, shopify app function schema beyond the login prompt, shopify app deploy, the Dev Dashboard, the execution of the Functions by Shopify (and therefore every statement about checkout behaviour, which is taken from the API references), the activation mutations, and network access. The Rust Function and the Rust configuration fragments were taken from the official templates and the Rust reference and were not compiled. The Script example was not run. Client ids, URLs, variant ids and tags in the examples are placeholders.

References