Diagnostic Modules, links, and data
Medusa variant options mismatch blocks creation
You add a variant, or import a CSV, or edit a product's options after variants already exist, and Medusa refuses to save it. The error talks about option lengths not matching, or a variant that already exists, and it is not obvious which variant or which option is actually wrong. Here is why Medusa v2 is strict about every variant carrying a complete, valid set of option values, and a script that scans your catalog and tells you exactly which product and variant to fix.
A Medusa v2 product's options array defines the allowed option axes, things like Color or Size, each with a title and a list of allowed values. Every ProductVariant must carry exactly one value for each of those titles in its own options map. Creation fails whenever a variant's options are incomplete relative to the product, a missing title, an extra or unknown title, or a value that is not in that option's values list, because the product module validates variant option completeness and consistency before it ever persists the row. Run a small Python or Node.js script that expands every product with its options and variants, computes the required title set from the product and the actual title set from each variant, and reports the exact missing titles, extra titles, and invalid values per variant, so a merchant can supply the one piece of data Medusa cannot infer: the correct value.
The problem in plain words
In Medusa v2, a product's options array is the contract for what a variant is allowed to look like. Each entry is a title, like Color or Size, and a values list of the choices that are valid under that title. A variant does not carry a free form description of itself. It carries an options map, title to value, and Medusa expects that map to have exactly one entry for every title the product defines, using a value that is actually in that title's allowed list.
The product module checks this before it lets the variant be created or updated. If a variant is missing a title the product requires, if it carries a title the product no longer defines, or if its value for a title is not one of the option's real values, the write is rejected. The most common surface is an error like Product options length does not match variant options length, but you also see duplicate or "already exists" conflicts when two different incomplete variants both collapse onto the same partial combination, because Medusa cannot tell them apart once the missing pieces are dropped.
Why it happens
Medusa's variant validation is strict on purpose, because the options map is what lets checkout and inventory tell variants apart. A few concrete ways stores end up with a mismatch:
- A CSV or bulk import builds each row's
optionsfrom partial source data, so a row missing a Size column produces a variant with only Color set. - A programmatic SDK call assembles
optionsfrom a partial object, and a typo or missing key silently drops a title before the request is even sent. - Someone edits a product's options in the admin UI after variants already exist, adding a new option title like Material, and Medusa v2 does not auto backfill that new title's value onto the variants that were created before it existed.
- An option's
valueslist is edited, a value is renamed or removed, and an existing variant is left pointing at a value that is no longer in that option's allowed list. - Two separate incomplete variants both drop the same missing title, so once Medusa reads only the remaining values they look like the same combination, and the second insert fails as a duplicate.
This is a common source of confusion because the failure often happens well after the product itself was created successfully, on the next variant add or the next options edit. Medusa's own issue tracker has reports of this exact "options length does not match" error on API product creation, of variants not being editable once this state exists, and of corrupted state in the edit options and edit variants admin screens for existing products. See the citations at the end for the exact threads and docs.
The correct option value for a specific variant, which Size a given SKU actually is, is business data that only a merchant knows. A script cannot guess it, so this is a detect and report problem, not an auto-fix problem. The one thing that is safe to automate is aligning the product's option definitions themselves, adding a missing option title or a missing value to an existing option, never inventing what value belongs on a specific variant.
The fix, as a flow
We list every product with its options and variants expanded, build the required title set from each product's options, and compare it against what each variant's options map actually has. Anything incomplete, extra, or invalid gets reported with the product id, the variant id, and exactly what is wrong, so a human operator can supply the missing value with full context.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read products and variants, and to write product options if you opt into the guarded repair. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, this fix only ever reports by default
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, this fix only ever reports by default
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List products with options and variants expanded
Ask for id,title,*options,*options.values,*variants,*variants.options,*variants.options.option so each product carries its full option definitions and every variant carries its actual option values. Page through with limit and offset since a real catalog will not fit in one page.
PRODUCT_FIELDS = (
"id,title,*options,*options.values,*variants,"
"*variants.options,*variants.options.option"
)
def list_products(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/products",
params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
const PRODUCT_FIELDS =
"id,title,*options,*options.values,*variants," +
"*variants.options,*variants.options.option";
async function* listProducts(sdk, limit = 100) {
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({ fields: PRODUCT_FIELDS, limit, offset });
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
Normalize a variant's options into a plain map
The expanded admin response gives each variant option entry as { option: { title }, value }, while SDK payloads sometimes carry a flatter options map already. Normalize both shapes into a single title -> value object before comparing, so the decision function only ever has to deal with one shape.
def normalize_variant_options(variant):
"""Accepts either the expanded admin shape (a list of
{option: {title}, value}) or an already-flat {title: value} map,
and returns a plain {title: value} dict either way."""
raw = variant.get("options")
if isinstance(raw, dict):
return dict(raw)
flat = {}
for entry in raw or []:
title = (entry.get("option") or {}).get("title")
if title:
flat[title] = entry.get("value")
return flat
function normalizeVariantOptions(variant) {
// Accepts either the expanded admin shape (a list of
// { option: { title }, value }) or an already-flat { title: value }
// map, and returns a plain { title: value } object either way.
const raw = variant.options;
if (raw && !Array.isArray(raw) && typeof raw === "object") return { ...raw };
const flat = {};
for (const entry of raw || []) {
const title = entry.option && entry.option.title;
if (title) flat[title] = entry.value;
}
return flat;
}
Find incomplete variants with one pure function
Compute the required title set from product.options[].title, then for each variant compare its normalized option titles against that set and check every value against the option's real values list. A variant is only flagged when it has a missing title, an extra title, or an invalid value, so a clean catalog reports nothing.
def find_incomplete_variants(product):
"""Pure: no I/O. Returns a list of {variant_id, variant_title,
missing_titles, extra_titles, invalid_values} for every variant whose
normalized options do not exactly match the product's option set."""
options = product.get("options") or []
required_titles = {o["title"] for o in options}
values_by_title = {o["title"]: {v["value"] for v in (o.get("values") or [])} for o in options}
results = []
for variant in product.get("variants") or []:
variant_options = normalize_variant_options(variant)
variant_titles = set(variant_options.keys())
missing_titles = sorted(required_titles - variant_titles)
extra_titles = sorted(variant_titles - required_titles)
invalid_values = []
for title, value in variant_options.items():
if title in required_titles and value not in values_by_title.get(title, set()):
invalid_values.append({"title": title, "value": value})
if missing_titles or extra_titles or invalid_values:
results.append({
"variant_id": variant.get("id"),
"variant_title": variant.get("title"),
"missing_titles": missing_titles,
"extra_titles": extra_titles,
"invalid_values": invalid_values,
})
return results
export function findIncompleteVariants(product) {
// Pure: no I/O. Returns a list of {variant_id, variant_title,
// missing_titles, extra_titles, invalid_values} for every variant whose
// normalized options do not exactly match the product's option set.
const options = product.options || [];
const requiredTitles = new Set(options.map((o) => o.title));
const valuesByTitle = new Map(
options.map((o) => [o.title, new Set((o.values || []).map((v) => v.value))])
);
const results = [];
for (const variant of product.variants || []) {
const variantOptions = normalizeVariantOptions(variant);
const variantTitles = new Set(Object.keys(variantOptions));
const missingTitles = [...requiredTitles].filter((t) => !variantTitles.has(t)).sort();
const extraTitles = [...variantTitles].filter((t) => !requiredTitles.has(t)).sort();
const invalidValues = [];
for (const [title, value] of Object.entries(variantOptions)) {
if (requiredTitles.has(title) && !(valuesByTitle.get(title) || new Set()).has(value)) {
invalidValues.push({ title, value });
}
}
if (missingTitles.length || extraTitles.length || invalidValues.length) {
results.push({
variant_id: variant.id,
variant_title: variant.title,
missing_titles: missingTitles,
extra_titles: extraTitles,
invalid_values: invalidValues,
});
}
}
return results;
}
Report only, never guess a variant's value
Run find_incomplete_variants against every product from the list, and log each offending product_id, variant_id, and the exact missing or invalid titles. The script never calls POST /admin/products/:id/variants/:variant_id to fill in a value on its own, because that value is a merchant decision. If you want the one safe automated case, adding a missing option title or a missing value to values via POST /admin/products/:id/options or /options/:option_id, gate it behind DRY_RUN and log the intended payload before any live write.
This script defaults to detect and report only. Setting a variant's missing option value must go through an explicit options map an operator supplies by hand on POST /admin/products/:id/variants/:variant_id. The only thing this script will ever write, and only with DRY_RUN=false, is aligning the product's own option definitions to values that already exist on its variants, never a guessed value on a variant itself.
The full code
Here is the complete script in one file for each language. It authenticates, lists every product with options and variants expanded, runs the pure decision function against each one, and prints a report of every mismatched variant for a merchant to fix.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa products whose variants have an options mismatch that would
block creation: a missing option title, an extra title, or an invalid value.
Report only. Never guesses or writes a variant's option value.
Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_variant_options_mismatch")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCT_FIELDS = (
"id,title,*options,*options.values,*variants,"
"*variants.options,*variants.options.option"
)
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_products(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/products",
params={"fields": PRODUCT_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
def normalize_variant_options(variant):
"""Accepts either the expanded admin shape (a list of
{option: {title}, value}) or an already-flat {title: value} map,
and returns a plain {title: value} dict either way."""
raw = variant.get("options")
if isinstance(raw, dict):
return dict(raw)
flat = {}
for entry in raw or []:
title = (entry.get("option") or {}).get("title")
if title:
flat[title] = entry.get("value")
return flat
def find_incomplete_variants(product):
"""Pure: no I/O. Returns a list of {variant_id, variant_title,
missing_titles, extra_titles, invalid_values} for every variant whose
normalized options do not exactly match the product's option set."""
options = product.get("options") or []
required_titles = {o["title"] for o in options}
values_by_title = {o["title"]: {v["value"] for v in (o.get("values") or [])} for o in options}
results = []
for variant in product.get("variants") or []:
variant_options = normalize_variant_options(variant)
variant_titles = set(variant_options.keys())
missing_titles = sorted(required_titles - variant_titles)
extra_titles = sorted(variant_titles - required_titles)
invalid_values = []
for title, value in variant_options.items():
if title in required_titles and value not in values_by_title.get(title, set()):
invalid_values.append({"title": title, "value": value})
if missing_titles or extra_titles or invalid_values:
results.append({
"variant_id": variant.get("id"),
"variant_title": variant.get("title"),
"missing_titles": missing_titles,
"extra_titles": extra_titles,
"invalid_values": invalid_values,
})
return results
def run():
token = get_token()
flagged_products = 0
flagged_variants = 0
for product in list_products(token):
mismatches = find_incomplete_variants(product)
if not mismatches:
continue
flagged_products += 1
for m in mismatches:
flagged_variants += 1
log.info(
"%s product %s (%s) variant %s (%s): missing=%s extra=%s invalid=%s",
"Would flag" if DRY_RUN else "Flagging",
product.get("id"), product.get("title"),
m["variant_id"], m["variant_title"],
m["missing_titles"], m["extra_titles"], m["invalid_values"],
)
log.info(
"Done. %d product(s), %d variant(s) flagged for merchant follow-up.",
flagged_products, flagged_variants,
)
if __name__ == "__main__":
run()
/**
* Find Medusa products whose variants have an options mismatch that would
* block creation: a missing option title, an extra title, or an invalid value.
* Report only. Never guesses or writes a variant's option value.
* Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCT_FIELDS =
"id,title,*options,*options.values,*variants," +
"*variants.options,*variants.options.option";
function normalizeVariantOptions(variant) {
// Accepts either the expanded admin shape (a list of
// { option: { title }, value }) or an already-flat { title: value }
// map, and returns a plain { title: value } object either way.
const raw = variant.options;
if (raw && !Array.isArray(raw) && typeof raw === "object") return { ...raw };
const flat = {};
for (const entry of raw || []) {
const title = entry.option && entry.option.title;
if (title) flat[title] = entry.value;
}
return flat;
}
export function findIncompleteVariants(product) {
// Pure: no I/O. Returns a list of {variant_id, variant_title,
// missing_titles, extra_titles, invalid_values} for every variant whose
// normalized options do not exactly match the product's option set.
const options = product.options || [];
const requiredTitles = new Set(options.map((o) => o.title));
const valuesByTitle = new Map(
options.map((o) => [o.title, new Set((o.values || []).map((v) => v.value))])
);
const results = [];
for (const variant of product.variants || []) {
const variantOptions = normalizeVariantOptions(variant);
const variantTitles = new Set(Object.keys(variantOptions));
const missingTitles = [...requiredTitles].filter((t) => !variantTitles.has(t)).sort();
const extraTitles = [...variantTitles].filter((t) => !requiredTitles.has(t)).sort();
const invalidValues = [];
for (const [title, value] of Object.entries(variantOptions)) {
if (requiredTitles.has(title) && !(valuesByTitle.get(title) || new Set()).has(value)) {
invalidValues.push({ title, value });
}
}
if (missingTitles.length || extraTitles.length || invalidValues.length) {
results.push({
variant_id: variant.id,
variant_title: variant.title,
missing_titles: missingTitles,
extra_titles: extraTitles,
invalid_values: invalidValues,
});
}
}
return results;
}
async function login() {
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function* listProducts(sdk, limit = 100) {
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({ fields: PRODUCT_FIELDS, limit, offset });
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
export async function run() {
const sdk = await login();
let flaggedProducts = 0;
let flaggedVariants = 0;
for await (const product of listProducts(sdk)) {
const mismatches = findIncompleteVariants(product);
if (mismatches.length === 0) continue;
flaggedProducts += 1;
for (const m of mismatches) {
flaggedVariants += 1;
console.log(
`${DRY_RUN ? "Would flag" : "Flagging"} product ${product.id} (${product.title}) ` +
`variant ${m.variant_id} (${m.variant_title}): ` +
`missing=${JSON.stringify(m.missing_titles)} extra=${JSON.stringify(m.extra_titles)} ` +
`invalid=${JSON.stringify(m.invalid_values)}`
);
}
}
console.log(
`Done. ${flaggedProducts} product(s), ${flaggedVariants} variant(s) flagged for merchant follow-up.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing above everything else is the mismatch finder, because it decides which variants actually block your catalog. Because find_incomplete_variants is pure, the tests feed in plain product and variant objects, no Medusa backend required.
from find_variant_options_mismatch import find_incomplete_variants, normalize_variant_options
def product(**over):
base = {
"id": "prod_1",
"options": [
{"title": "Color", "values": [{"value": "Red"}, {"value": "Blue"}]},
{"title": "Size", "values": [{"value": "S"}, {"value": "M"}]},
],
"variants": [],
}
base.update(over)
return base
def variant(**over):
base = {"id": "variant_1", "title": "Red / S", "options": {"Color": "Red", "Size": "S"}}
base.update(over)
return base
def test_complete_variant_is_not_flagged():
p = product(variants=[variant()])
assert find_incomplete_variants(p) == []
def test_missing_title_is_flagged():
v = variant(options={"Color": "Red"})
result = find_incomplete_variants(product(variants=[v]))
assert len(result) == 1
assert result[0]["missing_titles"] == ["Size"]
assert result[0]["extra_titles"] == []
assert result[0]["invalid_values"] == []
def test_extra_title_is_flagged():
v = variant(options={"Color": "Red", "Size": "S", "Material": "Cotton"})
result = find_incomplete_variants(product(variants=[v]))
assert result[0]["extra_titles"] == ["Material"]
def test_invalid_value_is_flagged():
v = variant(options={"Color": "Green", "Size": "S"})
result = find_incomplete_variants(product(variants=[v]))
assert result[0]["invalid_values"] == [{"title": "Color", "value": "Green"}]
def test_multiple_variants_only_flags_the_bad_one():
good = variant(id="variant_ok", options={"Color": "Blue", "Size": "M"})
bad = variant(id="variant_bad", options={"Color": "Red"})
result = find_incomplete_variants(product(variants=[good, bad]))
assert len(result) == 1
assert result[0]["variant_id"] == "variant_bad"
def test_normalize_variant_options_handles_expanded_admin_shape():
v = {
"id": "variant_2",
"options": [
{"option": {"title": "Color"}, "value": "Red"},
{"option": {"title": "Size"}, "value": "S"},
],
}
assert normalize_variant_options(v) == {"Color": "Red", "Size": "S"}
def test_normalize_variant_options_handles_flat_map():
v = {"id": "variant_3", "options": {"Color": "Blue", "Size": "M"}}
assert normalize_variant_options(v) == {"Color": "Blue", "Size": "M"}
def test_product_with_no_variants_reports_nothing():
assert find_incomplete_variants(product(variants=[])) == []
def test_expanded_shape_flows_through_find_incomplete_variants():
v = {
"id": "variant_4",
"title": "Green / L",
"options": [
{"option": {"title": "Color"}, "value": "Green"},
{"option": {"title": "Size"}, "value": "L"},
],
}
result = find_incomplete_variants(product(variants=[v]))
assert len(result) == 1
assert {"title": "Color", "value": "Green"} in result[0]["invalid_values"]
assert {"title": "Size", "value": "L"} in result[0]["invalid_values"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findIncompleteVariants } from "./find-variant-options-mismatch.js";
const product = (over = {}) => ({
id: "prod_1",
options: [
{ title: "Color", values: [{ value: "Red" }, { value: "Blue" }] },
{ title: "Size", values: [{ value: "S" }, { value: "M" }] },
],
variants: [],
...over,
});
const variant = (over = {}) => ({
id: "variant_1",
title: "Red / S",
options: { Color: "Red", Size: "S" },
...over,
});
test("complete variant is not flagged", () => {
const p = product({ variants: [variant()] });
assert.deepEqual(findIncompleteVariants(p), []);
});
test("missing title is flagged", () => {
const v = variant({ options: { Color: "Red" } });
const result = findIncompleteVariants(product({ variants: [v] }));
assert.equal(result.length, 1);
assert.deepEqual(result[0].missing_titles, ["Size"]);
assert.deepEqual(result[0].extra_titles, []);
assert.deepEqual(result[0].invalid_values, []);
});
test("extra title is flagged", () => {
const v = variant({ options: { Color: "Red", Size: "S", Material: "Cotton" } });
const result = findIncompleteVariants(product({ variants: [v] }));
assert.deepEqual(result[0].extra_titles, ["Material"]);
});
test("invalid value is flagged", () => {
const v = variant({ options: { Color: "Green", Size: "S" } });
const result = findIncompleteVariants(product({ variants: [v] }));
assert.deepEqual(result[0].invalid_values, [{ title: "Color", value: "Green" }]);
});
test("multiple variants only flags the bad one", () => {
const good = variant({ id: "variant_ok", options: { Color: "Blue", Size: "M" } });
const bad = variant({ id: "variant_bad", options: { Color: "Red" } });
const result = findIncompleteVariants(product({ variants: [good, bad] }));
assert.equal(result.length, 1);
assert.equal(result[0].variant_id, "variant_bad");
});
test("normalizes the expanded admin shape via findIncompleteVariants", () => {
const v = {
id: "variant_4",
title: "Green / L",
options: [
{ option: { title: "Color" }, value: "Green" },
{ option: { title: "Size" }, value: "L" },
],
};
const result = findIncompleteVariants(product({ variants: [v] }));
assert.equal(result.length, 1);
assert.deepEqual(result[0].invalid_values.map((iv) => iv.title).sort(), ["Color", "Size"]);
});
test("product with no variants reports nothing", () => {
assert.deepEqual(findIncompleteVariants(product({ variants: [] })), []);
});
Case studies
The CSV with a blank Size column on half the rows
A clothing store imported a few hundred variants from a spreadsheet where a formatting glitch left the Size column blank on every row for one supplier. The import script built each variant's options straight from the row, so those variants only ever carried a Color value. Some rows saved, most failed outright, and a few that used the same Color as another blank row collided as duplicates once Medusa dropped the missing Size.
Running the scan against the catalog listed every affected variant by id with missing_titles: ["Size"], which let the merchandising team go back to the original spreadsheet, read off the real size for each SKU, and fix the import in one clean pass instead of guessing from the product photos.
The Material option added months after variants existed
A homeware brand launched with just Color and Size, then months later added a Material option to differentiate a new fabric line, editing the product's options directly in the admin. Every variant created before that edit kept working in the storefront, but any attempt to touch those variants through the API, or add one more variant to the same product, started failing because the older variants had no Material value at all.
The scan flagged every pre-existing variant on that product with missing_titles: ["Material"]. The merchant already knew every one of those older variants was the original Cotton fabric, so they filled in that single value across the flagged variants through the admin, and new variant creation on the product started working again.
Run this scan any time a product refuses to save a new variant, or on a schedule against your whole catalog after a bulk import. It never invents a Size or a Color for you, it only tells you the exact product, the exact variant, and the exact title or value that is wrong. A merchant fills in the one thing only they know, and the next variant creation or edit on that product goes through clean.
FAQ
Why does Medusa refuse to create or save my product variant?
Every ProductVariant in Medusa v2 must carry exactly one value for each option title defined on its product's options array. If the variant is missing a title, carries a title the product does not define, or uses a value that is not in that option's values list, the product module rejects it before it is persisted, often with an error like Product options length does not match variant options length.
Why did this happen after a CSV import or a bulk script?
Bulk imports and programmatic SDK calls often build each variant's options map from partial source rows, so a row missing a Size column produces a variant with only Color set. Medusa v2 also does not auto backfill missing option values on existing variants when a product's options are edited afterward, so an option added later leaves every existing variant incomplete until someone fills in the missing value.
Can a script safely fix a variant options mismatch on its own?
Not the value itself. The correct option value, for example which Size a specific variant should be, is business data only a merchant knows, so a script should only detect and report the mismatch. The one safe automated repair is aligning the product's option definitions, adding a missing option title or a missing value to an option's values list, never guessing what value belongs on a specific variant.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #4882: Error "Product options length does not match variant options length" when adding a product via API. github.com/medusajs/medusa/issues/4882
- medusajs/medusa GitHub issue #9620: Product variants are not editable (v2). github.com/medusajs/medusa/issues/9620
- medusajs/medusa GitHub issue #4738: Admin UI, corrupted options in Edit Options and Edit Variants and Add Variants for existing product. github.com/medusajs/medusa/issues/4738
On the solution:
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
- Medusa Workflows Reference: createProductVariantsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createProductVariantsWorkflow
- Medusa Admin User Guide: Manage Product Variants. docs.medusajs.com/user-guide/products/variants
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows 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 a broken import?
If this saved you a support ticket or hours of guessing which variant was wrong, 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