Shopify metaobjects are great for structured data — size charts, materials, FAQs, store locators. But when you have dozens or hundreds of entries to manage, things get painful fast.
There's no CSV export. No bulk import. No "select all and change this field." You click into each entry one by one.
I hit this wall while managing metaobjects for a client's store. 50+ material entries, needed to update a field across all of them. After doing it manually for the third time, I started building MetaBulkify — a small app focused on CSV export/import for metaobjects with a dry-run preview.
Building it was fun. Debugging it was not. Here are the two hardest problems I ran into.
1. Excel silently rewrites your data
Export metaobjects to CSV. Open in Excel. Save. Import back. Every single row shows as "update."
Nothing changed, right? Wrong. Excel "helpfully" reformats values:
| Type | Original | After Excel |
|---|---|---|
| date | 2025-01-01 |
2025/1/1 |
| integer | 1 |
1.0 |
| boolean | true |
TRUE |
| date_time | 2025-01-01T10:00:00Z |
truncated or reformatted |
A naive string comparison sees these as different values. I didn't realize the problem existed until I saw "45 updates, 0 unchanged" on a CSV I hadn't touched.
The fix was a normalizeForComparison() function that parses each value by type before comparing:
function normalizeForComparison(csvVal, existingVal, typeName) {
if (csvVal === "" && existingVal === "") return ["", ""];
switch (typeName) {
case "date": {
// "2025/1/1" and "2025-01-01" both become "2025-01-01"
const csvDate = normalizeDate(csvVal);
const exDate = normalizeDate(existingVal);
if (csvDate && exDate) return [csvDate, exDate];
return [csvVal, existingVal];
}
case "number_integer": {
// "1" vs "1.0" — Number() parses both, Math.round drops the decimal
const a = Number(csvVal), b = Number(existingVal);
if (!isNaN(a) && !isNaN(b))
return [String(Math.round(a)), String(Math.round(b))];
return [csvVal, existingVal];
}
case "number_decimal": {
// "1.50" vs "1.5" — Number() normalizes trailing zeros
const a = Number(csvVal), b = Number(existingVal);
if (!isNaN(a) && !isNaN(b)) return [String(a), String(b)];
return [csvVal, existingVal];
}
case "boolean":
// "TRUE" vs "true"
return [csvVal.toLowerCase(), existingVal.toLowerCase()];
default:
return [csvVal, existingVal];
}
}
// For list.* types, parse the JSON array and normalize each element
function normalizeListForComparison(csvVal, existingVal, typeName) {
const innerType = typeName.replace(/^list\./, "");
const csvArr = JSON.parse(csvVal || "[]");
const exArr = JSON.parse(existingVal || "[]");
const normCsv = csvArr.map(v =>
normalizeForComparison(String(v), "", innerType)[0]
);
const normEx = exArr.map(v =>
normalizeForComparison(String(v), "", innerType)[0]
);
return [JSON.stringify(normCsv), JSON.stringify(normEx)];
}
Simple in retrospect. The lesson: if your app touches Excel at any point in the workflow, never compare raw strings.
2. CSV says "handle", Shopify says "GID" — and you need both
Metaobject reference fields store Shopify's internal GIDs like gid://shopify/Product/12345. But nobody wants to type GIDs in a spreadsheet. The CSV uses handles.
This creates a problem for the dry-run preview. You need to compare the CSV values against existing data to determine what changed. But you're comparing handles to GIDs. They'll never match.
My first implementation just gave up:
// For reference types, CSV has handle, existing has GID
// — can't compare directly.
// Mark as "potentially changed" (conservative approach)
Translation: every row with a reference field was marked as "update" even if nothing changed. Useless.
The fix required three steps:
- Collect all reference GIDs from existing entries
- Batch-resolve them to handles via
nodes()queries (250 per request) - Replace GIDs with handles in a copy of the existing data, then compare
// Resolve GIDs to handles in batches of 250 (Shopify's hard limit per request)
async function resolveGidsToHandles(admin, gids) {
const resolved = new Map();
for (let i = 0; i < gids.length; i += 250) {
const batch = gids.slice(i, i + 250);
const resp = await admin.graphql(
`query ResolveNodes($ids: [ID!]!) {
nodes(ids: $ids) {
id
... on Product { handle }
... on Collection { handle }
... on Metaobject { handle }
}
}`,
{ variables: { ids: batch } },
);
const json = await resp.json();
for (const node of json.data?.nodes ?? []) {
if (node?.id && node?.handle) resolved.set(node.id, node.handle);
}
}
return resolved;
}
And the reverse direction for import: resolve handles to GIDs before calling metaobjectUpsert. Each reference field type (product, collection, metaobject) needs its own resolution query.
For metaobject references, it gets worse — you need to know the target definition type (e.g., "author") to look up by handle. But the field definition only gives you a GID for the target definition. So that's another query:
// Query 1: field definition gives us a validation entry like
// { name: "metaobject_definition_id",
// value: "gid://shopify/MetaobjectDefinition/123" }
// That GID points at the *referenced* definition, not the type string.
const refDefIds = new Set();
for (const fd of fieldDefs) {
if (fd.type.name === "metaobject_reference") {
const v = fd.validations.find(v => v.name === "metaobject_definition_id");
if (v?.value) refDefIds.add(v.value);
}
}
// Query 2: resolve those definition GIDs → type strings (e.g. "author")
const resp = await admin.graphql(
`query($ids: [ID!]!) {
nodes(ids: $ids) { id ... on MetaobjectDefinition { type } }
}`,
{ variables: { ids: [...refDefIds] } },
);
// Query 3 (per row): now we can finally call
// metaobjectByHandle({ type: "author", handle: "shakespeare" })
// to get the GID that metaobjectUpsert actually needs.
Three queries deep just to resolve a reference field. Not documented anywhere as a pattern.
What's next
This is part 1 of a series on building MetaBulkify. Coming up:
- Part 2: Things Shopify doesn't tell you when building an app — OAuth scope traps, 200 OK that actually means throttled, and dev stores that lie about subscriptions
- Part 3: Building a Shopify app with Claude Code — spec-driven development with AI, and how I designed the pricing to let users try before they commit
Try MetaBulkify
Export and import Shopify metaobjects via CSV with dry-run preview.
→ MetaBulkify on the Shopify App Store
Free plan includes unlimited exports and 50 import rows.
I'm the developer — questions and feedback welcome in the comments.

Top comments (0)