Key Takeaways
- A Shopify GID like
gid://shopify/ProductVariant/44123456789012is roughly 3x longer than its numeric tail44123456789012. When you store thousands of them in one JSON metafield, that difference decides whether you hit the size cap.- Most metafield types cap at 64KB;
jsontype caps at 128KB for new apps (Shopify, Metafield limits). Large JSON values also slow the storefront because they are fetched on every page.- Store the numeric ID in storefront-facing metafields and have consumers compare by numeric tail, so the change is backward compatible with already-stored GIDs during rollout.
- Keep GIDs where a consumer genuinely needs them: Shopify Functions input, server-side matching, and customer or segment targeting.
If your Shopify app packs its configuration into a single JSON metafield, the format of the IDs inside it is not a cosmetic choice. We store each discount feature's entire rule set in one app-owned metafield value that the storefront reads on every page. Once a merchant has many rules, each targeting lists of products, variants, and collections, those ID arrays dominate the payload. Switching from full GIDs to numeric IDs cut that payload by roughly 3x and kept us comfortably inside Shopify's size limits.
This guide shows the exact producer/consumer pattern we use: how to emit numeric IDs when writing the metafield, how to match them on the storefront without a migration window breaking anything, and which IDs you should deliberately leave as GIDs.
Why GIDs Bloat Your Metafield
A Shopify Global ID is a URI, not a number. The variant ID 44123456789012 becomes gid://shopify/ProductVariant/44123456789012 once Shopify wraps it. The prefix gid://shopify/ProductVariant/ is 29 characters of pure overhead repeated on every single ID in your payload.
| ID form | Example | Length |
|---|---|---|
| Full GID (variant) | gid://shopify/ProductVariant/44123456789012 |
~43 chars |
| Numeric tail | 44123456789012 |
~14 chars |
| Full GID (product) | gid://shopify/Product/8512345678901 |
~35 chars |
| Numeric tail | 8512345678901 |
~13 chars |
That is the ~3x our own code comments cite. For one rule with a handful of IDs it is noise. For a shop with dozens of rules, each including and excluding lists of products, variants, and collections, it is the difference between a healthy payload and one that fails to write.
Two hard limits make this matter:
- Metafield size caps. Most metafield types cap at 64KB (65,536 bytes); the
jsontype caps at 128KB for new apps, with older apps grandfathered higher (Shopify, Metafield limits). One JSON value holding every rule can approach these caps as a catalog grows. - Function input caps. Shopify Functions do not receive metafield values larger than 10,000 bytes. The value is still stored and the Admin API still returns it, but the function reads
null(Shopify, Metafield limits). Lean payloads are not optional for function-driven apps.
There is a performance reason too. Shopify's own changelog notes that large JSON metafield values "negatively impact storefront performance, particularly when multiple large values are loaded together" (Shopify changelog, JSON metafield values limited to 128KB). A storefront metafield you read on every page is exactly that case, so a smaller value is both a correctness fix and a speed fix.
What We're Building
The pattern has three moving parts:
- A producer that converts GIDs to numeric IDs when it writes the storefront metafield.
- A consumer on the storefront that matches by numeric tail, so it works whether the stored value is numeric (new) or a full GID (old, pre-migration).
- A one-time resync command that rewrites every shop's metafield in the new format.
The backward-compatible comparison is the keystone. Because the consumer normalizes both sides to the numeric tail, you can deploy the consumer first, the producer second, and run the migration whenever, with no broken window in between.
Step 1: Emit Numeric IDs in the Producer
In this step you'll convert GIDs to numeric tails when building the metafield payload, so the stored value is as small as possible.
The core helper takes the substring after the last slash. It is null-safe and passes an already-numeric string through unchanged, which matters because your data may be mid-migration.
// app/Support/ConditionPayload.php
/**
* Numeric tail of a Shopify GID ("gid://shopify/ProductVariant/123" -> "123").
* Storing the numeric id instead of the full GID is ~3x smaller. Consumers
* compare by numeric tail. Returns null for empty input; passes an
* already-numeric string through unchanged.
*/
protected function numericId($gid): ?string
{
$s = (string) ($gid ?? '');
if ($s === '') {
return null;
}
$pos = strrpos($s, '/');
return $pos === false ? $s : substr($s, $pos + 1);
}
/** Numeric tails for an array of GIDs / {id} objects. */
protected function numericIds(array $items): array
{
return array_values(array_filter(array_map(
fn ($item) => $this->numericId(is_array($item) ? ($item['id'] ?? null) : $item),
$items,
)));
}Then the metafield builder uses the numeric variants of its split helpers for every product, variant, and collection list:
// app/Services/AppMetafieldService.php (inside a rule-payload builder)
if ($condition && $condition->condition_type === 'matching_condition') {
$includeProducts = $this->splitProductsAndVariantsNumeric($condition->specific_products ?? []);
$excludeProducts = $this->splitProductsAndVariantsNumeric($condition->exclude_products ?? []);
$payload['include'] = [
'products' => $includeProducts['products'],
'variants' => $includeProducts['variants'],
'collections' => $this->numericIds($condition->specific_collections ?? []),
'tags' => $condition->specific_tags ?? [], // tags stay as-is
];
$payload['exclude'] = [
'products' => $excludeProducts['products'],
'variants' => $excludeProducts['variants'],
'collections' => $this->numericIds($condition->exclude_collections ?? []),
'tags' => $condition->exclude_tags ?? [],
];
}What just happened: every product, variant, and collection ID that lands in the storefront metafield is now a bare number. The numericId helper is deliberately idempotent, so running it over already-numeric data is a no-op rather than a corruption risk.
Watch out: only the storefront app metafield builders use the numeric split. The GID-preserving versions still exist and are used where a consumer needs the full GID (see Step 4). Do not globally replace one with the other.
Step 2: Match by Numeric Tail in the Consumer
In this step you'll make the storefront matcher compare IDs by their numeric tail, which makes the whole change backward compatible.
The trick is to normalize both sides before comparing. Whether the metafield holds 44123456789012 or gid://shopify/ProductVariant/44123456789012, and whether the value you are checking it against is numeric or a GID, numTail reduces both to the same bare number.
// frontend/shared/conditions.js
// "gid://shopify/Product/123" -> "123", "123" -> "123". The metafield stores
// ids as NUMERIC; comparing by numeric tail on BOTH sides matches whether the
// metafield holds numeric (new) or full GIDs (old, pre-resync).
const numTail = (x) => String(x ?? '').split('/').pop();
const idsHas = (list, value) => {
const t = numTail(value);
return (list ?? []).some((x) => numTail(x) === t);
};
const idsIntersect = (list, values) =>
(list ?? []).some((x) => (values ?? []).some((v) => numTail(v) === numTail(x)));These three helpers back the product-targeting check used by every storefront feature:
export function isProductIncluded(product, variantGid, rule) {
const { condition_type, condition_logic, include, exclude } = rule;
const tags = product.tags ?? [];
const colls = product.collections ?? [];
// exclude wins
if (exclude?.products?.length && idsHas(exclude.products, product.id)) return false;
if (variantGid && exclude?.variants?.length && idsHas(exclude.variants, variantGid)) return false;
if ((exclude?.tags ?? []).some((t) => tags.includes(t))) return false;
if (exclude?.collections?.length && idsIntersect(exclude.collections, colls)) return false;
if (condition_type !== 'matching_condition' || !include) return true;
const idMatch =
idsHas(include.products, product.id) ||
(variantGid ? idsHas(include.variants, variantGid) : false);
const tagMatch = (include.tags ?? []).some((t) => tags.includes(t));
const collMatch = include.collections?.length ? idsIntersect(include.collections, colls) : false;
// ...condition_logic 'all' vs 'any' combine the group matches
}What just happened: the matcher no longer cares what ID format is stored. That single property is what lets you ship the producer and the migration independently without a flag day. Centralizing these helpers in one shared module (rather than copy-pasting numTail into each feature) means there is exactly one place where the comparison rule lives.
Step 3: Run a One-Time Resync
In this step you'll rewrite every active shop's metafield into the numeric format with a single command.
Because the consumer already matches both formats, this migration is safe to run at any time after the consumer is deployed, and safe to re-run.
php artisan flicky:resync-app-metafieldsThe command loops active shops and rewrites each feature's app metafield through the now-numeric producer. There is no dual-write window to coordinate and no risk of a half-migrated shop breaking, because isProductIncluded treats numeric and GID data identically.
Verify after running: read one shop's metafield back and confirm the ID arrays contain bare numbers, then load a storefront product page that a rule targets and confirm the widget still matches. If it matches before and after the resync, the backward-compatible comparison is doing its job.
Step 4: Know What to Leave as a GID
Not every ID should be shrunk. Three consumers need the full GID, and changing them would trade a size win for a correctness or checkout risk.
| Where | Keep GID? | Why |
|---|---|---|
| Storefront app metafield (product/variant/collection lists) | No, use numeric | Read on every page; this is the payload you are shrinking |
| Per-discount Function config metafield | Yes | The Discount Function reads GIDs; changing it risks checkout behavior for a tiny gain, and function input is separately capped at 10,000 bytes |
| Server-side matching (price resolution, analytics) | Yes | The backend already has full GIDs in hand; no size pressure there |
| Customer, segment, market, company targeting | Yes | Eligibility GIDs are few and are passed to native Shopify targeting that expects GIDs |
| Tags | N/A | Tags are human strings, not GIDs |
The principle: shrink the payload that is large, read often, and matched by your own code. Leave GIDs in payloads that are small, read rarely, or consumed by Shopify itself.
Verification
- Size: dump the metafield value before and after the resync for a shop with many rules; the byte count should drop noticeably, driven by the ID arrays.
- Backward compatibility: with the consumer deployed but before the resync, confirm a storefront widget still matches on a shop whose metafield still holds GIDs.
- Function safety: confirm the per-discount Function config metafield is unchanged and still under 10,000 bytes, so the Function keeps reading it (Shopify, Metafield limits).
- Storefront match parity: pick a product that should and should not match a rule, and confirm
isProductIncludedreturns the same result as before the change.
Key Takeaways
1. ID format is a storage decision, not a style preference. When you serialize thousands of IDs into one value, the ~3x overhead of the GID prefix can be the difference between writing and failing.
2. Make the consumer format-agnostic first. Normalizing both sides to the numeric tail turns a risky migration into a boring one. Deploy the consumer, then the producer, then migrate, in any spacing you like.
3. Shrink the hot payload, not everything. The storefront metafield is read on every page and matched by your code, so it is the right target. Function input, server matching, and native targeting keep their GIDs.
4. Centralize the comparison. One shared numTail/idsHas module beats a copy in every feature, because the backward-compatibility guarantee lives in exactly one place.
Frequently Asked Questions
Does storing numeric IDs break matching against Shopify data?
No, as long as consumers normalize both sides to the numeric tail before comparing. The numTail helper reduces both a stored 44123456789012 and an incoming gid://shopify/ProductVariant/44123456789012 to the same value, so the comparison is format-independent.
What is the actual metafield size limit?
Most metafield types cap at 64KB (65,536 bytes). The json type caps at 128KB for new apps, and apps that used JSON fields before April 1, 2026 are grandfathered at the older 2MB limit (Shopify, Metafield limits). Separately, Shopify Functions receive null for any metafield value over 10,000 bytes.
Should I shrink the metafield my Shopify Function reads?
Be careful. We keep GIDs in the per-discount Function config because the Function expects them and checkout behavior is not worth risking for a small size win. The more important constraint there is the 10,000-byte Function input cap, so keep that config lean by scoping it to a single rule rather than by changing the ID format.
How do I migrate existing shops without downtime?
Deploy the format-agnostic consumer first, then the numeric producer, then run a one-time resync command. Because the consumer matches both formats, there is no window where stored data and running code disagree.
Written by Duke, based on the production implementation of the Flicky Shopify app. Code samples are drawn from the shipping codebase and simplified for readability. See also our note on rendering widgets without layout shift and the per-rule discount architecture that reads these metafields.