name: product-import-export-plugin title: Product Import & Export description: Vendure plugin for importing and exporting product data via CSV. version: 3.6.4 tags:
A Vendure plugin for importing and exporting product data via CSV. Supports bulk product management and data migration between environments.

Vendure ^3.6.0
Add the plugin to your Vendure configuration.
| Option | Type | Default | Description |
|---|---|---|---|
updateProductSlug | boolean | true | Update product slug from the name field in the CSV |
restoreSoftDeleted | boolean | true | Restore soft-deleted products/variants found by id or SKU during import. If false, new products will be created instead. |
storageStrategy | ImportJobStorageStrategy | local disk | Strategy instance for queued import file storage and retrieval. |
storageStrategyFactory | (injector: Injector) => ImportJobStorageStrategy | undefined | Factory for DI-aware strategy creation (recommended for S3/remote storage). |
importJobStorage | 'local' | 's3' | 'local' | Deprecated compatibility flag. Prefer storageStrategy / storageStrategyFactory. |
rowValidationStrategy | ImportRowValidationStrategy | accepts all | Checks the rows of a file before anything is written. See Row validation. |
Available import storage strategies:
LocalImportJobStorageStrategy (default)S3ImportJobStorageStrategy| Option | Type | Default | Description |
|---|---|---|---|
defaultFileName | string | 'products_export.csv' | Default filename for exports |
exportAssetsAsOptions | ('url' | 'json')[] | ['url', 'json'] | Available formats for asset export |
defaultExportAssetsAs | 'url' | 'json' | 'url' | Default asset export format |
defaultExportFields | string[] | See above | Fields that will be pre-selected in the export UI |
requiredExportFields | string[] | ['name', 'sku'] | Fields that must always be included |
customExportColumns | CustomExportColumn[] | undefined | Extra CSV columns resolved per variant row. See Custom export columns below. |
relationLookups | Record<string, string> | undefined | Relation custom fields written by a column of the related row instead of its id. See Exporting relations by a column below. |
storageStrategy | 's3' | 'disk' | 'disk' | Where exported files are stored |
s3Options | object | undefined | S3 configuration (required when storageStrategy is 's3') |
The plugin supports configurable storage for exported files:
importOptions.rowValidationStrategy lets you reject rows by your own rules. It runs on the parsed file twice:
400 with { "errors": [...] } and nothing is queued.Each error is shown as Row <line>: <message>, in one list with the relation lookup errors, ordered by row. Line 1 is the header; blank and comma-only lines are not counted. Numbers count CSV records, so a quoted cell that spans several lines is still one row.
A job that fails this way ends in the FAILED state with the list as its error, instead of COMPLETED with the errors in its result. The same holds when a relation lookup no longer matches when the job runs.
Each ImportRowGroup is one product: its first row (line, product) and every variant row under it (variants[].line, variants[].variant).
labelId, labelsIds) and hold id strings, also when the file names the related rows by a lookup column such as :sku or :code. An empty cell, and a value a lookup could not resolve, is left out.Asset relation keeps its cell text under the field name, since assets are created only when the row is written. Other custom fields keep their cell text under the field name.product.productId is set when the product's first row has a product id in the first column, that is when the row updates an existing product by id. Without it, the importer matches existing products by variant SKU.product.customFields holds the custom fields of the product's first translation, which include every non-localised field; each entry of product.translations has its own.init(injector) and destroy() are called when the application starts and stops. The default strategy accepts every row.
If validate throws, the upload answers 500 and nothing is queued, and a running job fails with the thrown error. Return errors instead to show them by row.
Lookup columns must be plain text columns of the related entity, so a translated field (for example a collection's name, which lives on its translation entity) cannot be one. The strategy does not run when a lookup header is wrong, since no row is read then.
Add columns to the exported CSV whose value is computed per variant row, for example a permalink built from the product slug and variant SKU.
name – Must be non-empty, must not contain : (colon-suffixed headers are reserved for translated columns), must not collide with a built-in export field name, and must be unique among customExportColumns entries. These rules are validated when the plugin is initialised, so a misconfigured column fails fast at startup rather than mid-export.onExportStart – Optional. Awaited once per export job, before the first resolve call.resolve(ctx, injector, product, variant) – Called once per exported variant row. Its return value is written to the cell (String(value), or an empty cell for null/undefined). Use injector to pull in Vendure services via DI.name is included in the selected export fields, both in the header row and in the request; otherwise it is skipped entirely.resolve throws, the plugin logs a warning and writes an empty cell for that row instead of failing the whole export.name – Product name (use name:en, name:sv etc. for multiple languages)sku – Variant SKU (unique per variant)| Column | Format | Example |
|---|---|---|
name, slug, description | Text, or name:en, name:sv for translations | My Product |
assets | Pipe-separated URLs or existing asset sources, or JSON [{"id":1,"url":"...","name":"..."}] | https://example.com/1.jpg|source/b6/2.jpg |
channels | Channel tokens, pipe-separated (see Assigning products to channels) | club-a|club-b |
facets | facet:value pairs, pipe-separated | Brand:Acme|Category:Electronics |
optionGroups | Pipe-separated group names. Use name:code to share a group across products (see Sharing option groups) | Size|Color or size:shoe-size |
optionValues | Pipe-separated values (order matches option groups) | Large|Blue |
price | Number (in smallest currency unit) | 9999 |
taxCategory | Tax category name | standard |
stockOnHand | Number | 100 |
trackInventory | true or false | true |
variantAssets | Same format as assets | https://example.com/variant.jpg |
variantFacets | Same format as facets | Material:Cotton |
enabled | true or false | true |
| Custom fields | Column name = custom field name; value depends on field type | Add columns for any Product or ProductVariant custom fields defined in your Vendure config. Relation custom fields export the related entity's id (id|id for list fields), including fields that relate a product or variant to its own entity type. |
optionGroups and optionValues are required.400 with { "errors": ["Invalid custom field: product:clubName"] }, the Dashboard lists the errors, and nothing is imported.slug, description, assets, channels, facets, optionGroups and product: custom fields) is read from a product's first row, the row with a name. If a later variant row of the product fills one of these columns, that product is not imported and the import reports the column and line. Other products in the file still import. id may repeat on every row.id, otherwise by its source. It is linked without being uploaded again, so an unchanged export imports with every image kept, also with replace. A value that matches no asset is reported as an error. A URL is downloaded unless an asset with the same file name exists.A product is always assigned to the default channel and to the channel the import runs in. The channels column assigns it to more channels, named by token, so one file run from the default channel can fill several channels:
name:code) and its options join every channel named by a product that uses them, since the Shop API resolves an option's group in the request's channel only. A variant added to the product later in the file gets the same channels.400 with the rows and nothing is imported:
Row 3: channels: no channel with token 'club-x'Row 2: channels: no right to update products in channel 'club-b': the uploader needs UpdateCatalog or UpdateProduct in every channel the file names.Row 2: channels: an import run in channel 'club-a' can only name 'club-a', not 'club-b': outside the default channel, a file can only name the channel it runs in.ProductImporter with a context without a user is trusted, so it is not checked for rights; the token and channel rules still apply.channels): from the default channel the product's other channels, by channel id; from another channel only that channel's token, so a club's file never shows which other channels sell the product. An exported file imports again.A relation custom field can name the related row by one of its columns instead of by id. Add the column as a fourth segment to the header, after the type (the related entity's class name in lower case):
400 with { "errors": ["Invalid lookup column \"title\" on ProductLabel"] }.deletedAt are skipped. Matching is exact, so case and spacing count. A list field takes VALUE|VALUE.400 with the rows, for example Row 2: no ProductLabel with code ECO or Row 3: code ECO matches 2 ProductLabel rows. :sku on a relation to ProductVariant keeps its own wording: Row 2: no variant with SKU BASE-TEE-M and Row 3: SKU BASE-TEE-M matches 2 variants. The Dashboard lists the rows and nothing is imported. The import job checks again when it runs; if a value no longer matches exactly one row, the job fails with the same list and nothing is imported.productvariant, productlabel; case does not matter), and a field takes one lookup column. Otherwise the upload answers 400 and names the column. :sku on a relation to ProductVariant accepts any third segment, as it always has.The export writes relation custom fields as ids unless exportOptions.relationLookups names a column for the field. Keys are <owner>:<field>, values the column of the related entity:
variant:baseVariant:productvariant:sku, product:label:productlabel:code) with the related rows' values, VALUE|VALUE for a list field. The import reads that header as described above, so the file imports again, also into another database where the ids differ.Asset. Otherwise the application does not start and the error names the key.By default, each product gets its own option groups. To share an option group across
multiple products (e.g. a common "Size" group for all shoes), use the name:code syntax in the
optionGroups column: size:shoe-size. All products that reference the same code will share
a single option group. Products using a plain name (e.g. just size) will each get their own
independent option group.
The first product to reference a given code defines the full set of option values for that
shared group; subsequent products may use any subset of those values. Codes are language-
agnostic — only the name portion before the colon is translated per language column
(optionGroups:en, optionGroups:sv, etc.).
Use language-specific columns by suffixing the field name with the language code. The language codes must match those configured in your Vendure instance (e.g. en, sv).
| Column pattern | Example columns | Use case |
|---|---|---|
name:en, name:sv | Product name per lang | Translated product names |
slug:en, slug:sv | Slug per lang | Translated URLs |
description:en | Description per lang | Translated descriptions |
facets:en, facets:sv | Facets per lang | Translated facet names and values |
optionGroups:en | Option groups per lang | Translated option group names (e.g. Size, Color) |
optionValues:en | Option values per lang | Translated option values (e.g. Large, Blue) |
Example CSV with English and Swedish:
Fields that can have multiple values use the pipe character | as a separator:
Brand:Acme|Category:ElectronicsSize|ColorLarge|Blue (order matches option groups)https://example.com/1.jpg|https://example.com/2.jpg:::note The Admin UI offers basic import and export. Some features such as the export-all option and the list of exported files are only available in the Dashboard. New features will be added to the Dashboard only. :::
optionGroups and optionValues must be included.channels is available as a selectable export field (see Assigning products to channels).id (the Vendure product id) is available as a selectable export field. It is written after the translated name/slug/description columns, so it is never CSV column 0, since import treats column 0 positionally as a product id for by-id matching.customExportColumns is configured, only the columns whose name is selected are written to the CSV.productId is not available as a selectable export field.customFields are not selected, no custom-field columns are written to the CSV.name through facets) and before optionGroups, which is the last product column so it sits next to optionValues, the first variant column. Product custom fields are filled on a product's first row only. Import reads product data from that row, so edit product fields there.
If you use @vendure/email-plugin, you can add an email notification when an export finishes from the /email subpath.
The plugin now provides a default fallback template for product-export-complete/body.hbs.
If you define your own template in your app's template directory, your template takes precedence.
Note: templates for @vendure/email-plugin should be valid MJML.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.