Migrating from Salesforce to Odoo 19
What maps cleanly, what gets rebuilt, and how to tell whether the move is worth doing at all. Written by people who spent years in both.
Start here: do not migrate
Most people who ask us about this should stay where they are. Salesforce is good software. If it is running your business and people use it, the move costs more than it returns.
Stay on Salesforce if any of these are true:
- You run a managed package with no Odoo equivalent.
- You have fewer than about 20 internal users. The licence saving will not pay for the project.
- Your quoting is complex, and CPQ is doing real work.
- The org carries hundreds of validation rules or many branching flows.
- You run a validated or regulated system.
- Your build is under 18 months old and people use it.
- Licence price is the only reason on the table.
The move makes sense when
You were about to buy an ERP anyway. Most of your users are light touch. You have hundreds of portal logins. Your org is close to standard. Or you are paying twice because the CRM and the system that runs the work do not talk to each other.
The data model
Odoo is not a copy of Salesforce with different names. Two structural differences drive most of the work: companies and people live in one model, and leads and opportunities live in another.
| Salesforce | Odoo 19 | What changes |
|---|---|---|
| Account | res.partner with is_company = True | Companies and people share one model. There is no customer flag. Odoo counts orders in customer_rank. |
| Contact | res.partner with parent_id | Same model as the Account. parent_id is a single link, so one person belongs to one company. |
| AccountContactRelation | none | Salesforce puts one contact on many accounts. Odoo cannot. Indirect relationships need a custom model or they are lost. |
| Lead and Opportunity | crm.lead, split by type | One model and one table for both. Every filter, rule and integration has to say type = opportunity or it picks up leads. |
| Opportunity Products | sale.order.line on a quotation | crm.lead carries no product lines. Line detail lives on a quote, so the pipeline becomes quote driven. |
| OpportunityContactRole | none | One contact per opportunity, with no role. A buying committee needs a custom model. |
| Product2 | product.template and product.product | One product becomes two records. Order lines point at the variant, so map your IDs to product.product. |
| Pricebook2, PricebookEntry | product.pricelist rules | There is no standard price book. Every product carries one sales price and sells with no pricelist at all. |
| Quote and Order | sale.order | No separate quote object. Confirming a quotation turns that same record into the order. |
| Case | helpdesk.ticket or project.task | Helpdesk is Enterprise only. On Community it is project.task or an OCA module. |
Lead conversion, and the number that changes on go-live day
This is the mistake that costs the most trust, and it is easy to walk into.
In Salesforce a converted lead leaves four records behind: the closed Lead, an Account, a Contact and an Opportunity. In Odoo it is one crm.lead row that changes type from lead to opportunity. Load your converted leads as crm.lead rows and Odoo already has the opportunity, so every won deal is counted twice.
Decide this before the first extract
Write down which Salesforce records become crm.lead rows, which become res.partner rows, and which are dropped because Odoo already holds them. Our rule: leads where IsConverted = true are dropped from the lead extract entirely. Get the sales manager to agree to it in writing, because the pipeline number will change on day one and they need to know why before it happens, not after.
Automation: what clicks over and what becomes Python
Odoo has a declarative layer, but it is closer to Workflow Rules than to Flow. One trigger, one filter, a short list of actions. Anything with a second decision or a loop becomes Python.
| Salesforce | Odoo 19 | Difficulty |
|---|---|---|
| Workflow Rules, simple Flows | Automated actions (base.automation) | Clicks over |
| Flow with decisions or loops | Server action with Python (ir.actions.server) | Rewrite |
| Validation rules | Python constraints in a module | Rewrite |
| Apex triggers and classes | Python model methods in a custom module | Rewrite |
| Scheduled and batch Apex | Scheduled actions (ir.cron) | Mostly clicks over |
| Approval processes | Odoo Approvals, or a custom state machine | Depends |
| Record types | Nothing equivalent | Decide per record type |
Do not size the build by counting flows
Count two other things: flows with more than one decision element, and active validation rules. Those numbers predict the effort. Forty flows and two hundred validation rules is not a small automation project, it is two hundred Python constraints.
Users, security and sharing get rebuilt by hand
None of this migrates. It is rebuilt, and the mental model is inverted, so it is the area most often got wrong.
| Salesforce | Odoo 19 | What changes |
|---|---|---|
| Profile | res.groups | Groups are additive only. There is no profile underneath them. |
| Permission set | res.groups | Same mechanism. In Odoo 19 the field on the user is group_ids, renamed from groups_id. |
| Org-wide defaults and sharing rules | Record rules (ir.rule) | Salesforce starts closed and opens up. Odoo starts open and you restrict. |
| Role hierarchy | Nothing equivalent | A manager seeing a rep records is not free. You build it as a rule. |
| Field level security | Group on the field, or a view | There is no field security screen. |
Never rebuild a Private org-wide default as one global record rule
A global rule is one with no group on it, and nothing gets past it. Not the settings administrator, not the person who wrote it. Put ownership limits on group rules instead, because group rules OR together. Then test every acceptance case logged in as a real test user in that group, not as the administrator.
The data load
You will run the import ten or more times before go-live. It has to be re-runnable, and one rule decides whether it is.
Put the Salesforce ID in the Odoo id column, every file, every time
Use the 18-character ID, prefixed so it is obviously an external ID, for example sf.0013000000AbCdEAAV. Odoo then updates the same records on every run instead of creating new ones, and relationships resolve by ID rather than by name. Skip it and every correction is a full delete and reload, including any record a user has already touched.
Before loading contacts, run a match pass against the partners Odoo already holds. Vendors and user records are res.partner rows too. Match on normalised email first. Duplicate customers mean invoices against the wrong record and a ledger that does not balance.
Decide how much history is worth moving. Attachments go to ir.attachment, activity history to mail.message. Moving ten years of closed cases is usually a way to spend budget on data nobody opens.
Two things that surprise people
Odoo Standard has no external API access
If anything integrates with your CRM, you need the Custom tier. Price the comparison on Custom, not Standard, or the number in your business case is wrong.
Record types disappear, and reps get one form with everyone else's fields on it
Count your record types early and decide what each one actually did: a different picklist set, a different layout, a different sales process, or just a label. Every extra field on a form is a daily tax on the person filling it in, and this is the most common reason people say the new system is worse.
What to do this week
- Count your internal users, portal users, record types, active validation rules, and flows with more than one decision.
- List every managed package and integration, and decide what replaces each one.
- Write down the lead conversion rule and get sales to agree to it.
- Export one object with the 18-character IDs and load it into a free Odoo database. A practice run answers more than a demo does.
Not sure which way to go?
Send us your user count and your record type count. We will tell you honestly whether this is worth doing, including when the answer is to stay put.
Schedule a Consultation