Skip to content

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.

Python and Node.js Products REST API Safe by default (report first)
A product photo shoot setup
Photo by Randy Fath on Unsplash
The short answer

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.

Import attaches image to simple child SKUs only Parent row untouched no gallery entry created never inherited from children Parent gallery empty media_gallery_entries: [] API consumer gets nothing Storefront JS masks it by falling back to a child image
The parent's own gallery row is never populated, and the storefront's fallback hides that fact until an API consumer asks for the parent directly.

Why it happens

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.

The key insight

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.

Scheduled job runs on a timer List configurables and their children Read both galleries parent and each child Parent empty, child has one? yes no, in sync Report or upload child data untouched
The script only reports or fixes the configurables where the parent's own gallery is genuinely empty while a child's is not. Everything already in sync is left alone.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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()
step2.js
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();
}
3

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.

step3.py
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")
step3.js
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`);
}
4

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.

decide.py
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]
decide.js
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];
}
5

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
configurable_missing_image.py
"""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()
configurable-missing-image.js
/**
 * 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.

test_configurable_missing_image.py
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
configurable-missing-image.test.js
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

Bulk import

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.

Marketplace feed

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.

What good looks like

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:

  1. GitHub Issue: Configurable Product Image Parent Product Thumbnail not working. github.com/magento/magento2/issues/17174
  2. GitHub Issue: Don't want to show child product's images on configurable product page. github.com/magento/magento2/issues/28118
  3. 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:

  1. Adobe Commerce Web API: Product API quick reference, including media gallery entries. developer.adobe.com/commerce/webapi/rest/quick-reference
  2. Adobe Commerce Web API: Configurable Products API tutorial. developer.adobe.com/commerce/webapi/rest/tutorials/configurable-product
  3. 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.

Contact me on LinkedIn

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

Back to all Magento field notes