DEV Community

Curythm
Curythm

Posted on

I built a Shopify app to bulk edit metaobjects — here's what broke

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)];
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Translation: every row with a reference field was marked as "update" even if nothing changed. Useless.

The fix required three steps:

  1. Collect all reference GIDs from existing entries
  2. Batch-resolve them to handles via nodes() queries (250 per request)
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:


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)