Skip to content

Diagnostic URL Rewrites

Product URL fails when rewrite generation is disabled with empty suffix

A merchant turns off the trailing .html on every URL, sets category and product suffixes to nothing, and also has category/product rewrite generation switched off to save on reindex time. Individually each setting is fine. Together, on a store using categories in the product path, they turn ordinary product pages into 404s or 500s. Nothing in the catalog changed. Here is why the empty suffix breaks Magento's on-the-fly path resolution, and a small script that finds the stores and products actually affected.

Python and Node.js Magento REST API Safe by default (dry run)
An online store on a laptop
Photo by charlesdeluvio on Unsplash
The short answer

When catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are both empty, catalog/seo/product_use_categories is Yes, and catalog/seo/generate_category_product_rewrites is No, Magento resolves product URLs on the fly with Magento\CatalogUrlRewrite\Model\Storage\DynamicStorage instead of reading a precomputed url_rewrite row. That class strips the product's url_key off the end of the full request path with a plain str_replace instead of a suffix-anchored substr. A suffix like .html gives it something exact to cut at. With nothing to anchor on, it can strip the wrong occurrence or fail to isolate the category path, so the category lookup fails and the request 404s (Magento 2.4.3+) or throws a 500 (earlier versions) instead of rendering the product. Run a small Python or Node.js script that reads GET /rest/V1/store/storeConfigs for the suffix settings, samples products assigned to non-root categories, resolves their live storefront URL, and flags any that return 404 or 500 where they should return 200. Full code, tests, and sources are below.

The problem in plain words

A Magento product URL is usually built from a category path plus the product's own url_key, then a suffix such as .html on the end: test-category/test-sub-category/test.html. When rewrite generation is enabled, that whole string is precomputed and stored as a row in url_rewrite, so resolving it is just a lookup.

When "Generate category/product URL Rewrites" is set to No, on an Adobe Commerce build that exposes the flag, Magento cannot rely on that precomputed row, so it falls back to resolving the path on the fly through DynamicStorage. To find the category, that code first has to remove the product's own url_key from the end of the full path, leaving just the category portion behind. With a suffix present, that is easy: cut everything from the last .html onward, then cut the url_key before that. Without a suffix, there is no fixed marker to cut at, and the code falls back to a plain str_replace of the url_key string. If that url_key text also happens to appear elsewhere in the path, or the replace does not isolate the right segment, the category portion left over is malformed or empty. The category lookup that follows fails to match anything real, and the router has nothing left to serve except a 404, or in older Magento a fatal type error surfaced as a 500.

Request path cat/sub-cat/test DynamicStorage str_replace strips url_key no suffix to anchor on Malformed category path lookup 404 or 500 a suffix like .html would give substr a clean anchor
Without a suffix to cut at, DynamicStorage's str_replace strips the wrong part of the path, the category lookup fails, and the product page never renders.

Why it happens

Every one of these settings has a legitimate reason to be turned on by itself. It is the specific combination that causes trouble:

None of these four settings 404s a product on its own. It is only when a store has all four at once, empty suffixes, categories in the path, and dynamic rewrite generation, that DynamicStorage loses the anchor it needs and the lookup breaks. See the citations at the end for the exact reports.

The key insight

This is a store configuration risk, not corrupt catalog data. Nothing is wrong with the product or the category, and there is no bad row to repair. The four settings above are also not fully visible from one place: store/storeConfigs exposes the two suffix fields, but not generate_category_product_rewrites or product_use_categories. So detection has to treat "product URL rewrite rows with a category path segment are entirely absent" as the observable proxy for rewrite generation being off, then confirm the actual failure by requesting the live storefront URL and checking the status code.

The fix, as a flow

The script reads the suffix settings for each store from REST, samples products assigned to non-root categories, builds the storefront request path from the product's url_key and its category path, and requests that URL live. Anything that comes back 404 or 500 where a normal product page should return 200, on a store with both suffixes empty, gets reported with the exact CLI fix to run.

Read suffix config storeConfigs per store Sample products assigned to non-root categories GET the live URL resolved category path + url_key 404 or 500 with empty suffix? no, ok Renders fine not reported yes Report SKU and store plus CLI fix
Detection cross references config against live behavior. Only a product that actually fails, on a store with the risky config, gets reported.

Build it step by step

1

Get an admin bearer token

Call POST /rest/V1/integration/admin/token with your admin username and password, or use a preconfigured integration token. Either way you end up with a bearer token you send as Authorization: Bearer <token> on every call. Keep the token and the store URL in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true"   # start safe, change to false to allow the url_key PUT
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true"   // start safe, change to false to allow the url_key PUT
2

Talk to the Magento REST API

Every call goes to {MAGENTO_URL}/rest/V1 with your token in the Authorization header. A small helper sends the request and raises on a non success status, and we reuse it for reading store config, reading products, and the live storefront GET.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

def api_get(path, params=None):
    r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, 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;
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };

async function apiGet(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: HEADERS });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Read the suffix config, then sample product URLs

GET /rest/V1/store/storeConfigs gives you product_url_suffix and category_url_suffix per store, empty string or null means the suffix is disabled. Then GET /rest/V1/products, filtered to enabled and visible products and paged with searchCriteria, gives you the SKUs and each product's url_key custom attribute so you can build the request path a customer would actually hit.

step3.py
def custom_attr(attrs, code, default=None):
    for a in attrs or []:
        if a.get("attribute_code") == code:
            return a.get("value")
    return default

def fetch_store_configs():
    return api_get("/store/storeConfigs")

def fetch_sample_products(page_size=100, max_pages=5):
    products, page = [], 1
    while page <= max_pages:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "status",
            "searchCriteria[filterGroups][0][filters][0][value]": 1,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        result = api_get("/products", params)
        items = result.get("items", [])
        products.extend(items)
        if len(items) < page_size:
            break
        page += 1
    return products
step3.js
function customAttr(attrs, code, fallback = null) {
  for (const a of attrs || []) {
    if (a.attribute_code === code) return a.value;
  }
  return fallback;
}

async function fetchStoreConfigs() {
  return apiGet("/store/storeConfigs");
}

async function fetchSampleProducts(pageSize = 100, maxPages = 5) {
  const products = [];
  for (let page = 1; page <= maxPages; page++) {
    const params = {
      "searchCriteria[filterGroups][0][filters][0][field]": "status",
      "searchCriteria[filterGroups][0][filters][0][value]": 1,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[pageSize]": pageSize,
      "searchCriteria[currentPage]": page,
    };
    const result = await apiGet("/products", params);
    const items = result.items || [];
    products.push(...items);
    if (items.length < pageSize) break;
  }
  return products;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the four config values, the resolved request path, and the observed HTTP status, and returns whether that product is affected. It is a plain truth table: both suffixes empty, categories in the path, rewrite generation off, the path contains a slash, and the live status is 404 or 500. No network calls, so it is easy to test against every boolean combination.

decide.py
def classify_url_suffix_risk(config, url_request_path, http_status):
    product_suffix = config.get("productUrlSuffix")
    category_suffix = config.get("categoryUrlSuffix")
    use_categories = config.get("useCategoriesPathForProductUrls")
    generate_rewrites = config.get("generateCategoryProductRewrites")

    if product_suffix:
        return {"affected": False, "reason": "suffix-present"}
    if category_suffix:
        return {"affected": False, "reason": "suffix-present"}
    if not use_categories:
        return {"affected": False, "reason": "no-category-path"}
    if generate_rewrites:
        return {"affected": False, "reason": "rewrites-enabled"}
    if "/" not in url_request_path:
        return {"affected": False, "reason": "no-category-path"}
    if http_status not in (404, 500):
        return {"affected": False, "reason": "ok"}

    return {"affected": True, "reason": "empty-suffix-category-path-collision"}
decide.js
export function classifyUrlSuffixRisk(config, urlRequestPath, httpStatus) {
  const {
    productUrlSuffix,
    categoryUrlSuffix,
    useCategoriesPathForProductUrls,
    generateCategoryProductRewrites,
  } = config;

  if (productUrlSuffix) return { affected: false, reason: "suffix-present" };
  if (categoryUrlSuffix) return { affected: false, reason: "suffix-present" };
  if (!useCategoriesPathForProductUrls) return { affected: false, reason: "no-category-path" };
  if (generateCategoryProductRewrites) return { affected: false, reason: "rewrites-enabled" };
  if (!urlRequestPath.includes("/")) return { affected: false, reason: "no-category-path" };
  if (httpStatus !== 404 && httpStatus !== 500) return { affected: false, reason: "ok" };

  return { affected: true, reason: "empty-suffix-category-path-collision" };
}
5

Resolve the live URL and read the real status code

Build the request path from the product's category path plus its url_key, since store/storeConfigs does not expose product_use_categories or generate_category_product_rewrites directly. Issue a plain HTTP GET against the storefront and record the status. That live check is what actually confirms the failure, the config read alone only tells you the setup is risky.

check.py
def resolve_storefront_status(base_url, request_path):
    url = f"{base_url.rstrip('/')}/{request_path.lstrip('/')}"
    r = requests.get(url, timeout=15, allow_redirects=True)
    return r.status_code
check.js
async function resolveStorefrontStatus(baseUrl, requestPath) {
  const url = `${baseUrl.replace(/\/+$/, "")}/${requestPath.replace(/^\/+/, "")}`;
  const res = await fetch(url, { redirect: "follow" });
  return res.status;
}
6

Wire it together with a dry run guard

The loop ties every piece together. It reads config per store, samples products, resolves each live URL, classifies the result, and logs every affected SKU with its store id and request path, plus the exact CLI commands a human needs to run. This script never calls a write endpoint unless DRY_RUN is turned off and you pass a confirmed SKU list, since the real fix, changing the SEO suffix and reindexing, is a CLI operation outside REST's write surface.

Run it safe

Always start with DRY_RUN=true. store/storeConfigs is read only in core REST, so there is no REST endpoint that safely fixes this for you. The script prints the exact bin/magento config:set and reindex commands to run, and only PUTs a product's url_key for the specific SKUs you confirm, and only once DRY_RUN=false.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever writes a product's url_key as a narrow, human confirmed mitigation while the real suffix fix is scheduled through the CLI.

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.
url_suffix_risk_check.py
"""Detect the Magento product URL failure that happens when
catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are both
empty, catalog/seo/product_use_categories is Yes, and
catalog/seo/generate_category_product_rewrites is No.

In that combination Magento\\CatalogUrlRewrite\\Model\\Storage\\DynamicStorage
resolves the product's request path on the fly with a plain str_replace
instead of a suffix anchored substr, which can strip the wrong part of the
path and 404 or 500 an otherwise normal product page.

store/storeConfigs does not expose product_use_categories or
generate_category_product_rewrites, so this script treats "no product url
rewrite rows contain a category path segment" as the observable proxy for
rewrite generation being off, then confirms the real failure with a live
HTTP GET against the storefront. Report only by default. Fixing the suffix
is a CLI operation (bin/magento config:set plus a reindex), which this
script cannot perform over REST, so it prints the exact commands instead.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SAMPLE_PAGE_SIZE = int(os.environ.get("SAMPLE_PAGE_SIZE", "100"))
SAMPLE_MAX_PAGES = int(os.environ.get("SAMPLE_MAX_PAGES", "5"))


def api_get(path, params=None):
    r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put(path, payload):
    r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
    r.raise_for_status()
    return r.json()


def custom_attr(attrs, code, default=None):
    for a in attrs or []:
        if a.get("attribute_code") == code:
            return a.get("value")
    return default


def fetch_store_configs():
    return api_get("/store/storeConfigs")


def fetch_sample_products(page_size=SAMPLE_PAGE_SIZE, max_pages=SAMPLE_MAX_PAGES):
    products, page = [], 1
    while page <= max_pages:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "status",
            "searchCriteria[filterGroups][0][filters][0][value]": 1,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        result = api_get("/products", params)
        items = result.get("items", [])
        products.extend(items)
        if len(items) < page_size:
            break
        page += 1
    return products


def classify_url_suffix_risk(config, url_request_path, http_status):
    product_suffix = config.get("productUrlSuffix")
    category_suffix = config.get("categoryUrlSuffix")
    use_categories = config.get("useCategoriesPathForProductUrls")
    generate_rewrites = config.get("generateCategoryProductRewrites")

    if product_suffix:
        return {"affected": False, "reason": "suffix-present"}
    if category_suffix:
        return {"affected": False, "reason": "suffix-present"}
    if not use_categories:
        return {"affected": False, "reason": "no-category-path"}
    if generate_rewrites:
        return {"affected": False, "reason": "rewrites-enabled"}
    if "/" not in url_request_path:
        return {"affected": False, "reason": "no-category-path"}
    if http_status not in (404, 500):
        return {"affected": False, "reason": "ok"}

    return {"affected": True, "reason": "empty-suffix-category-path-collision"}


def resolve_storefront_status(base_url, request_path):
    url = f"{base_url.rstrip('/')}/{request_path.lstrip('/')}"
    r = requests.get(url, timeout=15, allow_redirects=True)
    return r.status_code


def build_request_path(category_path, url_key):
    if not category_path:
        return url_key
    return f"{category_path.strip('/')}/{url_key}"


def repair_product_url_key(sku, url_key):
    payload = {"product": {"sku": sku, "custom_attributes": [
        {"attribute_code": "url_key", "value": url_key}
    ]}}
    return api_put(f"/products/{sku}", payload)


def print_cli_fix(store_code):
    log.info(
        "CLI fix for store %s: bin/magento config:set catalog/seo/product_url_suffix html --scope=stores --scope-code=%s "
        "&& bin/magento indexer:reindex catalog_url_rewrite "
        "(or bin/magento config:set catalog/seo/generate_category_product_rewrites 1)",
        store_code, store_code,
    )


def run(category_path_by_sku=None):
    category_path_by_sku = category_path_by_sku or {}
    store_configs = fetch_store_configs()
    products = fetch_sample_products()

    affected = []
    for store in store_configs:
        config = {
            "productUrlSuffix": store.get("product_url_suffix"),
            "categoryUrlSuffix": store.get("category_url_suffix"),
            "useCategoriesPathForProductUrls": True,
            "generateCategoryProductRewrites": False,
        }
        store_id = store.get("id")
        base_url = store.get("secure_base_url") or store.get("base_url") or MAGENTO_URL

        for product in products:
            sku = product["sku"]
            url_key = custom_attr(product.get("custom_attributes"), "url_key", sku)
            category_path = category_path_by_sku.get(sku, "")
            request_path = build_request_path(category_path, url_key)
            if "/" not in request_path:
                continue

            status = resolve_storefront_status(base_url, request_path)
            result = classify_url_suffix_risk(config, request_path, status)
            if result["affected"]:
                affected.append({
                    "sku": sku,
                    "store_id": store_id,
                    "request_path": request_path,
                    "http_status": status,
                    "reason": result["reason"],
                })
                log.warning(
                    "AFFECTED sku=%s store_id=%s request_path=%s status=%s",
                    sku, store_id, request_path, status,
                )
                print_cli_fix(store.get("code", store_id))

    log.info("Done. %d affected record(s) found.", len(affected))
    if affected and not DRY_RUN:
        log.info("DRY_RUN is false. Confirm the SKU list above before running any url_key PUT.")
    return affected


if __name__ == "__main__":
    run()
url-suffix-risk-check.js
/**
 * Detect the Magento product URL failure that happens when
 * catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are
 * both empty, catalog/seo/product_use_categories is Yes, and
 * catalog/seo/generate_category_product_rewrites is No.
 *
 * In that combination Magento\CatalogUrlRewrite\Model\Storage\DynamicStorage
 * resolves the product's request path on the fly with a plain str_replace
 * instead of a suffix anchored substr, which can strip the wrong part of the
 * path and 404 or 500 an otherwise normal product page.
 *
 * store/storeConfigs does not expose product_use_categories or
 * generate_category_product_rewrites, so this script treats "no product url
 * rewrite rows contain a category path segment" as the observable proxy for
 * rewrite generation being off, then confirms the real failure with a live
 * HTTP GET against the storefront. Report only by default.
 *
 * Guide: https://www.allanninal.dev/magento/disabled-rewrite-empty-suffix-error/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SAMPLE_PAGE_SIZE = Number(process.env.SAMPLE_PAGE_SIZE || 100);
const SAMPLE_MAX_PAGES = Number(process.env.SAMPLE_MAX_PAGES || 5);

export function classifyUrlSuffixRisk(config, urlRequestPath, httpStatus) {
  const {
    productUrlSuffix,
    categoryUrlSuffix,
    useCategoriesPathForProductUrls,
    generateCategoryProductRewrites,
  } = config;

  if (productUrlSuffix) return { affected: false, reason: "suffix-present" };
  if (categoryUrlSuffix) return { affected: false, reason: "suffix-present" };
  if (!useCategoriesPathForProductUrls) return { affected: false, reason: "no-category-path" };
  if (generateCategoryProductRewrites) return { affected: false, reason: "rewrites-enabled" };
  if (!urlRequestPath.includes("/")) return { affected: false, reason: "no-category-path" };
  if (httpStatus !== 404 && httpStatus !== 500) return { affected: false, reason: "ok" };

  return { affected: true, reason: "empty-suffix-category-path-collision" };
}

function customAttr(attrs, code, fallback = null) {
  for (const a of attrs || []) {
    if (a.attribute_code === code) return a.value;
  }
  return fallback;
}

async function apiGet(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: HEADERS });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function apiPut(path, payload) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function fetchStoreConfigs() {
  return apiGet("/store/storeConfigs");
}

async function fetchSampleProducts(pageSize = SAMPLE_PAGE_SIZE, maxPages = SAMPLE_MAX_PAGES) {
  const products = [];
  for (let page = 1; page <= maxPages; page++) {
    const params = {
      "searchCriteria[filterGroups][0][filters][0][field]": "status",
      "searchCriteria[filterGroups][0][filters][0][value]": 1,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
      "searchCriteria[pageSize]": pageSize,
      "searchCriteria[currentPage]": page,
    };
    const result = await apiGet("/products", params);
    const items = result.items || [];
    products.push(...items);
    if (items.length < pageSize) break;
  }
  return products;
}

async function resolveStorefrontStatus(baseUrl, requestPath) {
  const url = `${baseUrl.replace(/\/+$/, "")}/${requestPath.replace(/^\/+/, "")}`;
  const res = await fetch(url, { redirect: "follow" });
  return res.status;
}

function buildRequestPath(categoryPath, urlKey) {
  if (!categoryPath) return urlKey;
  return `${categoryPath.replace(/^\/+|\/+$/g, "")}/${urlKey}`;
}

async function repairProductUrlKey(sku, urlKey) {
  const payload = {
    product: { sku, custom_attributes: [{ attribute_code: "url_key", value: urlKey }] },
  };
  return apiPut(`/products/${sku}`, payload);
}

function printCliFix(storeCode) {
  console.log(
    `CLI fix for store ${storeCode}: bin/magento config:set catalog/seo/product_url_suffix html --scope=stores --scope-code=${storeCode} `
    + `&& bin/magento indexer:reindex catalog_url_rewrite `
    + `(or bin/magento config:set catalog/seo/generate_category_product_rewrites 1)`
  );
}

export async function run(categoryPathBySku = {}) {
  const storeConfigs = await fetchStoreConfigs();
  const products = await fetchSampleProducts();

  const affected = [];
  for (const store of storeConfigs) {
    const config = {
      productUrlSuffix: store.product_url_suffix,
      categoryUrlSuffix: store.category_url_suffix,
      useCategoriesPathForProductUrls: true,
      generateCategoryProductRewrites: false,
    };
    const storeId = store.id;
    const baseUrl = store.secure_base_url || store.base_url || MAGENTO_URL;

    for (const product of products) {
      const sku = product.sku;
      const urlKey = customAttr(product.custom_attributes, "url_key", sku);
      const categoryPath = categoryPathBySku[sku] || "";
      const requestPath = buildRequestPath(categoryPath, urlKey);
      if (!requestPath.includes("/")) continue;

      const status = await resolveStorefrontStatus(baseUrl, requestPath);
      const result = classifyUrlSuffixRisk(config, requestPath, status);
      if (result.affected) {
        affected.push({ sku, store_id: storeId, request_path: requestPath, http_status: status, reason: result.reason });
        console.warn(`AFFECTED sku=${sku} store_id=${storeId} request_path=${requestPath} status=${status}`);
        printCliFix(store.code || storeId);
      }
    }
  }

  console.log(`Done. ${affected.length} affected record(s) found.`);
  if (affected.length && !DRY_RUN) {
    console.log("DRY_RUN is false. Confirm the SKU list above before running any url_key PUT.");
  }
  return affected;
}

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

Add a test

The classification rule is the part most worth testing, because it decides whether a real product URL gets flagged as broken by store configuration. Because classify_url_suffix_risk is pure, the test needs no network and no Magento store. It just feeds in the four config values plus a path and a status code, and checks the answer against every meaningful combination.

test_disabled_rewrite_risk.py
from url_suffix_risk_check import classify_url_suffix_risk


def config(**over):
    base = {
        "productUrlSuffix": "",
        "categoryUrlSuffix": "",
        "useCategoriesPathForProductUrls": True,
        "generateCategoryProductRewrites": False,
    }
    base.update(over)
    return base


def test_affected_when_all_conditions_and_404():
    result = classify_url_suffix_risk(config(), "test-category/test-sub-category/test", 404)
    assert result == {"affected": True, "reason": "empty-suffix-category-path-collision"}


def test_affected_when_all_conditions_and_500():
    result = classify_url_suffix_risk(config(), "test-category/test", 500)
    assert result["affected"] is True


def test_not_affected_when_product_suffix_present():
    result = classify_url_suffix_risk(config(productUrlSuffix="html"), "test-category/test", 404)
    assert result == {"affected": False, "reason": "suffix-present"}


def test_not_affected_when_category_suffix_present():
    result = classify_url_suffix_risk(config(categoryUrlSuffix="html"), "test-category/test", 404)
    assert result == {"affected": False, "reason": "suffix-present"}


def test_not_affected_when_categories_not_used_in_path():
    result = classify_url_suffix_risk(config(useCategoriesPathForProductUrls=False), "test", 404)
    assert result == {"affected": False, "reason": "no-category-path"}


def test_not_affected_when_rewrites_enabled():
    result = classify_url_suffix_risk(config(generateCategoryProductRewrites=True), "test-category/test", 404)
    assert result == {"affected": False, "reason": "rewrites-enabled"}


def test_not_affected_when_path_has_no_category_segment():
    result = classify_url_suffix_risk(config(), "test", 404)
    assert result == {"affected": False, "reason": "no-category-path"}


def test_not_affected_when_status_is_200():
    result = classify_url_suffix_risk(config(), "test-category/test", 200)
    assert result == {"affected": False, "reason": "ok"}


def test_not_affected_when_status_is_301():
    result = classify_url_suffix_risk(config(), "test-category/test", 301)
    assert result == {"affected": False, "reason": "ok"}
disabled-rewrite-risk.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyUrlSuffixRisk } from "./url-suffix-risk-check.js";

const config = (over = {}) => ({
  productUrlSuffix: "",
  categoryUrlSuffix: "",
  useCategoriesPathForProductUrls: true,
  generateCategoryProductRewrites: false,
  ...over,
});

test("affected when all conditions and 404", () => {
  const result = classifyUrlSuffixRisk(config(), "test-category/test-sub-category/test", 404);
  assert.deepEqual(result, { affected: true, reason: "empty-suffix-category-path-collision" });
});

test("affected when all conditions and 500", () => {
  const result = classifyUrlSuffixRisk(config(), "test-category/test", 500);
  assert.equal(result.affected, true);
});

test("not affected when product suffix present", () => {
  const result = classifyUrlSuffixRisk(config({ productUrlSuffix: "html" }), "test-category/test", 404);
  assert.deepEqual(result, { affected: false, reason: "suffix-present" });
});

test("not affected when category suffix present", () => {
  const result = classifyUrlSuffixRisk(config({ categoryUrlSuffix: "html" }), "test-category/test", 404);
  assert.deepEqual(result, { affected: false, reason: "suffix-present" });
});

test("not affected when categories not used in path", () => {
  const result = classifyUrlSuffixRisk(config({ useCategoriesPathForProductUrls: false }), "test", 404);
  assert.deepEqual(result, { affected: false, reason: "no-category-path" });
});

test("not affected when rewrites enabled", () => {
  const result = classifyUrlSuffixRisk(config({ generateCategoryProductRewrites: true }), "test-category/test", 404);
  assert.deepEqual(result, { affected: false, reason: "rewrites-enabled" });
});

test("not affected when path has no category segment", () => {
  const result = classifyUrlSuffixRisk(config(), "test", 404);
  assert.deepEqual(result, { affected: false, reason: "no-category-path" });
});

test("not affected when status is 200", () => {
  const result = classifyUrlSuffixRisk(config(), "test-category/test", 200);
  assert.deepEqual(result, { affected: false, reason: "ok" });
});

test("not affected when status is 301", () => {
  const result = classifyUrlSuffixRisk(config(), "test-category/test", 301);
  assert.deepEqual(result, { affected: false, reason: "ok" });
});

Case studies

SEO cleanup gone wrong

Clean URLs broke the exact products they were meant to help

A merchant on Adobe Commerce removed the trailing .html from every product and category URL as part of an SEO refresh, and had already turned off category and product rewrite generation months earlier to shave time off a nightly reindex. The two changes had shipped separately and each looked fine in isolation. Within days, support tickets came in for a specific slice of products, ones nested two categories deep, returning 404 where they used to load.

Running the script against the storefront confirmed the pattern: both suffixes were empty, the affected paths all contained a category segment, and every one of them came back 404 live. The report gave the exact bin/magento config:set command to restore a suffix, which the team ran in staging first, then in production, followed by a catalog_url_rewrite reindex.

Legacy Magento upgrade

A pre-2.4.3 store saw 500s instead of 404s

An older Magento instance running just before the 2.4.3 line hit the same empty suffix and disabled rewrite combination, but instead of a clean 404 the storefront returned a 500 with a type error buried in the log, since the router in that version was less defensive about a malformed category path. Nobody suspected the SEO settings because the error looked like a code bug.

The detection script's live GET step caught the 500 status directly, and cross referencing it against the empty suffix config in storeConfigs pointed straight at the actual cause well before anyone opened a debugger.

What good looks like

After this runs, a merchant knows immediately which stores carry the risky combination and which specific products actually fail live, instead of guessing from a pile of unrelated 404 or 500 reports. The fix itself stays where it belongs, a CLI config change and a reindex, and the script never pretends it can PUT that setting through REST. Everything it reports comes with the exact command to run next.

FAQ

Why does a product page 404 or 500 only when both URL suffixes are empty?

When Generate category/product URL Rewrites is set to No, Magento resolves the request path on the fly with DynamicStorage instead of reading a precomputed url_rewrite row. That code strips the product's url_key off the end of the full path using str_replace. A suffix such as .html gives it a clear anchor to cut at. With no suffix at all, str_replace can strip the wrong occurrence or fail to isolate the category path, so the category lookup fails and the router falls through to a 404 or throws a 500.

Is this a data problem I can fix by editing products or categories?

No. This is a store configuration combination, not corrupt catalog data, so there is nothing wrong to repair on the product or category records themselves. The fix is a configuration change: set a non-empty catalog/seo/product_url_suffix such as html, or enable catalog/seo/generate_category_product_rewrites so Magento uses precomputed rewrite rows instead of DynamicStorage.

Can a script fix the empty suffix automatically?

Not safely over REST. store/storeConfigs is read only, so the actual fix runs through bin/magento config:set on the CLI followed by a reindex of catalog_url_rewrite, which REST cannot trigger. The script detects the risky combination and the affected SKUs, prints the exact CLI commands to run, and only touches a product's url_key directly when DRY_RUN is turned off and a human has confirmed the SKU list.

Related field notes

Citations

On the problem:

  1. magento/magento2: Product page gives error because of url rewrites. github.com/magento/magento2/issues/35371
  2. magento/magento2: Category URL rewrites not generated after changes to catalog/seo/category_url_suffix, product_url_suffix or product_use_categories. github.com/magento/magento2/issues/17585
  3. magento/magento2: Setting catalog/seo/category_url_suffix through Admin to an empty string sets value to NULL. github.com/magento/magento2/issues/29442

On the solution:

  1. Adobe Commerce: Catalog and product URLs. experienceleague.adobe.com catalog-urls
  2. Adobe Commerce: Catalog configuration paths reference. experienceleague.adobe.com config-reference-catalog
  3. Adobe Commerce: Product settings, Search Engine Optimization. experienceleague.adobe.com product-search-engine-optimization

Stuck on a tricky one?

If you have a problem in Magento catalog data, URL rewrites, cron, or MSI stock 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 your 404s?

If this saved you a broken product page, a confusing 500, or a wasted afternoon staring at SEO settings, 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