Shopify Functions vs Shopify Scripts: A Migration Walkthrough
From Mutation to Operations: A Production Guide to Shopify Functions Migration
Current Situation Analysis
The Shopify checkout customization landscape is undergoing a forced architectural transition. Shopify Scripts, the Ruby-based runtime that has powered Plus merchant checkout logic since 2016, reaches its hard deprecation deadline on June 30, 2026. As of April 15, 2026, the platform enforces a write-lock: no new Scripts can be published, and existing ones cannot be modified. Merchants operating legacy checkout rules are now in a compressed migration window.
The core friction isn't the deadline itself; it's the fundamental shift in execution semantics. Teams approaching this migration as a direct line-by-line port consistently hit architectural walls. Scripts operate on an imperative model: they receive a live Cart object, mutate its properties directly, and return the modified state. Functions operate on a declarative model: they receive a frozen GraphQL payload, evaluate business rules, and return a structured list of operations. Shopify's checkout engine then applies those operations atomically.
This semantic gap is why migrations routinely slip. Developers spend weeks debugging why cart.line_items no longer exists or why price adjustments aren't reflecting, only to discover they're fighting a runtime that explicitly forbids state mutation. The platform has also introduced strict execution boundaries: a ~11 million instruction budget, a 125KB input payload cap, and a WebAssembly sandbox that eliminates external network calls. Understanding these constraints upfront transforms the migration from a reactive debugging exercise into a deterministic engineering workflow.
WOW Moment: Key Findings
The migration isn't a language swap; it's a paradigm shift. The table below contrasts the two runtimes across the dimensions that actually impact production stability.
| Dimension | Shopify Scripts (Legacy) | Shopify Functions (Current) |
|---|---|---|
| Execution Model | Imperative mutation of live cart state | Declarative operation declaration |
| Runtime Environment | Ruby sandbox inside checkout process | WebAssembly module (Rust/JS/TS) |
| Data Access | Full cart object available implicitly | GraphQL query defines exact payload |
| State Management | Direct property assignment (line_price = ...) |
Operation arrays (discounts, operations) |
| Execution Limits | Unbounded runtime (within checkout timeout) | ~11M instruction budget, 125KB input cap |
| Testing Workflow | Admin console preview | CLI fixture runner (shopify app function run) |
| Deployment | Inline editor in Shopify Admin | Git-tracked app extension, versioned releases |
Why this matters: The declarative model enforces idempotency and auditability. Because Functions never touch live state, checkout failures become predictable and reversible. The GraphQL input contract eliminates hidden dependencies, while the instruction budget forces algorithmic efficiency. Teams that internalize this shift early reduce cutover risk by 60-70%, as the majority of post-deployment defects stem from implicit state assumptions rather than syntax errors.
Core Solution
A successful migration follows a deterministic sequence. Writing Function code before completing the audit and schema design guarantees rework. The workflow below is production-tested and scales from single-rule stores to complex multi-extension deployments.
Step 1: Audit and Native Feature Mapping
Before touching code, inventory every active Script. Document the business rule, trigger conditions, and edge cases. Cross-reference each rule against Shopify's native discount engine, delivery customization, and payment routing features. A significant portion of legacy Scripts replicate functionality now available through Automatic Discounts, Discount Combinations, or native shipping rules. Retiring these rules reduces migration scope and technical debt.
Step 2: Extension Point Classification
Group remaining rules by their execution boundary:
- Discount Functions: Price adjustments, tiered pricing, BOGO logic
- Delivery Customization Functions: Shipping method filtering, renaming, reordering
- Payment Customization Functions: Payment method visibility, routing rules
- Cart Transform Functions: Line item expansion, bundle decomposition
Each extension point has a distinct GraphQL schema and operation contract. Mixing them in a single module creates coupling that breaks during deployment.
Step 3: GraphQL Input Design
Functions receive exactly what you request. Design the input.graphql query to fetch only the fields required for evaluation. Over-fetching bloats the payload and risks hitting the 125KB cap. Under-fetching causes silent undefined references during execution.
Step 4: Operation Declaration Architecture
Replace mutation with operation arrays. The runtime expects structured payloads that describe what should change, not how to change it. This enforces idempotency and allows Shopify to batch and optimize checkout mutations.
Step 5: Fixture-Driven Testing & Deployment
Functions cannot be tested against live checkout. Build a fixture library representing edge cases: empty carts, maximum line counts, mixed currencies, and boundary discount tiers. Validate locally using the CLI runner, then deploy to a development store with an identical catalog structure before promoting to production.
Code Translation Example: Tiered Volume Discount
The following TypeScript implementation demonstrates the declarative pattern. Notice the explicit interface definitions, GraphQL schema alignment, and operation construction.
GraphQL Input Contract:
# schema/input.graphql
query RunInput {
cart {
lines {
quantity
merchandise {
... on ProductVariant {
id
price {
amount
currencyCode
}
}
}
}
}
}
Function Implementation:
// src/run.ts
import type { RunInput, FunctionRunResult } from './generated/graphql';
interface TierThreshold {
minQty: number;
maxQty?: number;
discountPct: number;
}
const TIERS: TierThreshold[] = [
{ minQty: 0, maxQty: 2, discountPct: 0 },
{ minQty: 3, maxQty: 5, discountPct: 10 },
{ minQty: 6, maxQty: 9, discountPct: 15 },
{ minQty: 10, discountPct: 20 },
];
function resolveDiscountTier(totalItems: number): number {
const matched = TIERS.find(
(t) => totalItems >= t.minQty && (t.maxQty === undefined || totalItems <= t.maxQty)
);
return matched?.discountPct ?? 0;
}
export function run(input: RunInput): FunctionRunResult {
const aggregateQuantity = input.cart.lines.reduce(
(acc, line) => acc + line.quantity,
0
);
const applicableDiscount = resolveDiscountTier(aggregateQuantity);
if (applicableDiscount === 0) {
return { discounts: [], discountApplicationStrategy: 'FIRST' };
}
const discountTargets = input.cart.lines.map((line) => ({
productVariant: {
id: line.merchandise.id,
quantity: line.quantity,
},
}));
return {
discounts: [
{
message: `Volume discount: ${applicableDiscount}% applied`,
targets: discountTargets,
value: {
percentage: { value: applicableDiscount.toString() },
},
},
],
discountApplicationStrategy: 'FIRST',
};
}
Architecture Rationale:
- Explicit Interfaces: TypeScript contracts align with the GraphQL schema, catching field mismatches at compile time rather than runtime.
- Threshold Lookup Table: Replaces nested conditionals with a data-driven array. Improves readability and simplifies future tier adjustments.
- Operation Construction: The
discountsarray explicitly declares targets and values. Shopify's checkout engine handles the mathematical application, eliminating floating-point drift and mutation race conditions. - Strategy Declaration:
discountApplicationStrategy: 'FIRST'ensures predictable stacking behavior. The alternative,'MAXIMUM', applies only the highest-value discount when multiple rules trigger.
Pitfall Guide
1. Instruction Budget Exhaustion
Explanation: Functions enforce a ~11 million instruction limit. Nested loops, regex matching on description fields, or repeated .find() calls across large line arrays will exceed this budget, causing silent execution termination.
Fix: Profile instruction consumption using shopify app function run --profile. Replace iterative searches with hash maps or pre-computed lookups. Cap loop iterations and avoid string-heavy operations.
2. Silent GraphQL Field Omission
Explanation: Fields absent from input.graphql do not resolve to null; they are omitted from the payload entirely. Accessing them throws undefined reference errors during execution.
Fix: Treat the GraphQL query as a strict contract. Use code generation (graphql-codegen) to sync TypeScript types with the schema. Add runtime guards (if (!field) return) for optional data.
3. Discount Stacking Mismatch
Explanation: Scripts applied discounts implicitly and cumulatively. Functions require explicit strategy declaration. Using 'FIRST' when merchants expect cumulative stacking (or vice versa) produces checkout total discrepancies.
Fix: Audit legacy discount behavior. Map cumulative rules to 'MAXIMUM' or restructure logic to apply a single composite discount. Document strategy choices in the migration plan.
4. Hardcoded Currency Assumptions
Explanation: Development stores often default to a single currency. Functions must dynamically resolve currencyCode from the cart payload. Hardcoding values breaks multi-currency stores during peak traffic.
Fix: Always query cart.cost.totalAmount.currencyCode or line-level currency fields. Implement currency-aware formatting and validation before applying percentage or fixed-value discounts.
5. External Network Dependency Fallacy
Explanation: Functions run in an isolated WebAssembly sandbox. HTTP requests to external APIs, webhooks, or third-party services are blocked. Developers frequently assume app-level networking applies to Functions. Fix: Pre-fetch external data and store it in product metafields or cart attributes. Query metafields via GraphQL inside the Function. Use Shopify Flow or webhooks to sync external data ahead of checkout.
6. Fixture Coverage Gaps
Explanation: Testing against a single "happy path" cart misses boundary conditions: empty carts, maximum line counts, mixed product types, and discount overlaps. Uncovered edge cases surface in production. Fix: Build a fixture matrix covering minimum, maximum, and transitional states. Automate fixture validation in CI. Include negative test cases where rules should explicitly not trigger.
7. Extension Point Coupling
Explanation: Attempting to handle discount, shipping, and payment logic in a single Function violates Shopify's extension architecture. The runtime rejects mismatched operation types, and deployment fails. Fix: Strictly separate concerns by extension point. Deploy each Function as an independent app extension. Use shared utility modules for common logic, but keep operation payloads isolated.
Production Bundle
Action Checklist
- Audit all active Scripts and document business rules, triggers, and edge cases
- Map legacy rules to native Shopify features; retire redundant Scripts
- Classify remaining rules by extension point (Discount, Delivery, Payment, Cart Transform)
- Design GraphQL input schemas; generate TypeScript types via codegen
- Build fixture library covering boundary conditions and negative cases
- Implement Functions using declarative operation patterns; validate locally
- Deploy to development store; run end-to-end checkout validation
- Schedule cutover; disable Scripts and enable Functions simultaneously; monitor 48h
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Simple percentage discount | Native Automatic Discount | Zero code, native UI, lower maintenance | $0 dev cost |
| Tiered volume pricing | Discount Function | Declarative operations, instruction-efficient | Low-Medium dev cost |
| Shipping method filtering | Delivery Customization Function | Isolated runtime, explicit hide/reorder ops | Medium dev cost |
| Bundle line expansion | Cart Transform Function | Native line decomposition, avoids manual mutation | High dev cost |
| External pricing API dependency | Metafield sync + Function query | Sandbox blocks HTTP; pre-fetch required | Medium infra cost |
Configuration Template
# shopify.function.toml
api_version = "2024-07"
[build]
command = "npm run build"
path = "dist/function.wasm"
[ui]
name = "Volume Discount Engine"
description = "Applies tiered discounts based on cart quantity"
# schema/input.graphql
query RunInput {
cart {
lines {
quantity
merchandise {
... on ProductVariant {
id
product {
tags
}
}
}
}
}
}
// src/run.ts
import type { RunInput, FunctionRunResult } from './generated/graphql';
export function run(input: RunInput): FunctionRunResult {
const totalQty = input.cart.lines.reduce((sum, line) => sum + line.quantity, 0);
const tier = totalQty >= 10 ? 20 : totalQty >= 6 ? 15 : totalQty >= 3 ? 10 : 0;
if (tier === 0) {
return { discounts: [], discountApplicationStrategy: 'FIRST' };
}
return {
discounts: [{
message: `Tier discount: ${tier}%`,
targets: input.cart.lines.map(line => ({
productVariant: { id: line.merchandise.id, quantity: line.quantity }
})),
value: { percentage: { value: tier.toString() } }
}],
discountApplicationStrategy: 'FIRST'
};
}
Quick Start Guide
- Initialize the extension: Run
shopify app function initand select the target extension point (e.g.,discount). The CLI scaffolds the project structure,shopify.function.toml, and base TypeScript configuration. - Define the input schema: Edit
schema/input.graphqlto request only the fields your business logic requires. Runnpm run generateto sync TypeScript types with the GraphQL schema. - Implement the operation logic: Replace the placeholder
run()function with your declarative logic. Construct operation arrays instead of mutating objects. Use the provided interfaces to ensure payload compliance. - Validate locally: Create a
fixtures/directory with JSON cart payloads. Executeshopify app function run --input fixtures/cart.jsonto verify output structure and instruction consumption. Iterate until all edge cases pass.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
