Skip to content

Powered by Grav CMS + Helios Theme

Importing

This page covers bringing products, categories, variants, customers and orders into KahunaCart from CSV files. It is for a store administrator migrating off another platform, and for anyone who updates prices or stock in bulk from an export. Coming from Squarespace? Skip to Moving from Squarespace, because one command does the whole move.

Moving a whole KahunaCart store, or bringing one back from a bundle, is a different job. Use Restore from a bundle on the same screen; see Export and restore. The importer is for data that comes from somewhere else.

Before you begin

  • You need kahunacart.products.manage. Importing customers or orders also needs kahunacart.orders.manage.
  • Grav's scheduler must be running. A started import is queued rather than run in the browser. See Troubleshooting.
  • Export your data as CSV. Excel's .xlsx is not read; save as CSV first.

What the importer guarantees

Guarantee What it means
Never deletes A row in the store that your file does not mention is left as it was. There is no mode that removes anything.
Idempotent Running the same file twice does what running it once does. Every row is matched on the key you chose.
A row fails alone One bad price fails that row. The other 499 import.
A dry run writes nothing Not a row, not a counter, not a record of the rehearsal.
Resumable A run interrupted halfway continues on the first row it did not finish, without duplicating anything.
Round-trips Exporting the catalog and importing that export back changes nothing.

The entities and the order to import them

The importer handles five entities. Import them in this order, because each depends on the ones before it.

Order Entity Matched by Why it comes here
1 categories slug, id Nothing depends on categories, and products name them.
2 products sku, slug, id A product names its categories. A category that is missing is created from the name.
3 variants sku, id A variant must name a parent product that already exists.
4 customers email, id Nothing depends on customers.
5 orders number, id An order resolves its customer by email, creating one where there is none.

A variants row naming a product that is not in the store fails that row and says so. It does not invent a product.

An orders file that carries email addresses needs no customers file at all.

Import a file

  1. Go to KahunaCart → Import & Export, and on the Import tab, open Import a file.
  2. Set What is in the file to the entity the file describes.
  3. Choose the CSV. KahunaCart reads the header row and suggests a field for each column.
  4. Correct the suggestions. A column you leave unmapped is ignored, and the run says how many were.
  5. Choose the match key. It must be one of the columns you mapped.
  6. Choose what happens to a row whose match key is already in the store: Update or Skip.
  7. Click Save mapping if you will import this export again.
  8. Click Dry run, and read the report.
  9. Click Start import.

The import is queued and runs in the background. The Runs tab shows progress, and you can leave the screen.

Column mappings

A mapping says which column of your file is which KahunaCart field. It stores the entity, the match key, the update-or-skip rule and the column map. It stores nothing about a particular file, so next month's export of the same report runs through the same mapping.

Saved mappings drive both the admin importer and bin/plugin kahunacart import --mapping=. Manage them on the Saved mappings tab, and re-use one on the next file with Start from a saved mapping.

Header detection

KahunaCart matches each heading against its own field names and their known aliases, for all five entities. WooCommerce and Shopify column names are recognised: a Woo export's Regular price maps to compare_price, a Shopify export's Variant Price maps to price, and Handle, Body (HTML), Variant Inventory Qty and Financial status all land on the right field.

Matching is exact, on the field name first and its aliases second, after lowercasing and dropping a leading wc_ or woo_. There is no fuzzy pass.

Each field is claimed once, first column wins. A Woo export carrying both Regular price and Sale price maps the first to compare_price and the second to price.

You still map by hand anything the alias list does not know: a custom column, a field an add-on registered, or a heading your spreadsheet invented. The suggestion is a starting point and is never applied on its own.

The match key

The match key is the field each row is looked up by. It decides whether a row updates a record that is already in the store or creates a new one.

Entity Offered, best first
categories slug, id
products sku, slug, id
variants sku, id
customers email, id
orders number, id

Pick the key your source system is consistent about. A products file with reliable SKUs matches on sku; one from a platform whose SKUs are blank matches on slug.

A row whose match key finds nothing creates a record. A row whose match key finds one is updated or skipped, depending on the rule you chose.

Read a dry run

A dry run performs the whole import, with the same writers and the same values, inside a database transaction that is always rolled back. It cannot disagree with the run it rehearses, and it leaves every table untouched.

The report says how many rows would be created, updated, skipped and failed, names the first fifty rows individually, and gives every failure the row number of your own file.

Tip

Always dry-run a file from a new source. The commonest problem a dry run catches is a mapping pointing at the wrong export: every row fails with the same message, which is much easier to read before 20,000 products have been created.

Resume an interrupted run

A queued import is written in chunks. Each chunk is import.chunk_rows rows, default 200, and each chunk is one transaction and one background job that queues the next.

The run's position moves in the same transaction as the rows it wrote. A worker that stops between chunks (a deploy, a restart, a server that ran out of time) leaves a run that is not finished, and the chunk it stopped inside is rolled back whole.

In the usual case you do nothing. Bring the background worker back and it continues on the first row the run did not finish. The counters on the Runs tab carry on from where they stopped.

Rows already written are not written twice. Every writer matches on the mapping's match key, so replaying a chunk updates what it already wrote. Order lines are matched inside their order on SKU, or on title when there is none, so a resumed run does not double an order.

Lower import.chunk_rows to keep the progress bar smooth and the queue responsive. Raise it to finish a large file in fewer jobs.

Important

A run whose uploaded file has been removed from disk cannot continue. It is failed with that message. Upload the file again and start a new run.

Fix the rows that failed

A run with failures has an Error report action on the Runs tab. It downloads a CSV with the following details for every failed row:

Column Holds
line The row number in your file, counted from 1 with the header as row 1. This is the number your spreadsheet shows in the margin.
field The field that was wrong, where one field was.
error Why the row failed, in a sentence.
…your columns Every column of the original row, untouched.

line is your own file's numbering, so a description containing a line break still counts as one row.

  1. Download the error report.
  2. Correct the rows in your source file.
  3. Delete the three columns KahunaCart added.
  4. Import the file again.

Rows that already imported are matched and updated rather than duplicated.

Value formats

Type Accepted
amount 19.99, £1,299.00, 1.299,00. Currency symbols and thousands separators are stripped. Negative amounts fail.
number A whole number. 12, 1,200.
yes/no yes, no, true, false, 1, 0, y, n, and an empty cell meaning no.
word One of the values the field accepts. Synonyms from other stores are translated: WooCommerce's publish becomes published, cancelled becomes canceled.
list Comma-separated. Clothing, Sale is two entries.
date A unix time, an ISO 8601 date, or anything else PHP reads as a date.
text Anything.

Nothing is guessed. A value that does not fit its field's declared type fails that row with a message naming the column.

Delimiters are read from the header line, so a semicolon-separated European export needs no setting. A byte-order mark from Excel is stripped.

What each entity accepts

Products

id, slug, title, summary, description, status, visibility, featured, type, tax_class, shipping_class, position, sku, price, compare_price, stock_managed, stock_quantity, backorders, backorder_note, is_digital, weight_grams, categories, tags, min_qty, max_qty, qty_step, sold_individually, combine_variants, exclude_order_rules, exclude_category_rules, brand, item_condition, final_sale, agent_checkout, google_category, gtin, mpn, release_at.

title is required to create a product. Everything else is optional, and a field your file does not carry is not written. A two-column file of SKU and stock updates stock on the products it matches and changes nothing else. A row that would have to create a product and has no title fails on its own.

price is what the customer is charged. compare_price is the struck-through "was" price. WooCommerce calls these the sale price and the regular price. Scheduled, rule-driven discounting is a different feature; see Sales.

On a store that sells in more than one currency, the products and variants files also offer price_EUR and compare_price_EUR for each currency the store sells in, on top of the fields above. See Prices in other currencies.

Quantity rules in a CSV

Seven of the product fields are the quantity rules, and WooCommerce exports all of them at product level: the three numbers come out of its Min/Max Quantities extension and Sold individually? is Woo core.

Field Type Headings recognised
min_qty number Minimum allowed quantity, Meta: minimum_allowed_quantity
max_qty number Maximum allowed quantity, Meta: maximum_allowed_quantity
qty_step number Group of quantity, Meta: group_of_quantity
sold_individually yes/no Sold individually?
combine_variants yes/no Combine variants
exclude_order_rules yes/no Exclude from order rules, Meta: minmax_cart_exclude
exclude_category_rules yes/no Exclude from category rules, Meta: _minmax_category_group_of_exclude

A real Woo export headers the extension's numbers as post meta, so both the plain names and the meta names are detected. An empty cell means the file says nothing about the field and the stored rule stays. A 0 in one of the three numbers is the merchant saying there is no limit, and the product goes back to the store default.

Note

The rules a category carries are not a CSV field. A category CSV imports the tree only. Set a category's three numbers in the admin, over the API, or in a catalog file, which carries them for both categories and products.

Product data for search engines and agents in a CSV

The last seven product fields are the product data search engines and agents read. A Shopify export's Vendor and Variant Barcode and a WooCommerce export's Brands and GTIN, UPC, EAN, or ISBN map on their own.

Field Type Headings recognised
brand text Brand, Brands, Vendor
item_condition word Condition, Item condition, Google Shopping / Condition
final_sale yes/no Final sale, No returns
agent_checkout word Agent checkout
google_category text Google product category, Google category, Google Shopping / Google Product Category
gtin text GTIN, GTIN, UPC, EAN, or ISBN, Barcode, Variant Barcode, EAN, UPC
mpn text MPN, Google Shopping / MPN, Manufacturer part number

item_condition is new, refurbished or used. agent_checkout is automatic, allow or never. On a product row, gtin and mpn go on the product's default variant. A GTIN whose check digit does not match fails its row, and so does a brand or category over 190 characters or an MPN over 70. Spaces and dashes in a barcode are removed rather than refused.

Tip

Format a GTIN column as text in the spreadsheet, or the leading zero is gone before the file is saved.

Product safety in a CSV

Two more product fields are the product safety details the EU asks a shop to show, read the way the catalog file reads them. A column headed Manufacturer is read as the manufacturer, not the brand. A brand comes from Brand, Brands or Vendor.

Field Type Headings recognised
manufacturer text Manufacturer, Manufacturer name, Made by
safety_info Markdown text Safety info, Safety information, Safety warnings, Warnings

manufacturer is the manufacturer's name. One the store has is found whatever its case, and one it hasn't is created with just that name, for you to fill in under Catalogue → Manufacturers later, including the default safety information its products show when they have none of their own. A CSV has no column for that default text. It is set in the admin, over the API or in a catalog file. A product that names no manufacturer is on the store default. safety_info is the product's warnings and safety instructions, in Markdown, up to 20,000 characters.

As with every column, an empty cell means the file says nothing, so a blank manufacturer leaves a product's manufacturer as it is rather than clearing it. To put a product back on the store default, pick Store default in the product editor or send manufacturer_id: null through the API.

Pre-orders and digital variants in a CSV

Two more fields are read from a product row.

Field Type Headings recognised
is_digital yes/no Is digital, Digital variant, Digital (no shipping), No shipping
release_at text Release at, Release date, Out on, Pre-order date, Preorder date

is_digital on a product row makes its default variant a digital variant.

release_at is when the product comes out, which makes it a pre-order until then. Write a date and time such as 2026-11-13 00:00, or a date alone, which is midnight. It is read in the store's timezone. ISO 8601 with an offset also works, and it is how the products CSV export writes it. A cell it cannot read fails the row.

An empty cell leaves the date as it is. To take a pre-order off, clear Out on in the product editor or send release_at: null through the API.

Variants

id, product_slug, product_sku, sku, title, price, compare_price, stock_managed, stock_quantity, backorders, backorder_note, is_digital, weight_grams, position, gtin, mpn.

is_digital makes the variant a digital variant that needs no shipping.

A row that creates a variant needs a sku and must name its parent with product_slug or product_sku. An imported variant is never made the product's default variant, because moving the default is a price change on every card in the catalog.

Prices in other currencies

Each currency in currencies.enabled adds two fields to the product and variant files, price_<CODE> and compare_price_<CODE>: price_EUR, compare_price_JPY. A product row writes them on its default variant, the way its price column does. The cell is read in that currency's own decimal places, so 18.50 is euro cents and 2700 is yen, and a cell that is not an amount fails its row. An amount with too many decimal places fails its row with the same message as the API: 27.50 in a JPY field is refused, never rounded to 28.

A new product or variant row with a foreign price must also give its base price. Enter 0 if it is free. Without a foreign price, a new row with a blank base price defaults to 0. On an update, a blank base-price cell keeps the existing price. A base price of 0 is free in every currency unless you type a foreign price; that typed price wins.

An empty foreign-price cell leaves the amount alone. Put remove in the cell to delete it. A priced variant then cannot be bought there; a variant with a zero base price is free there unless a typed amount remains. A currency column that is no longer enabled fails its rows. See Pricing a product in another currency.

Categories

id, slug, title, description, parent_slug, position. title is required.

A parent_slug naming a category further down the file is created as a stub and filled in when the file reaches it, so a category file does not have to be sorted parent-first.

Customers

id, email, name, user_id, created_at. email is required.

Important

Importing customers creates no logins and sends no email. Send invitations from Customers afterwards, as a separate decision.

Orders

id, number, email, customer_name, status, payment_status, fulfillment_status, currency, total, provider, notes, created_at, completed_at, plus the line-item columns item_sku, item_title, item_qty, item_price. number is required.

Both WooCommerce and Shopify export one row per line item, repeating the order number. KahunaCart groups them: five rows sharing a number produce one order with five lines.

An order keeps its own currency. A row with no currency is in the store's own, and a row in a currency the store sells in (the base currency or one in currencies.enabled) is imported in it, with its total, item_price and refunded read with that currency's own decimal places: 1500 in a yen row is 1500 yen, and 15.00 in a euro row is 15 euro. A currency the store does not sell in fails its row and says which currencies the store does sell in, rather than being repriced on a guess. Add the currency to Configuration → General → Selling in More Than One Currency and run the file again, or convert the amounts first. The rows that were fine have already been imported, and the failed ones are in the failure file.

A file cannot move an existing order to another currency. A row updating a EUR order must name EUR, even when existing orders are skipped and only lines are added. A missing or blank currency is refused. The existing order can still be updated in its own currency after that currency is disabled.

Important

Importing an order does not send emails, reduce stock, grant downloads or fire an order-completed event. It is a record of something that already happened somewhere else. Its history records that it arrived by import.

Two things to know about imported orders

Both matter when you read reports over a store that has imported its history.

An imported order is created completed unless the file says otherwise. The default is completed rather than cart because a cart would be swept up by the abandoned-cart mailer and emailed to somebody who bought the thing two years ago. An order file that carries a status column gets exactly the statuses it names, from the four KahunaCart stores: cart, completed, canceled, archived.

An order import writes line items and no adjustments. No tax lines, no shipping lines, no discount adjustments are created. The file's total is stored as authoritative, because it is what the customer was actually charged. Two consequences follow:

  • An imported order's totals need not add up from its lines. Nothing recomputes them, and nothing warns that they differ.
  • A report over imported orders reads the order header rather than its lines. Per-line figures, such as top products by revenue, describe only the lines the file carried.

Uploaded files and retention

Uploads live in user/data/kahunacart/imports, protected with the same deny-all .htaccess the downloads directory gets. That protects Apache only; see Hardening for the nginx and Caddy rules. The stored filename is generated, never the one you uploaded.

An uploaded CSV is deleted as soon as its run finishes. Anything left behind (a failed run, a file uploaded and never started) is swept once it is older than import.keep_days days. The default is 30. Set it to 0 to keep uploads forever.

Warning

An uploaded CSV holds your cost base and your customers' names, emails and addresses, sitting in a file on the web server. On a shared host, or any server you do not control alone, set import.keep_days shorter than the default. The sweep runs on the background worker, so it needs Grav's scheduler running.

The round-trip guarantee

bin/plugin kahunacart export writes the catalog to a YAML file, and bin/plugin kahunacart import reads it back. Importing an export of the store it came from changes nothing at all. A test asserts it by checksumming every table before and after.

The catalog file covers categories, products, variants, product media, product options, attributes, product types and sales. It does not cover orders, customers or coupons. A store bundle carries those too.

That makes the pair safe for moving a catalog between a staging store and a live one: diff two exports to see what changed, and import one without wondering what else it will do.

The file carries picture paths, not the pictures. Before anything is written, import checks each picture and media path the file names against this store's images folders, user/images/kahunacart/products and user/images/kahunacart/categories, and warns about the ones that are not there, by product or category slug. It lists the first twenty and says how many more there are. It is a warning, not a refusal: the import goes ahead, and those products show no picture until you copy the files across. An embedded video has no file and is not checked.

A file with no products still imports when it carries categories, attributes, manufacturers, custom fields or sales. A file with none of these is refused.

The CSV importer holds the same property on its own match key. Export products as CSV, import that CSV, and the second run updates every row to the values it already had.

The Export… menus on Products and Customers download a products CSV and a customers CSV in the importer's own column names, so either file imports back with every column found by name and nothing to map. The products CSV has each product with its default variant. A product's other variants and its options travel in the catalog file. See Export… on Products and Customers.

Import from the command line

bin/plugin kahunacart import reads a YAML catalog file by default. Give it --mapping= and it reads a CSV through one of your saved mappings instead.

TXT
bin/plugin kahunacart import products.csv --mapping="Woo products"
bin/plugin kahunacart import products.csv --mapping="Woo products" --dry-run

bin/plugin kahunacart export writes the catalog back out, and bin/plugin kahunacart work is the background worker that drains queued imports and sweeps old uploads.

A command-line import runs in the foreground and prints its progress, chunk by chunk. If it is interrupted, run the same command again: rows already written are updated rather than duplicated.

See CLI for every command and its flags.

Moving from Squarespace

Squarespace doesn't give you one export. It gives you four separate files, and none of them fit an importer as they come. So we wrote a command that reads all four from one folder and brings the store and the site across in one go.

Download the exports

Export Where in Squarespace What comes across
Products Commerce → Inventory → ⋯ → Export all Every product with its variants, options, prices, sale prices, stock, weights, categories, tags and pictures
Orders Commerce → Orders → Download CSV Every order with its lines, totals, dates, and payment and fulfillment status
Customers Contacts → ⋯ → Export all contacts Each customer's email, name and the date they joined
Site Settings → Advanced → Import & Export → Export Your pages and your blog, as a WordPress export file

Put all four files in one folder. The file names don't matter, because each file is recognized by its columns.

The contacts export also holds everyone who signed up for your newsletter. Contacts who never placed an order are left out, and the command tells you how many.

Run it

Always start with a dry run:

BASH
bin/plugin kahunacart squarespace ~/Downloads/squarespace --dry-run

A dry run converts everything, checks it, and tells you what would be created and what would be updated. It writes nothing to the store or the pages. When it reads cleanly, run it for real:

BASH
bin/plugin kahunacart squarespace ~/Downloads/squarespace --weight-unit=lb --dimension-unit=in

Squarespace doesn't say what units its weights and sizes are in. Look at whether your Squarespace store is set to imperial or metric, and pass the matching units: lb and in for imperial, kg and cm for metric. If the products file has weights in it and you leave out --weight-unit, the command stops and asks. It won't guess, because reading pounds as kilos would more than double every parcel's weight.

What comes across

  • Products come in with every variant, and each variant keeps its options, price, stock and weight. A variant on sale arrives at its sale price, with the regular price as the compare price. Unlimited stock isn't tracked. A product you hid on Squarespace arrives as a draft. If your store had more than one shop page, each one becomes a category too.
  • Product addresses keep their slugs. Some Squarespace products have an address Squarespace made up (a long run of random letters and digits, or a bare number), and those get a slug from their title instead.
  • Pictures are downloaded from Squarespace's servers into user/images/kahunacart/products/squarespace/. This matters, because the picture links in the export stop working once you close the Squarespace site. A picture that fails to download is left off its product and listed at the end. Run the command again and it tries that picture again.
  • Descriptions are converted from Squarespace's HTML to Markdown.
  • Customers and orders go through the same importer as the Import & Export screen, so both show up on its Import tab under Runs. Orders keep their numbers, dates, totals and lines. They're recorded under the provider squarespace, so nothing ever tries to refund one through a payment driver that never took the money.
  • The site becomes Grav pages. Each page gets its own folder in user/pages. The blog becomes a listing page with one folder per post, and each post keeps its pictures, date, categories and tags. Anything Markdown can't hold, like an embedded map, stays in the page as HTML.
  • Redirects from every old Squarespace address to its new one. More on those below.

Titles come across exactly as they were typed, unless you add --tidy-titles. Plenty of Squarespace stores have titles in capitals, and this turns them into Title Case, moves a quoted name to the front, tidies the spacing and dashes, and drops an "Available in …" tail. Give it your brand name and it takes that off the front of every title too, since the whole shop is yours anyway. So --tidy-titles="Acme" turns ACME "SAGA" LEATHER HALTER into Saga Leather Halter. If you liked the capitals, a theme can still set titles that way with CSS.

Every product, category and page you had on Squarespace has an address that Google and your customers already know. The command writes a redirect for each one to kahunacart/redirects.yaml inside your export folder, for example /shop/p/reef-runner-tee to /shop/product/reef-runner-tee. It covers both Squarespace 7.1 product addresses (/shop/p/…) and the older 7.0 ones (/shop/…), and an old shop page that became a category redirects to that category.

To switch them on:

  1. Paste the redirects: block from that file into user/config/site.yaml.
  2. Set pages.redirect_default_code: 301 in user/config/system.yaml, so search engines carry your rankings over to the new addresses.

A Squarespace page at the store's own address, such as a Shop page at /shop, gets no redirect, because the store lives there now. Each old address in the file ends in $. Grav reads redirect keys as patterns, and without it /belts would also catch /belts/9755.

What doesn't come across

  • Gift cards are left out. Set up gift cards in KahunaCart instead.
  • Digital product files aren't in the export. Those products arrive as digital products, and you upload each file in the product editor.
  • Passwords, saved cards and subscriptions can't leave Squarespace. Imported customers have no login until you invite them from the Customers screen, and they pick a new password then. See Customer accounts.
  • Your design. How the site looks is the Grav theme's job.

The command names anything it left out, so you're never left wondering.

Run it again whenever you like

It keeps the same promises as every other import. Nothing is deleted, nobody is emailed, and running it a second time updates what's already there instead of adding it twice. A page folder that already exists is never overwritten, so your own home page stays yours.

If a Squarespace product has the same slug as a product already in the store, it updates that product. The command lists every product this applies to before it imports anything.

A few flags help when you're working through a move step by step:

Flag What it does
--dry-run Converts and checks everything, and writes nothing.
--weight-unit= The unit your Squarespace store weighs in: lb, oz, kg or g. Needed when the products file has weights.
--dimension-unit= The unit it measures in: in, cm or mm. Needed when the products file has sizes.
--only= Runs only the steps you name, for example --only=products,orders. The steps are products, customers, orders and site.
--no-images Skips the picture downloads.
--tidy-titles Tidies the product titles, as described above.
--products=, --orders=, --customers=, --site= Picks the file for a step when the folder holds two files of the same kind.

The converted files (catalog.yaml, customers.csv, orders.csv and redirects.yaml) are written to a kahunacart/ folder inside your export folder. Open them and you can see exactly what was imported.

Bulk edit

Bulk edit is the importer's companion for a catalog that is already in the store. It changes price, compare price, stock quantity, status, visibility, featured and the quantity limits across many products in one transaction, with a preview that writes nothing. Use it where a round trip through a spreadsheet is more work than the change is worth.

See Bulk edit for the fields, the modes and the admin.bulk.max_rows cap.