XML-RPC Data Transfer: Odoo 17 to 18
By Braincuber Team
Published on January 13, 2026
Your company has been running Odoo 17 for two years. You've got 12,000 customer records, 8,500 products, 45,000 sales orders, and countless invoices. Now you're upgrading to Odoo 18, but a full database migration isn't possible—the old system has too many customizations that won't carry over cleanly. You need to selectively transfer specific data: customers, products, and open orders. Everything else stays behind or gets recreated fresh.
XML-RPC (XML Remote Procedure Call) is a protocol that enables communication between systems over HTTP. In Odoo, XML-RPC acts as a web service API that lets external programs authenticate, read records, create entries, update data, and execute methods on any model. This makes it the ideal tool for selective data migration: you write a Python script that connects to both Odoo instances, reads data from the source (Odoo 17), and creates matching records in the destination (Odoo 18). No database dump, no module compatibility issues—just controlled, record-by-record transfer.
What is XML-RPC? A protocol that sends requests and receives responses over HTTP, with data formatted in XML. In Odoo, it provides remote access to models—authenticate, search, read, create, update, delete. Perfect for migrations, integrations, and data synchronization between Odoo instances.
Why Use XML-RPC for Data Transfer?
Selective Migration
Transfer only what you need—customers, products, orders. Leave behind outdated data, broken customizations, or records you don't want in the new system.
Secure Authentication
Uses Odoo's built-in authentication. Each connection requires database name, username, and password. API key authentication also supported.
Duplicate Handling
Check if records already exist before creating. Skip duplicates, update existing records, or merge data based on your business rules.
Any Model Support
Works with any Odoo model: res.partner, product.product, sale.order, account.move—even custom models you've created.
XML-RPC Transfer Workflow
XML-RPC DATA TRANSFER WORKFLOW
═══════════════════════════════════════════════════════════
STEP 1: ESTABLISH CONNECTIONS
───────────────────────────────────────────────────────────
Source (Odoo 17):
URL: http://localhost:8017
Database: company_odoo17
User: admin
Password: ********
Destination (Odoo 18):
URL: http://localhost:8018
Database: company_odoo18
User: admin
Password: ********
STEP 2: AUTHENTICATE TO BOTH INSTANCES
───────────────────────────────────────────────────────────
common.authenticate(db, username, password, {})
→ Returns user ID (uid) if successful
→ Returns False if authentication fails
STEP 3: FETCH DATA FROM SOURCE
───────────────────────────────────────────────────────────
models.execute_kw(
db, uid, password,
'res.partner', # Model name
'search_read', # Method
[[]], # Domain (empty = all records)
{'fields': [...]} # Fields to fetch
)
→ Returns list of dictionaries with record data
STEP 4: TRANSFORM DATA (IF NEEDED)
───────────────────────────────────────────────────────────
• Map old IDs to new IDs (for relational fields)
• Convert field formats if schema changed
• Handle renamed or removed fields
• Clean invalid data
STEP 5: CHECK FOR DUPLICATES IN DESTINATION
───────────────────────────────────────────────────────────
models.execute_kw(
db, uid, password,
'res.partner',
'search',
[[('email', '=', email)]],
{'limit': 1}
)
→ Returns list of matching IDs (empty if no match)
STEP 6: CREATE RECORDS IN DESTINATION
───────────────────────────────────────────────────────────
models.execute_kw(
db, uid, password,
'res.partner',
'create',
[record_data]
)
→ Returns new record ID
STEP 7: LOG RESULTS
───────────────────────────────────────────────────────────
Created: ABC Corporation (ID: 145)
Skipped (duplicate): xyz@email.com
Error: Missing required field 'name'
Step 1: Import the XML-RPC Library
Python 3 includes xmlrpc.client in the standard library. No additional installation is required.
import xmlrpc.client
Step 2: Configure Server Connections
Set up connection details for both the source (Odoo 17) and destination (Odoo 18) instances. Each connection needs the server URL, database name, username, and password.
# ═══════════════════════════════════════════════════════════
# SOURCE: Odoo 17 Instance
# ═══════════════════════════════════════════════════════════
source_url = "http://localhost:8017"
source_db = "company_production_v17"
source_user = "admin"
source_password = "your_admin_password"
# Create XML-RPC proxies for source
source_common = xmlrpc.client.ServerProxy(f'{source_url}/xmlrpc/2/common')
source_models = xmlrpc.client.ServerProxy(f'{source_url}/xmlrpc/2/object')
# Authenticate to source
source_uid = source_common.authenticate(source_db, source_user, source_password, {})
if not source_uid:
raise Exception("Failed to authenticate to Odoo 17 source")
print(f"Connected to Odoo 17: {source_db} (User ID: {source_uid})")
# ═══════════════════════════════════════════════════════════
# DESTINATION: Odoo 18 Instance
# ═══════════════════════════════════════════════════════════
dest_url = "http://localhost:8018"
dest_db = "company_production_v18"
dest_user = "admin"
dest_password = "your_admin_password"
# Create XML-RPC proxies for destination
dest_common = xmlrpc.client.ServerProxy(f'{dest_url}/xmlrpc/2/common')
dest_models = xmlrpc.client.ServerProxy(f'{dest_url}/xmlrpc/2/object')
# Authenticate to destination
dest_uid = dest_common.authenticate(dest_db, dest_user, dest_password, {})
if not dest_uid:
raise Exception("Failed to authenticate to Odoo 18 destination")
print(f"Connected to Odoo 18: {dest_db} (User ID: {dest_uid})")
Complete Transfer Script: Customers
This complete script transfers customer records (res.partner) from Odoo 17 to Odoo 18. It checks for duplicates by email address and skips records that already exist in the destination.
#!/usr/bin/env python3
"""
Transfer Customers from Odoo 17 to Odoo 18 using XML-RPC
Author: Your Company
Date: January 2026
"""
import xmlrpc.client
from datetime import datetime
# ═══════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════
# Source: Odoo 17
SOURCE_URL = "http://localhost:8017"
SOURCE_DB = "company_production_v17"
SOURCE_USER = "admin"
SOURCE_PASSWORD = "admin_password_here"
# Destination: Odoo 18
DEST_URL = "http://localhost:8018"
DEST_DB = "company_production_v18"
DEST_USER = "admin"
DEST_PASSWORD = "admin_password_here"
# ═══════════════════════════════════════════════════════════
# ESTABLISH CONNECTIONS
# ═══════════════════════════════════════════════════════════
print("=" * 60)
print("ODOO DATA TRANSFER: Customers (res.partner)")
print(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
# Connect to Source (Odoo 17)
print("\nConnecting to Odoo 17 (source)...")
source_common = xmlrpc.client.ServerProxy(f'{SOURCE_URL}/xmlrpc/2/common')
source_models = xmlrpc.client.ServerProxy(f'{SOURCE_URL}/xmlrpc/2/object')
source_uid = source_common.authenticate(SOURCE_DB, SOURCE_USER, SOURCE_PASSWORD, {})
if not source_uid:
raise Exception("Authentication failed for Odoo 17")
print(f" ✓ Connected to {SOURCE_DB} (UID: {source_uid})")
# Connect to Destination (Odoo 18)
print("\nConnecting to Odoo 18 (destination)...")
dest_common = xmlrpc.client.ServerProxy(f'{DEST_URL}/xmlrpc/2/common')
dest_models = xmlrpc.client.ServerProxy(f'{DEST_URL}/xmlrpc/2/object')
dest_uid = dest_common.authenticate(DEST_DB, DEST_USER, DEST_PASSWORD, {})
if not dest_uid:
raise Exception("Authentication failed for Odoo 18")
print(f" ✓ Connected to {DEST_DB} (UID: {dest_uid})")
# ═══════════════════════════════════════════════════════════
# FETCH CUSTOMERS FROM SOURCE
# ═══════════════════════════════════════════════════════════
print("\nFetching customers from Odoo 17...")
# Define fields to transfer
fields_to_fetch = [
'name', 'email', 'phone', 'mobile',
'street', 'street2', 'city', 'zip',
'country_id', 'state_id',
'company_type', 'is_company',
'vat', 'website', 'comment'
]
# Fetch all customer records
customers = source_models.execute_kw(
SOURCE_DB, source_uid, SOURCE_PASSWORD,
'res.partner',
'search_read',
[[['customer_rank', '>', 0]]], # Only actual customers
{'fields': fields_to_fetch}
)
print(f" ✓ Found {len(customers)} customers to transfer")
# ═══════════════════════════════════════════════════════════
# TRANSFER CUSTOMERS TO DESTINATION
# ═══════════════════════════════════════════════════════════
print("\nTransferring customers to Odoo 18...")
created_count = 0
skipped_count = 0
error_count = 0
for idx, customer in enumerate(customers, 1):
try:
email = customer.get('email')
name = customer.get('name', 'Unknown')
# Check for duplicate by email
if email:
existing = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.partner',
'search',
[[('email', '=', email)]],
{'limit': 1}
)
if existing:
print(f" [{idx}/{len(customers)}] SKIP: {name} (email exists)")
skipped_count += 1
continue
# Prepare record for creation
new_customer = {
'name': name,
'email': email,
'phone': customer.get('phone'),
'mobile': customer.get('mobile'),
'street': customer.get('street'),
'street2': customer.get('street2'),
'city': customer.get('city'),
'zip': customer.get('zip'),
'company_type': customer.get('company_type'),
'is_company': customer.get('is_company'),
'vat': customer.get('vat'),
'website': customer.get('website'),
'comment': customer.get('comment'),
'customer_rank': 1, # Mark as customer
}
# Handle country_id (Many2one field)
if customer.get('country_id'):
country_name = customer['country_id'][1] # [id, name]
country = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.country',
'search',
[[('name', '=', country_name)]],
{'limit': 1}
)
if country:
new_customer['country_id'] = country[0]
# Handle state_id (Many2one field)
if customer.get('state_id'):
state_name = customer['state_id'][1]
state = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.country.state',
'search',
[[('name', '=', state_name)]],
{'limit': 1}
)
if state:
new_customer['state_id'] = state[0]
# Create customer in destination
new_id = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.partner',
'create',
[new_customer]
)
print(f" [{idx}/{len(customers)}] CREATED: {name} (ID: {new_id})")
created_count += 1
except Exception as e:
print(f" [{idx}/{len(customers)}] ERROR: {name} - {str(e)}")
error_count += 1
# ═══════════════════════════════════════════════════════════
# SUMMARY
# ═══════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("TRANSFER COMPLETE")
print("=" * 60)
print(f" Total Records: {len(customers)}")
print(f" Created: {created_count}")
print(f" Skipped: {skipped_count}")
print(f" Errors: {error_count}")
print(f"\nFinished: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
Running the Script
Save the Script
Save the script as transfer_customers.py in your working directory.
Update Configuration
Edit the configuration section with your actual:
- Server URLs (replace localhost with actual IP/domain)
- Database names
- Admin credentials
Execute the Script
Run the script from your terminal:
python3 transfer_customers.py
Transfer Products Example
Here's how to transfer product records. The same pattern applies—fetch from source, handle relational fields, check for duplicates, create in destination.
#!/usr/bin/env python3
"""
Transfer Products from Odoo 17 to Odoo 18 using XML-RPC
"""
import xmlrpc.client
# Configuration (same as before)
SOURCE_URL = "http://localhost:8017"
SOURCE_DB = "company_production_v17"
SOURCE_USER = "admin"
SOURCE_PASSWORD = "admin_password"
DEST_URL = "http://localhost:8018"
DEST_DB = "company_production_v18"
DEST_USER = "admin"
DEST_PASSWORD = "admin_password"
# Connect to both instances
source_common = xmlrpc.client.ServerProxy(f'{SOURCE_URL}/xmlrpc/2/common')
source_models = xmlrpc.client.ServerProxy(f'{SOURCE_URL}/xmlrpc/2/object')
source_uid = source_common.authenticate(SOURCE_DB, SOURCE_USER, SOURCE_PASSWORD, {})
dest_common = xmlrpc.client.ServerProxy(f'{DEST_URL}/xmlrpc/2/common')
dest_models = xmlrpc.client.ServerProxy(f'{DEST_URL}/xmlrpc/2/object')
dest_uid = dest_common.authenticate(DEST_DB, DEST_USER, DEST_PASSWORD, {})
print("Fetching products from Odoo 17...")
# Fetch products
products = source_models.execute_kw(
SOURCE_DB, source_uid, SOURCE_PASSWORD,
'product.template',
'search_read',
[[]],
{
'fields': [
'name', 'default_code', 'barcode',
'list_price', 'standard_price',
'type', 'categ_id', 'description',
'sale_ok', 'purchase_ok', 'active'
]
}
)
print(f"Found {len(products)} products")
created = 0
skipped = 0
for product in products:
# Check for duplicate by internal reference (default_code)
default_code = product.get('default_code')
if default_code:
existing = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'product.template',
'search',
[[('default_code', '=', default_code)]],
{'limit': 1}
)
if existing:
print(f"SKIP: {product['name']} (code exists)")
skipped += 1
continue
# Map category
categ_id = False
if product.get('categ_id'):
categ_name = product['categ_id'][1]
categ = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'product.category',
'search',
[[('name', '=', categ_name)]],
{'limit': 1}
)
if categ:
categ_id = categ[0]
# Create product
new_product = {
'name': product['name'],
'default_code': default_code,
'barcode': product.get('barcode'),
'list_price': product.get('list_price', 0),
'standard_price': product.get('standard_price', 0),
'type': product.get('type', 'consu'),
'categ_id': categ_id or 1, # Default category if not found
'description': product.get('description'),
'sale_ok': product.get('sale_ok', True),
'purchase_ok': product.get('purchase_ok', True),
'active': product.get('active', True),
}
new_id = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'product.template',
'create',
[new_product]
)
print(f"CREATED: {product['name']} (ID: {new_id})")
created += 1
print(f"\nTransfer complete: {created} created, {skipped} skipped")
Handling Relational Fields
HANDLING RELATIONAL FIELDS IN XML-RPC TRANSFERS
═══════════════════════════════════════════════════════════
MANY2ONE FIELDS (e.g., country_id, category_id)
───────────────────────────────────────────────────────────
Source data returns: [id, "Display Name"]
Example: country_id = [233, "United States"]
To transfer:
1. Extract the name: country_name = data['country_id'][1]
2. Search in destination by name
3. Use the destination ID
Code:
if record.get('country_id'):
country_name = record['country_id'][1]
dest_country = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.country', 'search',
[[('name', '=', country_name)]],
{'limit': 1}
)
if dest_country:
new_record['country_id'] = dest_country[0]
ONE2MANY / MANY2MANY FIELDS
───────────────────────────────────────────────────────────
Source data returns: [id1, id2, id3, ...]
Example: tag_ids = [5, 12, 18]
To transfer:
1. Fetch related records from source
2. Create/find matching records in destination
3. Use [(6, 0, [list_of_ids])] format for M2M
Code:
# Fetch tag names from source
source_tags = source_models.execute_kw(
SOURCE_DB, source_uid, SOURCE_PASSWORD,
'res.partner.category', 'read',
[record['tag_ids']],
{'fields': ['name']}
)
# Find or create tags in destination
dest_tag_ids = []
for tag in source_tags:
dest_tag = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.partner.category', 'search',
[[('name', '=', tag['name'])]],
{'limit': 1}
)
if dest_tag:
dest_tag_ids.append(dest_tag[0])
else:
# Create tag if not exists
new_tag_id = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.partner.category', 'create',
[{'name': tag['name']}]
)
dest_tag_ids.append(new_tag_id)
# Set Many2many field
new_record['category_id'] = [(6, 0, dest_tag_ids)]
HANDLING MISSING REFERENCES
───────────────────────────────────────────────────────────
Strategy 1: Skip the field
new_record['country_id'] = dest_country[0] if dest_country else False
Strategy 2: Use default value
new_record['categ_id'] = dest_categ[0] if dest_categ else 1
Strategy 3: Create missing record
if not dest_country:
dest_country_id = dest_models.execute_kw(
DEST_DB, dest_uid, DEST_PASSWORD,
'res.country', 'create',
[{'name': country_name, 'code': 'XX'}]
)
Best Practices
✅ XML-RPC Transfer Best Practices:
- Test on staging first: Never run migration scripts directly on production. Test on copies of both databases
- Handle duplicates explicitly: Always check if records exist before creating. Use unique identifiers (email, reference, barcode)
- Transfer in order: Transfer master data first (countries, categories, users), then transactional data (orders, invoices)
- Log everything: Print progress and errors. Save logs to a file for review
- Batch large transfers: For thousands of records, process in batches of 100-500 to avoid timeouts
- Map IDs properly: Create a mapping dictionary of old_id → new_id for relational fields
- Handle errors gracefully: Wrap operations in try/except. Continue processing after errors
- Verify after transfer: Compare record counts and spot-check data in destination
Common Issues and Solutions
Authentication Failed
Verify: (1) Server URL is correct and accessible, (2) Database name is exact (case-sensitive), (3) Username and password are correct, (4) User has XML-RPC access enabled.
Field Does Not Exist
Field names may differ between versions. Check the destination model's field list using: models.execute_kw(db, uid, pwd, 'model.name', 'fields_get', [], {})
Timeout on Large Datasets
Process in batches. Use offset and limit in search_read: {'offset': 0, 'limit': 500}. Loop until no more records returned.
Conclusion
XML-RPC provides a reliable, controlled method for transferring data between Odoo instances. Unlike full database migrations, you choose exactly what data to transfer and how to handle it. The protocol works with any model, supports authentication, and allows for duplicate checking and data transformation during transfer. Whether migrating customers, products, orders, or custom records from Odoo 17 to Odoo 18, the pattern remains the same: connect to both instances, fetch from source, transform if needed, check for duplicates, and create in destination.
🎯 Key Takeaway: Connect to both instances → Authenticate → Fetch with search_read → Check duplicates with search → Create with create → Log results. Works for any model, any Odoo version combination.
