Diagnostic Catalog and Visibility
Configurable parent has no image while children do
A shopper browsing the storefront sees a perfectly good product photo on a configurable product page. But call the same product through the API and its media_gallery_entries array comes back empty. Nobody removed an image. The configurable parent simply never had one of its own, because Magento never copies or inherits images from the simple children up to the parent row. The storefront quietly covers for it by falling back to a child's image. An API consumer asking for the parent directly gets nothing. Here is why that gap opens up and a small script that finds every configurable where it has.
A configurable product's own gallery linkage in catalog_product_entity_media_gallery_value_to_entity is completely independent of its simple children's gallery entries. Magento never auto-copies or inherits images from a child to the parent row. If an importer or a product creation flow only attached images to the simple SKUs, the parent's own media_gallery_entries stays empty, even though the storefront masks it by falling back to a selected child's image through ImageBuilder and the configurable JavaScript widget. A script can detect this by calling GET /rest/V1/products/{sku} for every configurable, reading its own media_gallery_entries, then calling GET /rest/V1/configurable-products/{sku}/children and checking each child's gallery. A parent is flagged when its own array has zero non-disabled entries while at least one child has more than zero. Full code, tests, and a dry run guard are below.
The problem in plain words
In Magento 2 and Adobe Commerce, every product, whether it is a simple SKU or a configurable parent, has its own row of gallery entries. A configurable does not automatically borrow anything from its children. It is its own product entity with its own images, or, quite often, with none at all.
Most merchants and most import tools attach images to the sellable variants, the simple products, because that is what actually ships and what most import templates are built around. The configurable parent, which exists mainly to group those variants and drive the swatch or dropdown selection, frequently never gets a separate image uploaded to it. Nobody notices on the storefront, because the configurable product page JavaScript picks a child's image and displays it as soon as the page loads or a shopper selects an option. But that fallback lives entirely in the frontend. The parent's own catalog data is still empty.
Why it happens
- A CSV or API bulk import attaches image files only to the simple SKUs, since those are the sellable variants the import template was built around, and the configurable parent row is never given a separate gallery entry.
- A product creation flow, whether manual or scripted, creates the configurable and its children in sequence, uploads images to each child, and simply never includes a step that also uploads to the parent.
- The storefront's
ImageBuilderand the configurable product JavaScript widget fall back to a selected child's image the moment a shopper views the page, so the missing parent-level entry produces no visible defect for as long as shoppers only browse through the storefront. - The gap only becomes visible when an API consumer, a PWA storefront, a marketplace feed, or a mobile app, requests the parent product directly and reads its own
media_gallery_entries, since none of those consumers necessarily replicate the storefront's child-image fallback logic.
This is a well documented, recurring point of confusion rather than a one off misconfiguration. Some threads describe the parent thumbnail simply not working in category or cart views, others describe merchants deliberately wanting to suppress child images from showing on the configurable page at all, which only underscores how separate the two layers of gallery data really are. See the citations at the end for the exact GitHub issues and community threads.
A configurable parent's media gallery is not a computed or inherited value. It is a plain, independent row of data that happens to often be left empty, because the storefront papers over the gap with a child image fallback. A script cannot know which child's image is the right one to call canonical for the parent, since that is a merchandising call, but it can reliably detect every parent whose own gallery is empty while at least one child's gallery is not, and hand that list to a person to decide.
The fix, as a flow
We do not touch the storefront or guess at which image belongs on the parent. We add a job that lists configurable parents, reads each parent's own gallery and each child's gallery, and reports every parent where the parent has zero images and at least one child has more than zero. Only under an explicit opt in does it also create a new gallery entry on the parent, copying a recommended child's file without touching that child's own data.
Build it step by step
Get an admin bearer token
The script authenticates like any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" # start safe, change to false to allow the upload path
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" // start safe, change to false to allow the upload path
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and POST and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_post(path, payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPost(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
List configurables and read both galleries
Page through /V1/products filtered by type_id equal to configurable. Each product payload already embeds the parent's own media_gallery_entries, or you can call /V1/products/{sku}/media to fetch it explicitly. For each parent, call /V1/configurable-products/{sku}/children to get the simple children, then read each child's media_gallery_entries the same way.
def configurable_products(page_size=50):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
data = magento_get("/products", params)
items = data.get("items", [])
if not items:
return
for item in items:
yield item
if page * page_size >= data.get("total_count", 0):
return
page += 1
def children_for(sku):
return magento_get(f"/configurable-products/{sku}/children")
def gallery_for(sku):
return magento_get(f"/products/{sku}/media")
async function* configurableProducts(pageSize = 50) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/products", params);
const items = data.items || [];
if (!items.length) return;
for (const item of items) yield item;
if (page * pageSize >= (data.total_count || 0)) return;
page++;
}
}
async function childrenFor(sku) {
return magentoGet(`/configurable-products/${sku}/children`);
}
async function galleryFor(sku) {
return magentoGet(`/products/${sku}/media`);
}
Decide, with one pure function
Keep the decision in its own function that takes the parent's own gallery array and a map of child SKU to that child's gallery array, and returns a small verdict object. A pure function like this is easy to read and easy to test, which we do later. It counts the parent's non-disabled entries, collects every child that has at least one non-disabled entry, and flags the parent only when its own count is zero and at least one child has images.
def decide_missing_parent_image(parent_gallery, child_galleries):
parent_image_count = sum(1 for e in parent_gallery if not e.get("disabled"))
children_with_images = [
sku for sku, entries in child_galleries.items()
if sum(1 for e in entries if not e.get("disabled")) > 0
]
flagged = parent_image_count == 0 and len(children_with_images) > 0
recommended_fix_sku = None
if flagged:
recommended_fix_sku = _preferred_child(children_with_images, child_galleries)
return {
"flagged": flagged,
"parentImageCount": parent_image_count,
"childrenWithImages": children_with_images,
"recommendedFixSku": recommended_fix_sku,
}
def _preferred_child(children_with_images, child_galleries):
for sku in children_with_images:
entries = child_galleries.get(sku, [])
if any(not e.get("disabled") and "image" in (e.get("types") or []) for e in entries):
return sku
return children_with_images[0]
export function decideMissingParentImage(parentGallery, childGalleries) {
const parentImageCount = parentGallery.filter((e) => !e.disabled).length;
const childrenWithImages = Object.entries(childGalleries)
.filter(([, entries]) => entries.filter((e) => !e.disabled).length > 0)
.map(([sku]) => sku);
const flagged = parentImageCount === 0 && childrenWithImages.length > 0;
const recommendedFixSku = flagged
? preferredChild(childrenWithImages, childGalleries)
: null;
return {
flagged,
parentImageCount,
childrenWithImages,
recommendedFixSku,
};
}
function preferredChild(childrenWithImages, childGalleries) {
for (const sku of childrenWithImages) {
const entries = childGalleries[sku] || [];
if (entries.some((e) => !e.disabled && (e.types || []).includes("image"))) {
return sku;
}
}
return childrenWithImages[0];
}
Report by default, upload only when gated
The default output is a structured record per flagged parent: the parent SKU and id, the count of affected children, and the recommended child SKU to copy from. Only under an explicit DRY_RUN=false opt in does the script issue a corrective POST /V1/products/{sku}/media with a new mediaGalleryEntry, uploading the recommended child's image content as base64 straight onto the parent. That call creates a new gallery entry on the parent SKU. It never edits or removes anything from the child.
Always start with DRY_RUN=true. Which child's image is the right one to call canonical for the parent is a merchandising decision, so treat the recommended SKU as a suggestion for a person to confirm, not an instruction to blindly apply at scale.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, compares each configurable's own gallery against its children's, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Flag Magento 2 configurable products whose own media gallery is empty
while at least one simple child has images, safely.
A configurable's catalog_product_entity_media_gallery_value_to_entity
linkage is entirely independent of its children's gallery entries. Magento
never auto-copies or inherits images from children to the parent row. This
commonly appears after CSV or API bulk imports, or product creation flows,
where images are attached only to the simple SKUs. The storefront often
masks this by falling back to a child's image through ImageBuilder and the
configurable JavaScript widget, so the gap only surfaces when an API
consumer, a PWA, a marketplace feed, or a mobile app, requests the parent
directly. This reports the mismatch by default and only gates a narrow
corrective upload behind DRY_RUN=false. Run on a schedule. Safe to run
again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("configurable_missing_image")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_post(path, payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def decide_missing_parent_image(parent_gallery, child_galleries):
parent_image_count = sum(1 for e in parent_gallery if not e.get("disabled"))
children_with_images = [
sku for sku, entries in child_galleries.items()
if sum(1 for e in entries if not e.get("disabled")) > 0
]
flagged = parent_image_count == 0 and len(children_with_images) > 0
recommended_fix_sku = None
if flagged:
recommended_fix_sku = _preferred_child(children_with_images, child_galleries)
return {
"flagged": flagged,
"parentImageCount": parent_image_count,
"childrenWithImages": children_with_images,
"recommendedFixSku": recommended_fix_sku,
}
def _preferred_child(children_with_images, child_galleries):
for sku in children_with_images:
entries = child_galleries.get(sku, [])
if any(not e.get("disabled") and "image" in (e.get("types") or []) for e in entries):
return sku
return children_with_images[0]
def configurable_products(page_size=50):
page = 1
while True:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
data = magento_get("/products", params)
items = data.get("items", [])
if not items:
return
for item in items:
yield item
if page * page_size >= data.get("total_count", 0):
return
page += 1
def children_for(sku):
return magento_get(f"/configurable-products/{sku}/children")
def gallery_for(sku):
return magento_get(f"/products/{sku}/media")
def upload_entry_from_child(parent_sku, child_sku, child_entry):
payload = {
"entry": {
"media_type": "image",
"label": child_entry.get("label") or f"Copied from {child_sku}",
"position": 1,
"disabled": False,
"types": ["image", "small_image", "thumbnail"],
"content": {
"base64_encoded_data": child_entry.get("base64_encoded_data", ""),
"type": child_entry.get("content_type", "image/jpeg"),
"name": child_entry.get("file", f"{child_sku}.jpg"),
},
}
}
log.info("Uploading gallery entry to %s from %s", parent_sku, child_sku)
return magento_post(f"/products/{parent_sku}/media", payload)
def run():
flagged = 0
for parent in configurable_products():
sku = parent["sku"]
parent_id = parent.get("id")
children_raw = children_for(sku)
if not children_raw:
continue
parent_gallery = parent.get("media_gallery_entries") or gallery_for(sku)
child_galleries = {
child["sku"]: gallery_for(child["sku"]) for child in children_raw
}
verdict = decide_missing_parent_image(parent_gallery, child_galleries)
if not verdict["flagged"]:
continue
flagged += 1
log.warning(
"parent_sku=%s parent_id=%s affected_children=%d recommended_fix_sku=%s",
sku, parent_id, len(verdict["childrenWithImages"]), verdict["recommendedFixSku"],
)
if not DRY_RUN:
log.info(
"DRY_RUN is false, but this reference script still only reports. "
"Fetch the recommended child's image content and call "
"upload_entry_from_child(sku, verdict['recommendedFixSku'], entry) "
"once a human has confirmed the file."
)
log.info("Done. %d configurable(s) flagged.", flagged)
if __name__ == "__main__":
run()
/**
* Flag Magento 2 configurable products whose own media gallery is empty
* while at least one simple child has images, safely.
*
* A configurable's catalog_product_entity_media_gallery_value_to_entity
* linkage is entirely independent of its children's gallery entries.
* Magento never auto-copies or inherits images from children to the parent
* row. This commonly appears after CSV or API bulk imports, or product
* creation flows, where images are attached only to the simple SKUs. The
* storefront often masks this by falling back to a child's image through
* ImageBuilder and the configurable JavaScript widget, so the gap only
* surfaces when an API consumer requests the parent directly. This reports
* the mismatch by default and only gates a narrow corrective upload behind
* DRY_RUN=false. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/configurable-parent-missing-image/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideMissingParentImage(parentGallery, childGalleries) {
const parentImageCount = parentGallery.filter((e) => !e.disabled).length;
const childrenWithImages = Object.entries(childGalleries)
.filter(([, entries]) => entries.filter((e) => !e.disabled).length > 0)
.map(([sku]) => sku);
const flagged = parentImageCount === 0 && childrenWithImages.length > 0;
const recommendedFixSku = flagged
? preferredChild(childrenWithImages, childGalleries)
: null;
return {
flagged,
parentImageCount,
childrenWithImages,
recommendedFixSku,
};
}
function preferredChild(childrenWithImages, childGalleries) {
for (const sku of childrenWithImages) {
const entries = childGalleries[sku] || [];
if (entries.some((e) => !e.disabled && (e.types || []).includes("image"))) {
return sku;
}
}
return childrenWithImages[0];
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPost(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function* configurableProducts(pageSize = 50) {
let page = 1;
while (true) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "type_id",
"searchCriteria[filterGroups][0][filters][0][value]": "configurable",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const data = await magentoGet("/products", params);
const items = data.items || [];
if (!items.length) return;
for (const item of items) yield item;
if (page * pageSize >= (data.total_count || 0)) return;
page++;
}
}
async function childrenFor(sku) {
return magentoGet(`/configurable-products/${sku}/children`);
}
async function galleryFor(sku) {
return magentoGet(`/products/${sku}/media`);
}
async function uploadEntryFromChild(parentSku, childSku, childEntry) {
const payload = {
entry: {
media_type: "image",
label: childEntry.label || `Copied from ${childSku}`,
position: 1,
disabled: false,
types: ["image", "small_image", "thumbnail"],
content: {
base64_encoded_data: childEntry.base64_encoded_data || "",
type: childEntry.content_type || "image/jpeg",
name: childEntry.file || `${childSku}.jpg`,
},
},
};
console.log(`Uploading gallery entry to ${parentSku} from ${childSku}`);
return magentoPost(`/products/${parentSku}/media`, payload);
}
export async function run() {
let flagged = 0;
for await (const parent of configurableProducts()) {
const sku = parent.sku;
const parentId = parent.id;
const childrenRaw = await childrenFor(sku);
if (!childrenRaw || !childrenRaw.length) continue;
const parentGallery = parent.media_gallery_entries || (await galleryFor(sku));
const childGalleries = {};
for (const child of childrenRaw) {
childGalleries[child.sku] = await galleryFor(child.sku);
}
const verdict = decideMissingParentImage(parentGallery, childGalleries);
if (!verdict.flagged) continue;
flagged++;
console.warn(
`parent_sku=${sku} parent_id=${parentId} affected_children=${verdict.childrenWithImages.length} recommended_fix_sku=${verdict.recommendedFixSku}`
);
if (!DRY_RUN) {
console.log(
`DRY_RUN is false, but this reference script still only reports. Fetch the ` +
`recommended child's image content and call uploadEntryFromChild(sku, ` +
`verdict.recommendedFixSku, entry) once a human has confirmed the file.`
);
}
}
console.log(`Done. ${flagged} configurable(s) flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which parents get flagged and which child gets recommended. Because we kept decide_missing_parent_image pure, the test needs no network and no Magento store. It just feeds in plain arrays and objects of gallery entries and checks the answer.
from configurable_missing_image import decide_missing_parent_image
def entry(**over):
base = {"disabled": False, "types": ["image", "small_image", "thumbnail"]}
base.update(over)
return base
def test_flags_when_parent_empty_and_child_has_image():
result = decide_missing_parent_image([], {"CHILD-1": [entry()]})
assert result["flagged"] is True
assert result["parentImageCount"] == 0
assert result["childrenWithImages"] == ["CHILD-1"]
assert result["recommendedFixSku"] == "CHILD-1"
def test_not_flagged_when_parent_has_image():
result = decide_missing_parent_image([entry()], {"CHILD-1": [entry()]})
assert result["flagged"] is False
assert result["recommendedFixSku"] is None
def test_not_flagged_when_no_children_have_images():
result = decide_missing_parent_image([], {"CHILD-1": [], "CHILD-2": []})
assert result["flagged"] is False
assert result["childrenWithImages"] == []
assert result["recommendedFixSku"] is None
def test_disabled_entries_do_not_count_as_images():
result = decide_missing_parent_image(
[entry(disabled=True)], {"CHILD-1": [entry(disabled=True)]}
)
assert result["flagged"] is False
def test_prefers_child_whose_entry_type_includes_image():
result = decide_missing_parent_image(
[],
{
"CHILD-1": [entry(types=["thumbnail"])],
"CHILD-2": [entry(types=["image", "small_image"])],
},
)
assert result["flagged"] is True
assert set(result["childrenWithImages"]) == {"CHILD-1", "CHILD-2"}
assert result["recommendedFixSku"] == "CHILD-2"
def test_falls_back_to_first_child_when_none_typed_image():
result = decide_missing_parent_image(
[],
{
"CHILD-1": [entry(types=["thumbnail"])],
"CHILD-2": [entry(types=["small_image"])],
},
)
assert result["flagged"] is True
assert result["recommendedFixSku"] == "CHILD-1"
def test_no_children_at_all_is_not_flagged():
result = decide_missing_parent_image([], {})
assert result["flagged"] is False
assert result["parentImageCount"] == 0
assert result["recommendedFixSku"] is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideMissingParentImage } from "./configurable-missing-image.js";
const entry = (over = {}) => ({ disabled: false, types: ["image", "small_image", "thumbnail"], ...over });
test("flags when parent empty and child has image", () => {
const result = decideMissingParentImage([], { "CHILD-1": [entry()] });
assert.equal(result.flagged, true);
assert.equal(result.parentImageCount, 0);
assert.deepEqual(result.childrenWithImages, ["CHILD-1"]);
assert.equal(result.recommendedFixSku, "CHILD-1");
});
test("not flagged when parent has image", () => {
const result = decideMissingParentImage([entry()], { "CHILD-1": [entry()] });
assert.equal(result.flagged, false);
assert.equal(result.recommendedFixSku, null);
});
test("not flagged when no children have images", () => {
const result = decideMissingParentImage([], { "CHILD-1": [], "CHILD-2": [] });
assert.equal(result.flagged, false);
assert.deepEqual(result.childrenWithImages, []);
assert.equal(result.recommendedFixSku, null);
});
test("disabled entries do not count as images", () => {
const result = decideMissingParentImage(
[entry({ disabled: true })],
{ "CHILD-1": [entry({ disabled: true })] }
);
assert.equal(result.flagged, false);
});
test("prefers child whose entry type includes image", () => {
const result = decideMissingParentImage([], {
"CHILD-1": [entry({ types: ["thumbnail"] })],
"CHILD-2": [entry({ types: ["image", "small_image"] })],
});
assert.equal(result.flagged, true);
assert.deepEqual(new Set(result.childrenWithImages), new Set(["CHILD-1", "CHILD-2"]));
assert.equal(result.recommendedFixSku, "CHILD-2");
});
test("falls back to first child when none typed image", () => {
const result = decideMissingParentImage([], {
"CHILD-1": [entry({ types: ["thumbnail"] })],
"CHILD-2": [entry({ types: ["small_image"] })],
});
assert.equal(result.flagged, true);
assert.equal(result.recommendedFixSku, "CHILD-1");
});
test("no children at all is not flagged", () => {
const result = decideMissingParentImage([], {});
assert.equal(result.flagged, false);
assert.equal(result.parentImageCount, 0);
assert.equal(result.recommendedFixSku, null);
});
Case studies
The PWA that showed blank tiles for every configurable
An apparel brand migrated to a headless PWA storefront that called /V1/products/{sku} directly for category listings. Thousands of configurables had been created by a CSV import that attached photos only to the size and color simple SKUs. The old Luma storefront had never shown a problem, because its configurable widget quietly grabbed a child image on page load. The new PWA had no such fallback built in, and every configurable tile rendered blank.
Running the detection script against the catalog surfaced the exact list of parent SKUs with zero gallery entries alongside a recommended child SKU for each. The catalog team reviewed the list, confirmed the right hero shot per style, and uploaded it to each parent in a single afternoon instead of discovering the gap tile by tile in production.
The feed that rejected products for missing images
A home goods retailer pushed its catalog to a marketplace integration that read each configurable product directly from the Magento REST API and required at least one image on the parent record to accept a listing. A batch of new configurables had been created through a product import tool that, by design, only wrote images to the child SKUs, so the marketplace rejected every one of them with a generic missing image error.
The team ran the script in dry run first, saw which parents lacked images and which child image each would inherit, and used that report to bulk upload the recommended entries. The next feed sync accepted the listings cleanly, and the report became a standing pre-check before every future import batch.
After this runs on a schedule or right after an import, a configurable parent with no image of its own is caught before an API consumer, not a person browsing the storefront, discovers it as a blank tile or a rejected feed entry. The report carries the parent SKU and id, how many children actually have images, and which child is the best candidate to copy from, so a person can confirm the right shot fast. Keep the actual upload a human confirmed decision, since picking the canonical image for a parent is a merchandising call a script should never make silently.
FAQ
Why does my Magento configurable product have no image in the API but shows one on the storefront?
The storefront often falls back to a selected child's image through ImageBuilder and the configurable JavaScript widget, so shoppers never notice the parent itself has an empty media_gallery_entries array. An API consumer that requests the parent product directly, such as a PWA, a marketplace feed, or a mobile app, gets that empty array with no fallback, because the fallback logic lives in the storefront layer, not in the product data itself.
Does Magento automatically copy a child's image to the configurable parent?
No. The parent's own catalog_product_entity_media_gallery_value_to_entity linkage is entirely independent of its children's gallery entries. Magento never auto-copies or inherits images from children to the parent row, so if an importer or product creation flow only attaches images to the simple SKUs, the configurable parent is left with no gallery entries of its own unless someone uploads one directly.
Is it safe to automatically copy a child's image onto the parent SKU?
Not silently. Which child's image is the canonical one for the parent is a merchandising decision a script cannot safely guess, so the safe default is to detect and report the mismatch only. If auto-remediation is wanted, gate it behind an explicit DRY_RUN=false flag and have it create a new gallery entry on the parent through POST /rest/V1/products/{sku}/media, which never touches the child's own data.
Related field notes
Citations
On the problem:
- GitHub Issue: Configurable Product Image Parent Product Thumbnail not working. github.com/magento/magento2/issues/17174
- GitHub Issue: Don't want to show child product's images on configurable product page. github.com/magento/magento2/issues/28118
- Meetanshi: Solved, No Image for Configurable Product in Magento 2 Cart Page. meetanshi.com/blog/solved-no-image-for-configurable-product-in-magento-2-cart-page
On the solution:
- Adobe Commerce Web API: Product API quick reference, including media gallery entries. developer.adobe.com/commerce/webapi/rest/quick-reference
- Adobe Commerce Web API: Configurable Products API tutorial. developer.adobe.com/commerce/webapi/rest/tutorials/configurable-product
- Adobe Commerce Web API: Search using REST APIs and searchCriteria syntax. developer.adobe.com/commerce/webapi/rest/use-rest/perform-searches
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce catalog data, inventory, orders, or indexing that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up your missing product images?
If this saved you a blank storefront tile or a rejected marketplace feed, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi