Shopify Product CSV Validator & Cleaner
Pricing
from $1.00 / 1,000 variant checkeds
Shopify Product CSV Validator & Cleaner
Checks your Shopify product CSV before import: each variant's errors and warnings (columns, options, SKUs, GTIN check digits, prices, image URLs), one row per issue, a summary and a cleaned CSV; failed and unchanged rows are never charged. Not affiliated with or endorsed by Shopify.
Pricing
from $1.00 / 1,000 variant checkeds
Rating
0.0
(0)
Developer
COMPASS DEV
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
1
Monthly active users
18 days ago
Last modified
Categories
Share
Checks your Shopify product CSV before you import it and returns every variant's verdict with its errors and warnings, one row per issue and a summary, plus a cleaned CSV with the safe fixes; failed and unchanged rows are never charged.
Paste your product CSV (or pick the key-value store that holds it) and see, line by line, what Shopify's import would refuse or misread: unknown or misspelt columns, missing titles, bad URL handles, broken variant options, duplicate SKUs and barcodes, GTIN/EAN/UPC check digits, prices with currency symbols or decimal commas, compare-at prices below the price, invalid image URLs, wrong true/false and status values, weights with units. Built for Shopify merchants and agencies, dropshippers with supplier feeds, migrations from other platforms, and AI agents that prepare listings. Not affiliated with or endorsed by Shopify.
What it does
- Checks the whole file against Shopify's published product CSV format: the current column names (URL handle, Description, SKU, Barcodes, Option1 name …) and the older ones Shopify still accepts (Handle, Body (HTML), Variant SKU, Variant Price, Image Src …), market, Google Shopping and metafield columns.
- One row per variant with its
verdict(ready,warningsorerrors), aqualityScore, its issue codes and each issue's line, column, message and suggested value. A product's own issues (title, handle, images, tags) are listed on its first variant. - One free row per issue (
rowType: "issue"), handy in a spreadsheet, and one free summary row per file: counts,importReady,feedQualityScore, the issue counts and the unknown columns. - A cleaned CSV (
CLEANED_CSVin the run's key-value store): your file with only the safe fixes applied (trimmed cells, column name letter case,Yes→true,Active→active,$29.99→29.99,1,299.00→1299.00,10.0→10, UTF-8 with LF line ends). It never invents or changes a fact: no title, brand, barcode, price, weight, handle or image is ever made up or changed; those get a suggestion only. - Watch mode (give a watch list name in
stateName): upload each new version of your feed and get only the variants that arenewor whose issueschanged, plus aprice_jumpwarning when a price moved bypriceJumpPercentor more. A watch run with nothing new returns exactly one freeno_datarow that says so. - Nothing is fetched: image URLs are checked as text only; the actor sends no request to Shopify or to your images.
- You are never charged for failed results: failed, no_data, issue, summary and unchanged rows are free.
Quick start
Paste your CSV (header line first) in Product CSV (text), or as JSON:
{ "csvText": "URL handle,Title,Option1 name,Option1 value,SKU,Price\nmug,Stoneware Mug,Title,Default Title,MUG-350,12.50\n", "currency": "USD" }
A large file in your own key-value store (upload it with the Apify API or another Actor), checked every week in watch mode:
{ "keyValueStoreId": "aBcDeFgHiJkLmNoPq", "recordKey": "products.csv", "stateName": "my-store", "currency": "EUR", "decimalComma": true }
Input
| Field | Type | Default | Description |
|---|---|---|---|
csvText | text | — (prefilled with a small sample) | Your Shopify product CSV, header line first; up to about 9 MB |
keyValueStoreId | key-value store (picker) | — | Your store that holds the CSV as a record; the picker gives this run read access to it only |
recordKey | string | — | The record's key, e.g. products.csv |
mode | watch or export | — | Empty: watch when stateName is given, otherwise export. An explicit mode wins |
stateName | string | — | Watch list name. Watch mode without a name uses the list default |
currency | ISO 4217 code | — | Your store's currency, checked and shown beside every price |
decimalComma | boolean | false | Prices use a decimal comma (29,99): read and fixed as 29.99 |
targetMarginPercent | integer 0–99 | — | Warn when (price − cost per item) / price is under this percent |
priceJumpPercent | integer 1–10,000 | 50 | Watch: a price that moved this much since the last run is a price_jump warning |
issueRows | boolean | true | Also write one free row per issue |
writeCleanedCsv | boolean | true | Save CLEANED_CSV with the safe fixes |
maxItems | integer 1–200,000 | 100000 | Most charged variant rows per run; in watch mode the rest come in the next run |
Both csvText and a store record may be given: each file is checked on its own (the second cleaned file is
CLEANED_CSV_2). Changing decimalComma or targetMarginPercent starts a new watch baseline.
Output
A variant row:
{"status": "ok","attempts": 0,"error": null,"input": "csvText","rowType": "variant","mode": "export","watchList": null,"noDataReason": null,"sourceRow": 3,"handle": "pet-gps-tracker","sku": "GPS-RED","options": "Color: Red","price": 29.99,"compareAtPrice": 24.99,"costPerItem": null,"currency": "USD","barcodes": ["8712345678902"],"verdict": "errors","qualityScore": 65,"errorCount": 1,"warningCount": 2,"issueCodes": ["gtin_check_digit", "price_currency_symbol", "compare_at_not_higher"],"issues": [{ "severity": "warning", "code": "gtin_check_digit", "column": "Barcodes", "sourceRow": 3, "message": "This looks like a GTIN/EAN/UPC but its check digit is wrong: Shopify keeps it as a custom barcode, and Google or Meta will reject it. Confirm it with the manufacturer.", "suggestedValue": null, "fixApplied": false },{ "severity": "error", "code": "price_currency_symbol", "column": "Price", "sourceRow": 3, "message": "Price holds a currency symbol or code; Shopify wants the number only (9.99).", "suggestedValue": "29.99", "fixApplied": true },{ "severity": "warning", "code": "compare_at_not_higher", "column": "Compare-at price", "sourceRow": 3, "message": "The compare-at price (24.99) is not above the price (29.99), so no discount shows.", "suggestedValue": null, "fixApplied": false }],"changeType": null,"previousIssueCodes": null,"priceChangePercent": null,"url": "https://console.apify.com/storage/key-value-stores/aBcDeFgHiJkLmNoPq","source": "Your own product CSV, checked against Shopify's published product CSV format. Not affiliated with or endorsed by Shopify.","scrapedAt": "2026-10-05T09:00:00.000Z"}
(The issue and summary columns of a variant row are null; the dataset's Issues and Summary views show those rows.)
| Status | Meaning | Charged? |
|---|---|---|
ok | A checked variant (rowType: "variant"), an issue row or a summary row | Variant rows: export yes; watch baseline, new and changed yes. Issue and summary rows: no |
no_data | Only a header line, image lines only for a handle, no such record, or nothing new since the last run | No |
failed | The file can't be read (not a Shopify CSV, not comma-separated, an unclosed quote), a line has the wrong number of values, or the record can't be read | No |
The key-value store holds CLEANED_CSV, OUTPUT (each file's summary) and RUN_REPORT (counts, charged and free rows,
stop reason).
Issue codes
Errors are what Shopify's import refuses or misreads; warnings import but are likely wrong. ✓ = fixed in CLEANED_CSV.
| Code | Severity | What it means |
|---|---|---|
unknown_column | error | A column Shopify's product CSV doesn't have (only Collection may be added) |
column_name_case | warning ✓ | A known column written with other letter case or spacing |
duplicate_column, unnamed_column | error | Two columns for the same field; a column with no name |
barcode_columns_conflict | error | Both Barcode and Barcodes (Shopify refuses the file) |
variant_metafield_column | error | Variant metafields aren't supported in the product CSV |
missing_title_column | error | No Title column |
mixed_column_formats, line_endings ✓, not_utf8 | warning | Old and new column names mixed; CRLF line ends; text that isn't UTF-8 |
title_missing, title_too_long | error | No Title on the product's first line; over 255 characters |
handle_invalid | error | A URL handle with spaces or other characters (suggestion only) |
handle_missing, handle_not_contiguous | warning | No handle (Shopify makes one); a product's lines not together |
duplicate_handle | error | Two products share a URL handle |
title_on_variant_line | warning | A later line has another Title (Shopify reads the first) |
status_missing | error | The Status column is present but empty |
too_many_tags, too_many_images, too_many_barcodes | error | Over 250 tags, 250 images, 20 barcodes |
seo_title_too_long, seo_description_too_long | warning | Over 70 / 320 characters |
alt_text_too_long, collection_too_long | error | Over 512 / 255 characters |
image_url_invalid | error | Not an https:// or http:// image address (checked as text) |
image_url_not_https, image_file_name_suffix, duplicate_image | warning | http://; a _thumb/_small/_medium file name; the same image twice |
option_value_missing, option_name_missing, option_name_changed | error | Option names and values that don't match |
duplicate_option_combination | error | Two variants with the same options |
variants_without_options, default_title_with_variants | error | Several variants but no option; Default Title with several variants |
duplicate_sku, whitespace ✓ | warning | A SKU used twice; spaces around a handle, SKU or barcode |
sku_missing_custom_fulfillment | error | A custom fulfillment service without a SKU |
price_currency_symbol ✓, price_thousands_separator ✓ | error | $29.99, 1,299.00 |
price_decimal_comma | error | 29,99 (✓ with decimalComma) |
price_invalid, price_negative | error | Not a number (free, 29 dollars); below 0 |
price_missing, price_zero, price_decimals | warning | No price (Shopify sets 0.00); 0; more than 2 decimals |
compare_at_not_higher, cost_above_price, margin_below_target | warning | No discount shows; a loss on each sale; under your margin target |
invalid_boolean | error | Not true/false (✓ for yes/no/1/0) |
invalid_enum | error | Status, inventory policy, tracker, units, fulfillment service (✓ for letter case) |
invalid_integer, invalid_number, weight_not_whole_grams | error | Not a whole number (✓ for 10.0); not a number; grams with a unit or decimals (suggested in grams) |
packed_size_partial | error | Only some of the four packed product columns |
related_value_missing | warning | Inventory tracked but the out-of-stock policy blank |
gtin_check_digit, gtin_length, isbn_check_digit | error / warning | A wrong GS1 check digit or length: an error for a typed barcode (gtin:, upc:, ean:), a warning for an untyped one (Shopify keeps it as a custom barcode, Google and Meta reject it) |
barcode_scientific_notation | error | A spreadsheet turned the barcode into 8.71E+12 |
gtin_leading_zero_lost, duplicate_barcode | warning | An 11-digit UPC (leading zero dropped); one barcode on two variants |
price_jump | warning | Watch: the price moved by priceJumpPercent or more since the last run |
Output fields
| Field | Type | Description |
|---|---|---|
status | string (or null) | ok = a checked variant (charged in export mode, and in watch mode when it is a baseline, new or changed variant), an issue row or a summary row (both free); no_data = nothing to check (only a header line, image lines only for a handle, no such record, or nothing new since the last run) (free); failed = the file or a line could not be read (free) |
attempts | integer (or null) | Reads made for this row: 0 for text in the input, 1 for a key-value store record |
error | string (or null) | Why a row is no_data or failed; null on ok rows |
input | string (or null) | Which file the row is about: csvText, or the key-value store ID and record key |
rowType | string (or null) | variant = one variant line (its verdict and issues); issue = one issue (free); summary = the file (free) |
mode | string (or null) | watch or export |
watchList | string (or null) | Watch list name (watch mode); null in export mode |
noDataReason | string (or null) | Why a row is no_data: empty_file (only a header line), images_only (image lines only for a handle), nothing_new (watch: no change since the last run), not_found (no such record); null otherwise |
sourceRow | integer (or null) | The line in your file (1 = the header line), as your spreadsheet numbers it |
handle | string (or null) | The product's URL handle |
sku | string (or null) | The variant's SKU |
options | string (or null) | The variant's options, e.g. Color: Red / Size: S |
price | number (or null) | The price as Shopify would read it (null when it is missing or unreadable) |
compareAtPrice | number (or null) | The compare-at price as Shopify would read it |
costPerItem | number (or null) | The cost per item as Shopify would read it |
currency | string (or null) | Your store currency (the currency input, ISO 4217), shown beside the prices; null when not given |
barcodes | array (or null) | The variant's barcodes (from Barcodes or the older Barcode column) |
verdict | string (or null) | ready (no issue), warnings (warnings only) or errors (at least one error: Shopify would refuse or misread something) |
qualityScore | integer (or null) | 100 minus 25 per error and 5 per warning (0 to 100) |
errorCount | integer (or null) | The variant's errors (its product's issues count on its first variant) |
warningCount | integer (or null) | The variant's warnings |
issueCodes | array (or null) | The codes of the variant's issues (see the README's issue table) |
issues | array (or null) | Each issue: severity, code, column, line, message, suggested value and whether CLEANED_CSV applies it |
changeType | string (or null) | Watch mode: baseline (first run of the list), new (a variant the list did not have) or changed (its issues changed, or its price jumped); null in export mode |
previousIssueCodes | array (or null) | Watch mode: the variant's issue codes at the last run |
priceChangePercent | number (or null) | Watch mode: how much the price moved since the last run |
severity | string (or null) | Issue rows: error (Shopify would refuse or misread it) or warning (it imports, but something is likely wrong) |
issueCode | string (or null) | Issue rows: the issue code (see the README) |
column | string (or null) | Issue rows: the column as named in your header line; null for a whole-line or whole-file issue |
value | string (or null) | Issue rows: the cell's value, only for identifiers, numbers and codes (never titles, descriptions or vendors) |
message | string (or null) | Issue rows: what is wrong, in one sentence |
suggestedValue | string (or null) | Issue rows: the value to use instead, when one is clear |
fixApplied | boolean (or null) | Issue rows: true when CLEANED_CSV already has the suggested value (a safe formatting fix) |
relatedRows | array (or null) | Issue rows: the other line(s) involved (the first duplicate, the product line) |
rowsRead | integer (or null) | Summary: data lines read (the header not counted) |
productCount | integer (or null) | Summary: products (URL handles) |
variantCount | integer (or null) | Summary: variant lines checked |
imageRowCount | integer (or null) | Summary: lines that only add images |
variantsReady | integer (or null) | Summary: variants with no issue |
variantsWithWarnings | integer (or null) | Summary: variants with warnings only |
variantsWithErrors | integer (or null) | Summary: variants with at least one error |
errorTotal | integer (or null) | Summary: all errors (variants, products and file) |
warningTotal | integer (or null) | Summary: all warnings |
fileIssueCount | integer (or null) | Summary: issues of the header or the file itself |
importReady | boolean (or null) | Summary: true when the file has no error and every line could be read |
feedQualityScore | integer (or null) | Summary: the variants' average quality score, minus 10 per file error and 5 per unreadable line (0 to 100) |
csvFormat | string (or null) | Summary: current (today's column names), legacy (the older names Shopify still accepts) or mixed |
unknownColumns | array (or null) | Summary: columns that are not in Shopify's product CSV |
issueCounts | array (or null) | Summary: how many of each issue, most frequent first |
cleanedCsvKey | string (or null) | Summary: the record in the run's key-value store that holds the cleaned CSV (null when writeCleanedCsv is off) |
unchangedCount | integer (or null) | Watch mode summary: variants, lines and file issues unchanged since the last run (not returned) |
removedCount | integer (or null) | Watch mode summary: variants in the last run that are not in this file |
url | string (or null) | The run's key-value store in Apify Console, which holds CLEANED_CSV and OUTPUT |
source | string (or null) | Where the data comes from |
scrapedAt | string (or null) | When the row was written (ISO 8601, UTC) |
Pricing
Pay per event: one variant-check event per checked variant (export: every variant; watch: baseline, new and
changed variants).
| Plan | Price per variant | Per 1,000 variants |
|---|---|---|
| Free (no discount) | $0.0015 | $1.50 |
| Bronze | $0.0013 | $1.30 |
| Silver | $0.00115 | $1.15 |
| Gold (and Platinum, Diamond) | $0.0010 | $1.00 |
- Never charged: failed rows (a file or line that can't be read), no_data rows, issue rows, summary rows, image lines and unchanged variants. You are never charged for failed results.
- Apify also charges its small per-run start event. There are no usage fees on top, so the actor is eligible for x402 agent payments.
Use it from AI agents
One clear main input, csvText (the CSV as text); every variant row has status, verdict, issueCodes and issues, and
the summary row has importReady. Call it through the Apify API, the Apify MCP server
(mouadapi/shopify-product-feed-validator) or x402 agentic payments. Copy-paste call (your Apify token in place of
YOUR_APIFY_TOKEN):
$curl -X POST "https://api.apify.com/v2/acts/mouadapi~shopify-product-feed-validator/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" -H "Content-Type: application/json" -d '{"csvText": "URL handle,Title,Option1 name,Option1 value,SKU,Barcodes,Price\u000amug,Stoneware Mug,Title,Default Title,MUG-350,4006381333931,12.50\u000a", "currency": "USD"}'
import { ApifyClient } from 'apify-client';import { readFileSync } from 'node:fs';const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });const run = await client.actor('mouadapi/shopify-product-feed-validator').call({ csvText: readFileSync('products.csv', 'utf8'), currency: 'USD' });const { items } = await client.dataset(run.defaultDatasetId).listItems();const notReady = items.filter((r) => r.rowType === 'variant' && r.verdict === 'errors');
Limits
- Shopify's product CSV only (V1). Google Merchant Center and Meta catalog profiles are planned.
- Up to 200,000 lines and about 9 MB of text in
csvText; a larger file goes in a key-value store record. - Image URLs are checked as text: an address that is well formed but doesn't exist is not found (the actor fetches no URL).
- Barcodes are checked by their GS1 check digit and length only (no barcode database): a valid number can still belong to another product.
- Watch mode keeps one entry per variant in your own named key-value store; variants removed from the feed are counted in
removedCount.
Known issues
- Shopify's importer can change its messages and its column names (it keeps older names working). The checks follow its published format; an import can still fail for reasons the file doesn't show (an image Shopify can't download, a custom fulfillment service that isn't set up in your admin, a product category Shopify doesn't know).
- Option values, SKUs, barcodes, prices and handles come back in the rows; titles, descriptions, vendors, tags and other
free text never do (they stay in your file and in
CLEANED_CSV).
FAQ
Does it change my file? No. Your input is never changed; CLEANED_CSV is a separate copy with formatting fixes only,
and every fix is listed on its issue (fixApplied: true).
Why is a price with a comma an error and not fixed? 29,99 and 1,299 look alike; set decimalComma: true if your
prices use a decimal comma, and the cleaned CSV fixes them.
Why did a watch run return one no_data row? Nothing changed since the last run of the watch list; the row says how
many were unchanged. It is free.
Data and licence
- Input: your own file. It is read only in this run (and, in watch mode, its variants' issue codes and prices are kept in your own watch list store).
- The rules follow Shopify's published product CSV format (https://help.shopify.com/en/manual/products/import-export/using-csv). This actor is not affiliated with or endorsed by Shopify.