To import historical orders into Shopify you use the Admin GraphQL API’s orderCreate mutation. The admin’s CSV importer handles products, customers and inventory, but orders are export only. orderCreate backdates the order through processedAt, and its options argument stops the import claiming stock or emailing the customer.
The mutation itself is the easy part. The parts that catch people are the fields you cannot set, the emails that fire anyway, and the chance that you cannot read your own imported orders back afterwards.
Queries here were written against Admin API version 2026-07, the current stable release as of September 2026.
What the CSV importer actually covers
Shopify’s own help documentation is blunt about the scope: you can use CSV files to import and export “products, customers, inventory, admin and POS users”, plus “orders (export only), and discounts (export only)”. There is no order import CSV, no hidden template, no setting to enable one.
The CSV migration guide lists Products and Customers as the two CSV imports, and points at migration apps or the API for everything else. It also gives the sequence that matters: “Import your products first, then your customers, and then any historical orders so that products and customers can be properly connected to the orders.”
Run it in that order or your line items attach to nothing and your orders belong to guest customers. If you are still on the customer step, we wrote up how consent state moves across with customer records, which is the other half of this job.
How to import historical orders into Shopify
orderCreate takes two arguments, order and options. It requires the write_orders access scope, and the reference carries a restriction worth reading twice: “This mutation is only accessible to apps authenticated using offline tokens.” The session token from an embedded admin session will not authenticate this call. If your app is on the current token model, see our notes on Shopify’s expiring offline access tokens.
mutation ImportHistoricalOrder($order: OrderCreateOrderInput!, $options: OrderCreateOptionsInput) {
orderCreate(order: $order, options: $options) {
order {
id
name
processedAt
displayFinancialStatus
}
userErrors {
field
message
}
}
}
The variables are where the migration lives:
{
"order": {
"processedAt": "2024-11-14T09:32:00Z",
"currency": "GBP",
"email": "[email protected]",
"financialStatus": "PAID",
"fulfillmentStatus": "FULFILLED",
"sourceName": "woocommerce",
"sourceIdentifier": "wc-10482",
"tags": ["migrated"],
"customer": {
"toAssociate": { "id": "gid://shopify/Customer/7391048" }
},
"lineItems": [
{
"variantId": "gid://shopify/ProductVariant/44820398",
"quantity": 2,
"priceSet": { "shopMoney": { "amount": "24.00", "currencyCode": "GBP" } }
}
],
"shippingLines": [
{
"title": "Royal Mail 48",
"priceSet": { "shopMoney": { "amount": "3.95", "currencyCode": "GBP" } }
}
],
"transactions": [
{
"kind": "SALE",
"status": "SUCCESS",
"gateway": "woocommerce_stripe",
"processedAt": "2024-11-14T09:32:00Z",
"amountSet": { "shopMoney": { "amount": "51.95", "currencyCode": "GBP" } }
}
]
},
"options": {
"inventoryBehaviour": "BYPASS",
"sendReceipt": false,
"sendFulfillmentReceipt": false
}
}
Set sourceIdentifier on every order you create. The input field is documented as “The ID of the order placed on the originating platform”, and the orders connection exposes a matching source_identifier search filter for reading them back. orderCreate has no idempotency key, so that filter is the only cheap way to check whether a legacy order is already in the store before you create it a second time.
Which dates survive the import
processedAt is the only order date you control. The input field is documented as “The date and time (ISO 8601 format) when an order was processed”, and there is no createdAt field in OrderCreateOrderInput at all. Order.createdAt is described as being “set when the customer completes checkout and remains unchanged throughout an order’s lifecycle”, so on an imported order it records the moment you called the API, not the moment the customer paid three years ago.
That sounds worse than it is. The orders connection sorts by PROCESSED_AT by default, so backdated orders land in the right place in the admin and in your own queries. Reports and exports keyed off the processed date behave. Anything keyed off creation date shows a wall of orders on your cutover day.
Transactions carry their own processedAt, so backdate those to match rather than letting them default.
You can also set name, documented as “The order name, generated by combining the order_number property with the order prefix and suffix”. Set it when customers quote old order numbers to your support team. Keep sourceIdentifier for the machine lookups either way.
How do you stop the import emailing customers and claiming stock?
The defaults in OrderCreateOptionsInput are already the ones you want, which is a rare kindness. inventoryBehaviour defaults to BYPASS (“Do not claim inventory”), and both sendReceipt and sendFulfillmentReceipt default to false. The other two inventory values are DECREMENT_IGNORING_POLICY and DECREMENT_OBEYING_POLICY, which claim stock while ignoring or following the product’s inventory policy. Pass all three options explicitly anyway, so nobody reading the migration script in a year has to look up what the defaults were.
Then there is the email those flags do not cover. From the CSV migration guide: “When you migrate your historical orders, any staff member, including the account owner, that is set to receive new order notifications will receive a new order email for each imported order.”
Two thousand orders, two thousand emails to the owner’s inbox, on a day when they are already nervous. Turn the recipients off under Settings > Notifications > Staff notifications before the run, and turn them back on after. Shopify’s staff notification documentation covers the toggle, which suspends a recipient without deleting the configuration.
Recording payments that already happened
The transactions array describes what the old platform did, without moving any money now. Each entry takes amountSet, gateway and its own processedAt, plus kind (default SALE) and status (default SUCCESS). financialStatus accepts PAID, PENDING, AUTHORIZED, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, VOIDED and EXPIRED, so a partly refunded order can at least land with an honest badge.
If an order arrives unpaid and you need to settle it afterwards, orderCreateManualPayment records “a manual payment for an Order that isn’t fully paid”, and it takes its own processedAt for exactly this reason. Check the access requirements before you build on it. It needs write_orders plus the mark_orders_as_paid permission, and its amount field requires the API client to be installed on a Shopify Plus store.
What you cannot bring across
OrderCreateOrderInput has no createdAt, no cancelledAt and no fulfillments field. You can mark an order FULFILLED through fulfillmentStatus, but the tracking numbers and per-item fulfilment detail from the old system have no home on the create call.
Discounts thin out too. The reference states that the mutation “doesn’t support applying multiple discounts, such as discounts on line items”, and that only one discount code can be set per order. It is equally direct about the rest: “Automatic discounts won’t be applied unless you replicate the logic of those discounts in your custom implementation.” An order that originally carried a code plus an automatic promotion will not reconstruct exactly. Decide early whether you are recording the totals that were actually charged or the discount mechanics that produced them, because you cannot have both.
Throughput is capped on the stores you develop against. The reference notes that on a development or trial store you “can create a maximum of five new orders per minute”, which is fine for a smoke test and useless as a rehearsal of the real run.
The last one surprises people after the import is finished. Only the last 60 days of orders are readable from the Order object by default. Going further back needs the read_all_orders scope, which is not self-service: you request it from the Partner Dashboard, describe why your app needs it, and wait for Shopify to approve. You can write three years of order history into a store through an app that cannot then read most of it back.
What I would do
Get read_all_orders approved before the migration, not after, because verifying an import you cannot query is guesswork. Import products, then customers, then orders. Mute staff notifications, take processedAt and the transaction dates from the source system, and set sourceIdentifier on every order. Keep your own table mapping legacy order id to Shopify order GID, because a re-run is not safe on its own. Go oldest first, so a failure part way through leaves a clean boundary.
I would not do it for every store. If the only requirement is that customers can see what they bought, a read-only archive of the old storefront for twelve months costs less than a full import and the reconciliation that follows it. Import when finance, support or a loyalty app needs the history inside Shopify. Otherwise work out what the history is for before you spend a fortnight moving it, and consider spending that fortnight on the 301 map from WooCommerce URLs, which is the part of a cutover that decides whether you keep your traffic.
Whoooop runs replatforming projects onto Shopify, including the order and customer imports that never fit a CSV. If you want a second pair of eyes on a migration plan before the cutover date, our Shopify development work is where that starts.