E-commerce
Shopify-migratie post-mortem: één UTF-8 BOM en 19 uur
Een Tilburgse fabrikant met 31 medewerkers migreerde dinsdagavond van WooCommerce 8 naar Shopify. Woensdagochtend waren 4.200 EAN-codes corrupt. Eén byte deed het.

Het was 03:47 op een woensdag in mei toen de magazijnchef bij een metaalfabrikant in Tilburg inlogde op de nieuwe Shopify-admin en zag dat de eerste scan van de ochtend niets opleverde. De handheld las EAN 8714123450019 tegen de product picker. Shopify gaf niets terug. Hij scande een tweede doos, zelfde resultaat. Hij scande een derde doos van een compleet andere SKU. Zelfde resultaat. Om 04:10 had hij de leider van de nachtploeg gebeld. Om 04:30 had de leider de operations director gebeld. Om 05:00 zaten we in een videocall.
De migratie was de avond ervoor om 22:00 afgetekend. Ze was negen weken vooruit gepland. De catalogus bevatte 4.247 actieve SKU's. Daarvan hadden er nu 4.203 een leidende onzichtbare byte vóór hun barcodeveld staan. De fabriek kon niet picken of verzenden.
Dit is een post-mortem van hoe één leverancier-CSV met een UTF-8 BOM een werkende e-commerce-operatie platlegde, hoe de rollback 19 uur duurde in plaats van de 90 minuten die ons draaiboek beloofde, en wat we daarna hebben veranderd.
De situatie
De klant is een metaalfabrikant met 31 medewerkers in de buurt van Tilburg, die verkoopt via een B2B-portaal en een B2C-webshop. Ongeveer 60% van de omzet bestaat uit groothandelorders aan industriële kopers in de Benelux. De overige 40% is directe verkoop aan hobbyisten. Hun stack vóór de migratie was WordPress 6.4 met WooCommerce 8.4, een custom plugin die PDF-voorraadbladen koppelde aan productvarianten, en een 14 jaar oude leveranciersfeed die elke nacht als CSV via SFTP binnenkwam.
WooCommerce was traag geworden op de groothandelscatalogus. Zoeken duurde 4 tot 9 seconden op een warme cache. Het ops-team wilde Shopify Plus voor de zoekfunctie, de inventory locations en het staff role model. De CEO wilde stoppen met betalen voor managed hosting die per kwartaal PHP-onderhoud nodig had. We waren het eens dat de migratie in mei zou plaatsvinden, op een dinsdagavond, na de laatste magazijnshift.
Het migratieplan dat we opleverden
Het plan zag eruit zoals migratieplannen er meestal uitzien. Alle producten en varianten exporteren uit WooCommerce naar het Shopify-product-CSV-formaat. De nachtelijke leveranciersfeed-importer laten draaien tegen de nieuwe Shopify GraphQL Admin API in plaats van de oude WooCommerce REST API. DNS omzetten om 22:00. De WooCommerce-database 72 uur in read-only-modus houden voor het geval we moesten terugrollen. Drie dagen droogtests op een Shopify development store waren allemaal geslaagd. Voorbeeldorders werden netjes afgehandeld. Barcodes scanden. Het draaiboek had een rollback-stap die we op 90 minuten schatten.
Het stuk dat we onderschat hebben, was de leveranciersfeed. Elke leverancier levert een andere vorm CSV aan. Sommige zijn tab-delimited. Sommige quoten alles. Eén leverancier levert een XLSX vermomd als bestand met een .csv-extensie. We hadden importer-scripts voor al die leveranciers in PHP geschreven en die draaiden al jaren tegen WooCommerce zonder zichtbare datacorruptie. We namen aan dat die scripts in een week tijd geport konden worden naar Node en op Shopify gericht. Dat ging niet.
03:47 op woensdag
De eerste scan die mislukte, ging zo. De picker scande een doos met label 8714123450019. De Shopify product picker gaf geen resultaat. We logden in op de Shopify-admin en zochten de EAN als tekst. Nog steeds niets. Toen kopieerden we de EAN uit de Shopify-productdetailpagina naar een hex editor. De eerste drie bytes van het barcodeveld waren 0xEF 0xBB 0xBF. Daarna kwam de 13-cijferige EAN.
Die drie bytes zijn de UTF-8-representatie van een byte order mark. De Unicode FAQ is daar duidelijk over: een BOM wordt niet aanbevolen aan het begin van UTF-8-streams, omdat het code in de war brengt die niet weet dat hij hem moet overslaan. Het barcodeveld van Shopify is platte tekst. Het strippt de BOM niet. De GS1 EAN-13 check-digit-logica die onze handheld-scanners gebruiken doet dat ook niet. Dus de scanners lazen 13 cijfers, de Shopify-index bevatte 13 cijfers plus drie onzichtbare bytes, en ze matchten niet.
UTF-8 vereist geen byte order mark. UTF-16 wel. Als je CSV's van leveranciers accepteert, ga er dan vanuit dat er ten minste één binnenkomt met een BOM, en dat je importer die stilletjes opslaat in de eerste kolom, tenzij je hem strippt.
De BOM en de leverancier die zijn exporter veranderde
De volgende vraag was hoe een BOM door negen weken testen heen had kunnen komen. We grepten de staging-importlogs op die bytes. Niets. We controleerden de leveranciersfeeds die we tijdens de droogtests hadden binnengehaald. Niets. Toen haalden we de meest recente feed van de SFTP-server, die om 21:48 op de migratieavond was binnengekomen, en daar zat hij. Eén leverancier, een distributeur van roestvrijstalen fittingen in Eindhoven, had een nieuw ERP-systeem in gebruik genomen dat CSV's exporteerde met een BOM. Hun vorige exporter deed dat niet. Onze negen weken aan droogtests hadden de feeds van de maand ervoor gebruikt.
Dit is het deel van het verhaal dat pijn doet. We hadden een clausule in het draaiboek staan die zei "haal de meest recente leveranciersfeeds binnen vóór cutover". Dat hadden we niet gedaan. De droogtest-pipeline wees naar een bevroren S3-snapshot uit maart, en we hadden die snapshot nooit ververst, omdat het verversen van de snapshot betekende dat we de importer-testmatrix opnieuw moesten draaien, en de importer was vier weken eerder als klaar verklaard. De wijziging in de exporter van de leverancier was aangekondigd in een release note die niemand aan onze kant had gelezen.
Waarom onze validators het doorlieten
Onze PHP-importer voor WooCommerce gebruikte fgetcsv met standaardinstellingen. fgetcsv strippt geen BOM van het eerste veld. Node's csv-parse doet dat ook niet in default-modus. Beide geven je vrolijk een string die begint met U+FEFF, en je code slaat hem op.
In het WooCommerce-importpad hadden we een regex op de barcodekolom staan die niet-cijferige tekens stripte vóór de waarde MySQL bereikte. Die regex was de enige reden dat WooCommerce dit probleem nooit had gehad in productie. Toen we de importer porten naar Node voor de Shopify-cutover, hielden we het schema, hielden we de API-calls, en lieten we de regex vallen omdat "Shopify de EAN valideert". Shopify valideert de EAN inderdaad. Maar het strippt geen U+FEFF. Het behandelt de resulterende string als geen-geldige-EAN en slaat hem toch op, want het barcodeveld op de variant-resource is vrije tekst.
De migratie liet de nieuwe Node-importer om 22:11 los op de leveranciersfeed. Om 22:34 had elk product dat deze leverancier raakte (4.203 van de 4.247 SKU's, omdat deze distributeur de bron is voor het grootste deel van de catalogusmetadata) een corrupte barcode. De overige 44 SKU's waren custom producten die de klant zelf maakte en hadden schone barcodes. Dat waren de enige 44 die om 03:47 scanden.
De rollback van 19 uur die 90 minuten had moeten zijn
Om 05:00 besloten we tot rollback. Het draaiboek zei: DNS terugzetten naar de WooCommerce-server, WooCommerce uit read-only-modus halen, de orders die 's nachts waren binnengekomen opnieuw afspelen (dat waren er drie), klaar. We hadden 90 minuten geschat.
Wat we niet in het draaiboek hadden staan, was wat te doen met die drie orders. Shopify had zijn eigen ordernummers aangemaakt, beginnend bij 1001. WooCommerce stond op 38291. Het boekhoudteam had één van de drie orders al in Twinfield geboekt tegen het Shopify-nummer. Het leveranciersportaal was aangepast om dropship-orders naar een nieuw endpoint te sturen. Twee van die dropship-orders waren al gepickt. De DNS-flip ging snel. De state reconciliation niet.
We hebben zes uur besteed aan het bouwen van een spreadsheet van elk systeem dat was geraakt in de zeven uur dat Shopify live stond, in welke staat elk systeem zich bevond, en welk commando dat zou terugdraaien. We hebben nog eens negen uur besteed aan het handmatig uitvoeren van die commando's en het verifiëren van elk commando tegen de brondata. We hebben vier uur besteed aan een schone her-import van de leveranciersfeed van die nacht, deze keer met de BOM gestript, in WooCommerce, zodat de catalogus woensdag's voorraad nog zou weerspiegelen. De laatste stap was klaar om 23:51 op woensdagavond. De fabriek verstuurde één dag te laat. Geen enkele klantorder ging verloren. Geen factuur werd dubbel geboekt. De klant had een rotdag.
Wat we veranderden
We hebben geen lang post-mortem-document geschreven. We veranderden vijf dingen en maakten van elk daarvan een harde pre-cutover-gate voor elke migratie die we hierna draaien.
Eén: elke CSV-importer strippt nu een BOM van de eerste byte van het eerste veld vóór alle andere bewerkingen. Het is een wijziging van drie regels. Het had er vanaf de dag dat we het script porten in moeten staan. In Node:
import { readFile } from 'node:fs/promises'
export async function readCsv(path) {
let text = await readFile(path, 'utf8')
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1)
return text
}
Twee: elke importer valideert nu elk veld tegen een strict schema voordat het doelsysteem geraakt wordt. Voor EAN's betekent dat: 13 cijfers, GS1 check digit geverifieerd, geen leading of trailing whitespace, geen niet-printbare tekens. Een rij die de validatie niet doorstaat wordt gelogd en overgeslagen, niet opgeslagen.
Drie: de droogtest-pipeline haalt nu aan het begin van elke droogtest de meest recente leveranciersfeed binnen. De bevroren snapshot is weg. Als een leverancier zijn exporter in de afgelopen maand heeft gewijzigd, komen we daar vóór cutover achter, niet tijdens.
Vier: het cutover-draaiboek bevat nu een "vergelijk een sample records tussen oud en nieuw"-stap, die draait nadat de importer klaar is en voordat DNS omgaat. We pakken 50 willekeurige SKU's, halen de barcode op uit beide systemen, en diffen op byteniveau. Als één byte verschilt in één sample, stopt de cutover.
Vijf: het rollback-plan bevat de reconciliation-stappen nu expliciet. Elk extern systeem dat tijdens het cutover-venster een write kan ontvangen heeft een benoemde eigenaar, een commando om zijn state uit te lezen, en een commando om die terug te draaien. Het plan wordt eenmaal gerepeteerd vóór cutover met een stopwatch. Duurt het meer dan 60 minuten in de repetitie, dan gaat de cutover die nacht niet door.
Een migratiedraaiboek is pas klaar als de rollback end-to-end is gerepeteerd tegen elk systeem dat tijdens het cutover-venster een write kan ontvangen.
De knop die je vandaag kunt omzetten
Als je een Shopify- of WooCommerce-shop draait en je accepteert leveranciersfeeds, open dan nu de meest recente CSV van elke leverancier in een hex viewer. Op macOS of Linux:
xxd supplier-feed.csv | head -1
Als de eerste drie bytes ef bb bf tonen, heeft je feed een UTF-8 BOM. Controleer of je importer hem strippt vóór het eerste veld wordt opgeslagen. Die audit van vijf minuten vangt niet elk migratierisico op, maar wel het risico dat onze Tilburgse klant een dag uit de lucht haalde.
Toen we de migratie-pipeline voor deze klant herbouwden, was het punt waar we steeds op terugkwamen het gat tussen wat onze importer accepteerde en wat het downstream-systeem daadwerkelijk opsloeg. We behandelen elke legacy migratie nu eerst als een reconciliation-probleem en dan pas als een datatransformatie-probleem, wat de omgekeerde volgorde is van wat de meeste draaiboeken aanhouden.
Kern
Een migratie-rollback die niet end-to-end is gerepeteerd tegen elk downstream-systeem, is een plan, geen rollback.
FAQ
Wat is een UTF-8 BOM en waarom corrumpeert die CSV-imports?
Een byte order mark is een reeks van drie bytes (0xEF 0xBB 0xBF) aan het begin van een UTF-8-bestand. UTF-8 vereist hem niet, maar sommige Windows- en ERP-exporters voegen hem toe. Als je importer hem niet strippt, belanden die bytes binnenin het eerste veld.
Hoe lang zou een Shopify-migratie-rollback eigenlijk moeten duren?
Reken op 60 tot 120 minuten als je het end-to-end hebt gerepeteerd. Zonder repetitie: 4 tot 20 uur. Het grootste deel van de tijd gaat naar het reconciliëren van externe systemen (boekhouding, leveranciersportalen, ERP's) die tijdens de cutover writes ontvingen.
Valideert Shopify EAN-barcodes bij import?
Shopify controleert het barcodeformaat in sommige flows, maar slaat het barcodeveld op varianten op als vrije tekst. Het strippt geen leidende BOM of andere onzichtbare tekens. Valideer aan jouw kant voordat de waarde de Admin API bereikt.
Hoe detecteer ik een BOM in een leverancier-CSV voordat ik die importeer?
Draai xxd op het bestand en kijk naar de eerste drie bytes. Als die ef bb bf tonen, heeft het bestand een UTF-8 BOM. Op Linux meldt file -I de encoding ook met de BOM-vlag actief op de meeste distributies.
Waarom werkte dezelfde importer jarenlang op WooCommerce zonder deze bug?
Het WooCommerce-importpad had een defensieve regex die niet-cijferige tekens uit de barcodekolom stripte vóór de insert. Die regex verstopte de bug. Toen we de importer porten naar Node, lieten we de regex vallen in de aanname dat Shopify zou valideren. Dat deed het niet.