By EasyCatch Growth Desk — weekly technical deep-dive for the Shopify community. Written 2026-11-09.
TL;DR
A merchant on the Tinker theme posted last week that his garments carry two or more photos per variant, yet the gallery keeps downloading 20+ images from variants the shopper never selected — “it’s killing my performance.” The first reply from Mustafa_Ali is the sentence this whole article unpacks: “Shopify only ties one featured image to each variant, so the ‘hide other variants’ media’ toggle hides down to that one image and leaves the rest of the gallery alone. Showing a full set of images per variant isn’t in the data model, so it comes down to the theme.” A second, much busier thread — how long processing new product photos actually takes you, where an apparel merchant puts a 30-SKU drop at four to five hours and names the bottleneck precisely: “renaming 90 files by SKU, then uploading each one to the right variant … that part alone takes 2 hours per drop.”
Both threads describe the same constraint from opposite ends. The images table is not a photo album bolted onto a product; it is a join table whose binding column holds exactly one value per variant row. That cardinality is the reason the toggle cannot do what merchants expect, the reason alt-text matching became a cottage industry, and the reason a “successful” import can still ship the wrong photograph. This is a schema fact, not a theme bug and not an operator error. Our earlier piece on collapsed option slots covered what happens when attributes outnumber the three option columns; this one starts one step later, at the moment a converted file has to become a specific variant’s image.
1. The images table is a join table with a single slot
A Shopify product CSV looks like one flat file. It is three tables interleaved, and each one answers a different question:
- products — one row per product, keyed by
Handle. CarriesTitle,Body (HTML),Tags, and the option declarations (Option1 Name,Option2 Name,Option3 Name). - variants — one row per purchasable unit, repeating the same
Handle, supplyingOption1 Value…Option3 Value,Variant SKU,Variant Price,Variant Grams. - images — separate rows carrying
Image Src,Image Position,Alt Text, and one binding column:Variant Image.
The cardinality is asymmetric on purpose. A product can own an unlimited number of image rows. A variant row, however, exposes exactly one Variant Image field — and that field does not take a list, a comma-separated string, or an index into a gallery. It takes a URL string that must match an Image Src value elsewhere in the same file. When it matches, that media item becomes the variant’s featured image: the one the product page swaps to when a shopper picks the colour.
Do the arithmetic on the merchant’s catalogue. Six colours, five sizes, three photos per colour: 30 variant rows, 18 image rows, and 30 bindings — of which each variant can point at only one of its colour’s three photos. The other 12 photos are product-level media with no variant anchor, so they are eligible to appear in the gallery for every selection. Shopify’s “hide other variants’ media” setting hides precisely the images bound to other variants; the unbound majority stays visible, and the browser downloads it. That is the thread’s complaint in mechanical form, and it is also why the answer was “this comes down to the theme.”
2. What the browser actually does to a WebP file
Supplier catalogues from domestic wholesale marketplaces hand you WebP far more often than JPEG. The standard route is a canvas transcode inside the browser: draw the decoded image onto a canvas, then canvas.toDataURL('image/jpeg', quality) and upload the resulting bytes. It works, and it is lossy in four ways that matter for apparel:
- Alpha is not representable. JPEG has no alpha channel. If the canvas is transparent when you draw a cut-out garment, the transparent pixels composite against black by default, and you get a garment with a charcoal halo. Filling the canvas with white (
fillStyle = '#fff',fillRectover the full area) beforedrawImageis not cosmetic — it is the difference between a usable product photo and a rejected one. - Chroma subsampling and 8-bit rounding. JPEG encoders typically subsample chroma at 4:2:0, which is invisible on fabric but very visible on thin print detail and on saturated reds and blues, where the hue shifts by a few degrees. At
quality = 0.92this is acceptable; at0.7a printed pattern dissolves into mush. - Colour space is assumed, not measured. Canvas composites in sRGB. A supplier file tagged Display P3 or Adobe RGB gets reinterpreted without conversion, and saturated colours come out flatter than the original. For solid colours this is tolerable; for brand-accurate textiles it is not.
- Metadata is dropped, including orientation. EXIF disappears in the transcode. A WebP that relied on an orientation flag becomes a sideways JPEG — and it will look sideways on the product page because there is no longer a tag telling the browser to rotate it.
There is a fifth trap that is not about pixels at all. Wholesale CDNs frequently serve WebP behind a URL that ends in .jpg, rewritten by a query string such as ?imageMogr2/format/webp or ?x-oss-process=image/format,webp. Any pipeline that routes on the file extension — “it ends in .jpg, skip conversion” — will upload WebP bytes under a JPEG name. Shopify’s media pipeline sniffs the actual content, and when the sniffed type does not match what its media processor accepts, the row imports cleanly and the media slot stays empty. No error, no warning, upload success. This is the same silent-drop class that made a name for itself in our CDN image guide, and it is why a Local Canvas Transpiler that decodes and re-encodes locally, rather than trusting the extension, produces a materially different failure rate. The first bytes of a real JPEG are FF D8 FF; if your “JPEG” does not start there, it is not one.
3. Cartesian expansion and the per-row repetition rule
Colour times size is where the images table stops being abstract. Six colours and five sizes expand to 30 variant rows, each representing one purchasable unit, each repeating Handle and the identical Option1 Name / Option2 Name declarations from the product row. Two rules govern the image columns during that expansion, and both are easy to get subtly wrong:
Rule one: repeat the binding, not the URL. If each colour has its own photo, the Image Src URL for that colour appears on one image row, and the same URL string appears as Variant Image on each of the five variant rows for that colour. One media item, five bindings. The common mistake is the reverse: copying the image row five times, once per size. That creates five duplicate media items of the same photograph, which bloats the gallery to 30 items, gives the theme five candidates for “featured”, and makes position ordering meaningless.
Rule two: set the binding on every row, not on the first row of the group. If you write Variant Image only on the first variant row of each colour block, the remaining 24 rows in a 30-variant product carry an empty binding. The storefront does not error — it falls back to the product’s primary image for those variants, so a shopper selecting “Navy, XL” sees the white version. This is the mechanism behind most “wrong colour shipped” complaints, and it survives every re-import, because the emptiness is preserved in the file.
Image Position is the third piece. It defines gallery order, and the lowest position of a product’s images is what shoppers and themes treat as the primary. Positions must be unique within a product; duplicate or missing positions leave ordering to the importer’s discretion, so the same file can produce a different gallery on two stores. And note the interaction with rule one: one bound image per variant plus a deterministic position order is the only combination that yields a gallery a theme can filter reliably — which is exactly the workaround the Tinker thread ended up recommending, via alt text, because the data model cannot express “three photos belong to the Navy variant.”
A Matrixify-compliant ZIP produced by an expansion engine solves this by construction rather than by discipline: the variant rows are generated from the colour-by-size matrix, every generated row inherits the colour’s Image Src as its Variant Image, and positions are assigned sequentially per product, so the binding count equals the variant count by definition. That is checkable after the fact, which matters more than elegance.
4. Why alt-text matching is a workaround, not a fix
The Tinker thread converged on tagging each image’s alt text with the colour name, then filtering the gallery in Liquid. It works, and it is the cheapest path when a merchant has two hundred products live and no appetite for a re-import. Its costs are worth stating plainly:
Alt Textlives on the image row, so it is shared by every variant bound to that image. You cannot store three alt texts for one photo. If one photo serves two variants, the tagging scheme has to assume one photo belongs to one variant only — which is often false.- Alt text is free-form. Any later re-import that carries an empty
Alt Textcolumn overwrites the tags, and the gallery silently reverts to showing everything. - Filename and slug matching break when a handle changes; handles change when a title is edited, and nothing warns you.
- CSS hiding does not stop downloads. The merchant’s performance complaint is specifically about bytes over the wire, which is why the thread kept coming back to “the browser could still download them.”
The durable fix is upstream: give each variant its own file per view, encode the view in the filename and the position, and let the theme map deterministically instead of guessing. That is a file-naming and CSV-generation problem, which is the layer a 100% Local-First exporter controls — not a Liquid problem.
5. A pre-flight you can count, not eyeball
Before any import, these four counts answer whether the file is structurally sound. They are cheap, deterministic, and they catch every failure mode above:
- Variant rows should equal colours multiplied by sizes (plus any legitimate one-off rows). If the count is lower, part of the matrix collapsed during generation.
- Distinct
Image Srcvalues should equal the number of gallery photos you intend, not the number of times a URL is mentioned. Duplicates inflate the gallery. - Non-empty
Variant Imagerows should equal variant rows that have their own photo. Anything less means those variants will fall back to the product’s primary image. - Transcode sanity: output bytes at least 60 percent of the source, pixel dimensions identical to the source, alpha flattened against white, and the first three bytes equal to
FF D8 FF.
Run those four and the “import succeeded but the photo is wrong” class disappears. Skip them and the storefront becomes the test harness — with customers as the assertion library.
Conclusion
The Tinker merchant was not fighting a theme setting. He was fighting a join table that holds one binding per variant, and the “hide other variants’ media” toggle simply cannot express what he wanted. The same asymmetry explains the four-hour photo drop: the renaming and matching loop exists because humans are manually creating the one-to-one binding that the schema requires.
Five things to check, in order:
- Count variant rows against the colour-by-size matrix before importing.
- Count distinct
Image Srcvalues against the photos you actually want in the gallery. - Ensure every variant row that has its own photo carries a non-empty
Variant Image. - Give every image a unique
Image Positionso the primary is deterministic. - Verify each converted file is genuinely JPEG (
FF D8 FF), full-size, alpha-flattened, and white-matted.
Do those five and the gallery stops being a lottery — on Tinker, on Dawn, and on whatever theme you migrate to next.
About EasyCatch
EasyCatch is a 100% Local-First copilot for 1688-to-Shopify sourcing. Its Local Canvas Transpiler converts supplier WebP images to JPEG entirely inside your browser — decoding first, re-encoding second, so a mislabeled CDN URL cannot slip a WebP past the import — and its CSV engine expands colour-by-size variants into Matrixify-compliant ZIPs with the per-row Variant Image binding already repeated on every generated row. No supplier link, cost price, or SKU structure ever leaves your machine. Zero-Server Privacy by design.