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.

Python and Node.js Medusa Admin API Report only, no guessed values
The short answer

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.

Product options Color, Size both required New variant options: { Color: Red } Size missing length check fails Rejected options length does not match Nothing saved
The product requires a value for every option title. A variant missing even one title, here Size, is rejected before it is ever persisted.

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:

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 key insight

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.

List products options + variants expanded Required titles from product.options[].title Check each variant titles and values Complete and valid? no yes, variant is fine Report mismatch for merchant follow up
The script only reports. It never invents a variant's missing option value, because that is business data only a merchant knows.

Build it step by step

1

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.

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

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.

step2.py
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"]
step2.js
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;
}
3

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.

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

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.

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

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.

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

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.

Run it safe

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_variant_options_mismatch.py
"""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-variant-options-mismatch.js
/**
 * 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.

test_variant_options_mismatch.py
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"]
variant-options-mismatch.test.js
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

Bulk import

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.

Options edited after the fact

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.

What good looks like

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:

  1. 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
  2. medusajs/medusa GitHub issue #9620: Product variants are not editable (v2). github.com/medusajs/medusa/issues/9620
  3. 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:

  1. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
  2. Medusa Workflows Reference: createProductVariantsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createProductVariantsWorkflow
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes