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.
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.
Why it happens
ProductRepository::save()calls an internalassignProductToWebsites()step on every save, regardless of caller. It is not a separate opt-in action, it runs as part of the normal save path.- CLI scripts, cron-triggered import jobs, and custom
catalog_product_save_afterobservers frequently call the repository without setting an explicit store or website scope, because the code was written assuming a single-website store. - REST
PUT /V1/products/{sku}calls have the same exposure. If the request or the area code the API runs under resolves to the admin store code, the same short-circuit applies to a REST-triggered save just as it does to a raw PHP one. - When the save context's store code equals the admin store code, the assignment logic can take the branch that assigns to the default or first website only, instead of reading and preserving the product's existing
website_idsbefore writing.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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")
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`);
}
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.
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,
}
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 };
}
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.
def add_website_link(sku, website_id):
body = {"productWebsiteLink": {"sku": sku, "website_id": website_id}}
return magento_post(f"/products/{sku}/websites", body)
async function addWebsiteLink(sku, websiteId) {
const body = { productWebsiteLink: { sku, website_id: websiteId } };
return magentoPost(`/products/${sku}/websites`, body);
}
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{'}'}.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- Magento 2 GitHub issue: ProductRepository::save assigns the product to all websites. github.com/magento/magento2/issues/8520
- Magento 2 GitHub issue: updating a product via the REST API assigns it to all websites automatically. github.com/magento/magento2/issues/11324
- 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:
- Adobe Commerce developer docs: REST API reference for products. developer.adobe.com/commerce/webapi/rest/reference
- Adobe Commerce developer docs: using REST endpoints. developer.adobe.com/commerce/webapi/rest/use-rest
- 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.
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