Why Functions, why now
Shopify Scripts were the previous mechanism for custom checkout logic on Shopify Plus. They ran Ruby, they were limited to Plus, and Shopify announced their sunset for August 2025. Functions are the replacement: WebAssembly modules (Rust or JS source), running inside Shopify's infrastructure at every checkout decision point, with strict execution budgets (5ms / 256MB / 11MB output). They are not Plus-only for every use case, pricing-related Functions still require Plus, but shipping-rate and payment-customisation Functions work on all plans.
We use Functions for three pricing patterns far more often than anything else. The rest of this post is the working code for each.
Pattern 1: B2B tier pricing
Business case. A wholesale customer with a Bronze / Silver / Gold tier gets 5% / 10% / 15% off every line item, automatically, with no discount code entered.
Mechanism. A discountCustomerRunFunction reads the authenticated customer's tier metafield and emits a percentage discount on every line item.
Function manifest. The targeting block declares which API the Function runs on; the input.graphql below dictates what data Shopify hands you at each invocation.
api_version = "2024-10"
name = "b2b-tier-pricing"
type = "function"
[build]
command = "cargo build --release --target wasm32-wasip1"
path = "target/wasm32-wasip1/release/b2b_tier_pricing.wasm"
[ui.paths]
create = "/"
details = "/"
[[extensions.targeting]]
target = "purchase.product-discount.run"
input_query = "src/run.graphql"
export = "run"Tell Shopify what you actually need from the cart input. Asking for less keeps execution time down, we routinely run at 0.8-1.2ms with this query.
query Input {
cart {
buyerIdentity {
customer {
metafield(namespace: "b2b", key: "tier") { value }
}
}
lines {
id
quantity
cost {
amountPerQuantity { amount }
}
}
}
}Pure Rust. Compiles to wasm32-wasip1. Shopify guarantees a 5ms budget per invocation; this function lands well under 2ms even on a 50-line cart.
use shopify_function::prelude::*;
use shopify_function::Result;
generate_types!(
query_path = "src/run.graphql",
schema_path = "schema.graphql"
);
#[shopify_function]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let tier = input
.cart
.buyer_identity
.as_ref()
.and_then(|b| b.customer.as_ref())
.and_then(|c| c.metafield.as_ref())
.map(|m| m.value.as_str())
.unwrap_or("");
let percent: f64 = match tier {
"bronze" => 5.0,
"silver" => 10.0,
"gold" => 15.0,
_ => return Ok(output::FunctionRunResult { discounts: vec![], discount_application_strategy: output::DiscountApplicationStrategy::First }),
};
let discounts = input
.cart
.lines
.iter()
.map(|line| output::Discount {
value: output::Value::Percentage(output::Percentage { value: percent }),
targets: vec![output::Target::ProductVariant(output::ProductVariantTarget {
id: line.id.clone(),
quantity: None,
})],
message: Some(format!("{}% B2B tier discount", percent as i32)),
})
.collect();
Ok(output::FunctionRunResult {
discounts,
discount_application_strategy: output::DiscountApplicationStrategy::First,
})
}The metafield design that makes this work:
Namespace + key: b2b.tier
Type: Single-line text
Access: Storefronts: read; Admin: read/write
Values: bronze | silver | gold | <unset>
Recommended: limit valid values via a Shopify Flow workflow that
fires on the "customer tag added" trigger and writes the metafield
from a curated tag list.Deploy. shopify app deployfrom the extension folder pushes the wasm binary and registers it. The Function then needs to be turned on as a Discount in the admin (Discounts > Create discount > Automatic discount > your Function). The activation is the part many teams miss the first time, the Function exists but won't run until it's wrapped in an active discount. When we ship Functions through our Shopify development engagement, the activation step is part of the merchant-facing handover doc, not a developer-only checklist.
Pattern 2: Volume discounts
Business case. Buy 5 of any variant, get 10% off; buy 10, get 15% off; buy 25+, get 20% off. Applied per-variant, not across the cart total.
Mechanism. Same Function target as Pattern 1, but the input query reads quantities and the logic applies a tiered percentage per line.
use shopify_function::prelude::*;
use shopify_function::Result;
generate_types!(
query_path = "src/run.graphql",
schema_path = "schema.graphql"
);
const TIERS: &[(i32, f64)] = &[
(25, 20.0),
(10, 15.0),
(5, 10.0),
];
#[shopify_function]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let discounts = input
.cart
.lines
.iter()
.filter_map(|line| {
let qty: i32 = line.quantity.into();
let tier = TIERS.iter().find(|(threshold, _)| qty >= *threshold)?;
Some(output::Discount {
value: output::Value::Percentage(output::Percentage { value: tier.1 }),
targets: vec![output::Target::ProductVariant(output::ProductVariantTarget {
id: line.id.clone(),
quantity: None,
})],
message: Some(format!("Volume discount: {}% off at qty {}+", tier.1 as i32, tier.0)),
})
})
.collect();
Ok(output::FunctionRunResult {
discounts,
discount_application_strategy: output::DiscountApplicationStrategy::First,
})
}The detail that catches teams: the threshold is the line-item quantity, not the cart total. If the customer adds 5 of variant A and 3 of variant B, only variant A gets the discount. If you want the discount to apply per-cart-total, the loop above sums first and applies a single value, two extra lines of Rust.
The matching theme-side message that explains the math on the PDP, so customers know what they will get before they hit the cart:
<dl class="volume-pricing">
{% assign tiers = '5,10|10,15|25,20' | split: '|' %}
{% for t in tiers %}
{% assign parts = t | split: ',' %}
<dt>Buy {{ parts[0] }}+</dt>
<dd>{{ parts[1] }}% off this variant</dd>
{% endfor %}
</dl>
<p class="volume-pricing-note">
Discount applies automatically at checkout. Per-variant.
</p>Pattern 3: Verified member rates
Business case. Wholesale members get an across-the-board rate (cost-plus, e.g. 30% off retail) on a subset of the catalogue tagged wholesale-eligible. The eligibility is verified via an external API call out (think VAT-number lookup, or membership status), but Functions cannot make network calls.
Mechanism. Solve the network-call limitation by lifting the verification result into a customer metafield, written by a background job that can call out. The Function then trusts the metafield.
use shopify_function::prelude::*;
use shopify_function::Result;
generate_types!(
query_path = "src/run.graphql",
schema_path = "schema.graphql"
);
const MEMBER_DISCOUNT_PERCENT: f64 = 30.0;
#[shopify_function]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let verified = input
.cart
.buyer_identity
.as_ref()
.and_then(|b| b.customer.as_ref())
.and_then(|c| c.verified_member.as_ref())
.map(|m| m.value == "true")
.unwrap_or(false);
if !verified {
return Ok(output::FunctionRunResult {
discounts: vec![],
discount_application_strategy: output::DiscountApplicationStrategy::First,
});
}
let discounts = input
.cart
.lines
.iter()
.filter(|line| {
// only discount lines whose product has the eligibility tag
line.merchandise
.as_ref()
.and_then(|m| m.product.as_ref())
.map(|p| p.has_wholesale_tag)
.unwrap_or(false)
})
.map(|line| output::Discount {
value: output::Value::Percentage(output::Percentage { value: MEMBER_DISCOUNT_PERCENT }),
targets: vec![output::Target::ProductVariant(output::ProductVariantTarget {
id: line.id.clone(),
quantity: None,
})],
message: Some("Wholesale member rate".to_string()),
})
.collect();
Ok(output::FunctionRunResult {
discounts,
discount_application_strategy: output::DiscountApplicationStrategy::First,
})
}The input GraphQL pulls the verification flag and the product tag presence:
query Input {
cart {
buyerIdentity {
customer {
verifiedMember: metafield(namespace: "b2b", key: "verified_member") { value }
}
}
lines {
id
quantity
merchandise {
... on ProductVariant {
product {
hasWholesaleTag: hasAnyTag(tags: ["wholesale-eligible"])
}
}
}
}
}
}The background job that owns the verification can be anywhere, we usually put it on the same Node API that handles the merchant's account portal. It listens for a customer-tag-added event (via a Shopify webhook), calls out to whatever verification service the merchant uses, and writes b2b.verified_member on the customer record via the Admin API. The Function then sees the result instantly on the next cart load. We build the webhook + admin-API layer behind this pattern as part of our API & integration engagement, usually 1-2 weeks alongside the Function itself.
Performance and execution limits
| Limit | Value | What to do if you bump into it |
|---|---|---|
| Execution time | 5 ms | Narrow the input query first; filter before you map; avoid string formatting in hot loops. |
| Memory | 256 MB | Practically unhittable for pricing; relevant for shipping Functions that pre-compute matrices. |
| Output size | 11 MB | A discount per cart line will not get close. Bulk shipping rates can. |
| Network access | None | Use the metafield-via-background-job pattern from Pattern 3. |
| Active discount Functions per shop | 25 | Almost never relevant; bigger constraint is the 5 simultaneously-running discounts at checkout. |
Migrating from Shopify Scripts
If you are coming from Scripts, three concepts move:
- Input.cart maps to
Input.cartin GraphQL , the structure is similar but you opt into fields rather than getting the whole cart. Tighter inputs run faster. - Customer-tag checks become metafield checks. Tags still work but they require a longer query. Metafields are quicker and more structured.
- The
cart.discount_codeshort-circuit goes away. Functions run alongside discount codes; if you want a Function to skip when a discount code is present, check for it in the input and early-return.
The wider Shopify cost picture, whether you should be on Plus to use these patterns at all, is the subject of our companion Plus vs Advanced cost study. Most B2B merchants we run this work for are already on Plus for B2B catalogues; the few on Advanced need at minimum the Functions pricing API, which means an upgrade.
Adjacent reading from our Shopify and architecture clusters:
Research
Shopify Plus vs Advanced: A Cost-Per-Order Analysis at 7 Revenue Tiers
Real CPO math for Shopify Plus vs Advanced across $500k to $50M GMV, and the GMV at which the upgrade pays back. 24 audited merchants.
Research
Replatforming to Shopify: Anatomy of One Magento Migration + 23 Engagements of Data
Opens with one specific Magento 2 to Shopify Plus migration end-to-end, then aggregates cost and timeline across 23 replatforms.
Research
The Multi-Tenant SaaS Architecture Decision: Cost & Engineering Hours Across 4 Patterns
Per-pattern cost, isolation, and onboarding eng-hours for the four common multi-tenancy approaches. TIC, AOC, BCM metrics.
The Shopify build engagement that includes Functions, the API engagement where the background metafield writer lives, and the retainer that keeps it all running:
Service
Shopify Development Services
Custom themes, migration to Shopify, Shopify apps, supplier-feed automation.
Service
API & Integration
Custom REST/GraphQL APIs and third-party integrations.
Service
Maintenance & Support
Post-launch stability, security, monthly improvements.
Frequently asked questions
- What's the difference between Shopify Functions and Scripts?
- Functions are WebAssembly modules (Rust or JS source) that run inside Shopify's infrastructure with strict 5ms / 256MB / 11MB execution budgets. Scripts were the Ruby-based predecessor, Plus-only, and are being sunset in August 2025. Functions are the modern replacement and are not Plus-only for every use case, pricing Functions still require Plus.
- Can a Shopify Function make a network call?
- No. Functions run inside a sandboxed runtime with no network access. The pattern for external verification (membership, VAT lookup, etc.) is to do the network work in a background job that writes the result to a customer metafield; the Function then reads the metafield at checkout.
- How fast does a Shopify pricing Function run in practice?
- Well under the 5ms budget for typical carts. A tight input GraphQL query against a 50-line cart lands at 0.8-1.2ms in our production deployments. The optimisation lever is the input query, ask for less data and execution time drops accordingly.
