The fragmented schema dilemma: why legacy supplier feeds break storefront discovery
When wholesale distributors or multi-brand retailers scale, their product data is rarely authored by a single central team. Data originates from vendor spreadsheets, legacy PIM systems, EDI feeds, and manufacturer CSVs.
Each supplier adopts idiosyncratic naming conventions: Mill North exports 'Colour' (UK spelling), 'Roll Width', and 'Cut Type'; Mill South exports 'Color' (US spelling), 'Width (in)', and 'Length'; European Weavers export 'Shade', 'Fabric Width', and 'Unit'.
If these feeds are imported directly into Shopify, the platform treats each unique attribute label as an entirely distinct option definition. The consequences on the storefront are catastrophic: (1) Shopify's Search & Discovery app generates collection filters based on exact option names, so filtering by 'Colour: Navy' will only display products from Mill North, (2) neighbouring product pages render scrambled Option1 and Option2 selector dropdowns, and (3) compound supplier strings like '54 / Navy Blue / Heavy Commercial' prevent shoppers from filtering by width or colour independently.
Attempting to resolve these naming discrepancies after products are live in Shopify is excruciatingly slow. Renaming options via productOptionUpdate on 28,000 products requires over 80,000 API mutations, consuming hours of GraphQL rate-limit bucket capacity while triggering thousands of theme re-index jobs.
The synonym dictionary: resolving colour, shade, and width into canonical keys
The foundation of a resilient migration pipeline is an algorithmic synonym dictionary.
Before generating Shopify product payloads, the ingestion engine passes every raw attribute key through a normalisation mapping table. The table associates hundreds of supplier variations with a controlled set of canonical option names: 'Color' resolves colour, colorway, shade, pattern color, col, and finish; 'Width' resolves bolt width, roll width, width (in), fabric width, and dimension width; 'Cut Type' resolves cut, selling unit, length type, and order increment.
Beyond key normalisation, option values must undergo string hygiene: collapsing duplicate whitespace, normalising title casing, and standardising dimensional units (e.g. converting 54 in, 54-inch, and 54 inches into a consistent 54" format).
Option slot priority and the 3-option platform constraint
Shopify natively constrains each product to three option slots (Option1, Option2, Option3).
In complex industries like commercial textiles, industrial fasteners, or bespoke framing, legacy products frequently possess four, five, or six attribute dimensions: Colour, Width, Cut Type, Fabric Weight, Rub Count, and Flame Retardancy.
A naive migration script that maps attributes dynamically without position rules will assign slots haphazardly, causing Option1 to represent Colour on some products and Fabric Weight on others.
Our normalisation pipeline enforces a deterministic priority hierarchy: Slot 1 (Visual/Primary) is assigned to the primary aesthetic dimension (Color); Slot 2 (Physical/Secondary) is assigned to the primary physical dimension (Width); Slot 3 (Operational/Tertiary) is assigned to the purchasing unit (Cut Type). Dimensions beyond the 3-option ceiling are systematically stripped from the variant matrix and routed to typed Shopify Metafields (custom.fabric_weight, custom.rub_count), keeping storefront variant pickers clean and responsive.
import assert from "node:assert/strict";
export interface LegacyVariantInput {
rawSku: string;
supplierCode: string;
rawAttributes: Record<string, string>;
rawPriceCents: number;
inventoryQty: number;
}
export interface CanonicalOptionConfig {
canonicalName: string;
priorityPosition: 1 | 2 | 3;
synonyms: string[];
}
export interface NormalizedVariantOutput {
sku: string;
option1: { name: string; value: string };
option2?: { name: string; value: string };
option3?: { name: string; value: string };
priceCents: number;
inventoryQuantity: number;
overflowAttributes?: Record<string, string>;
}
export class VariantOptionNormalizer {
private synonymMap: Map<string, CanonicalOptionConfig> = new Map();
constructor(configs: CanonicalOptionConfig[]) {
for (const cfg of configs) {
this.synonymMap.set(cfg.canonicalName.toLowerCase().trim(), cfg);
for (const syn of cfg.synonyms) {
this.synonymMap.set(syn.toLowerCase().trim(), cfg);
}
}
}
public sanitizeOptionValue(val: string): string {
let clean = val.trim().replace(/\s+/g, " ");
return clean.replace(/(\d+)\s*(?:inches|inch|in\b)/gi, '$1"');
}
public normalizeProduct(
productId: string,
rawVariants: LegacyVariantInput[]
): {
canonicalOptionNames: string[];
variants: NormalizedVariantOutput[];
hasOverflow: boolean;
} {
const discoveredOptions = new Map<string, CanonicalOptionConfig>();
// Discover all canonical options across variant set
for (const v of rawVariants) {
for (const key of Object.keys(v.rawAttributes)) {
const canonical = this.synonymMap.get(key.toLowerCase().trim());
if (canonical && !discoveredOptions.has(canonical.canonicalName)) {
discoveredOptions.set(canonical.canonicalName, canonical);
}
}
}
const sorted = Array.from(discoveredOptions.values()).sort(
(a, b) => a.priorityPosition - b.priorityPosition
);
const primaryOptionNames = sorted.slice(0, 3).map(o => o.canonicalName);
const hasOverflow = sorted.length > 3;
const normalizedVariants: NormalizedVariantOutput[] = rawVariants.map(v => {
const optionMap: Record<string, string> = {};
const overflow: Record<string, string> = {};
for (const [k, val] of Object.entries(v.rawAttributes)) {
const cleanVal = this.sanitizeOptionValue(val);
const canonical = this.synonymMap.get(k.toLowerCase().trim());
if (canonical && primaryOptionNames.includes(canonical.canonicalName)) {
optionMap[canonical.canonicalName] = cleanVal;
} else {
overflow[k] = cleanVal;
}
}
const opt1Name = primaryOptionNames[0] ?? "Title";
const out: NormalizedVariantOutput = {
sku: v.rawSku.trim(),
option1: { name: opt1Name, value: optionMap[opt1Name] ?? "Default Title" },
priceCents: v.rawPriceCents,
inventoryQuantity: v.inventoryQty,
...(Object.keys(overflow).length > 0 ? { overflowAttributes: overflow } : {})
};
if (primaryOptionNames[1]) {
out.option2 = { name: primaryOptionNames[1], value: optionMap[primaryOptionNames[1]] ?? "Standard" };
}
if (primaryOptionNames[2]) {
out.option3 = { name: primaryOptionNames[2], value: optionMap[primaryOptionNames[2]] ?? "Standard" };
}
return out;
});
return { canonicalOptionNames: primaryOptionNames, variants: normalizedVariants, hasOverflow };
}
}Faceted collection filters and GraphQL rate-limit mitigation
Once variant option names and positions are harmonised, they integrate cleanly with Shopify's native collection filtering.
In modern Shopify Online Store 2.0 themes, faceted collection filters query option names directly via filter.v.option.<name>. Because every blue fabric across all forty suppliers now carries Option1: Color with normalised values ('Navy Blue' rather than 'Navy_54_Bolt'), Shopify's Search & Discovery engine compiles a clean, comprehensive facet list where a shopper selecting 'Navy Blue' sees all matching products across all manufacturers in a single view.
Furthermore, pre-normalising options outside Shopify eliminates catastrophic GraphQL rate-limit penalties during migration: (1) zero post-import option updates are required, (2) variants are created atomically alongside products via productCreate or productVariantsBulkCreate with final option names already defined, and (3) API rate-limit capacity is conserved for historical order ingestion and real-time inventory reconciliation.
For complex catalogues requiring custom attribute structures beyond standard variants, consult our guide on Shopify metaobject migration and Shopify Metafield Definitions migration type casting.
When this needs an engineer
You do not need an automated variant option normaliser if your catalogue contains fewer than two hundred products, if you source from a single supplier with disciplined naming standards, or if your products possess simple, non-variant single SKU structures.
You need an engineer when your catalogue is large and fragmented: when migrating tens of thousands of SKUs across dozens of independent suppliers; when inconsistent option names (Colour vs Color vs Shade) break faceted collection filters; when supplier feeds contain compound strings that must be decomposed into clean dimensional options; when variant matrices exceed Shopify's 3-option ceiling and require programmatic routing to metafields; and when large-scale catalogue cutovers cannot afford the GraphQL rate-limit delays caused by post-migration data repair scripts.
Explore our engineering services in Shopify migration services, inspect our catalogue migration case study collection, or review how large-scale product architecture was delivered in The Fabric Co. case study.
Send us your raw supplier feed extracts, option dictionaries, and product matrix requirements. We will architect a clean, automated ingestion pipeline that unifies your catalogue before cutover day.
