Example: Add an Excel source to the bronze and silver layers

Important

Some or all of this functionality is available as part of a preview release. The content and the functionality are subject to change.

This example shows how to load a small Excel workbook into a dedicated bronze lakehouse and then merge the data into an existing wealth screening model of the Nonprofit data solutions.

This article includes the sample workbook data inline so you can recreate the workbook in Excel and use it as-is with the notebook examples.

This example assumes that the workbook resolves packaged sample Contact and Account source-system IDs to existing Constituent rows, and then updates the existing packaged sample WealthScreening rows for those same constituents.

The workbook is aligned to the packaged sample data in this repo. Its EntitySourceSystemId values are source-system IDs from packaged sample Contact and Account rows, not ConstituentId values.

The workbook updates existing WealthScreening rows and maps workbook codes onto these existing silver lookup tables:

  • WealthScreeningCapacityRange
  • WealthScreeningConstituentRating

What already exists in Nonprofit data solutions

This pattern assumes the base package already provides these assets:

  • the WealthScreening, WealthScreeningCapacityRange, and WealthScreeningConstituentRating silver tables
  • the downstream gold FactWealthScreening process
  • the packaged sample Contact, Account, Constituent, WealthScreening, and SourceSystemIdMapping data used by the workbook

Data flow

Excel workbook (Excel Bronze Files)
    -> Excel Bronze staging tables
  -> Silver Source registration (WealthScreeningExcel)
  -> Existing Silver lookup tables:
      WealthScreeningCapacityRange
      WealthScreeningConstituentRating
  -> Excel-source mapping rows for those lookups:
      SourceSystemIdMapping
  -> Existing Silver fact-like table updated in place:
      WealthScreening
  -> Existing Gold process:
       FactWealthScreening

When to use this pattern

Use this pattern when:

  • The workbook is a small external enrichment source.
  • The workbook references constituents that already exist in silver.
  • The workbook should behave as its own source system with its own SourceId.
  • The workbook should update existing WealthScreening rows instead of creating parallel rows.

Don't use this pattern if the workbook is expected to create brand new constituents. That scenario is a broader source-onboarding scenario.

Prerequisites

  • The packaged sample Contact, Account, Constituent, WealthScreening, and SourceSystemIdMapping data is already installed.
  • You can create a dedicated bronze lakehouse for the Excel source.
  • You have access to create and run notebooks in the Fabric workspace.

If you're following this article against the packaged sample data, keep the example EntitySourceSystemId values as-is. They already point to sample Contact and Account source-system IDs that the solution installs.

Only replace the example EntitySourceSystemId values if you adapt this pattern to your own environment.

If you use your own workbook, also adjust the existing-row lookup logic in the prepare and sync sections so the notebook updates the correct existing WealthScreening row.

Create the workbook

Create a new workbook in Excel, keep the sheet names exactly as shown here, paste each block into cell A1 of the matching sheet, and save the file as WealthScreeningExcel-Example.xlsx.

Sheet: CapacityRange

CapacityRangeCode	CapacityRangeName
LOW	Under 10K
MEDIUM	10K-100K
HIGH	100K+
MAJOR	500K+

Sheet: ConstituentRating

ConstituentRatingCode	ConstituentRatingName
LOW	Low
MEDIUM	Medium
HIGH	High
EXCEPTIONAL	Exceptional

Sheet: WealthScreening

WealthScreeningExcelId	EntityType	EntitySourceSystemId	CapacityRangeCode	ConstituentRatingCode	LastScreeningDate
WS-0001	contact	7d0db8e9-a964-4f25-9390-3095e5beaaf9	HIGH	HIGH	2026-03-01
WS-0002	contact	6dd2ca43-dc5f-44f0-b675-30729ce6e996	MEDIUM	LOW	2026-03-02
WS-0003	account	c0c27239-c493-4246-8884-9cafa35f2df8	MAJOR	EXCEPTIONAL	2026-03-03
WS-0004	contact	3f41080b-6333-437f-8ff7-20c01871cee4	LOW	MEDIUM	2026-03-04
WS-0005	account	1fbea1f2-d901-4505-b2d1-c05fa95ee7eb	HIGH	HIGH	2026-03-05
WS-0006	contact	313bda3f-959d-4e76-bd5f-d893ecb156c9	MEDIUM	MEDIUM	2026-03-06

Upload the workbook

Upload the workbook you created to a dedicated bronze lakehouse for the Excel source, such as Fundraising_Excel_BR.

The package doesn't include a generic shared bronze lakehouse artifact. The packaged lakehouses are source-specific, such as Fundraising_SalesforceNPSP_BR, plus the shared Fundraising_SL and Fundraising_GD lakehouses.

Recommended upload path:

Files/WealthScreeningExcel-Example.xlsx

In the Fabric UI:

  1. Open the Excel bronze lakehouse.
  2. Open the Files pane.
  3. Upload WealthScreeningExcel-Example.xlsx.

If you change the file name, update the path in the notebook examples.

The examples in this article use /lakehouse/default/Files/WealthScreeningExcel-Example.xlsx, which resolves against the lakehouse attached to the notebook as the default lakehouse.

Create a dedicated notebook

Create a notebook such as Fundraising_Excel_Bronze_Silver_Transform and attach the Excel bronze lakehouse as the notebook's default lakehouse.

Packaged notebooks get their lakehouse metadata during installation, but a manually created notebook doesn't. That difference is why you need to attach the default lakehouse for the custom Excel notebook.

Important

You must attach a default lakehouse. Without a default lakehouse, Spark SQL statements that use partial namespaces fail with UnsupportedOperationException: No default context found.

In this step, create the notebook, attach its default lakehouse, and define the cell layout for the remaining implementation steps.

Use the notebook cells in this order:

Cell Purpose
Cell 1 Run Fundraising_Config
Cell 2 Add imports and constants
Cell 3 Register the Excel source
Cell 4 Load workbook sheets into bronze staging tables
Cell 5 Prepare existing WealthScreening rows for update
Cell 6 Map CapacityRangeCode
Cell 7 Map ConstituentRatingCode
Cell 8 Resolve workbook rows to existing constituents
Cell 9 Sync WealthScreening

If a step contains multiple code blocks separated by explanation, keep them in the same target cell unless the article explicitly says otherwise.

Add the notebook setup cells

Add this code to Cell 1:

%run {PREFIX}_Fundraising_Config

Add this code to Cell 2:

import pandas as pd
from pyspark.sql import functions as F

WEALTH_SCREENING_EXCEL_SOURCE_ID = '5d4dd0d4-1df9-4f69-a0ee-4f0c0b5f6d2d'
WEALTH_SCREENING_EXCEL_SOURCE_NAME = 'WealthScreeningExcel'
PACKAGED_SAMPLE_SOURCE_ID = 'bb05204b-9818-4e53-9036-6b29b936b0fb'

excel_path = '/lakehouse/default/Files/WealthScreeningExcel-Example.xlsx'

Fundraising_Config provides reusable helpers for the silver lakehouse and source mapping logic.

PACKAGED_SAMPLE_SOURCE_ID is the source identifier for the packaged sample data used by the existing Contact, Account, Constituent, WealthScreening, and lookup rows.

Ensure that the notebook is attached to a default lakehouse in the Fabric notebook UI. Any attached lakehouse is enough to establish the default Spark context; the examples in this article assume the Excel bronze lakehouse is that default.

excel_path = '/lakehouse/default/Files/WealthScreeningExcel-Example.xlsx' only works when the workbook you created exists in the attached default lakehouse under Files.

Keep the path as /lakehouse/default/... when you use pd.read_excel(). The packaged sample-data notebook uses file:/lakehouse/default/... for Spark and mssparkutils, but the pandas example in this article shouldn't use the file: prefix.

Use unqualified table names for bronze staging tables in this notebook because the notebook is already attached to the bronze lakehouse.

This notebook is intentionally separate from the packaged source transforms because Excel processing in this example isn't source-system watermark sync.

Register the new source

Add this code to Cell 3 to register the workbook as its own source in silver.

Use the WEALTH_SCREENING_EXCEL_SOURCE_ID value from the setup cell as the stable source GUID for this Excel integration across environments.

spark.sql(f"""
MERGE INTO {silver_lakehouse_name}.Source AS target
USING (
    SELECT
        '{WEALTH_SCREENING_EXCEL_SOURCE_ID}' AS SourceId,
        current_timestamp() AS CreatedDate,
        current_timestamp() AS ModifiedDate,
        '{WEALTH_SCREENING_EXCEL_SOURCE_NAME}' AS Name
) AS source
ON target.SourceId = source.SourceId
WHEN MATCHED THEN UPDATE SET
    target.ModifiedDate = source.ModifiedDate,
    target.Name = source.Name
WHEN NOT MATCHED THEN INSERT (
    SourceId, CreatedDate, ModifiedDate, Name
) VALUES (
    source.SourceId, source.CreatedDate, source.ModifiedDate, source.Name
)
""")

You need this source registration so the workbook mappings stay separate from the packaged sample source mappings.

This separation matters because:

  • workbook-driven lookup mappings must carry the Excel SourceId
  • SourceSystemIdMapping must distinguish workbook keys from packaged sample keys
  • cross-source joins should always resolve through SourceSystemIdMapping

Load the workbook into bronze staging tables

Add this code to Cell 4.

capacity_range_pd = pd.read_excel(excel_path, sheet_name='CapacityRange')
constituent_rating_pd = pd.read_excel(excel_path, sheet_name='ConstituentRating')
wealth_screening_pd = pd.read_excel(excel_path, sheet_name='WealthScreening')

capacity_range_df = (
    spark.createDataFrame(capacity_range_pd)
    .select(
        F.col('CapacityRangeCode').cast('string').alias('CapacityRangeCode'),
        F.col('CapacityRangeName').cast('string').alias('CapacityRangeName')
    )
)

constituent_rating_df = (
    spark.createDataFrame(constituent_rating_pd)
    .select(
        F.col('ConstituentRatingCode').cast('string').alias('ConstituentRatingCode'),
        F.col('ConstituentRatingName').cast('string').alias('ConstituentRatingName')
    )
)

wealth_screening_df = (
    spark.createDataFrame(wealth_screening_pd)
    .select(
        F.col('WealthScreeningExcelId').cast('string').alias('WealthScreeningExcelId'),
        F.lower(F.col('EntityType').cast('string')).alias('EntityType'),
        F.col('EntitySourceSystemId').cast('string').alias('EntitySourceSystemId'),
        F.col('CapacityRangeCode').cast('string').alias('CapacityRangeCode'),
        F.col('ConstituentRatingCode').cast('string').alias('ConstituentRatingCode'),
        F.to_date('LastScreeningDate').alias('LastScreeningDate')
    )
)

capacity_range_df.write.mode('overwrite').format('delta').saveAsTable(
    'WealthScreeningExcel_CapacityRange'
)

constituent_rating_df.write.mode('overwrite').format('delta').saveAsTable(
    'WealthScreeningExcel_ConstituentRating'
)

wealth_screening_df.write.mode('overwrite').format('delta').saveAsTable(
    'WealthScreeningExcel_WealthScreening'
)

Use overwrite semantics in this example because the workbook is treated as a small batch source.

Recommended bronze staging table shapes:

  • WealthScreeningExcel_CapacityRange: CapacityRangeCode, CapacityRangeName
  • WealthScreeningExcel_ConstituentRating: ConstituentRatingCode, ConstituentRatingName
  • WealthScreeningExcel_WealthScreening: WealthScreeningExcelId, EntityType, EntitySourceSystemId, CapacityRangeCode, ConstituentRatingCode, LastScreeningDate

If you need true incremental processing, add file versioning or change-tracking logic before you adapt this notebook.

Prepare existing WealthScreening rows for update

Add this helper to Cell 5.

def get_existing_wealth_screening():
    return (
        get_silver_table('WealthScreening')
        .select(
            F.col('WealthScreeningId'),
            F.col('ConstituentId'),
            F.col('CreatedDate').alias('ExistingCreatedDate')
        )
    )

This helper lets the notebook reuse the existing WealthScreening rows instead of creating new rows for the same constituent.

This separation matters because:

  1. The sample data already contains one WealthScreening row per constituent.
  2. Matching by ConstituentId keeps the total row count stable across reruns.

Map CapacityRangeCode to the existing lookup table

Add this code to Cell 6.

capacity_range_bridge = spark.createDataFrame(
    [
        ('LOW', 'Under $10,000'),
        ('MEDIUM', '$10,000 - $49,999'),
        ('HIGH', '$100,000 - $499,999'),
        ('MAJOR', '$500,000 - $999,999')
    ],
    ['SourceSystemId', 'TargetName']
)

existing_capacity_ranges = (
    get_silver_table('WealthScreeningCapacityRange')
    .filter(F.col('SourceId') == F.lit(PACKAGED_SAMPLE_SOURCE_ID))
    .select(
        F.col('Name').alias('TargetName'),
        F.col('WealthScreeningCapacityRangeId')
    )
)

capacity_range_stage = (
    spark.table('WealthScreeningExcel_CapacityRange')
    .select(F.col('CapacityRangeCode').cast('string').alias('SourceSystemId'))
    .dropna(subset=['SourceSystemId'])
    .dropDuplicates(['SourceSystemId'])
    .join(capacity_range_bridge, 'SourceSystemId', 'left')
    .join(existing_capacity_ranges, 'TargetName', 'left')
    .select(
        F.col('WealthScreeningCapacityRangeId'),
        F.current_timestamp().alias('CreatedDate'),
        F.current_timestamp().alias('ModifiedDate'),
        F.lit(WEALTH_SCREENING_EXCEL_SOURCE_ID).alias('SourceId'),
        F.col('SourceSystemId')
    )
)

capacity_range_mapping_stage = (
    capacity_range_stage
    .select(
        F.col('WealthScreeningCapacityRangeId').alias('SilverRecordId'),
        F.col('SourceSystemId'),
        F.col('SourceId'),
        F.lit('WealthScreeningCapacityRange').alias('SourceTable')
    )
)

invalid_capacity_ranges = capacity_range_stage.filter(
    F.col('WealthScreeningCapacityRangeId').isNull()
)

if not invalid_capacity_ranges.rdd.isEmpty():
    display(invalid_capacity_ranges)
    raise ValueError('CapacityRange sheet contains codes that are not mapped to an existing WealthScreeningCapacityRange row.')

capacity_range_mapping_stage.createOrReplaceTempView('capacity_range_mapping_stage')

spark.sql(f"""
MERGE INTO {silver_lakehouse_name}.SourceSystemIdMapping AS target
USING capacity_range_mapping_stage AS source
ON target.SourceId = source.SourceId
AND target.SourceSystemId = source.SourceSystemId
AND target.SourceTable = source.SourceTable
WHEN MATCHED THEN UPDATE SET
    target.SilverRecordId = source.SilverRecordId
WHEN NOT MATCHED THEN INSERT (SilverRecordId, SourceSystemId, SourceId, SourceTable)
VALUES (source.SilverRecordId, source.SourceSystemId, source.SourceId, source.SourceTable)
""")

This step preserves the packaged sample mappings and adds a second set of Excel-owned mappings for the same lookup rows.

Map ConstituentRatingCode to the existing lookup table

Add this code to Cell 7.

constituent_rating_bridge = spark.createDataFrame(
    [
        ('LOW', 'Low'),
        ('MEDIUM', 'Medium'),
        ('HIGH', 'High'),
        ('EXCEPTIONAL', 'Exceptional')
    ],
    ['SourceSystemId', 'TargetName']
)

existing_constituent_ratings = (
    get_silver_table('WealthScreeningConstituentRating')
    .filter(F.col('SourceId') == F.lit(PACKAGED_SAMPLE_SOURCE_ID))
    .select(
        F.col('Name').alias('TargetName'),
        F.col('WealthScreeningConstituentRatingId')
    )
)

constituent_rating_stage = (
    spark.table('WealthScreeningExcel_ConstituentRating')
    .select(F.col('ConstituentRatingCode').cast('string').alias('SourceSystemId'))
    .dropna(subset=['SourceSystemId'])
    .dropDuplicates(['SourceSystemId'])
    .join(constituent_rating_bridge, 'SourceSystemId', 'left')
    .join(existing_constituent_ratings, 'TargetName', 'left')
    .select(
        F.col('WealthScreeningConstituentRatingId'),
        F.current_timestamp().alias('CreatedDate'),
        F.current_timestamp().alias('ModifiedDate'),
        F.lit(WEALTH_SCREENING_EXCEL_SOURCE_ID).alias('SourceId'),
        F.col('SourceSystemId')
    )
)

constituent_rating_mapping_stage = (
    constituent_rating_stage
    .select(
        F.col('WealthScreeningConstituentRatingId').alias('SilverRecordId'),
        F.col('SourceSystemId'),
        F.col('SourceId'),
        F.lit('WealthScreeningConstituentRating').alias('SourceTable')
    )
)

invalid_constituent_ratings = constituent_rating_stage.filter(
    F.col('WealthScreeningConstituentRatingId').isNull()
)

if not invalid_constituent_ratings.rdd.isEmpty():
    display(invalid_constituent_ratings)
    raise ValueError('ConstituentRating sheet contains codes that are not mapped to an existing WealthScreeningConstituentRating row.')

constituent_rating_mapping_stage.createOrReplaceTempView('constituent_rating_mapping_stage')

spark.sql(f"""
MERGE INTO {silver_lakehouse_name}.SourceSystemIdMapping AS target
USING constituent_rating_mapping_stage AS source
ON target.SourceId = source.SourceId
AND target.SourceSystemId = source.SourceSystemId
AND target.SourceTable = source.SourceTable
WHEN MATCHED THEN UPDATE SET
    target.SilverRecordId = source.SilverRecordId
WHEN NOT MATCHED THEN INSERT (SilverRecordId, SourceSystemId, SourceId, SourceTable)
VALUES (source.SilverRecordId, source.SourceSystemId, source.SourceId, source.SourceTable)
""")

This step also preserves the packaged sample mappings and adds Excel-owned mappings for the same silver lookup rows.

Resolve workbook rows to existing constituents

Add this code to Cell 8.

constituent_mapping = (
    get_silver_table('SourceSystemIdMapping')
    .filter(F.col('SourceId') == F.lit(PACKAGED_SAMPLE_SOURCE_ID))
    .filter(F.col('SourceTable').isin('Contact', 'Account'))
    .select(
        F.col('SourceSystemId').alias('MappedEntitySourceSystemId'),
        F.col('SourceTable').alias('MappedSourceTable'),
        F.col('SilverRecordId').alias('MappedPartySilverId')
    )
)

constituents = (
    get_silver_table('Constituent')
    .select('ConstituentId', 'ContactId', 'AccountId')
)

excel_ws = spark.table('WealthScreeningExcel_WealthScreening')

resolved_parties = (
    excel_ws.join(
        constituent_mapping,
        (F.col('EntitySourceSystemId') == F.col('MappedEntitySourceSystemId')) &
        (
            ((F.col('EntityType') == F.lit('contact')) & (F.col('MappedSourceTable') == F.lit('Contact'))) |
            ((F.col('EntityType') == F.lit('account')) & (F.col('MappedSourceTable') == F.lit('Account')))
        ),
        'left'
    )
)

resolved_constituents = (
    resolved_parties.join(
        constituents,
        (
            ((F.col('EntityType') == F.lit('contact')) & (F.col('MappedPartySilverId') == F.col('ContactId'))) |
            ((F.col('EntityType') == F.lit('account')) & (F.col('MappedPartySilverId') == F.col('AccountId')))
        ),
        'left'
    )
)

Why not join directly on silver GUIDs:

  • The workbook knows source-system IDs, not silver IDs.
  • SourceSystemIdMapping is the canonical bridge across sources.

Sync WealthScreening

Add this code to Cell 9.

capacity_mapping = (
    get_silver_table('SourceSystemIdMapping')
    .filter(F.col('SourceId') == F.lit(WEALTH_SCREENING_EXCEL_SOURCE_ID))
    .filter(F.col('SourceTable') == F.lit('WealthScreeningCapacityRange'))
    .select(
        F.col('SourceSystemId').alias('MappedCapacityRangeCode'),
        F.col('SilverRecordId').alias('CapacityRangeId')
    )
)

rating_mapping = (
    get_silver_table('SourceSystemIdMapping')
    .filter(F.col('SourceId') == F.lit(WEALTH_SCREENING_EXCEL_SOURCE_ID))
    .filter(F.col('SourceTable') == F.lit('WealthScreeningConstituentRating'))
    .select(
        F.col('SourceSystemId').alias('MappedConstituentRatingCode'),
        F.col('SilverRecordId').alias('ConstituentRatingId')
    )
)

resolved_wealth_screening = (
    resolved_constituents
    .join(capacity_mapping, F.col('CapacityRangeCode') == F.col('MappedCapacityRangeCode'), 'left')
    .join(rating_mapping, F.col('ConstituentRatingCode') == F.col('MappedConstituentRatingCode'), 'left')
    .join(get_existing_wealth_screening(), 'ConstituentId', 'left')
)

invalid_rows = resolved_wealth_screening.filter(
    F.col('ConstituentId').isNull() |
    F.col('CapacityRangeId').isNull() |
    F.col('ConstituentRatingId').isNull() |
    F.col('WealthScreeningId').isNull()
)

if not invalid_rows.rdd.isEmpty():
    display(invalid_rows)
    raise ValueError('Workbook contains rows that cannot be resolved to existing Constituent, lookup, or WealthScreening IDs.')

wealth_screening_stage = (
    resolved_wealth_screening
    .select(
        F.col('WealthScreeningId'),
        F.col('ConstituentId'),
        F.col('CapacityRangeId'),
        F.col('ConstituentRatingId'),
        F.col('ExistingCreatedDate').alias('CreatedDate'),
        F.current_timestamp().alias('ModifiedDate'),
        F.col('LastScreeningDate'),
        F.lit(WEALTH_SCREENING_EXCEL_SOURCE_ID).alias('SourceId'),
        F.col('WealthScreeningExcelId').cast('string').alias('SourceSystemId')
    )
)

wealth_screening_stage.createOrReplaceTempView('wealth_screening_stage')

spark.sql(f"""
MERGE INTO {silver_lakehouse_name}.WealthScreening AS target
USING (
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY WealthScreeningId ORDER BY ModifiedDate DESC) AS rn
        FROM wealth_screening_stage
    ) WHERE rn = 1
) AS source
ON target.WealthScreeningId = source.WealthScreeningId
WHEN MATCHED THEN UPDATE SET
    target.ConstituentId = source.ConstituentId,
    target.CapacityRangeId = source.CapacityRangeId,
    target.ConstituentRatingId = source.ConstituentRatingId,
    target.SourceId = source.SourceId,
    target.SourceSystemId = source.SourceSystemId,
    target.ModifiedDate = source.ModifiedDate,
    target.LastScreeningDate = source.LastScreeningDate
""")

This approach keeps the existing WealthScreeningId stable while assigning ownership of the row to the Excel source system.

Update orchestration

For recurring execution, use pipeline orchestration rather than manual notebook runs.

Add the Excel notebook to Fundraising_BR_Ingestion.DataPipeline, not to the top-level Fundraising_Orchestration pipeline.

Recommended activity order:

  1. Import sample data
  2. Transform Excel wealth screening to silver
  3. The remaining source activities in their existing order

This order ensures that the packaged sample mappings already exist before the Excel notebook runs.

Verify the results

Verify the source and lookup mappings

display(spark.sql(f"""
SELECT 'Source' AS CheckType, Name AS CheckValue, COUNT(*) AS RowCount
FROM {silver_lakehouse_name}.Source
WHERE Name = '{WEALTH_SCREENING_EXCEL_SOURCE_NAME}'
GROUP BY Name

UNION ALL

SELECT 'Mapping' AS CheckType, SourceTable AS CheckValue, COUNT(*) AS RowCount
FROM {silver_lakehouse_name}.SourceSystemIdMapping
WHERE SourceId = '{WEALTH_SCREENING_EXCEL_SOURCE_ID}'
    AND SourceTable IN ('WealthScreeningCapacityRange', 'WealthScreeningConstituentRating')
GROUP BY SourceTable
"""))

Expected results:

  • one Source row with Name = 'WealthScreeningExcel'
  • mapping rows for WealthScreeningCapacityRange
  • mapping rows for WealthScreeningConstituentRating

WealthScreening mapping rows aren't expected in this update-in-place pattern because the article reuses the existing WealthScreeningId values instead of creating Excel-owned IDs for that table.

Verify that WealthScreening now uses the Excel source

display(spark.sql(f"""
SELECT SourceId, COUNT(*) AS RowCount
FROM {silver_lakehouse_name}.WealthScreening
GROUP BY SourceId
ORDER BY RowCount DESC
"""))

Expected result: The rows updated by this workbook now use {WEALTH_SCREENING_EXCEL_SOURCE_ID} as their SourceId, because the workbook acts as the owning source system for WealthScreening.

Verify that the row count stays stable

display(spark.sql(f"""
SELECT COUNT(*) AS RowCount
FROM {silver_lakehouse_name}.WealthScreening
"""))

Expected result: if you start from the packaged sample data used in this example, the row count remains stable at 1200.

Verify that gold enrichment picks up the data

display(spark.sql(f"""
SELECT COUNT(*)
FROM {gold_lakehouse_name}.FactWealthScreening
"""))

Expected result: rows exist after Fundraising_SL_GD_Enrichment runs.

Troubleshoot common issues

Workbook reruns create duplicate silver rows

In this update-in-place pattern, reruns shouldn't create new WealthScreening rows at all.

Verify that the WealthScreening sync logic still:

  • joins get_existing_wealth_screening() by ConstituentId
  • validates that WealthScreeningId isn't null before the merge
  • uses only WHEN MATCHED THEN UPDATE and doesn't include WHEN NOT MATCHED THEN INSERT

The workbook contains unresolved rows

If the notebook raises ValueError: Workbook contains rows that cannot be resolved to existing Constituent, lookup, or WealthScreening IDs., check these items:

  1. The ConstituentRatingCode mapping step is completed fully. The notebook must not stop at building constituent_rating_stage; it must also merge constituent_rating_mapping_stage into SourceSystemIdMapping. Otherwise, ConstituentRatingId remains null in the final validation.
  2. The unresolved rows are displayed before the exception.
  3. Every EntitySourceSystemId in the workbook exists in the packaged sample SourceSystemIdMapping rows.
  4. Every workbook lookup code is present in the bridge tables used in the capacity range and constituent rating mapping sections.
  5. Every resolved constituent already has one WealthScreening row to update.

The workbook file isn't found

If the notebook raises FileNotFoundError: /lakehouse/default/Files/WealthScreeningExcel-Example.xlsx, verify that:

In this article, /lakehouse/default/... refers to the notebook's attached default lakehouse, not some other lakehouse in the workspace.

Don't change this path to file:/lakehouse/default/... when you use pd.read_excel(). That file: prefix is used in the packaged sample-data notebook for Spark and mssparkutils, not for the pandas Excel read shown here.

Fix it as follows:

  1. Check which lakehouse is attached to the notebook as default.
  2. Verify that the workbook you created exists in that lakehouse under Files.
  3. If you saved the workbook with a different file name or uploaded it to a different lakehouse, change excel_path to match the actual location you want to read.
  4. If you used the temporary shortcut and uploaded the workbook to Fundraising_SL/Files, keep Fundraising_SL attached while you run the workbook load step.

After you fix the file location or lakehouse attachment, rerun the setup cells and then rerun the workbook load cell.

The notebook has no default context

If the notebook raises UnsupportedOperationException: No default context found, attach a default lakehouse to the notebook and rerun the setup cells in order.

The SQL examples in this article use partial namespaces such as Fundraising_SL.Source, which Fabric resolves only when a default lakehouse context is attached.

After you attach the lakehouse:

  1. Confirm that excel_path still points to the attached lakehouse.
  2. Rerun Cell 1, Cell 2, and Cell 3 in order.
  3. If your deployed environment prefixes item names, attach the prefixed Excel bronze lakehouse rather than the unprefixed example name.

Summary

This pattern lets you integrate a small external workbook without changing the packaged source connectors:

  • load the workbook into bronze staging tables
  • register the workbook as its own source
  • keep lookup-code mapping stable through SourceSystemIdMapping
  • resolve cross-source references through existing source mappings
  • map workbook lookup codes onto the existing wealth screening lookup tables
  • update the existing WealthScreening rows in place
  • reuse the existing gold FactWealthScreening process

The same structure works well for other small external sources such as CSV exports, vendor files, or manually maintained enrichment workbooks.

See also