Skip to content

Diagnostic Catalog and Visibility

Product silently reassigned to the wrong store on save

A product that has always lived on three websites gets saved by a CLI import script, a cron job, or a REST call, and comes back visible on only one. Nothing in the admin UI complained, no error was logged, and the merchandiser who set up the multi-website assignment never touched it. Here is why Magento can quietly force a product onto only its default website during save, and a script that finds every product this happened to before a customer notices it missing.

Python and Node.js Magento REST API Guarded auto-repair (dry run default)
Labeled packs on a shelf
Photo by Franki Chamaki on Unsplash
The short answer

Magento\Catalog\Model\ProductRepository::save() runs an internal assignProductToWebsites() step. When the save happens in, or resolves to, the admin store context, common for CLI scripts, cron-triggered imports, custom catalog_product_save_after observers, or REST calls that never pass an explicit store scope, this step can force-assign the product only to the default or first website instead of preserving its existing multi-website website_ids, silently overwriting the catalog_product_website table. Run a small Python or Node.js script that reads extension_attributes.website_ids from GET /V1/products/{sku} (or the flat list from GET /V1/products/{sku}/websites), compares it against an expected-mapping table you keep outside Magento, and flags any drift. The full code, tests, and a dry run guard are below.

The problem in plain words

Assigning a product to more than one website is normal for stores that run separate brands, regions, or B2B and B2C storefronts off one Magento instance. That assignment lives in the catalog_product_website table, and once it is set, nothing should change it unless someone deliberately edits the Websites section on the product and saves.

But ProductRepository::save(), the same method every programmatic save goes through whether it comes from a CLI script, a scheduled import, an observer on catalog_product_save_after, or a REST PUT, includes an internal step that decides which websites the product belongs to. When the save context resolves to the admin store code instead of the store scope the caller actually intended, that step can take a shortcut and assign the product to only the default website, discarding every other website id that was there a moment before. The product still saves without an error. It is just quietly narrower than it was.

CLI, cron, or REST save no explicit store scope ProductRepository::save context resolves as admin assignProductToWebsites short-circuits Only default website website_ids = [1] Other stores lose the product The save reports success. No error, no warning, no admin notice. catalog_product_website is overwritten silently, so the drift hides until a customer or a report finds it.
The save succeeds and looks routine. The quiet part is the website assignment narrowing to the default store without anyone asking for that.

Why it happens

This has been reported independently for direct ProductRepository::save() PHP calls and for REST PUT /V1/products/{sku} updates, both tracing back to the same admin-store-context branch mis-resolving the intended website scope on save. See the citations at the end for the exact issue threads.

The key insight

Losing a website assignment is not always a bug. A merchant might genuinely remove a product from a website on purpose. So the safe rule is never "restore whatever changed." It is "restore only what disappeared with nothing extra added," and leave anything that looks like it could be a deliberate edit for a human to review. That split, missing versus unexpected, is what keeps an automated repair from ever undoing someone's real decision.

The fix, as a flow

The script pulls each product's actual website_ids through the REST API, compares that array against an expected mapping you maintain outside Magento, such as a CSV or JSON file, and classifies the result as missing ids, unexpected ids, or both. Only when ids are missing and nothing unexpected is present does it call the write endpoint to add them back, and only when DRY_RUN is explicitly set to false.

GET /V1/products/{sku} actual website_ids Expected mapping SKU to website ids, kept outside Magento decideWebsiteDrift missing, unexpected, likelyForcedDefault Missing only, DRY_RUN false? yes no, flag and stop POST products/{sku}/websites re-add each missing website id
Every SKU gets a report line either way. Only a pure lost assignment, missing ids with nothing unexpected, is ever eligible for the guarded write, and only when DRY_RUN is explicitly false.

Build it step by step

1

Get an admin token and your expected mapping

Get an admin token by calling POST {MAGENTO_URL}/rest/V1/integration/admin/token with your admin username and password, or use a long lived integration token. Keep the base URL and token in environment variables, and maintain the SKU to expected website ids mapping outside Magento, as a small JSON file is enough to start.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export ADMIN_STORE_CODE="admin"
export EXPECTED_WEBSITES_JSON='{"SKU-1": [1, 2], "SKU-2": [1, 3]}'
export DRY_RUN="true"   # start safe, change to false to repair pure lost assignments
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export ADMIN_STORE_CODE="admin"
export EXPECTED_WEBSITES_JSON='{"SKU-1": [1, 2], "SKU-2": [1, 3]}'
export DRY_RUN="true"   // start safe, change to false to repair pure lost assignments
2

Talk to the Magento REST API

Every call sends the admin token as a bearer header. Two small helpers, one for GET and one for POST, wrap the requests, raise on a bad status code, and return the parsed JSON body so the rest of the script only deals with plain data.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def magento_post(path, body):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        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) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPost(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Read the actual website assignment for a SKU

Call GET /V1/products/{'{'}sku{'}'} and read extension_attributes.website_ids, an int array, or call the shorter GET /V1/products/{'{'}sku{'}'}/websites, which returns the flat list of website ids Magento currently has linked for that SKU straight from the catalog_product_website table.

step3.py
def actual_website_ids(sku):
    product = magento_get(f"/products/{sku}")
    ext = product.get("extension_attributes", {}) or {}
    ids = ext.get("website_ids")
    if ids is not None:
        return ids
    return magento_get(f"/products/{sku}/websites")
step3.js
async function actualWebsiteIds(sku) {
  const product = await magentoGet(`/products/${sku}`);
  const ext = product.extension_attributes || {};
  if (ext.website_ids !== undefined) return ext.website_ids;
  return magentoGet(`/products/${sku}/websites`);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the actual ids, the expected ids, and the store context code the save ran under, and returns a struct describing the drift. It normalizes both arrays into sets, computes what is missing and what is unexpected, and flags the specific signature of the forced default assignment: actual reduces to exactly [1], expected has more than one id, and the store context code equals the admin store code.

decide.py
DEFAULT_WEBSITE_ID = 1

def decide_website_drift(actual_website_ids, expected_website_ids, store_context_code, admin_store_code="admin"):
    actual = sorted(set(actual_website_ids))
    expected = sorted(set(expected_website_ids))
    missing = sorted(set(expected) - set(actual))
    unexpected = sorted(set(actual) - set(expected))
    is_drifted = bool(missing) or bool(unexpected)
    likely_forced_default = (
        actual == [DEFAULT_WEBSITE_ID]
        and len(expected) > 1
        and store_context_code == admin_store_code
    )
    return {
        "isDrifted": is_drifted,
        "missing": missing,
        "unexpected": unexpected,
        "likelyForcedDefault": likely_forced_default,
    }
decide.js
const DEFAULT_WEBSITE_ID = 1;

export function decideWebsiteDrift(actualWebsiteIds, expectedWebsiteIds, storeContextCode, adminStoreCode = "admin") {
  const actual = [...new Set(actualWebsiteIds)].sort((a, b) => a - b);
  const expected = [...new Set(expectedWebsiteIds)].sort((a, b) => a - b);
  const actualSet = new Set(actual);
  const expectedSet = new Set(expected);
  const missing = expected.filter((id) => !actualSet.has(id));
  const unexpected = actual.filter((id) => !expectedSet.has(id));
  const isDrifted = missing.length > 0 || unexpected.length > 0;
  const likelyForcedDefault =
    actual.length === 1 &&
    actual[0] === DEFAULT_WEBSITE_ID &&
    expected.length > 1 &&
    storeContextCode === adminStoreCode;
  return { isDrifted, missing, unexpected, likelyForcedDefault };
}
5

Repair only the unambiguous case, guarded by DRY_RUN

When missing is non-empty and unexpected is empty, that is a pure lost assignment, safe to re-add. Call POST /V1/products/{'{'}sku{'}'}/websites with {'{'}"productWebsiteLink": {'{'}"sku": sku, "website_id": id{'}'}{'}'} for each missing id. This is the documented ProductWebsiteLinkRepositoryInterface::save call, and it is idempotent per website id. Anything with an unexpected id is only flagged, never touched, because removing a website could be a deliberate merchant change.

apply.py
def add_website_link(sku, website_id):
    body = {"productWebsiteLink": {"sku": sku, "website_id": website_id}}
    return magento_post(f"/products/{sku}/websites", body)
apply.js
async function addWebsiteLink(sku, websiteId) {
  const body = { productWebsiteLink: { sku, website_id: websiteId } };
  return magentoPost(`/products/${sku}/websites`, body);
}
6

Wire it together with a dry run guard

The loop reads every SKU in the expected mapping, pulls its actual website ids, runs the pure decision function, and only calls the write endpoint when the drift is a pure lost assignment and DRY_RUN is explicitly false. Every other case, including anything with an unexpected id, only produces a report line: expected versus actual versus drift type. There is no code path that ever calls DELETE /V1/products/{'{'}sku{'}'}/websites/{'{'}websiteId{'}'}.

Run it safe

Always start with DRY_RUN=true, and treat any SKU with an unexpected website id as a flag for a human, not something the script should touch. A false-positive removal could take a product off a live storefront, so this script never deletes a website link automatically, no matter what DRY_RUN is set to.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pulls the actual website ids for every SKU in your expected mapping, runs the pure decision function, reports every drift it finds, and repairs only the pure lost assignment case when you explicitly turn off dry run.

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.
repair_website_drift.py
"""Detect and safely repair Magento products force-assigned to the wrong website on save.

Magento\\Catalog\\Model\\ProductRepository::save() runs an internal
assignProductToWebsites() step on every save. When the save context resolves to
the admin store code, common for CLI scripts, cron-triggered imports, custom
catalog_product_save_after observers, or REST calls that skip an explicit store
scope, this step can force-assign the product only to the default website,
silently overwriting catalog_product_website and dropping every other website
the product used to be on.

This script reads the actual website_ids for each SKU in your expected mapping,
compares them with decideWebsiteDrift, and by default only reports the drift.
Only when the drift is a pure lost assignment, missing ids with nothing
unexpected, does it call POST /V1/products/{sku}/websites to add each missing
id back, and only under an explicit DRY_RUN=false operator override. It never
calls the DELETE websites endpoint. Run on a schedule after any bulk save,
import, or deploy that touches ProductRepository::save. Safe to run again and
again.

Guide: https://www.allanninal.dev/magento/product-force-assigned-wrong-store/
"""
import os
import json
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_website_drift")

MAGENTO_URL = os.environ.get("MAGENTO_URL", "https://demo.example.com").rstrip("/")
TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN", "token_dummy")
ADMIN_STORE_CODE = os.environ.get("ADMIN_STORE_CODE", "admin")
STORE_CONTEXT_CODE = os.environ.get("STORE_CONTEXT_CODE", ADMIN_STORE_CODE)
EXPECTED_WEBSITES_JSON = os.environ.get("EXPECTED_WEBSITES_JSON", "{}")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

DEFAULT_WEBSITE_ID = 1


def magento_get(path):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def magento_post(path, body):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def actual_website_ids(sku):
    product = magento_get(f"/products/{sku}")
    ext = product.get("extension_attributes", {}) or {}
    ids = ext.get("website_ids")
    if ids is not None:
        return ids
    return magento_get(f"/products/{sku}/websites")


def decide_website_drift(actual_website_ids, expected_website_ids, store_context_code, admin_store_code="admin"):
    actual = sorted(set(actual_website_ids))
    expected = sorted(set(expected_website_ids))
    missing = sorted(set(expected) - set(actual))
    unexpected = sorted(set(actual) - set(expected))
    is_drifted = bool(missing) or bool(unexpected)
    likely_forced_default = (
        actual == [DEFAULT_WEBSITE_ID]
        and len(expected) > 1
        and store_context_code == admin_store_code
    )
    return {
        "isDrifted": is_drifted,
        "missing": missing,
        "unexpected": unexpected,
        "likelyForcedDefault": likely_forced_default,
    }


def add_website_link(sku, website_id):
    body = {"productWebsiteLink": {"sku": sku, "website_id": website_id}}
    return magento_post(f"/products/{sku}/websites", body)


def run():
    expected_map = json.loads(EXPECTED_WEBSITES_JSON)
    flagged = 0
    repaired = 0

    for sku, expected_ids in expected_map.items():
        actual_ids = actual_website_ids(sku)
        drift = decide_website_drift(actual_ids, expected_ids, STORE_CONTEXT_CODE, ADMIN_STORE_CODE)

        if not drift["isDrifted"]:
            continue

        flagged += 1
        log.warning(
            "Drift on sku=%s expected=%s actual=%s missing=%s unexpected=%s likely_forced_default=%s",
            sku, sorted(set(expected_ids)), sorted(set(actual_ids)),
            drift["missing"], drift["unexpected"], drift["likelyForcedDefault"],
        )

        safe_to_repair = drift["missing"] and not drift["unexpected"]
        if not safe_to_repair:
            log.warning("Sku=%s has an unexpected website id, flagging only, no auto-repair.", sku)
            continue

        if DRY_RUN:
            log.info("Sku=%s would add missing website id(s) %s (dry run).", sku, drift["missing"])
            continue

        for website_id in drift["missing"]:
            add_website_link(sku, website_id)
            log.info("Sku=%s added back website id %s.", sku, website_id)
        repaired += 1

    log.info("Done. %d sku(s) flagged, %d sku(s) repaired.", flagged, repaired)


if __name__ == "__main__":
    run()
repair-website-drift.js
/**
 * Detect and safely repair Magento products force-assigned to the wrong website on save.
 *
 * Magento\Catalog\Model\ProductRepository::save() runs an internal
 * assignProductToWebsites() step on every save. When the save context resolves to
 * the admin store code, common for CLI scripts, cron-triggered imports, custom
 * catalog_product_save_after observers, or REST calls that skip an explicit store
 * scope, this step can force-assign the product only to the default website,
 * silently overwriting catalog_product_website and dropping every other website
 * the product used to be on.
 *
 * This script reads the actual website_ids for each SKU in your expected mapping,
 * compares them with decideWebsiteDrift, and by default only reports the drift.
 * Only when the drift is a pure lost assignment, missing ids with nothing
 * unexpected, does it call POST /V1/products/{sku}/websites to add each missing
 * id back, and only under an explicit DRY_RUN=false operator override. It never
 * calls the DELETE websites endpoint. Run on a schedule after any bulk save,
 * import, or deploy that touches ProductRepository::save. Safe to run again and
 * again.
 *
 * Guide: https://www.allanninal.dev/magento/product-force-assigned-wrong-store/
 */
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 ADMIN_STORE_CODE = process.env.ADMIN_STORE_CODE || "admin";
const STORE_CONTEXT_CODE = process.env.STORE_CONTEXT_CODE || ADMIN_STORE_CODE;
const EXPECTED_WEBSITES_JSON = process.env.EXPECTED_WEBSITES_JSON || "{}";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DEFAULT_WEBSITE_ID = 1;

export function decideWebsiteDrift(actualWebsiteIds, expectedWebsiteIds, storeContextCode, adminStoreCode = "admin") {
  const actual = [...new Set(actualWebsiteIds)].sort((a, b) => a - b);
  const expected = [...new Set(expectedWebsiteIds)].sort((a, b) => a - b);
  const actualSet = new Set(actual);
  const expectedSet = new Set(expected);
  const missing = expected.filter((id) => !actualSet.has(id));
  const unexpected = actual.filter((id) => !expectedSet.has(id));
  const isDrifted = missing.length > 0 || unexpected.length > 0;
  const likelyForcedDefault =
    actual.length === 1 &&
    actual[0] === DEFAULT_WEBSITE_ID &&
    expected.length > 1 &&
    storeContextCode === adminStoreCode;
  return { isDrifted, missing, unexpected, likelyForcedDefault };
}

async function magentoGet(path) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPost(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function actualWebsiteIds(sku) {
  const product = await magentoGet(`/products/${sku}`);
  const ext = product.extension_attributes || {};
  if (ext.website_ids !== undefined) return ext.website_ids;
  return magentoGet(`/products/${sku}/websites`);
}

async function addWebsiteLink(sku, websiteId) {
  const body = { productWebsiteLink: { sku, website_id: websiteId } };
  return magentoPost(`/products/${sku}/websites`, body);
}

export async function run() {
  const expectedMap = JSON.parse(EXPECTED_WEBSITES_JSON);
  let flagged = 0;
  let repaired = 0;

  for (const [sku, expectedIds] of Object.entries(expectedMap)) {
    const actualIds = await actualWebsiteIds(sku);
    const drift = decideWebsiteDrift(actualIds, expectedIds, STORE_CONTEXT_CODE, ADMIN_STORE_CODE);

    if (!drift.isDrifted) continue;

    flagged++;
    console.warn(
      `Drift on sku=${sku} expected=${JSON.stringify([...new Set(expectedIds)].sort((a, b) => a - b))} ` +
      `actual=${JSON.stringify([...new Set(actualIds)].sort((a, b) => a - b))} ` +
      `missing=${JSON.stringify(drift.missing)} unexpected=${JSON.stringify(drift.unexpected)} ` +
      `likely_forced_default=${drift.likelyForcedDefault}`
    );

    const safeToRepair = drift.missing.length > 0 && drift.unexpected.length === 0;
    if (!safeToRepair) {
      console.warn(`Sku=${sku} has an unexpected website id, flagging only, no auto-repair.`);
      continue;
    }

    if (DRY_RUN) {
      console.log(`Sku=${sku} would add missing website id(s) ${JSON.stringify(drift.missing)} (dry run).`);
      continue;
    }

    for (const websiteId of drift.missing) {
      await addWebsiteLink(sku, websiteId);
      console.log(`Sku=${sku} added back website id ${websiteId}.`);
    }
    repaired++;
  }

  console.log(`Done. ${flagged} sku(s) flagged, ${repaired} sku(s) repaired.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

decide_website_drift is the part most worth testing, because it decides which SKUs get repaired versus only flagged. It is a pure set comparison with no I/O, so the test needs no network and no Magento store. It just feeds in plain arrays and a store code and checks the answer.

test_force_website_drift.py
from repair_website_drift import decide_website_drift


def test_no_drift_when_ids_match_regardless_of_order():
    result = decide_website_drift([2, 1], [1, 2], "default")
    assert result["isDrifted"] is False
    assert result["missing"] == []
    assert result["unexpected"] == []


def test_detects_missing_website_id():
    result = decide_website_drift([1], [1, 2, 3], "admin")
    assert result["isDrifted"] is True
    assert result["missing"] == [2, 3]
    assert result["unexpected"] == []


def test_detects_unexpected_website_id():
    result = decide_website_drift([1, 2, 9], [1, 2], "default")
    assert result["isDrifted"] is True
    assert result["missing"] == []
    assert result["unexpected"] == [9]


def test_flags_likely_forced_default_signature():
    result = decide_website_drift([1], [1, 2, 3], "admin", "admin")
    assert result["likelyForcedDefault"] is True


def test_not_forced_default_when_store_context_is_not_admin():
    result = decide_website_drift([1], [1, 2, 3], "default", "admin")
    assert result["likelyForcedDefault"] is False


def test_not_forced_default_when_expected_is_single_website():
    result = decide_website_drift([1], [1], "admin", "admin")
    assert result["likelyForcedDefault"] is False
    assert result["isDrifted"] is False


def test_dedupes_duplicate_ids_in_input():
    result = decide_website_drift([1, 1, 2], [1, 2, 2], "default")
    assert result["isDrifted"] is False
website-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideWebsiteDrift } from "./repair-website-drift.js";

test("no drift when ids match regardless of order", () => {
  const result = decideWebsiteDrift([2, 1], [1, 2], "default");
  assert.equal(result.isDrifted, false);
  assert.deepEqual(result.missing, []);
  assert.deepEqual(result.unexpected, []);
});

test("detects missing website id", () => {
  const result = decideWebsiteDrift([1], [1, 2, 3], "admin");
  assert.equal(result.isDrifted, true);
  assert.deepEqual(result.missing, [2, 3]);
  assert.deepEqual(result.unexpected, []);
});

test("detects unexpected website id", () => {
  const result = decideWebsiteDrift([1, 2, 9], [1, 2], "default");
  assert.equal(result.isDrifted, true);
  assert.deepEqual(result.missing, []);
  assert.deepEqual(result.unexpected, [9]);
});

test("flags likely forced default signature", () => {
  const result = decideWebsiteDrift([1], [1, 2, 3], "admin", "admin");
  assert.equal(result.likelyForcedDefault, true);
});

test("not forced default when store context is not admin", () => {
  const result = decideWebsiteDrift([1], [1, 2, 3], "default", "admin");
  assert.equal(result.likelyForcedDefault, false);
});

test("not forced default when expected is single website", () => {
  const result = decideWebsiteDrift([1], [1], "admin", "admin");
  assert.equal(result.likelyForcedDefault, false);
  assert.equal(result.isDrifted, false);
});

test("dedupes duplicate ids in input", () => {
  const result = decideWebsiteDrift([1, 1, 2], [1, 2, 2], "default");
  assert.equal(result.isDrifted, false);
});

Case studies

Cron import

A nightly PIM feed quietly narrowed a shared catalog

A home goods retailer ran a nightly cron job that pulled updated pricing from their PIM and saved it back through ProductRepository::save() for every changed SKU. The job never set a store scope, since it was written when the store only had one website. Months later they added a second brand website sharing the same catalog, and from that point on, every SKU the nightly job touched came out assigned to only the original website.

Nobody noticed for weeks because the products still worked fine on the original site. The second brand's catalog just kept shrinking a little every night. Running the detection script against the full SKU list surfaced the exact list of affected products, all missing the second website id and nothing unexpected added, so the guarded repair added them back safely.

REST integration

An ERP sync flattened a multi-region setup

A distributor ran separate websites for three regions off one Magento instance, with most SKUs shared across all three but a handful restricted to just one region for compliance reasons. Their ERP integration called PUT /V1/products/{sku} on every inventory update, and those calls resolved to the admin store context.

Every inventory sync from the ERP was quietly collapsing shared products down to the default website only, while leaving the single-region products alone since they already only had one website id. The script's expected mapping caught the shared products the moment their website_ids dropped to [1], flagged the exact likelyForcedDefault signature, and the guarded repair restored the regions without touching the SKUs that were correctly single-region on purpose.

What good looks like

Run on a schedule after any bulk save, cron import, or deploy that touches product saves, this script turns a silent, hard to notice website drift into a short, specific list of SKUs with exactly what changed: missing, unexpected, or both. The unambiguous lost-assignment case repairs itself once you trust the dry run output. Anything that looks like it could be a deliberate merchant edit stays a flag for a human, so the script never guesses wrong about which website a product should be on.

FAQ

Why does a Magento product lose its multi-website assignment after a save?

ProductRepository::save runs an internal assignProductToWebsites step. When the save happens in, or resolves to, the admin store context, which is common for CLI scripts, cron-triggered imports, custom catalog_product_save_after observers, or REST calls that do not explicitly pass a store scope, this step can force-assign the product only to the default website instead of preserving its existing website_ids, silently overwriting the catalog_product_website table.

How do I know if a product was force-assigned to the wrong website?

Call GET /V1/products/{sku} or GET /V1/products/{sku}/websites and compare the returned website_ids against an expected-mapping table you keep outside Magento. If a product that should be on several websites now shows only website id 1, and the save happened in an admin store context, that is the signature of the forced default assignment.

Is it safe to auto-repair a lost website assignment?

Only for the unambiguous case where a website id is missing and nothing unexpected was added. The script defaults to DRY_RUN=true and only calls POST /V1/products/{sku}/websites to add back a missing website id when there are no unexpected extra ids, since removing a website could be a deliberate merchant change and must never be auto-corrected.

Related field notes

Citations

On the problem:

  1. Magento 2 GitHub issue: ProductRepository::save assigns the product to all websites. github.com/magento/magento2/issues/8520
  2. Magento 2 GitHub issue: updating a product via the REST API assigns it to all websites automatically. github.com/magento/magento2/issues/11324
  3. Adobe Commerce docs: set up multiple websites, stores, and store views in the Admin. experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/multi-sites/ms-admin

On the solution:

  1. Adobe Commerce developer docs: REST API reference for products. developer.adobe.com/commerce/webapi/rest/reference
  2. Adobe Commerce developer docs: using REST endpoints. developer.adobe.com/commerce/webapi/rest/use-rest
  3. Adobe Commerce docs: set up multiple websites, stores, and store views in the Admin. experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/multi-sites/ms-admin

Stuck on a tricky one?

If you have a problem in Magento catalog, indexing, cron, MSI stock, or order grid sync 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 save you a missing-catalog scare?

If this saved you hours of chasing a silent website assignment drift, 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