Moving from Salesforce to Dynamics 365: A Practical Data Migration Guide

How teams actually move years of Salesforce data into Dynamics 365 Customer Engagement without losing the parts that matter, and what genuinely cannot be carried across.

This practical guide outlines the key steps and pitfalls of migrating large volumes of historical data from Salesforce to Dynamics 365 Customer Engagement. It covers planning, data mapping, using tools like KingswaySoft or Azure Data Factory, preserving audit timestamps (overriddencreatedon), handling attachments, and executing validation to ensure a complete transition without data loss. The short answer to how people do this without losing everything: they decide up front, in writing, exactly what "everything" is, they accept that a handful of things genuinely cannot be carried across, and they design around those instead of discovering them during cutover weekend.

What actually gets lost, and what stops it

Data loss on these projects is rarely dramatic. Nothing gets deleted. Things arrive stripped of the context that made them useful: a created date, a parent, an owner, a currency. Each of these has one specific mechanic behind it and one decision that prevents it, and every one of those decisions has to be made before the first batch runs.

What goes missingWhy it happensWhat prevents it
Every record shows the same created date: the day you loaded itDataverse stamps createdon at insert. The only way to change it is to supply overriddencreatedon in the same create request, and it cannot be set on an update afterwards.Set overriddencreatedon on create, with the override privilege granted to the migration account before the first batch runs. A record loaded without it keeps the load date permanently.
Child records lose their parent, or attach to the wrong oneDataverse keys rows on a GUID it generates. If the load resolves relationships by name, by email, or by a spreadsheet of new GUIDs, anything ambiguous silently lands somewhere wrong.Carry the Salesforce 18 character Id into a dedicated column on every table, define it as an alternate key, and resolve every lookup through that key rather than through a display value.
Records that differ only in letter case collide during upsertThe Salesforce 15 character Id is case sensitive, so two distinct records can differ only in capitalization. Dataverse alternate key matching is not case sensitive, so those two become one.Always extract and store the 18 character Id, never the 15 character form. This is a one line decision at extract time and an unrecoverable data merge if it is missed.
Notes, attachments, and files never arriveThey are a separate object graph in Salesforce (Attachment, Note, ContentVersion and ContentDocumentLink) and they do not come across with the record data. They are also the slowest part of the move.Plan files as their own workstream from day one, with their own extract, their own throughput budget, their own Dataverse destination, and their own count and byte level reconciliation.
Owners collapse onto the migration accountSalesforce owners include people who have left. Their user records are inactive, so there is nothing valid to assign to in Dataverse and the load falls back to whoever is running it.Build the user mapping before any data moves, and agree an explicit named fallback owner per team for leavers, so unassigned work is visible instead of hidden behind one service account.
Amounts change value or land in the wrong currencyA Dataverse money column needs transactioncurrencyid and an exchange rate. Load an amount without them and it is interpreted as base currency, and the base value columns are recalculated from that.Map CurrencyIsoCode to the Dataverse transaction currency on every money bearing table, and reconcile with control totals per currency rather than only with row counts.
Long text is truncated at loadA standard Dataverse text column tops out at 4000 characters. A Salesforce long text area holds far more, and most loaders will happily cut the value or fail the row.Profile the real maximum length per field in the source, not the declared length, and provision multiline text columns where the data actually needs them.
Closed work reopens itselfMany tables will not accept an arbitrary state on create, and closed opportunities and resolved cases have their own closure semantics rather than a plain status field.Load records active, then run a deliberate second pass that sets state and status, and use the proper close operations for opportunities and cases so the closure is real.
Field history disappearsThe Dataverse audit log is system managed. There is no supported way to write historical Salesforce field history rows into it, whatever a migration tool appears to offer.Archive the Salesforce history objects into a read only Dataverse table or an external store, and say plainly in the plan that this is an archive rather than native audit.
Work done during the migration window is lostSalesforce stays live while the bulk load runs. Anything created or edited between the extract and the switch exists only in the source unless somebody goes looking for it.Design the delta load at the same time as the bulk load, using LastModifiedDate on the source and upsert on the alternate key so the delta is a repeatable operation and not a rescue.

Notice how many of these are one way doors. Created dates, identity keys, and letter case on the source Id all have to be right in the create request, because there is no update that fixes them afterwards. That is why the sequencing below puts the boring decisions first.

What you can preserve, and what you cannot

The most useful thing a migration plan can contain is an honest list of what Dataverse will and will not carry. Several of the rows below are a no, or a yes with a condition attached, and knowing which on day one is worth more than any tool choice, because it turns a cutover surprise into a design decision.

WhatPreserved?How it works in Dataverse
Original created dateYes, on create onlySupply overriddencreatedon in the create request as a UTC value. Dataverse writes it through to createdon and keeps overriddencreatedon as the record of the override. The migration account needs the privilege that allows overriding created on and created by during import, and the value cannot be applied later, so a table loaded without it has to be reloaded.
Original created by userYes, with impersonationSend the create as the migrated user by setting the caller on the request. The migration account needs the act on behalf of another user privilege, and the impersonated user has to exist and be enabled in Dataverse at the moment the row is written, which is why leavers have to be dealt with in the user mapping rather than at load time.
Original last modified date and userNoDataverse always stamps modifiedon and modifiedby with the load. There is no override. Where the business genuinely uses last modified, land the source values in custom columns such as legacy modified on and legacy modified by, and put them on the form so nobody is misled by the native fields.
Record ownerYesSet ownerid on create, resolved through the user and team mapping. Decide the fallback for inactive owners before the load rather than during it, and make the fallback a real named team so unowned work surfaces.
Record identityNot the same value, but reliably linkedDataverse rows are keyed on a GUID. Keep the Salesforce 18 character Id in a dedicated column with an alternate key on it, resolve all relationships through that key, and the migration becomes idempotent and re-runnable. Where an integration truly needs a deterministic GUID, set the primary key explicitly on create.
Open and closed stateYes, in a second passCreate records active, then set statecode and statuscode in a follow up pass, using the proper close operations for opportunities and cases. Attempting to set final state on create is the usual cause of closed work coming back to life in the new system.
Notes, attachments, and filesYes, as a separate workstreamExtract Salesforce Attachment, Note, and ContentVersion content, then load into the Dataverse destination you chose: notes, file columns, or a SharePoint document location, associated to the parent through the source Id. Budget for encoding overhead, file storage capacity, and API throughput, and reconcile counts and total bytes.
Field level historyNo, archive insteadThe Dataverse audit log cannot be back-filled. Load the Salesforce history objects into a read only table or an external store, and keep audit switched off during the migration so the load itself does not fill your log capacity with entries nobody will ever read.
Formula and roll-up valuesRecalculated, not copiedDataverse calculated and roll-up columns are derived, so loading a value into them is not possible or not durable. Either re-express the logic in Dataverse and let it calculate, or, where the historical number matters as a point in time fact, load it into a plain column and stop calling it a formula.
Record types, sharing, and validation rulesRedesigned, not migratedThese have no one to one equivalent. Record types become some combination of forms, business process flows, choice columns, and security roles. Salesforce sharing becomes business units, teams, and access. Plan this as design work in the build, not as a data mapping row.

The single most expensive mistake in this table is overriddencreatedon. It works only in the create request, so a table that goes in without it cannot be corrected by an update: it has to be deleted and reloaded, with every child relationship rebuilt behind it. Verify it on the first batch of the first table, not at the end.

Choosing the tools

There is no single tool that does a large Salesforce to Dataverse migration well from end to end. The bulk relational load, the file workstream, and the state and closure pass have different requirements, and the projects that go smoothly tend to be the ones that admitted this early rather than bending one tool around every problem.

KingswaySoft SSIS Integration Toolkit

Good at: The default choice for a large Dynamics 365 Customer Engagement migration. Purpose built Dataverse source and destination components, upsert on alternate keys, lookup resolution against the target, multithreaded writes, per row error output, and native handling of notes, attachments, and impersonation. Packages are code you can re-run, version, and hand over.

Where it runs out: Licensed, and it needs SSIS and Visual Studio, so somebody has to own that environment. It is a developer tool rather than something a business analyst drives, and package design quality decides your throughput.

Azure Data Factory or Synapse pipelines

Good at: Strong where the migration is already cloud shaped: large extracts staged into Azure storage or a SQL database, transformation at scale, scheduled and monitored runs, and a Dataverse sink that supports upsert against an alternate key. Good fit when the same platform will carry ongoing integration after go live.

Where it runs out: Thinner on the Dataverse specific mechanics. Relationship resolution, impersonated creates, and note and attachment handling take more building than they do in a purpose built toolkit, so complexity moves into the staging layer.

Dataverse dataflows (Power Query)

Good at: Low friction and in the box. Good for reference and configuration data, smaller tables, and one off corrective loads, with key column mapping for upsert and a familiar transformation experience.

Where it runs out: Not the tool for the high volume tables or for anything needing overriddencreatedon, impersonation, or careful ordering. Refresh behaviour and error reporting get uncomfortable as volume grows.

Custom code against the Dataverse Web API or SDK

Good at: Complete control, which is what the awkward parts need: setting overriddencreatedon on create, impersonating the original owner, chaining requests, and the bulk create and update messages for throughput. Usually the right answer for files and for the state and closure pass.

Where it runs out: You own everything, including retry on service protection limits, batching, logging, and restartability. Written casually it becomes the slowest and least observable part of the project.

Configuration Migration Tool

Good at: The supported way to move configuration and reference data between Dataverse environments with GUIDs preserved. Useful for moving your own mapping and lookup tables through dev, test, and production.

Where it runs out: Not a Salesforce migration tool and not for transactional volume. It belongs in the release process, not in the data workstream.

Whatever the mix, three properties matter more than the brand on the box. The load has to be idempotent, so a re-run corrects rather than duplicates. It has to log per row, so an exception report is a query and not an investigation. And it has to survive service protection limits by backing off and retrying, because a load that treats throttling as a failure will never finish a large table. If throughput rather than mapping is what is hurting, the batching, parallelism, and limit arithmetic are covered separately in fast Dataverse bulk import for millions of rows.

Step-by-step: how the migration actually runs

Steps 1 to 6 are decisions and preparation, and they are where migrations are won or lost. Steps 7 to 11 move the data. Steps 12 and 13 prove it and put it live. The order matters, because several of the early decisions cannot be revisited once rows exist.

  1. Profile the Salesforce org as it really is

    Inventory every object, custom field, picklist, record type, relationship, and automation in scope, with row counts, file counts, total file volume, and the genuine maximum length of every text field. Find what is actually used: fields populated on a handful of records, objects nobody has written to in years, automations that fire on create. This profile is what turns arguments about scope into a conversation about numbers.

  2. Write the loss register and get it signed

    Go table by table and record one of three decisions for each: migrated, archived, or deliberately dropped. Include the things that cannot be preserved natively, such as last modified metadata and field level history, and say where they land instead. This document is the reason a migration finishes without an argument, because everybody agreed in advance what "without losing everything" meant on this project.

  3. Design the Dataverse target model and the field level mapping

    Map each object to a table and each field to a column and data type. Picklists become choices with an agreed value set, multi-selects become choice columns, long text areas become multiline text sized from the profiled maximum, and Salesforce relationships become Dataverse lookups or many-to-many relationships. Decide owner, business unit, and security mapping at the same time, because those affect every row you write. The mapping document is the contract the build and the migration scripts both follow.

  4. Fix the identity strategy on the 18 character Id

    Add a Salesforce Id column to every migrated table, load the 18 character form, and define an alternate key on it. Never the 15 character form: it is case sensitive while Dataverse key matching is not, so two distinct records can merge into one during upsert and there is no clean recovery. With the key in place, every relationship resolves by source Id, failed batches re-run safely, and integrations that still point at the old Ids keep working.

  5. Build the user, team, and currency mapping before any data moves

    Produce a row per Salesforce user with the Dataverse user or team they map to, and an explicit decision for every leaver. Impersonated creates need the target user to exist and be enabled at load time, so this cannot be improvised mid run. Do the same for currencies: map CurrencyIsoCode to the Dataverse transaction currency and agree the exchange rate treatment, because money columns silently interpret an amount as base currency when no currency is supplied.

  6. Choose the tool per workstream rather than one tool for everything

    Use the purpose built toolkit or your pipeline platform for the bulk relational load, and accept that files, impersonated creates, and the state pass often want custom code against the Web API. KingswaySoft gives you Dataverse aware components and upsert on alternate keys inside SSIS. Azure Data Factory suits a staged, cloud native shape and doubles as your ongoing integration platform. Dataflows are for reference data and corrective loads. Whatever you pick, the load has to be re-runnable, logged per row, and restartable from a failure.

  7. Turn off everything that reacts to a write

    Disable plugin steps, real-time workflows, Power Automate flows, business rules, and duplicate detection rules for the duration of the load, and turn auditing off so the migration does not consume log capacity recording itself. Leave them on and every row fires side effects, ownership and dates get overwritten, throughput collapses, and the result is untrustworthy. Write down what was disabled so re-enabling it is a checklist rather than a memory test.

  8. Load in dependency order, upserting on the alternate key

    Users, business units, and teams first, then currencies and reference data, then parents before children, then activities and files last. Resolve every lookup through the source Id alternate key rather than a name or an email. Batch the writes, use the bulk operations where the tool supports them, and handle service protection limits properly: back off and retry on throttling rather than treating it as a failure, because a load that cannot survive being throttled cannot survive a large table.

  9. Preserve created date and ownership in the same create request

    Set overriddencreatedon and ownerid on create, and impersonate the original creator where created by matters, having granted the override and act on behalf of another user privileges to the migration account beforehand. There is no second chance here: overriddencreatedon cannot be applied on update, so a table that goes in without it has to be deleted and reloaded. Verify on the first batch, not on the last.

  10. Migrate notes, attachments, and files as their own workstream

    Extract Salesforce Attachment, Note, and ContentVersion content along with the ContentDocumentLink rows that say what they belong to, then load into the destination you chose in the mapping: Dataverse notes, file columns, or a SharePoint document location, associated to parents through the source Id. Run it in batches with per file logging, plan for encoding overhead and file storage capacity, and expect this to be the longest running part of the migration by a distance.

  11. Run the second pass for state, closures, and everything create could not carry

    With the data in and related, set statecode and statuscode, close opportunities and cases through the proper close operations so the closure is genuine rather than a status value, apply any values that depend on records loaded later, and load the history archive tables. Keep this pass as a separate, re-runnable job, because it is the one you will run again after every rehearsal.

  12. Reconcile until the numbers agree, then rehearse the whole thing

    Compare row counts per table, control totals on every money and numeric column broken down by currency, referential integrity checks for children with an empty parent lookup, file counts and total bytes, and a field by field sample of records the business chose rather than records you chose. Produce an exception report of everything that did not load and why. Then run the entire sequence end to end in a sandbox against full volume, because the rehearsal is what tells you how long cutover actually takes.

  13. Cut over with a delta load, then re-enable automation deliberately

    Freeze Salesforce, run the delta for everything changed since the bulk extract using LastModifiedDate and the same upsert on the alternate key, reconcile again, and only then re-enable plugins, flows, workflows, duplicate detection, and auditing, in that order and with a check after each. Turning audit on after the load rather than before it means the log starts clean on day one. Keep the source system readable for an agreed period, because the questions arrive in the first week and they are much cheaper to answer than to argue about.

The validation that proves nothing was lost

Row counts alone prove almost nothing. A table can have exactly the right number of rows while every created date is wrong, every owner is the same person, and half the amounts landed in the wrong currency. Each check below is written as the question it answers, so the reconciliation pack reads as evidence rather than as output.

CheckQuestion it answersWhat passing looks like
Row count per table, source against targetDid everything arrive?Counts match, or every difference is explained by a line in the loss register and is present in the exception report.
Control totals on money and numeric columns, split by currencyDid the values survive intact?Sums agree per currency. A count that matches while a total does not usually means currency or precision, not missing rows.
Referential integrity sweepDid anything lose its parent?Zero child records with an empty parent lookup where the source had one, and zero rows whose source Id did not resolve during the load.
Created date distributionDid the history survive?Created dates spread across the years the source covers. A spike on the load date means overriddencreatedon did not apply and that table needs reloading.
Ownership distributionDid the records land with the right people?Owners spread across the mapped users and teams. A large share sitting on the migration account means the user mapping failed quietly.
File count and total bytesDid the attachments really come across?Counts and byte totals agree per parent table, and a sample of files opens and renders correctly rather than merely existing as rows.
State and status breakdownIs closed work still closed?The open and closed split matches the source per table, and closed opportunities and resolved cases behave as closed in the user interface.
Field by field sample chosen by the businessWould the people who use this data accept it?The business picks the records, not the migration team, and signs off each sampled record against the source screen.
Business process rehearsal on migrated dataDoes the new system work with this data in it?Key reports, views, and processes run against migrated records and produce numbers the business recognises.

Two of these rows are the ones people skip and later regret. The created date distribution catches a failed overriddencreatedon while there is still time to reload, and the ownership distribution catches a broken user mapping before the sales team finds it. Both are a single grouped query and both should run after every batch, not once at the end.

How we run these at Solzet

Solzet is a Microsoft Dynamics 365 Customer Engagement and Power Platform consultancy in Yerevan, Armenia. Cross-platform data migration sits inside our implementation and project rescue work, so we see these from both ends: the ones we plan from the start, and the ones we are called into after the load has already happened. The second kind is almost always the same two problems. Created dates were not preserved, so every record looks like it was born on load day and the only fix is a delete and reload. Or the relationships were resolved on a display value instead of the source Id, so the parents are wrong in a way that no amount of spot checking finds.

Our own sequencing is the one above: profile first, write the loss register and get it signed, and treat the identity and timestamp decisions as one way doors rather than configuration. We build loads that are idempotent and logged per row, because the value of a re-runnable load only becomes obvious the first time a batch fails at three in the morning. And we reconcile per batch rather than at the end, so a broken assumption costs an hour instead of a weekend.

We work inside Dynamics 365 Customer Engagement and the Power Platform only: Sales, Customer Service, Field Service, model driven and canvas apps, Power Automate cloud and desktop flows, Power Pages, Dataverse, plug-ins and custom APIs, and PowerApps Component Framework controls. We do not take on Business Central or Finance and Operations work, so if your migration target is one of those we will say so at the first conversation rather than after a scoping exercise.

The companion piece to this guide is our Salesforce to Dataverse migration best practices page, which covers the same move from the design and pitfalls angle rather than the execution one. The wider delivery is described under Dynamics 365 consulting. Where the migration is one symptom of an implementation that has already gone wrong, the rescue and takeover guide describes how we pick that up, and the health check and technical audit is how we assess an environment before committing to a plan.

Frequently Asked Questions

How do people migrate from Salesforce to Dynamics 365 without losing anything?

They decide what "anything" means before they start. A migration plan that survives contact with reality names, table by table, what is migrated, what is archived somewhere else, and what is deliberately dropped, and it acknowledges the handful of things Dataverse genuinely will not carry, such as last modified metadata and field level history. After that it is mechanics: keep the Salesforce 18 character Id as an alternate key so relationships and re-runs are reliable, set overriddencreatedon on create so records keep their real age, turn off every plugin and flow during the load, treat files as their own workstream, and reconcile with counts, control totals, and referential integrity before anybody signs anything.

What is overriddencreatedon and why does it matter?

It is the Dataverse column that lets a data migration state the original creation date of a record. Supply it as a UTC value in the create request and Dataverse writes it through to createdon, keeping overriddencreatedon as the record of the override. It matters because createdon is otherwise stamped at insert, so without it every migrated record looks like it was created on load day, which breaks age based reporting, service history, and anything a salesperson uses to judge how long a relationship has existed. Two constraints decide your plan: the migration account needs the privilege that allows overriding created on and created by during import, and the value cannot be set on an update, so a table loaded without it has to be deleted and reloaded rather than corrected.

Should we use KingswaySoft or Azure Data Factory for the migration?

Both work, and on a large migration the honest answer is usually a combination. KingswaySoft inside SSIS is the default for Dynamics 365 Customer Engagement because its Dataverse components handle the platform specific parts natively: upsert on alternate keys, lookup resolution, impersonation, notes and attachments, and per row error output. Azure Data Factory fits when the shape is already cloud native, with large extracts staged into storage or SQL and transformed at scale, and it doubles as the platform for integration after go live. Dataflows are for reference data and corrective loads. Custom code against the Web API usually still ends up owning files, impersonated creates, and the state and closure pass, whichever main tool you choose.

Can Salesforce audit history and field history be migrated into Dynamics 365?

No. The Dataverse audit log is system managed and cannot be back-filled with historical rows, whatever a tool appears to offer. The workable approach has two parts: preserve the original created date on the live records with overriddencreatedon so they keep their true age, and load the Salesforce history objects into a read only Dataverse table or an external store as an archive. Keep auditing switched off during the migration itself, because otherwise the load fills your log capacity recording its own writes, and turn it on after cutover so the audit trail starts clean on the first real day.

How are Salesforce attachments and files moved into Dataverse?

As a separate workstream with its own plan, because they are a separate object graph in Salesforce and the slowest part of the move. Extract the classic Attachment and Note objects along with ContentVersion and the ContentDocumentLink rows that say what each file belongs to, then load into whichever destination the mapping chose: Dataverse notes, file columns, or a SharePoint document location, associated to the parent record through the source Id. Budget for encoding overhead, file storage capacity, and API throughput, run it in logged batches so it can restart, and reconcile file counts and total bytes per parent table rather than assuming success.

Why did our migrated records all end up owned by one account?

Because the user mapping was incomplete and the load fell back to whoever ran it. Salesforce owners include people who have left, and an inactive or missing user in Dataverse gives the loader nothing valid to assign to. The fix is to build the mapping as a real deliverable before data moves, with a row per Salesforce user and an explicit decision for every leaver, usually a named team rather than one service account so unassigned work is visible. Impersonated creates have the same requirement: the target user has to exist and be enabled at the moment the row is written.

How long does a Salesforce to Dynamics 365 migration take?

It depends on volume, file size, the number of objects in scope, and how much of the source is worth keeping, so any number quoted without seeing the org profile is guesswork. What is predictable is where the time goes: the mapping and the loss register take longer than people expect, the bulk relational load is usually the fastest part, files are usually the slowest, and the rehearsal is what actually tells you how long cutover weekend will be. That is the point of running the full sequence end to end against full volume in a sandbox: the rehearsal converts an estimate into a measured duration you can plan the business around.

Can Solzet run a Salesforce to Dynamics 365 migration for us?

Yes. Solzet is a Microsoft Dynamics 365 Customer Engagement and Power Platform consultancy based in Yerevan, Armenia, and cross-platform data migration is part of our implementation and project rescue work. We profile the source org, design the Dataverse target model and the field level mapping, build the identity, user, and currency strategies, run the loads in dependency order with created dates and ownership preserved, move notes and files, and reconcile before anybody signs off. We also pick up migrations that have already gone wrong, which usually means reloading the tables that lost their created dates and rebuilding the relationships that resolved on the wrong key. We work directly with end clients and white-label for Microsoft partners across Europe and the US.

Years of Salesforce data to move? Let us map it first.

Send us the shape of your Salesforce org, the objects in scope, the row counts, and the file volume, and we will come back with the target model, the loss register, and a load plan that keeps the created dates, the relationships, and the ownership intact. Solzet delivers Dynamics 365 Customer Engagement and Power Platform work from Yerevan, Armenia, directly for end clients and white-label for Microsoft partners across Europe and the US.