Import Discount Codes Into Shopify in Bulk

Shopify will not import discount codes from a CSV. The way in is the Admin GraphQL API: create one discount per rule set with discountCodeBasicCreate, then attach the old codes to it with discountRedeemCodeBulkAdd, up to 250 codes a call, and poll the job it hands back until done returns true.

That sounds like an afternoon’s work. What catches people out is the shape of the data on the Shopify side, which does not match what WooCommerce or Magento hands you.

Why there is no CSV import for discount codes

Shopify’s CSV tooling covers products, customers, inventory and admin/POS users. Orders and discounts are export only, and the help centre is blunt about it: “Discounts can be exported only to a CSV file.” Every code has to go in over the API, the same way order history and customer consent state do.

Everything below is written against Admin API version 2026-07, current as of September 2026. Creating discounts and codes needs the write_discounts access scope, and polling needs read_discounts.

What maps across from a WooCommerce or Magento coupon

Shopify splits a coupon in two. The discount holds the rules. The redeem code holds a string and nothing else: DiscountRedeemCodeInput has exactly one field, code. Value, start and end dates, minimum spend, customer selection and usage limit all live on the parent discount, shared by every code beneath it.

WooCommerce works the other way round. Each coupon owns its own usage limit per coupon, usage limit per user, expiry date, “Individual use only” flag, minimum and maximum spend and product restrictions. Two coupons that differ in any of those are two discounts on Shopify, however similar they look in the export.

So the first job is not writing code. It is a group-by. Bucket the export by rule set (value, type, dates, minimum spend, restrictions, combinability) and count the buckets. A thousand codes at 10% off with no conditions is one discount. A thousand codes with a thousand different expiry dates is a thousand discounts, and at that point the question is whether they should move at all.

Magento sites usually come across more cleanly, because a cart price rule with auto-generated coupon codes is already one rule with many codes, and those codes export to CSV.

Four mappings that are not obvious:

  • “Individual use only” becomes the combinesWith booleans on the discount (orderDiscounts, productDiscounts, shippingDiscounts), all set to false.
  • Usage limit per user becomes appliesOncePerCustomer, which is a boolean. Once per customer, or no limit. A limit of three per customer has nowhere to go.
  • Free shipping coupons need a different mutation, discountCodeFreeShippingCreate, not the basic one.
  • Email restrictions become customerSelection with named customers, and a discount can name at most 100 specific customers, products or variants.

Does usageLimit apply per code, or across the discount?

This decides whether 40,000 single-use coupons survive the move. The help centre describes a usage limit on a discount code, which reads like a shared ceiling across the discount. A Shopify staff reply on the developer forum is explicit that it is not: “that limit of 500 applies to each individual redeem code you create, not the combined total across all codes”, and “the limit is tracked per redeem code, not across the parent discount”.

usageLimit: 1 on the parent with 40,000 codes under it therefore gives you 40,000 single-use codes, which is what a legacy coupon list normally needs. appliesOncePerCustomer is a separate constraint layered on top.

One ceiling to keep in view while you are generating: a store has a cumulative limit of 20,000,000 unique discount codes, and codes have to be deleted to make room once that is reached.

How to bulk import discount codes into Shopify

Create the parent discount first. code is required on discountCodeBasicCreate, so the first legacy code goes in here and the rest follow in batches.

mutation CreateDiscount($basicCodeDiscount: DiscountCodeBasicInput!) {
  discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) {
    codeDiscountNode {
      id
      codeDiscount {
        ... on DiscountCodeBasic {
          title
          codesCount { count }
        }
      }
    }
    userErrors { field message }
  }
}
{
  "basicCodeDiscount": {
    "title": "Legacy 10% off (WooCommerce import)",
    "code": "LEGACY-0001",
    "startsAt": "2026-10-01T00:00:00Z",
    "usageLimit": 1,
    "appliesOncePerCustomer": true,
    "customerSelection": { "all": true },
    "customerGets": {
      "value": { "percentage": 0.1 },
      "items": { "all": true }
    },
    "combinesWith": {
      "orderDiscounts": false,
      "productDiscounts": false,
      "shippingDiscounts": false
    }
  }
}

percentage is a decimal, so 0.1 is 10% off. Keep the returned codeDiscountNode.id; it is the discountId every batch needs.

Then the codes, 250 per call:

mutation AddCodes($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
  discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
    bulkCreation { id done codesCount importedCount failedCount }
    userErrors { code field message }
  }
}

The client side is a chunking loop that keeps the job IDs rather than waiting on each one:

const SHOP = process.env.SHOP!;         // my-store.myshopify.com
const TOKEN = process.env.ADMIN_TOKEN!; // offline access token
const CHUNK = 250;

const ADD_CODES = `#graphql
  mutation AddCodes($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
    discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
      bulkCreation { id done codesCount importedCount failedCount }
      userErrors { code field message }
    }
  }`;

async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const res = await fetch(`https://${SHOP}/admin/api/2026-07/graphql.json`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Shopify-Access-Token": TOKEN,
    },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} from the Admin API`);
  return res.json() as Promise<T>;
}

async function addCodes(discountId: string, codes: string[]): Promise<string[]> {
  const jobIds: string[] = [];

  for (let i = 0; i < codes.length; i += CHUNK) {
    const batch = codes.slice(i, i + CHUNK).map((code) => ({ code }));
    const body = await admin<any>(ADD_CODES, { discountId, codes: batch });
    const { bulkCreation, userErrors } = body.data.discountRedeemCodeBulkAdd;

    if (userErrors.length > 0) {
      throw new Error(`batch ${i / CHUNK}: ${userErrors[0].message}`);
    }
    jobIds.push(bulkCreation.id);
  }

  return jobIds;
}

Checking that every code landed

Each call returns a job, not the codes. bulkCreation.done is false while the job is queued, so read the result back later with discountRedeemCodeBulkCreation:

query BulkCreation($id: ID!) {
  discountRedeemCodeBulkCreation(id: $id) {
    done
    codesCount
    importedCount
    failedCount
    codes(first: 50) {
      edges {
        node {
          code
          errors { code message }
        }
      }
    }
  }
}

importedCount and failedCount say where the batch landed, and the per-code errors name the strings that were rejected. Discount codes are unique across a store, which is why codeDiscountNodeByCode can look one up with nothing but the string, so a duplicate in your export fails on its own row instead of taking the batch down with it.

Check failedCount on every job before anyone announces the migration is done, because the alternative way to find out is a customer standing at checkout with a code the new store has never heard of.

How long 50,000 codes takes

Two hundred calls. A mutation costs 10 points by default against a bucket that restores 100 points a second on standard plans and 1,000 on Plus, so the calls are not the constraint. The queued jobs are, and they clear in the background while you get on with the rest of the cutover.

You can wrap almost any mutation in bulkOperationRunMutation, but there is no gain here, because discountRedeemCodeBulkAdd is already asynchronous. Save the bulk operations route for the product and customer side of the same migration.

What I would actually do

Export the coupon list, then throw most of it away. Expired codes, codes that hit their usage limit, campaign codes from three Christmases ago: none of that needs to exist on the new store, and every row you skip is one less thing in a discounts admin that staff have to search. Bucket what is left by rule set, create one discount per bucket with startsAt at cutover, import the codes, and check failedCount before switching DNS.

Per-code expiry is the exception. If each code dies 30 days after it was issued, one discount cannot express that, and the alternative is a thousand discounts with one code each, every one of them its own row in the discounts admin and its own discountCodeBasicCreate call. Codes shaped like that come out of a loyalty or rewards platform, and the cheaper answer is to let its Shopify equivalent reissue them after cutover rather than carrying the old strings across.

Whoooop runs WooCommerce and Magento replatforms onto Shopify, including the parts that have no importer behind them. If you are looking at a coupon export and a cutover date, our Shopify development work covers this end of it.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch