← Blog
Business Apps·05 July 2026·12 min read

LookupImportPlus: No more guessing on Dataverse lookup imports

Dataverse's standard Excel import guesses on duplicate names — silently linking the wrong record. LookupImportPlus resolves lookups deterministically (GUID, business key, or search field) or escalates to you. With a real import run walking every matching method, screen by screen.

LookupImportPlus: No more guessing on Dataverse lookup imports

Every Power Platform admin has a “Contoso GmbH” story. You export a spreadsheet of contacts, someone fills in a company name in a text column, you import it back — and Dataverse quietly links half your new contacts to the wrong Contoso, because two accounts happen to share that name. Nobody gets an error. Nobody gets a warning. You just find out weeks later when a salesperson can’t see their own contact.

LookupImportPlus is a small Power Apps tool built to make that impossible. This post walks through what it does, why the problem is harder than it looks, and how to actually use it — screen by screen, ending with one real import run where you see every matching method in action.

The problem: guessing isn’t resolving

If you’ve worked with Dataverse (Microsoft’s business database behind Dynamics 365 and Power Apps), you already know a lookup: a column on one table that points to a record on another, like a Contact’s Parent Account pointing at an Account record. Under the hood a lookup isn’t the name you see — it’s a reference to a specific record by its GUID (a globally unique ID), plus the target table.

Dataverse’s built-in Excel import doesn’t know that. When you import a spreadsheet with a “Parent Account” column full of company names, it resolves each name by searching for a match — and if it finds more than one, it silently takes the first result. Two accounts named “Contoso GmbH”? Congratulations, some fraction of your imported contacts are now attached to the wrong one, and the import log says “success.”

LookupImportPlus exists to remove that silent guess. Its rule is simple: resolve deterministically, or stop and ask a human. Never guess.

The technical challenge

A few things make “just resolve the lookup properly” harder than it sounds:

  • Names aren’t unique. The obvious identifier (GUID) is the one thing your Excel column almost never has, because business users don’t work in GUIDs.
  • Polymorphic lookups. Some Dataverse lookups — customerid, ownerid, regardingobjectid — can point at several different tables. A customerid might be an Account or a Contact. Each target table has its own field names (an Account searches by name, a Contact by fullname), so “search for a match” needs a different query per possible target.
  • Writing the link. Once you know the target record, you set the lookup through Dataverse’s Web API using a special annotated field called @odata.bind:
POST /contacts
{ "firstname": "Max", "lastname": "Mustermann",
  "parentcustomerid_account@odata.bind": "/accounts(8f2c…-guid)" }

That’s a write instruction, not a name lookup — which is exactly why getting the read side right (finding the correct GUID first) matters so much.

  • OData filters. If your source data can also come from a Dataverse view defined in FetchXML, that has to translate too:
<condition attribute="statecode" operator="eq" value="0"/>   →   statecode eq 0
<condition attribute="name" operator="like" value="%Contoso%"/> → contains(name,'Contoso')

None of this is exotic — but strung together, it’s exactly the kind of “should be simple” problem that quietly corrupts data at scale if you don’t take it seriously.

The idea: configuration-first, versioned, round-trip

LookupImportPlus doesn’t start from a spreadsheet. It starts from a job configuration — a saved, versioned description of how one Dataverse table should be exported and re-imported, including exactly how each lookup column should be resolved. Every import run keeps an immutable snapshot of the configuration it used, so editing the config later never silently reinterprets an old run.

The day-to-day cycle looks like this:

Configure → Export (template or data) → edit in Excel → Import (upload → dry run → resolve conflicts → commit).

Hands-on walkthrough

1. Job configurations — the start page

The landing screen lists every saved configuration as a card: target table, operation, how many columns and lookups it covers, its version, and whether it’s a draft. Each card has Export ▾, Bearbeiten (edit), Import starten (start an import run), and delete.

Job configuration list showing a "Contacts – Parent Account" config card with version, entity, and action buttons The configuration list (dark theme) — one config, “Contacts – Parent Account,” version 4.

The same configuration list in light theme The light theme variant. The theme toggle (moon/sun icon) sits top-right next to the language switch.

Click Neue Konfiguration (New configuration) to open the guided wizard — a numbered, tabbed flow where later tabs unlock once you’ve picked a target entity: Entity & Source, General (name, operation, strict/partial write mode), Columns, Lookups.

2. Columns — pick what travels

The Columns tab lists every attribute on the chosen table, with filters for search, selected only, lookups only, required only, and writable only — genuinely useful once a table has 200+ system columns.

Columns tab showing the attribute list with checkboxes, filters, and Preview/Export buttons The Columns tab — ticking columns to include, with the lookups-only filter available.

3. Data preview — checking before you export

Opened from the Columns tab, this modal shows up to 10/25/50 real records, toggling between CRM columns (raw Dataverse values) and Schema columns (the exact Excel layout the export produces), with colour-coded groups and a legend.

Data preview modal toggled to Schema columns, showing colour-coded lookup groups and the generated technical columns The schema view reveals what the export really produces: alongside “Parent Account”, the technical columns “Parent Account Id” (GUID), “Parent Account Type” and “Parent Account Number” — the channels for GUID and business-key matching.

4. Lookups — the heart of the tool

Every lookup column you selected gets its own configuration card. This is where the “never guess” promise is built, and the card bundles everything a match needs:

Lookup configuration card "Parent Account → parentcustomerid" showing visible Excel column, business-key column, escalate conflict strategy, polymorphic account/contact targets, per-target search field, and a condition on modifiedon A single lookup in detail — target field parentcustomerid, business-key column “Parent Account Number”, conflict strategy escalate, account/contact targets (polymorphic scope), and a condition “Modified On ≥ relative date −7 days”.

For a polymorphic lookup you tick which target tables are in scope, then configure per target: its search field, its business-key attribute, and any extra conditions — because the same lookup might need account.name for one target and contact.fullname for another.

5. Import run — upload, dry run, decide

Uploading a filled spreadsheet (or loading the built-in demo file) first runs a configuration check (schema-drift protection), then a dry run that classifies every row with a determinate progress bar.

Import run screen with stat tiles (1 Ready, 2 Conflicts, 1 Error, 4 rows total), Strict/Partial toggle, and a row-level results table Dry-run results for the demo file — four rows, four different outcomes. We walk each one below.

You choose Strict (write nothing until everything’s resolved) or Partial (write the clean rows now), then commit.

6. Conflict basket — deciding at scale

Every unresolved lookup lands here, grouped by the source value — so many rows sharing “Contoso GmbH” are one decision, not many.

Conflict basket grouped by source value with target field, affected rows, candidate count, and status The conflict basket — “nothing is guessed automatically.” Every decision is logged: rule, candidates shown, chosen GUID, user, timestamp.

7. Import history

Every run is listed with its frozen configuration snapshot and counts — traceable back to the exact row and lookup decision.

Import history table with one completed run: configuration, mode Partial, 4 rows, 1 written, 2 conflicts, status Completed Import history (light theme) — a Partial run: 4 rows read, 1 written cleanly, 2 conflicts left open.

The matching model

The resolution order is fixed and never improvises — the app documents these three stages right in the Lookups tab:

Explainer box "How a lookup is resolved" with the three numbered stages GUID column, Business Key, Search field, and the warning that multiple/zero hits escalate The three matching stages, as the app documents them itself — first hit wins.

1. GUID column        → retrieve by id, verify target type → bind
2. Business key        → query by unique alternate attribute
3. Search field + conditions → query candidates
     exactly one → resolved | zero → NotFound | many → Ambiguous (→ conflict list)

Import scenarios: the matching methods in action

Enough model — let’s watch it on a real run. The built-in demo file has four rows, and each deliberately lands in a different matching outcome, visible in the dry-run screen above:

Last NameParent AccountResolutionStatusWhat fires
SchmidtFabrikam Inc.22222222… (account)✅ ResolvedSearch field, exactly 1 hit
MustermannContoso GmbH2 candidates⚠️ AmbiguousSearch field, >1 hit → escalate
Adventure Worksno hit❌ Missing requiredValidation before matching
BergUnknown Corpno hit❌ Not foundSearch field, 0 hits

Scenario 1 — Unique name: the happy path

Schmidt → Fabrikam Inc.: exactly one account is named “Fabrikam Inc.”. GUID and business-key columns are empty, so stage 3 (search field) fires; name eq 'Fabrikam Inc.' returns a single hit → resolved, and the commit would bind parentcustomerid_account@odata.bind to that account. No human involved. This is how you want most rows to go.

Scenario 2 — Ambiguous name: two “Contoso GmbH”

Mustermann → Contoso GmbH: stage 3 fires again — but the name search finds two “Contoso GmbH” accounts. The conflict strategy is escalate, so the app doesn’t guess; it pushes the row into the basket. Open the group:

Resolve detail page for "Contoso GmbH": warning "lookup not uniquely resolvable", the OData query used, the time anchor, and two candidates differing by accountnumber, each with a radio and deep link The resolve page exposes what it matched on: name eq 'Contoso GmbH' and modifiedon ge 2026-06-28… — and shows the two candidates, which differ only by accountnumber (100230 vs. 200981).

Pick the right record (optionally “apply to all rows with this value”) and commit. Every decision is logged. That’s the manual path — but you can head off this exact conflict in three places, without ever seeing the basket:

a) GUID round-trip (stage 1). Export real data, unhide the “Parent Account Id” column, paste the correct GUID on known-ambiguous rows, re-import. GUID wins outright.

b) Business key: the account number (stage 2). That’s what “Parent Account Number” is for. The two Contoso differ by accountnumber (100230 vs. 200981). Put the right number in the business-key column and the app resolves via the unique alternate attribute — before the name search even starts.

c) A condition: a second attribute (stage 3). No GUID or number, but another distinguishing field in the row? Add it as a condition:

name = Excel["Parent Account"]  AND  address1_country = Excel["Country"]

Now only the German Contoso GmbH matches. (The demo already uses a modifiedon condition as a time anchor; add your own on the same pattern.)

Scenario 3 — Missing required field: caught before matching

Adventure Works with no last name: the lookup would resolve, but the row is missing a required field (lastname). The app checks that before writing and flags “missing required”, instead of throwing a raw API exception at commit time. Fill the value in Excel and re-import — it’s data validation, not a lookup problem, surfaced early.

Scenario 4 — Not found: zero hits

Berg → Unknown Corp: stage 3 searches but finds no “Unknown Corp” account → Not found. That’s more honest than a wrong hit. Usually it’s a source typo or a record that doesn’t exist yet. You decide: create the account, fix the name, or deliberately skip — “not found” gets a row with “Edit →” in the basket too.

The through-line

Four rows, four outcomes — and not one of them guessed. Every status maps straight to a matching stage: resolved (stage 3, 1 hit), ambiguous (stage 3, >1 hit, escalate), not found (stage 3, 0 hits), missing-required (validation before). And for the unpleasant cases you have two levers — GUID (stage 1) and business key (stage 2) — to make them vanish on the next round-trip.

Under the hood (brief)

LookupImportPlus is a Power Apps Code App — you write real React + Fluent UI code instead of building screens visually, using Vite. Data access goes through the @microsoft/power-apps SDK’s Dataverse data source, which exposes far more than the basic connector docs suggest — full entity/attribute/relationship metadata, OData paging, and the navigation properties needed for polymorphic lookups — so no custom connector or plug-in was needed for the core feature set.

Configurations and run history currently live in the browser’s localStorage; the app itself is the only thing installed in your Dataverse environment. Before every run, a schema-drift preflight re-checks the saved configuration against the table’s current metadata and blocks on real errors while surfacing warnings.

Limitations & roadmap

  • Row-by-row writes. Commits go one record at a time (bounded-concurrency pool, per-row retry) rather than server-side bulk operations like CreateMultiple/UpdateMultiple. Next throughput milestone.
  • No shared, persisted history — yet. Config and history live in each user’s browser storage. Team-shared, Dataverse-backed audit tables (lip_jobconfiguration, lip_importjob) are a planned phase two.
  • No async queue for very large jobs. For big volumes, the plan is a Dataverse-native Custom API that processes rows as a background job — deliberately not an external Azure function. Not built yet.

None of these block the core promise — deterministic, auditable lookup resolution.

Try it yourself

The full source is open on GitHub: github.com/brunsforge/LookupImportPlus.

git clone https://github.com/brunsforge/LookupImportPlus
cd LookupImportPlus
npm install && npm run dev        # local, demo data, no Dataverse needed
npm run deploy -- -EnvironmentUrl https://<org>.crm.dynamics.com   # to a trial

The local dev mode runs entirely against an in-memory mock — including the two deliberately colliding “Contoso GmbH” accounts from the scenarios above — so you can see a conflict happen and resolve it without touching a real environment first.

See also