Shopify Function input query variables let you declare $variables in a Function’s input query and have Shopify fill them from a JSON metafield on the function owner before the query runs. The merchant’s configuration (collection ids, tags, thresholds) is resolved into the query itself, so a field like inAnyCollection(ids: $collectionIds) comes back as a boolean per cart line and your Wasm never sees the list at all.
That is the whole feature. What takes explaining is where the values come from, what the three files look like, and when you would still read the metafield the old way instead.
Everything below was checked against the 2026-10 Discount Function API reference and the 2026-10 Admin API in October 2026. The mechanism is the same for the other Function APIs; only the owner object changes.
Where do the variable values come from?
From one JSON metafield on the function owner. For a discount Function the owner is the discount; for a cart validation it is the validation; for a cart transform it is the cart transform. The variables guide names the two places people put it by mistake: “Input query variables do not utilize metafields on the shop or app installation.” Set it on the shop and every variable silently arrives as null.
The metafield value is a JSON object whose top-level keys are the variable names and whose values match the variable types. You point the Function at that metafield from shopify.extension.toml, next to the target’s input_query:
api_version = "2026-10"
[[extensions]]
name = "t:name"
handle = "collection-discount"
type = "function"
[[extensions.targeting]]
target = "cart.lines.discounts.generate.run"
input_query = "src/cart_lines_discounts_generate_run.graphql"
export = "cart_lines_discounts_generate_run"
input_variables = { namespace = "$app:collection-discount", key = "config" }
input_variables is per target. A Function with two targets can read two different metafields, or the same one twice.
Three limits worth knowing before you design the JSON. Only json type metafields are supported. Any list variable with more than 100 elements makes the input query return an error, so a “pick collections” UI needs a cap, not a scroll. And a variable that is missing from the JSON becomes null; if you declared it with a ! suffix, the Function run fails outright rather than running with a default. Declare variables as nullable unless a missing value really should stop the discount.
What does an input query with variables look like?
The query declares the variables in its signature. No directive, no special syntax; it is ordinary GraphQL:
query CartInput($excludedCollectionIds: [ID!], $vipCollectionIds: [ID!]) {
cart {
lines {
id
merchandise {
__typename
... on ProductVariant {
id
product {
inExcludedCollection: inAnyCollection(ids: $excludedCollectionIds)
inVIPCollection: inAnyCollection(ids: $vipCollectionIds)
}
}
}
}
}
discount {
discountClasses
}
}
The metafield that feeds it looks like this once set:
{
"excludedCollectionIds": [
"gid://shopify/Collection/1",
"gid://shopify/Collection/2"
],
"vipCollectionIds": [
"gid://shopify/Collection/4"
]
}
And the Function body gets two booleans per line instead of two arrays to intersect. The JavaScript target export, with the 2026-10 output shape:
// @ts-check
import { ProductDiscountSelectionStrategy } from '../generated/api';
/**
* @typedef {import('../generated/api').Input} Input
* @typedef {import('../generated/api').CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
*/
/**
* @param {Input} input
* @returns {CartLinesDiscountsGenerateRunResult}
*/
export function cartLinesDiscountsGenerateRun(input) {
const candidates = input.cart.lines
.filter((line) => {
if (line.merchandise.__typename !== 'ProductVariant') return false;
const product = line.merchandise.product;
return product.inVIPCollection && !product.inExcludedCollection;
})
.map((line) => ({
message: 'VIP 10% OFF',
targets: [{ cartLine: { id: line.id } }],
value: { percentage: { value: 10 } },
}));
if (candidates.length === 0) {
return { operations: [] };
}
return {
operations: [
{
productDiscountsAdd: {
selectionStrategy: ProductDiscountSelectionStrategy.First,
candidates,
},
},
],
};
}
Nothing in that file knows which collections the merchant chose. Change the metafield and the next run picks up the new ids without a redeploy, and the generated Input type in generated/api still matches because the query did not change.
How do you set the metafield when the merchant saves?
For a discount, at creation time, through the metafields field on DiscountAutomaticAppInput. The 2026-10 reference lists it as [MetafieldInput!] with a default of [], and describes it as the hook for “dynamic function configuration with different parameters”. Note that the same reference marks functionId as deprecated; use functionHandle, which is the handle from the extension TOML above.
mutation CreateCollectionDiscount {
discountAutomaticAppCreate(
automaticAppDiscount: {
title: "VIP collections"
functionHandle: "collection-discount"
discountClasses: [PRODUCT]
startsAt: "2026-10-13T00:00:00Z"
metafields: [
{
namespace: "$app:collection-discount"
key: "config"
type: "json"
value: "{\"excludedCollectionIds\":[\"gid://shopify/Collection/1\"],\"vipCollectionIds\":[\"gid://shopify/Collection/4\"]}"
}
]
}
) {
automaticAppDiscount {
discountId
}
userErrors {
field
message
}
}
}
For edits afterwards, metafieldsSet with ownerId set to the discount id and the same namespace, key and type: "json". The guide’s own example does exactly that. The $app: prefix matters: it is the reserved namespace that stops another app reading or overwriting your configuration, and both guides tell you to use it.
The merchant-facing side of this is an admin UI extension or an App Home page that writes that JSON. The metafields for input queries guide covers the wiring; the [extensions.ui.paths] block in the extension TOML gives the discount its create and details routes.
Variables, or a metafield read inside the query?
The older pattern still works and is still in the reference examples: query the metafield in the body and parse it yourself.
query CartInput {
cart {
lines {
id
merchandise {
__typename
... on ProductVariant {
id
product {
id
}
}
}
}
}
discount {
discountClasses
metafield(namespace: "$app:collection-discount", key: "config") {
jsonValue
}
}
}
jsonValue hands you a parsed object, so there is no JSON.parse in the Function. But every cart line now arrives with a product id, and your code has to decide whether that product is in a chosen collection. It cannot. The input query is the only window the Function has onto the shop, and a product id tells you nothing about collection membership. You would have to request inAnyCollection with the ids hard-coded, which is the problem variables exist to solve.
So the split is by what the configuration does. If a value filters the input (collection ids, tags, product ids), it belongs in a variable, because Shopify can apply it while resolving the query and hand back a boolean. If a value shapes the output (a percentage, a message, a quantity threshold), read it with jsonValue and use it in the Function; there is no query argument that would consume it anyway. Most real Functions want both: variables for the filter, jsonValue for the numbers. They can share the same metafield, since input_variables and the body’s metafield(...) field can point at one namespace and key.
Smaller input is the practical win. The Function only receives what the query returns, so a boolean per line is less to deserialise than a product id plus a list of collections to compare it against, and the instruction count is where you would see the difference. The Functions testing post covers how to measure that against the per-run limit. When you test with shopify app function run, the fixture you feed it is the query result, after Shopify has resolved the variables, so you are testing the Function body and not the variable resolution. Capture real inputs from the Function logs rather than hand-writing them, and re-capture when the JSON shape changes.
Two situations push you back to the metafield-in-body pattern. The first is more than 100 ids in a list: variables will error, so paginate the choice in the UI or store the membership on the product as a metafield and query that instead. The second is configuration that needs structure a flat key-per-variable JSON cannot express well, such as a list of tiers each with its own threshold and rate. For that, read jsonValue and, if staff need to edit the tiers in admin, consider an app-owned metaobject; our metaobjects versus metafields post covers when that trade is worth it.
What I would do
Start every new Function with input_variables pointing at one $app:-prefixed JSON metafield on the owner, declare every variable nullable, and keep the lists under 100 by capping the picker. Put the filtering ids in variables and the numeric settings in the same metafield read through jsonValue. Write the metafield at create time through the owner mutation’s metafields field and update it with metafieldsSet, never on the shop. If you are moving an existing Function off hard-coded ids, change the query and the TOML first, deploy, then migrate the UI; the generated/api types will tell you which fields in the Function body are now booleans. If you are still on the legacy discount API, the Discount Function API migration comes first, because everything above is written against the cart.lines.discounts.generate.run target.
Whoooop builds Shopify Functions and the admin extensions that configure them, usually for merchants who have outgrown what a discount app’s settings page can express. If you have a Function with ids baked into the query, or an app whose “settings” is a shop metafield that the Function cannot see, that is the kind of Shopify development work we do.