Shopify Odoo Integration

Shopify Odoo historical order import: a playbook for clean data migration

Importing 50,000 historical Shopify orders into Odoo without creating a data mess requires careful preparation, a clear customer dedupe strategy, and a rollback plan. This playbook covers every step.

The migration that took six months to clean up

A Shopify merchant with four years of sales history decided to bring all their historical orders into a newly deployed Odoo instance. The goal was simple: have a single source of truth for customer lifetime value, product sales history, and accounting. They ran the import on a Friday afternoon. By Monday morning, they had 47,000 orders in Odoo — and 61,000 customer records, because the import had created duplicate partner records for customers who had placed orders under slightly different email addresses, or who had their name formatted differently across orders.

The accounting team spent the next six months reconciling. Some customers had three Odoo partner records. Orders were split across them. Invoices didn't roll up cleanly. Customer lifetime value reports were useless.

A historical order import done correctly is one of the most valuable data operations you can run when setting up a Shopify Odoo integration. Done carelessly, it creates technical debt that compounds every week.

This playbook covers the complete process: pre-import audit, customer dedupe, product matching, tax handling, pagination, idempotency, validation, rollback, and the transition to live two-way sync.

---

Why historical import is hard: the data shape mismatch problem

Current Shopify orders are designed to be synced with a live Odoo instance where products, customers, and configuration already exist. Historical orders have no such guarantee. They may reference:

  • Products that have since been deleted from your Shopify catalog
  • Variants that have been archived or merged
  • Customers who have changed their email address
  • Tax rates that no longer apply (because you expanded to new states or changed your tax configuration)
  • Discount codes that have expired
  • Payment gateways that you've since discontinued
  • Shipping methods that no longer exist in your carrier configuration

Every one of these creates a decision point during import: do you skip the order, create a placeholder, or attempt to match to the nearest current record?

The answer depends on why you are importing historical orders. Common reasons:

1. Accounting reconciliation: you need the revenue, cost, and payment records in Odoo for historical periods 2. Customer history: you want customer lifetime value and purchase history in Odoo's CRM 3. Product analytics: you want per-product sales history for buying decisions 4. Compliance: your jurisdiction requires invoice records for a certain number of years

Each use case has different data quality requirements. An accounting import needs accurate financials but might not need perfect customer deduplication. A CRM import needs accurate customer records but might not need precise tax breakdowns.

Define your use case before writing a single import script.

---

Pre-import audit: what data you actually have in Shopify

Before importing anything, audit your Shopify data. This takes a few hours but prevents days of cleanup.

Run a data quality report

Using Shopify's Admin API or a bulk operations query, generate counts for:

``graphql { orders(first: 250) { edges { node { id name email createdAt cancelledAt financialStatus fulfillmentStatus lineItems(first: 50) { edges { node { sku product { id status } variant { id } } } } } } } } ``

Specifically count:

  • Total orders in your date range
  • Orders with no customer email (guest checkouts without an account)
  • Orders referencing products with status = ARCHIVED
  • Orders referencing product variants that no longer exist (null variant.id)
  • Cancelled orders (do you want to import these?)
  • Orders with refunds (how many are partially vs fully refunded?)
  • Orders with financial status pending (unpaid orders — do you want these?)

The output of this audit sets expectations for how clean the import will be and how many exceptions you'll need to handle.

Decide on date range

Historical imports rarely need to go back to day one of your Shopify store. Common date ranges:

  • Accounting-driven: current fiscal year plus one prior year (2-3 years at most)
  • Tax-driven: check your jurisdiction's statute of limitations (typically 3-7 years)
  • CRM-driven: last 12-24 months of active customers

Going back more than 3 years rarely adds value and significantly increases the data quality challenges (archived products, defunct customer emails, changed tax structures).

Assess volume

A rough estimate of API calls needed:

  • Base queries: ~1 API call per 250 orders
  • Line item details: typically included in order query
  • Refund details: ~1 API call per order with refunds
  • Customer details: ~1 API call per unique customer (with dedupe, much fewer)

For 50,000 orders with a 20% refund rate:

  • ~200 order list queries (50,000 / 250)
  • ~10,000 refund detail queries
  • Shopify's API rate limit on the REST API is 2 calls/second (40/minute on the standard plan, 80/minute on Shopify Plus)

At 2 calls/second, 10,200 calls takes about 1.4 hours. Plan accordingly.

---

Customer dedupe strategy

Customer deduplication is the most consequential decision in a historical import. Get it wrong and you have the mess described at the top of this guide.

Email-based deduplication (recommended)

The email address is the most reliable deduplication key for Shopify customers. Most Shopify orders have an email, even guest checkouts.

Algorithm: 1. Before importing any customer, normalize the email (lowercase, trim whitespace) 2. Query Odoo for an existing res.partner with that email 3. If found, use the existing partner — do not create a new one 4. If not found, create a new partner

```python def find_or_create_customer(shopify_order, odoo_client): email = shopify_order.get('email', '').strip().lower()

if not email:

Guest checkout with no email - use single anonymous customer

return get_or_create_anonymous_customer(odoo_client)

Search for existing partner by email

existing = odoo_client.search_read( 'res.partner', [['email', '=', email], ['type', '=', 'contact']], fields=['id', 'name', 'email'] )

if existing: return existing[0]['id']

Create new partner

customer_data = build_customer_data(shopify_order) return odoo_client.create('res.partner', customer_data) ```

Edge cases in email deduplication

Customer changed their email: A customer who ordered under alice@gmail.com in 2022 and alice@newdomain.com in 2024 will be created as two separate partners. This is unavoidable without cross-referencing Shopify's customer ID. If Shopify customer IDs are available on historical orders, use them as a secondary deduplication key.

Multiple customers sharing an email: Rare but possible (family members using a shared email, business orders under the same company email). For these, the first match wins. Add a comment to the partner record noting the deduplication.

Typos in historical emails: alce@gmail.com vs alice@gmail.com — email fuzzy matching is usually not worth the complexity. Skip typo correction and allow duplicate partners to exist for these edge cases. They are rare.

Single-customer mode for guest checkouts

For guest checkouts with no email, you have two options:

1. Single anonymous customer: Create one Odoo partner called "Shopify Guest Customer" and assign all guest checkout orders to it. This is the simplest approach and works well if guest orders are a small fraction of your total orders.

2. Order-level customer: Create a partner per guest order using the shipping address. This produces many single-order partners but preserves the shipping history.

Single anonymous customer mode is recommended for historical imports. If you later want to identify guest customers, you can do that via an address-matching exercise as a separate enrichment step.

Account-import mode

If you want to import full customer records (email, phone, all addresses, tags, marketing preferences) in addition to orders, run a separate customer import pass before the order import. This ensures all customers exist in Odoo before any order tries to find them, and allows you to review the customer data quality before any orders are associated.

---

Product matching: SKU vs barcode vs name

Historical orders may reference Shopify products that have changed since the order was placed. Your matching strategy:

Priority 1: Variant ID

Shopify's variant_id on a line item is the most reliable identifier for matching to an Odoo product variant. If you have Odoo configured with Shopify variant IDs stored in a custom field or in the product's barcode field (a common pattern), use this for primary matching.

Priority 2: SKU

The SKU (sku field on the Shopify line item) is usually stable across product restructuring. If the SKU exists on an active Odoo product, match to it.

```python def find_odoo_product(shopify_line_item, odoo_client): sku = shopify_line_item.get('sku', '').strip()

if sku: products = odoo_client.search_read( 'product.product', [['default_code', '=', sku]], fields=['id', 'name', 'default_code'] ) if products: return products[0]['id']

Fallback: try by name

product_title = shopify_line_item.get('title', '') variant_title = shopify_line_item.get('variant_title', '') search_name = f"{product_title} ({variant_title})" if variant_title and variant_title != 'Default Title' else product_title

products = odoo_client.search_read( 'product.product', [['name', 'ilike', product_title]], fields=['id', 'name', 'default_code'] ) if products: return products[0]['id'] # Take first match, log for review

return None ```

Handling unmatched products

When a product cannot be matched:

1. Create a placeholder product: Create an Odoo product called [IMPORTED] {shopify_title} with a note that it's a historical import placeholder. This preserves order completeness.

2. Skip the line item: Log the unmatched line item and skip it. The order will have an incorrect total in Odoo.

3. Skip the entire order: If product accuracy is critical, skip orders with any unmatched products and flag them for manual processing.

Option 1 (placeholder product) is recommended for accounting imports. Option 3 (skip order) is appropriate for CRM imports where order completeness matters more than import coverage.

Log every placeholder product created during the import. After the import, review the log and replace placeholders with real products where possible.

---

Tax handling for historical orders: use as-shipped values

This is the most important rule for historical order imports:

Do not recompute taxes on historical orders. Use the tax amounts as they appear in the Shopify order payload.

Historical orders were placed under tax rates, fiscal positions, and customer classifications that may have changed since. If you let Odoo recompute taxes based on the current configuration, the historical order's tax total in Odoo will not match the amount the customer was charged.

The approach:

1. Write the historical order to Odoo as a confirmed sale order or directly as a posted invoice 2. Write the unit price as the price the customer paid, post any discounts 3. Write the tax as a fixed amount using a "historical import" tax record in Odoo

Create a dedicated Odoo tax called Historical Import Tax with amount_type = 'fixed' and amount = 0. This is a placeholder tax record you will override per line using the tax_amount field or by using Odoo's price-included tax mechanism.

The more practical approach for accounting imports is to bypass the sale order entirely and write historical orders directly as account.move records (invoices and credit notes) with explicit line amounts, tax amounts, and totals. This gives you precise control over every financial figure:

```python def create_historical_invoice(shopify_order, partner_id, odoo_client): move_lines = []

for line in shopify_order['line_items']: product_id = find_odoo_product(line, odoo_client) unit_price = float(line['price']) discount = compute_discount_pct(line, shopify_order) tax_amount = float(line['tax_lines'][0]['price']) if line['tax_lines'] else 0

move_lines.append({ 'product_id': product_id, 'name': line['title'], 'quantity': line['quantity'], 'price_unit': unit_price, 'discount': discount, 'tax_ids': [], # no automatic tax computation

Tax will be written as a separate line

})

if tax_amount > 0:

Add tax line explicitly

move_lines.append({ 'name': f"Tax: {line['title']}", 'account_id': get_tax_account(odoo_client), 'price_unit': tax_amount, 'quantity': 1, 'tax_ids': [], })

invoice = odoo_client.create('account.move', { 'move_type': 'out_invoice', 'partner_id': partner_id, 'invoice_date': shopify_order['created_at'][:10], 'ref': shopify_order['name'], # e.g. "#1234" 'invoice_line_ids': [(0, 0, line) for line in move_lines], })

return invoice ```

---

Refunds: orders that have already been refunded

Historical orders with refunds require a two-step import:

1. Import the original order as an invoice (posted) 2. Import the refund as a credit note against that invoice

The credit note must reference the original invoice. In Odoo, this is done by setting reversed_entry_id on the credit note to the original invoice's ID.

The refund amount should come directly from Shopify's refund payload — refund.transactions[].amount for the payment refund, and the refund_line_items for the product-level refund.

Do not recompute whether a refund is full or partial from the order's current state. Some orders may have been partially refunded in complex ways (partial refund on some items, discount adjustment on others). Use the exact amounts from Shopify's refund records.

---

Pagination and rate limit management: 50,000 orders without exhausting your API budget

Importing 50,000 orders requires careful pagination management. Shopify's API returns pages of 250 orders maximum. To page through all orders:

```python import time from typing import Iterator

def fetch_all_orders(start_date: str, end_date: str) -> Iterator[dict]: """Fetch all orders between dates, handling pagination and rate limits.""" params = { 'created_at_min': start_date, 'created_at_max': end_date, 'limit': 250, 'status': 'any', 'fields': 'id,name,email,created_at,financial_status,line_items,tax_lines,discount_codes,refunds,billing_address,shipping_address,total_price,subtotal_price,total_tax,gateway,payment_gateway_names' }

while True: response = shopify_api_get('/orders.json', params) orders = response['orders']

if not orders: break

yield from orders

Check for next page link in response headers

link_header = response.get('_headers', {}).get('link', '') if 'rel="next"' not in link_header: break

Extract next page cursor from link header

next_cursor = extract_cursor_from_link(link_header) params = {'page_info': next_cursor, 'limit': 250}

Respect rate limits

time.sleep(0.5) # 2 calls per second = 0.5s between calls ```

Checkpoint and resume

A 50,000-order import at safe API rates (2 calls/second) takes 1-2 hours. If the import process crashes or you need to restart it, you should be able to resume from where you left off without reprocessing completed orders.

Implement a checkpoint system:

```python class ImportCheckpoint: def __init__(self, checkpoint_file: str): self.file = checkpoint_file self.data = self._load()

def _load(self) -> dict: try: with open(self.file) as f: return json.load(f) except FileNotFoundError: return {'last_processed_order_id': None, 'processed_count': 0}

def save(self, order_id: str, count: int): self.data = {'last_processed_order_id': order_id, 'processed_count': count} with open(self.file, 'w') as f: json.dump(self.data, f)

def should_skip(self, order_id: str) -> bool:

If we have a checkpoint and this order came before it, skip

last = self.data.get('last_processed_order_id') if last and int(order_id) <= int(last): return True return False ```

Save the checkpoint after every 100 successfully imported orders. This limits the worst-case rework to 100 orders if the process crashes.

---

Idempotency: re-running an import partially must not duplicate

If you run the import, it fails partway through, and you run it again — you must not create duplicate Odoo records for the orders that were already imported.

The idempotency key is the Shopify order ID. Before creating any Odoo record for an order, check whether an Odoo record with that Shopify order ID already exists.

For sale orders, use Odoo's client_order_ref or a custom field to store the Shopify order name (#1234). For invoices, use ref. Before creating, query:

``python def order_already_imported(shopify_order_name: str, odoo_client) -> bool: existing = odoo_client.search_read( 'account.move', [ ['ref', '=', shopify_order_name], ['move_type', 'in', ['out_invoice', 'out_refund']] ], fields=['id'] ) return len(existing) > 0 ``

This check adds one API call per order but completely eliminates duplicate import risk.

---

Validation step: spot-check 10 orders before bulk-running

Before running the full import on all 50,000 orders, run it on a sample of 10 orders and verify each one manually.

Choose your 10 sample orders to cover your edge cases: 1. A simple order with one line item and one tax 2. An order with multiple line items and different tax rates 3. An order with a discount code applied 4. A guest checkout (no customer email) 5. An order that was fully refunded 6. An order that was partially refunded 7. An order with multiple fulfillments 8. An order from an international customer (different currency or tax jurisdiction) 9. A very old order (2+ years) with an archived product 10. An order with a very high value (to check that large amounts handle correctly)

For each of these 10 orders, manually compare the Odoo record to the Shopify order:

  • [ ] Odoo order total = Shopify order total_price
  • [ ] Odoo tax total = Shopify order total_tax
  • [ ] Number of line items matches
  • [ ] Customer record correct (or anonymous placeholder)
  • [ ] Refund credit note present (if applicable)
  • [ ] No duplicate Odoo records created

Only proceed with the full bulk import after all 10 spot-checks pass.

---

Rollback plan: how to undo a bad batch

Before running any import, prepare a rollback procedure. You should be able to undo the import cleanly.

Method 1: Odoo "imported by" tag

Before starting the import, create an Odoo tag: shopify_import_2026_04_26 (use the actual date). Assign this tag to every partner, product placeholder, sale order, and invoice you create during the import.

To rollback, search for all records with this tag and delete or archive them. This requires that no other operations have been performed on these records (no manual edits, no payments posted, etc.).

Method 2: Database snapshot (self-hosted only)

On self-hosted Odoo (or Odoo.sh development branches), take a PostgreSQL snapshot before the import:

``bash pg_dump -Fc -d odoo_production -f /backup/pre_import_$(date +%Y%m%d_%H%M%S).pgdump ``

If the import goes wrong, restore the snapshot. This is the cleanest rollback but requires database access (not available on Odoo Online).

Method 3: Batch by date range

Rather than importing all 50,000 orders at once, import one month at a time. After each month, verify the data before proceeding to the next. This limits the blast radius of any error to one month's data.

This is the most conservative approach and is recommended for first-time imports or if your data quality is uncertain.

---

Going forward: from one-time import to live two-way sync

After the historical import is complete, the transition to live sync requires one important decision: what is the cutover date?

The cutover date is the point after which all new orders will be handled by the live Shopify Odoo connector rather than the historical import process. Orders before the cutover were imported by the historical process. Orders after the cutover are synced in real time.

Setting the cutover date

The cutover date should be: 1. After the historical import is fully complete and validated 2. After the live connector is configured and tested in staging 3. At the beginning of a low-activity period (start of a week, not during a sale event)

A common pattern: set the historical import cutover to the first day of the current month. Import everything before that date. Start the live connector from that date forward.

Handling orders that span the cutover

Some orders placed before the cutover date may still have fulfillment or refund events after the cutover. For example, an order from March 15 (before the cutover) might be fulfilled on April 2 (after the cutover). The live connector will receive a fulfillment event for an order that was imported historically.

The connector must handle this gracefully by looking up the Odoo record for the historical order and updating it with the fulfillment, rather than trying to create a new Odoo record.

Verifying the transition

After going live, run a parallel validation for the first week: compare the live connector's outputs against what the historical import would have created. If there are discrepancies (e.g., customer dedupe is working differently in the live connector than it did during the historical import), correct them before they accumulate.

---

Summary

A clean historical Shopify Odoo order import requires:

1. Pre-import audit: count your edge cases before writing a single import 2. Customer dedupe: email-first with a single anonymous customer for guests 3. Product matching: SKU primary, placeholder for unmatched 4. Tax handling: use as-shipped amounts, do not recompute 5. Refund handling: two-step import (invoice + credit note), exact amounts 6. Pagination: checkpoint-and-resume, rate limit compliance 7. Idempotency: check for existing records before creating 8. Spot-check 10 orders: validate your full edge case coverage before bulk run 9. Rollback plan: tag-based or snapshot-based, established before import begins 10. Cutover plan: define the transition to live sync before starting the import

Synco Connector supports historical order import with configurable date ranges, customer dedupe, idempotency controls, and a built-in spot-check mode.

Install from the Shopify App Store or visit our historical order import guide and order sync reference for configuration details.

Keep reading

Related guides