#5325·vendure

createProductVariants rejects multiple option-less variants (unlike import/update)

Author: kwerieCreated Sep 9, 2026Updated Sep 15, 2026
Labelstype: bug 🐛

Describe the bug

ProductVariantService.createProductVariant throws error.product-variant-options-combination-already-exists when creating a second variant on a product that has no option groups. This is only reachable via the API; the Admin UI (dashboard) has no path to it, since it forks a new product into either "Simple product" (a single variant, no options) or "Product with options" (variants defined by option groups). Nothing in the UI lets you add a second variant to an option-less product.

The behavior is also inconsistent with the rest of the core: a product can hold multiple option-less variants when they're created via FastImporterService, and since #3361 the updateProductVariant path explicitly skips this collision check (isUpdateOperation). Only the create path still rejects them.

The check is logically meaningless when a product has zero option groups; there is no option combination to collide on, so every variant resolves to the same empty option set ('' === '') and the second one is always rejected.

To Reproduce

The Admin UI offers no way to reproduce this (see above), so it must be done via the Admin API. For a product that has no option groups:

  1. Create a first variant via the createProductVariants mutation with optionIds: []. This succeeds.
  2. Create a second variant for the same product, again with optionIds: [].
  3. See error: error.product-variant-options-combination-already-exists.

Expected behavior

A product with no option groups should accept multiple variants on the create path, consistent with FastImporterService.createProductVariant and the updateProductVariant mutation, which both already allow it.

Actual behavior

The second createProductVariants call fails with a UserInputError (error.product-variant-options-combination-already-exists), and no variant is created.

Error logs

UserInputError: error.product-variant-options-combination-already-exists
    at ProductVariantService.validateVariantOptionIds (.../service/services/product-variant.service.ts)
    at ProductVariantService.createSingle (...)
    at ProductVariantService.create (...)

Environment (please complete the following information):

  • @vendure/core version: 3.7.3
  • Nodejs version: 24.11.1
  • Database (mysql/postgres etc): postgres 16
  • Operating System (Windows/macOS/Linux): Linux
  • Browser (if applicable): N/A
  • Package manager (npm/yarn/pnpm): pnpm 10.2.0

Configuration

N/A, reproducible on a default configuration.

Minimal reproduction

Against the Admin API, for a product (id: 1 below) that has no option groups:

graphql
# 1st variant: succeeds
mutation {
  createProductVariants(input: [{
    productId: 1
    sku: "SKU-A"
    optionIds: []
    translations: [{ languageCode: en, name: "Variant A" }]
  }]) { id sku }
}

# 2nd variant: throws error.product-variant-options-combination-already-exists
mutation {
  createProductVariants(input: [{
    productId: 1
    sku: "SKU-B"
    optionIds: []
    translations: [{ languageCode: en, name: "Variant B" }]
  }]) { id sku }
}

The relevant check is the loop at the end of the private validateVariantOptionIds in the ProductVariantService:

typescript
product.variants
    .filter(v => !v.deletedAt)
    .forEach(variant => {
        const variantOptionIds = this.sortJoin(variant.options, ',', 'id');
        if (isUpdateOperation) return;
        if (variantOptionIds === inputOptionIds) {
            throw new UserInputError('error.product-variant-options-combination-already-exists', {
                variantName: this.translator.translate(variant, ctx).name,
            });
        }
    });

A minimal fix would be to skip this check when the product has no active option groups, symmetric to the isUpdateOperation carve-out added in #3361, e.g. return early when activeOptions.length === 0, since there is no meaningful combination to validate.

If the "one variant per option-less product" limit is intentional, then consider this a request to relax it, but the fact that FastImporterService and the updateProductVariant path both permit multiple option-less variants suggests the create-path rejection is an oversight rather than a deliberate constraint.

Workaround

Subclass ProductVariantService and override validateVariantOptionIds to delegate to super and swallow only error.product-variant-options-combination-already-exists when optionIds is empty (which can only occur when the product has no option groups); rethrow everything else. Alternatively, create the variant through FastImporterService.createProductVariant, which bypasses the check though it runs in its own import context and does not publish ProductVariantEvent.

Motivation / use case

We hit this while syncing products from an external ERP. The ERP groups several sellable SKUs under a single parent product, but those SKUs are not differentiated by any structured attribute (no size/colour/etc.); they differ only by SKU and description. Modelling them as option groups would be artificial: it forces us to invent an option whose only value is the SKU, which then surfaces as a meaningless variant selector on the storefront.

The initial catalogue load goes through FastImporterService, so these multi-variant option-less products get created fine. The problem is incremental sync: when a new SKU arrives for an already-imported parent, we create the variant through the createProductVariants service/mutation, which is the one path that rejects it. So the same product shape is creatable in bulk and editable via update, but cannot be extended one variant at a time.

Being able to have multiple variants without option groups would be a genuinely useful supported capability for (ERP) integrations where a product legitimately has several SKUs with no structured variant axis. Today it's only achievable by bypassing core (import service or a validateVariantOptionIds override), which is fragile.

Additional context

  • The create-path check is long-standing; it is not a recent regression.
  • #3361 (Feb 2025) added isUpdateOperation to skip this same check on the update path, which is the precedent for extending the carve-out to the create path.
  • Happens consistently, on a default configuration.

If the direction sounds right, we're happy to open a PR for this. Our default would be the activeOptions.length === 0 carve-out plus a test, but we're open to whatever shape you prefer, e.g. gating it behind a config option if you'd rather not change the default behavior.