Building a Power Query pipeline that serves multiple tenants from shared infrastructure requires more than adding a filter — it demands layered isolation guarantees, credential management discipline, and query patterns that fail safely when configuration goes wrong. This lesson teaches you the complete architecture for building multi-tenant ETL pipelines you can trust with sensitive data.

You've built a beautiful ETL pipeline. It connects to your data warehouse, applies cleansing logic, reshapes the data, and loads it into a Power BI dataset in seconds. Then your manager asks: "Great — can we use this for all twelve of our client tenants?" Suddenly, your single-tenant masterpiece is staring down a multi-tenant nightmare.
Multi-tenant data isolation isn't just a matter of slapping a filter on a query. When you serve multiple tenants from a shared pipeline — whether those are enterprise business units, external SaaS customers, or regional subsidiaries — you're dealing with layered problems: ensuring that Tenant A's data never bleeds into Tenant B's output, managing credentials that may differ per tenant, preventing query folding breakdowns that silently return unfiltered data, and keeping the whole architecture maintainable as tenant count grows from 3 to 30 to 300. Getting any one of these wrong doesn't just create a bad report. It creates a data breach.
By the end of this lesson, you'll be able to design and implement a genuinely robust multi-tenant ETL pipeline in Power Query. You'll know how to structure parameterized filtering so tenant context is injected safely, how credential scoping works and where it breaks, how to write query patterns that are predictably safe even when folding fails, and how to architect a shared pipeline that scales without becoming a maintenance catastrophe.
What you'll learn:
This lesson assumes you're comfortable with intermediate-to-advanced Power Query work. Specifically, you should understand:
You should also have a working Power BI Desktop or Power Query in Excel/Fabric environment to follow along with the examples.
Before writing a single line of M code, let's be precise about what we're solving. In a multi-tenant context, isolation means two distinct things, and conflating them is one of the most common architectural errors.
Data isolation means that when Tenant A's pipeline runs, it retrieves only Tenant A's data — not because we retrieve everything and then filter in memory, but because the query itself is scoped to that tenant. The distinction matters enormously for both security and performance. If you pull 10 million rows from a database and then filter down to the 50,000 rows belonging to Tenant A, you've exposed all 10 million rows to the Power Query engine process. In Power BI service, that's the on-premises data gateway process. Any logging, error output, or query plan exposure now touches every tenant's data simultaneously.
Credential isolation means that Tenant A's data is accessed with Tenant A's credentials wherever the security model requires it. This is relevant when tenants have their own database schemas, their own SharePoint sites, their own API keys, or their own storage accounts. In a well-designed enterprise data platform, you often have both: a shared analytical database where tenant filtering is row-based, AND per-tenant source systems where you need separate credentials to even connect.
Key insight: Data isolation and credential isolation are separate concerns. You can have strong data isolation with shared credentials (if all tenants share a single data source that handles row-level security internally), or you can have credential isolation without adequate data isolation (if you have per-tenant connections but your query logic still retrieves cross-tenant data). A robust design addresses both.
A third concept that often gets overlooked is isolation auditability — the ability to demonstrate, for any given refresh, exactly what tenant context was active, what data was requested, and what was returned. This matters for compliance, for debugging, and for building trust with clients that you've actually done this correctly.
The first thing most Power Query practitioners try is a simple parameter filter. You create a Power Query parameter called TenantID, set it to a text value like "Acme Corp", then apply it in a filter step:
let
Source = Sql.Database("dw.company.com", "AnalyticsDB"),
OrdersTable = Source{[Schema="dbo", Item="Orders"]}[Data],
FilteredRows = Table.SelectRows(OrdersTable, each [TenantID] = TenantID)
in
FilteredRows
This works for a single-tenant scenario. You change the parameter value, refresh, and you get Acme Corp's orders. But as a multi-tenant pattern, it has three serious problems:
Problem 1: Manual parameter management. If you have 12 tenants, you need 12 separate reports, each with its own parameter set. When you add a new transformation step, you need to replicate it across all 12 files. This is not a pipeline — it's 12 copies of a pipeline.
Problem 2: No enforcement boundary. Nothing in this pattern prevents a misconfigured parameter from returning all tenants' data. If TenantID is accidentally set to null, Table.SelectRows with each [TenantID] = null will return only null-TenantID rows (probably nothing), but if you use each [TenantID] <> null, you get everything. Small mistakes in filter logic have catastrophic consequences.
Problem 3: Query folding is fragile. Power Query might fold the filter to the database, or it might not — and when it doesn't, you've pulled unfiltered data into the query engine. We'll examine this in detail shortly.
A more robust approach uses a tenant configuration table as the single source of truth, combined with function-based query generation. Instead of a single parameter, you maintain a table — whether in a separate Excel file, SharePoint list, or a configuration schema in your database — that defines each tenant's properties:
| TenantKey | TenantName | Schema | FilterColumn | FilterValue | CredentialGroup |
|---|---|---|---|---|---|
| T001 | Acme Corp | acme | ClientCode | ACME | GroupA |
| T002 | Globex Inc | globex | ClientCode | GLOBEX | GroupA |
| T003 | Initech | shared | TenantID | T003 | GroupB |
This table-driven approach means that adding a new tenant requires only a new row, not a new file. Your M code reads this configuration and generates tenant-appropriate queries dynamically. Here's what a function that encapsulates the tenant query pattern looks like:
// Saved as a query named: fn_GetTenantOrders
(TenantConfig as record) as table =>
let
// Extract tenant properties from the config record
SchemaName = TenantConfig[Schema],
FilterCol = TenantConfig[FilterColumn],
FilterVal = TenantConfig[FilterValue],
// Connect to source
Source = Sql.Database("dw.company.com", "AnalyticsDB"),
// Navigate to schema-specific table when applicable
OrdersTable = if SchemaName = "shared" then
Source{[Schema="dbo", Item="Orders"]}[Data]
else
Source{[Schema=SchemaName, Item="Orders"]}[Data],
// Apply tenant filter — this step is the isolation boundary
FilteredRows = Table.SelectRows(
OrdersTable,
each Record.Field(_, FilterCol) = FilterVal
),
// Enforce that we always have a non-null filter value before proceeding
GuardedResult = if FilterVal = null or FilterVal = ""
then error Error.Record(
"IsolationViolation",
"Tenant filter value is null or empty — refusing to return unfiltered data",
[TenantKey = TenantConfig[TenantKey]]
)
else FilteredRows
in
GuardedResult
Notice the guard clause at the end. This is non-negotiable in a production multi-tenant pipeline. Rather than trusting that the configuration table is always well-formed, the function actively refuses to proceed if the filter value is missing. This is the "fail closed" principle applied to ETL — when in doubt, return an error rather than unfiltered data.
Warning: Never use
try...otherwiseto silently swallow isolation errors. A common anti-pattern is wrapping the entire tenant query in atry fn_GetTenantOrders(config) otherwise #table(...)to avoid refresh failures. If the tenant filter fails, you want the refresh to fail loudly. A silent empty table is infinitely preferable to silently returning the wrong tenant's data, but an explicit error is better still because it triggers alerts and investigation.
Once you have the function, you can invoke it across the entire tenant configuration table to produce a consolidated dataset for processing:
// Query: AllTenantOrders
let
// Load tenant configuration
TenantConfig = Excel.Workbook(
File.Contents("\\networkshare\config\TenantConfig.xlsx"),
true
){[Name="Tenants"]}[Data],
// Add a column that invokes our function for each tenant
TenantResults = Table.AddColumn(
TenantConfig,
"Orders",
each fn_GetTenantOrders(_),
type table
),
// Expand all tenant results into a unified table
ExpandedOrders = Table.ExpandTableColumn(
TenantResults,
"Orders",
{"OrderID", "OrderDate", "Amount", "Status"},
{"OrderID", "OrderDate", "Amount", "Status"}
)
in
ExpandedOrders
This pattern — often called the "fan-out" approach — is excellent for analytics scenarios where you genuinely need a unified view across tenants (for platform-level reporting). But notice something important: this pattern retrieves all tenants' data in a single refresh. If your goal is strict isolation per delivery — one report per tenant, delivered separately — then you don't want to expand everything. Instead, you want to use the function pattern to generate per-tenant query files, either through Power BI's template mechanism or through programmatic file generation.
For per-tenant delivery, the pattern is simpler: one Power BI file per tenant, using a parameter to select the tenant config row, calling the shared function. The function library lives in a shared Power BI template file that each tenant file references via a common M function pattern.
Query folding is the mechanism by which Power Query translates M expressions into native source queries — SQL for databases, OData for SharePoint, and so on. When folding works, your filter gets pushed to the database, and only filtered data travels across the wire. When folding breaks, the full dataset loads into memory before filtering. In a multi-tenant scenario, this has security and performance implications that can be severe.
The detailed mechanics of query folding are covered in the Power Query performance: master folding, buffering and optimization techniques lesson. Here, we'll focus specifically on the patterns that break folding in multi-tenant designs.
Dynamic column references. In our function above, we use Record.Field(_, FilterCol) to dynamically reference the filter column by name. This is necessary when different tenants use different filter column names, but it breaks query folding. The SQL connector cannot translate a dynamic column reference into a WHERE clause because it doesn't know the column name at query plan time.
If all your tenants use the same filter column (which they should if your schema is well-designed), use a direct column reference instead:
// FOLDS to database:
FilteredRows = Table.SelectRows(OrdersTable, each [TenantID] = FilterVal)
// DOES NOT FOLD — dynamic column reference:
FilteredRows = Table.SelectRows(OrdersTable, each Record.Field(_, FilterCol) = FilterVal)
Adding custom columns before filtering. If you compute a derived column before applying your tenant filter, you break the fold chain. Always filter first, transform second.
Using M-native functions that have no SQL equivalent. Text.Contains, List.Contains, and similar functions break folding. If you need to filter on a list of values, use Table.SelectRows with a set of OR conditions, or better yet, pass the filter list to a native query.
For SQL Server and other relational databases, the safest approach for guaranteed folding is a native query with a parameterized WHERE clause:
// Query: fn_GetTenantOrders_NativeSQL
(TenantConfig as record) as table =>
let
TenantKey = TenantConfig[TenantKey],
FilterVal = TenantConfig[FilterValue],
// Guard clause — same principle as before
_ = if FilterVal = null or FilterVal = ""
then error Error.Record("IsolationViolation", "Empty filter value", [TenantKey = TenantKey])
else null,
// Construct a parameterized native query
// Note: Value.NativeQuery handles parameter binding safely
Source = Sql.Database("dw.company.com", "AnalyticsDB"),
Result = Value.NativeQuery(
Source,
"SELECT OrderID, OrderDate, Amount, Status, TenantID
FROM dbo.Orders
WHERE TenantID = @TenantID",
[TenantID = FilterVal]
)
in
Result
Value.NativeQuery with a parameters record is the Power Query equivalent of a parameterized SQL query — the parameter value is bound separately from the query text, which prevents SQL injection and guarantees that the filter is applied at the database engine level. There is no ambiguity about whether folding is happening: you've written the SQL yourself.
Warning: Do not construct native SQL queries through string concatenation. The pattern
"SELECT * FROM Orders WHERE TenantID = '" & FilterVal & "'"is a SQL injection vulnerability. If your tenant filter values come from any external configuration, concatenating them into raw SQL is a critical security flaw. Always useValue.NativeQuerywith a parameter record.
You can verify whether a query step folds by right-clicking the step in the Applied Steps pane and looking for "View Native Query." If the option is grayed out, that step doesn't fold. You should build this verification into your pipeline development process — after any change to the tenant filtering logic, confirm that the filter step still shows a valid native query.
For automated validation, you can use the Table.Profile function in diagnostic mode or instrument your queries with step-level timing using Diagnostics.Trace (available in Power BI Desktop with diagnostic mode enabled).
Credentials in Power Query are scoped to data source definitions — a combination of the connection type, the server/URL, the database, and (in some cases) additional parameters. Understanding exactly how this scoping works is essential for multi-tenant designs where different tenants may require different authentication.
When Power Query evaluates a query that connects to a data source, it looks up the applicable credential using a matching algorithm that goes from most-specific to least-specific. For a SQL Server connection, it tries to match on:
This means that if you have a credential stored for dw.company.com/AnalyticsDB and your query connects to dw.company.com/AnalyticsDB, it will use that credential. If you change the database name dynamically — for example, to dw.company.com/AcmeDB for Tenant A and dw.company.com/GlobexDB for Tenant B — Power Query needs separate credentials for each database.
The credential scoping lesson (connecting to SQL Server in Power Query: native queries and credential management) covers the mechanics in detail. The key insight for multi-tenant design is:
Key insight: If your multi-tenant architecture uses per-tenant databases or schemas with separate credentials, you cannot dynamically construct the database name in M and expect existing credentials to automatically match. Power Query will prompt for new credentials or fail silently depending on the refresh context. You must pre-register credentials for each tenant's data source definition.
When tenants have isolated databases (a common SaaS pattern), the connection function itself must accept the database name as a parameter, and credentials must be stored per database:
// fn_GetTenantConnection
(TenantConfig as record) as any =>
let
ServerName = "dw.company.com",
DatabaseName = TenantConfig[DatabaseName], // e.g., "AcmeAnalytics", "GlobexAnalytics"
// Each unique DatabaseName requires its own stored credential
Connection = Sql.Database(ServerName, DatabaseName, [
CreateNavigationProperties = false
])
in
Connection
In Power BI Desktop, when you first run this with a new database name, it will prompt for credentials and store them scoped to dw.company.com/AcmeAnalytics. When you add Globex as a tenant, it will prompt again for dw.company.com/GlobexAnalytics. In Power BI Service with a gateway, you must pre-configure each data source in the gateway settings before scheduling refreshes.
This credential registration process doesn't scale gracefully. Twelve tenants means twelve separate data source registrations in the gateway, each requiring manual credential entry or scripted setup via the Power BI REST API.
For most enterprise multi-tenant scenarios, the better architecture is a shared credential against a single data warehouse where tenant isolation is enforced by the data access layer — and Power Query applies a filter that aligns with that security boundary.
In this model, your database uses row-level security or a tenant partitioning scheme, and the Power Query filter reinforces (but does not solely rely upon) the database-level security. The layered model looks like this:
This defense-in-depth approach means that no single failure can cause a data breach. It's more work to set up, but it's the only pattern that a security-conscious enterprise should deploy.
Tip: If your database platform supports it, use schema-level permissions to create per-tenant service accounts with access only to their tenant's data, even when the underlying data lives in shared tables. Then use Power Query's credential matching to connect with the appropriate service account per tenant. This shifts the isolation enforcement to the database layer, where it belongs, and reduces Power Query's isolation responsibility to "filter correctly AND fail if filter is missing."
A shared ETL pipeline means that the transformation logic is written once and applied to each tenant's data stream. The risk is that logic errors in the shared code affect all tenants simultaneously. The opportunity is that fixes and enhancements propagate to all tenants at once. Managing this trade-off requires careful architectural discipline.
The safest pattern is to write all transformation logic in functions that accept a table as input and return a table as output — with no internal references to tenant identity. The tenant filter happens before the function is called, and any tenant-specific configuration (like column name mappings that differ per tenant) is passed as a parameter.
This pattern is directly analogous to the clean architecture principles discussed in building multi-stage staging architectures in Power Query. Your pipeline should have:
// fn_CleanseOrders — completely tenant-agnostic
// Takes a raw orders table, returns a cleansed table
(RawOrders as table) as table =>
let
// Type enforcement — same for all tenants
Typed = Table.TransformColumnTypes(RawOrders, {
{"OrderID", type text},
{"OrderDate", type date},
{"Amount", type number},
{"Status", type text},
{"TenantID", type text}
}),
// Remove nulls in critical fields
NonNullOrders = Table.SelectRows(Typed,
each [OrderID] <> null and [Amount] <> null
),
// Standardize status values
StandardizedStatus = Table.TransformColumns(NonNullOrders, {
{"Status", Text.Upper, type text}
})
in
StandardizedStatus
Notice that fn_CleanseOrders knows nothing about tenants. It just cleanses whatever table you give it. The calling pattern then orchestrates the pipeline stages:
// Query: TenantOrders_Acme
let
// Stage 1: Fetch with tenant isolation
Config = TenantConfigTable{[TenantKey = "T001"]},
RawOrders = fn_GetTenantOrders(Config),
// Stage 2: Cleanse (tenant-agnostic)
Cleansed = fn_CleanseOrders(RawOrders),
// Stage 3: Validate tenant integrity after cleansing
// This ensures no cross-contamination happened during transformations
Validated = Table.SelectRows(Cleansed, each [TenantID] = Config[FilterValue]),
GuardCheck = if Table.RowCount(Validated) <> Table.RowCount(Cleansed)
then error Error.Record(
"IntegrityViolation",
"Post-cleanse tenant validation failed — row count mismatch suggests cross-tenant contamination",
[Expected = Config[FilterValue], Found = "mixed"]
)
else Validated
in
GuardCheck
The double validation pattern — filter at the source, then verify after transformation — is the ETL equivalent of defense in depth. The second SelectRows call after fn_CleanseOrders should always be a no-op (returning the same number of rows), because the cleansing function doesn't add rows from other tenants. But if it's ever not a no-op, you have a serious bug, and this check catches it immediately.
Real-world multi-tenant pipelines almost always encounter schema differences between tenants. Tenant A calls the field CustomerCode, Tenant B calls it ClientID, and Tenant C doesn't have it at all. Managing this without turning your pipeline into tenant-specific spaghetti requires a schema mapping table.
// Schema mapping in TenantConfig
// TenantKey | SourceColumn | TargetColumn
// T001 | CustomerCode | ClientIdentifier
// T002 | ClientID | ClientIdentifier
// T003 | null | ClientIdentifier (field doesn't exist)
// fn_ApplySchemaMapping
(InputTable as table, Mappings as table) as table =>
let
// Mappings is a table with SourceColumn and TargetColumn columns
// Only remap columns that exist in the source table
// Get the columns that actually need renaming
ExistingColumns = Table.ColumnNames(InputTable),
ApplicableMappings = Table.SelectRows(
Mappings,
each List.Contains(ExistingColumns, [SourceColumn])
and [SourceColumn] <> null
),
// Build rename pairs: { {"SourceCol", "TargetCol"}, ... }
RenamePairs = Table.ToRows(
Table.SelectColumns(ApplicableMappings, {"SourceColumn", "TargetColumn"})
),
// Apply renames
Renamed = if List.Count(RenamePairs) > 0
then Table.RenameColumns(InputTable, RenamePairs)
else InputTable,
// Add null column for any target columns that don't exist in source
MissingMappings = Table.SelectRows(
Mappings,
each not List.Contains(ExistingColumns, [SourceColumn])
or [SourceColumn] = null
),
MissingColumnNames = Table.Column(MissingMappings, "TargetColumn"),
WithNullColumns = List.Accumulate(
MissingColumnNames,
Renamed,
(state, colName) => Table.AddColumn(state, colName, each null, type text)
)
in
WithNullColumns
This function takes the tenant's schema mapping configuration and normalizes their raw data to a standard schema, adding null columns for missing fields. The key property is that it's driven entirely by configuration — adding a new column mapping for a tenant is a configuration change, not a code change.
Note: The
List.Accumulatepattern for adding multiple columns is powerful but can be slow for very wide schemas because each iteration adds a new step to the query evaluation. If you're normalizing tables with many missing columns (more than 20 or so), consider building the full target schema as a template table and usingTable.Combinewith the typed template to fill in missing columns at once, which is both more readable and more efficient.
Some multi-tenant scenarios involve not just row-level filtering within a shared database, but genuinely separate source systems per tenant — each with their own credentials. Think of a consulting firm that connects to each client's individual CRM, extracts data, and transforms it into a standardized format. This is credential isolation in its most complete form.
For this pattern, your tenant configuration table includes the connection details:
| TenantKey | ConnectionType | ServerURL | DatabaseOrPath | AuthMethod |
|---|---|---|---|---|
| T001 | SqlServer | acme-sql.database.windows.net | AcmeCRM | ServicePrincipal |
| T002 | SharePoint | https://globex.sharepoint.com | /sites/DataExport | OAuth |
| T003 | OData | https://initech.crm.dynamics.com | api/data/v9.2 | BasicAuth |
Building a single function that handles multiple connection types requires a dispatcher pattern:
// fn_GetTenantConnection
(TenantConfig as record) as any =>
let
ConnType = TenantConfig[ConnectionType],
URL = TenantConfig[ServerURL],
Connection = if ConnType = "SqlServer" then
Sql.Database(URL, TenantConfig[DatabaseOrPath])
else if ConnType = "SharePoint" then
SharePoint.Tables(URL, [ApiVersion = 15])
else if ConnType = "OData" then
OData.Feed(URL & "/" & TenantConfig[DatabaseOrPath])
else
error Error.Record(
"UnsupportedConnectionType",
"Connection type not implemented: " & ConnType,
[TenantKey = TenantConfig[TenantKey]]
)
in
Connection
Warning: When you use dynamic connection strings (constructing the URL from configuration), Power Query cannot pre-register credentials at design time. In Power BI Service, this means you must use a gateway that has credentials registered for each endpoint, and those credentials must be registered before the first scheduled refresh. Failure to pre-register results in a "credentials required" error that blocks all tenants in a fan-out pipeline, not just the affected one.
The credential registration challenge in a large multi-tenant deployment is one of the strongest arguments for standardizing on a single connection type and a single shared data warehouse, even if the source data lives in separate systems. Your ETL ingestion process (outside Power Query) loads all tenant data into a standardized warehouse, and Power Query only ever connects to that one warehouse with one set of credentials. Per-tenant isolation is then purely a filtering concern.
A pipeline that serves one tenant might refresh in 30 seconds. Twelve tenants at 30 seconds each, running sequentially, is 6 minutes. At 100 tenants, you're at nearly an hour — if they're sequential. Understanding how Power Query evaluates multi-tenant fan-out queries is essential for keeping refresh times reasonable.
When you use Table.AddColumn with a function invocation (as in the fan-out pattern), Power Query may evaluate the function calls in parallel, depending on the runtime environment. In Power BI Desktop, there's limited parallelism. In Power BI Service with a gateway, the gateway service manages query concurrency.
The critical constraint is the data source connection limit. Most databases have a maximum concurrent connection count, and if your fan-out pattern spawns 100 simultaneous connections, you'll hit that limit and get sporadic failures. The safe pattern is to use Table.Buffer to control evaluation timing, and to test with realistic tenant counts during development.
For large tenant counts, incremental refresh is essential. Each tenant's query should support a date-range filter so that refreshes only process new or changed data rather than the full history. The automating incremental data refreshes in Power Query lesson covers the implementation in detail. For multi-tenant pipelines, the key consideration is that each tenant may have different data freshness requirements — one tenant needs hourly refreshes, another is fine with daily.
In Power BI Premium, you can set incremental refresh policies per dataset. For multi-tenant pipelines where each tenant has their own dataset (the recommended pattern for strict isolation), this gives you per-tenant refresh scheduling.
// Incremental-refresh-compatible tenant query
// Assumes RangeStart and RangeEnd parameters exist (Power BI incremental refresh)
(TenantConfig as record, RangeStart as datetime, RangeEnd as datetime) as table =>
let
FilterVal = TenantConfig[FilterValue],
GuardClause = if FilterVal = null or FilterVal = ""
then error Error.Record("IsolationViolation", "Empty tenant filter", null)
else null,
Source = Value.NativeQuery(
Sql.Database("dw.company.com", "AnalyticsDB"),
"SELECT OrderID, OrderDate, Amount, Status, TenantID
FROM dbo.Orders
WHERE TenantID = @TenantID
AND ModifiedDate >= @RangeStart
AND ModifiedDate < @RangeEnd",
[
TenantID = FilterVal,
RangeStart = RangeStart,
RangeEnd = RangeEnd
]
)
in
Source
Note that all three filter parameters are bound via Value.NativeQuery's parameter record — tenant isolation AND date range filtering are both handled safely at the database level.
You cannot ship a multi-tenant pipeline without explicit isolation tests. "I'm pretty sure the filter is working" is not an acceptable standard when the consequence of being wrong is a data breach.
Build a set of test queries in your Power BI development file that verify isolation properties. These are not queries you load to the data model — they're diagnostic queries you run during development and after every significant change.
// Test: VerifyTenantIsolation
// Verifies that fn_GetTenantOrders for Tenant T001 returns ONLY T001 data
let
Config = TenantConfigTable{[TenantKey = "T001"]},
Result = fn_GetTenantOrders(Config),
// Check for cross-tenant contamination
ForeignRows = Table.SelectRows(Result, each [TenantID] <> Config[FilterValue]),
ForeignCount = Table.RowCount(ForeignRows),
TestResult = if ForeignCount > 0
then "FAIL: " & Number.ToText(ForeignCount) & " foreign-tenant rows found"
else "PASS: All " & Number.ToText(Table.RowCount(Result)) & " rows belong to T001"
in
TestResult
// Test: VerifyNullFilterGuard
// Verifies that the guard clause fires when filter value is null
let
BadConfig = [TenantKey = "TEST", Schema = "shared",
FilterColumn = "TenantID", FilterValue = null,
DatabaseName = "AnalyticsDB"],
TestResult = try fn_GetTenantOrders(BadConfig)
otherwise "PASS: Guard clause fired correctly",
FinalResult = if TestResult is table
then "FAIL: Function returned a table with null filter — isolation breach!"
else TestResult
in
FinalResult
// Test: VerifyRowCountSanity
// Verifies each tenant returns a non-zero result (catches misconfigured filters)
let
AllTests = Table.AddColumn(
TenantConfigTable,
"RowCount",
each Table.RowCount(fn_GetTenantOrders(_))
),
ZeroRowTenants = Table.SelectRows(AllTests, each [RowCount] = 0),
Result = if Table.RowCount(ZeroRowTenants) > 0
then "WARNING: " & Number.ToText(Table.RowCount(ZeroRowTenants)) &
" tenants returned zero rows — check filter configuration"
else "PASS: All tenants returned data"
in
Result
Run these tests after every change to your pipeline logic. Document the expected results and compare against them. If you're deploying via CI/CD (possible with Power BI's deployment pipelines), consider whether you can automate these validation queries as part of your deployment checks.
Let's put these concepts together in a realistic scenario. You're building a multi-tenant analytics pipeline for a SaaS HR platform. Three tenants share a single SQL Server database (HRAnalytics) where all employee data lives in a single Employees table with a CompanyCode column for tenant identification.
Your task: Build a parameterized, isolated query pipeline that:
Step 1: Create an Excel file named TenantConfig.xlsx with a sheet named Tenants containing three columns: TenantKey, TenantName, CompanyCode. Add three rows: (HR001, Contoso, CONT), (HR002, Fabrikam, FAB), (HR003, Northwind, NW).
Step 2: In Power Query, create a new query to load this configuration file. Name it TenantConfigTable.
Step 3: Create a function query named fn_GetTenantEmployees using the Value.NativeQuery pattern shown earlier. The native SQL should filter on CompanyCode = @CompanyCode.
Step 4: Create a function named fn_CleanseEmployees that accepts a raw employee table and applies type enforcement and null removal. It should be completely company-agnostic.
Step 5: Create a query named TenantEmployees_Contoso that:
TenantConfigTablefn_GetTenantEmployees with that configfn_CleanseEmployees on the resultCompanyCode = "CONT")Step 6: Build the three isolation test queries described in the previous section and verify they all pass.
Bonus challenge: Add a fourth tenant with CompanyCode = null to your config table. Verify that your guard clause fires and raises a meaningful error rather than allowing the query to proceed.
Check whether you're using any of the folding breakers we discussed: dynamic column references, custom columns before filtering, or M-native text functions. Use the "View Native Query" option in Applied Steps to confirm. The fix is to either rewrite the logic to be foldable, or switch to explicit Value.NativeQuery with SQL.
Each unique combination of server + database (or URL, for web sources) requires a separate credential entry. If you're dynamically constructing connection strings, Power Query sees each unique connection as a new data source. The fix is either to standardize on a single data source with row-level filtering, or to pre-register all credentials before running the fan-out. In Power BI Service, use the gateway's data source management UI or the Power BI REST API to register credentials programmatically.
This is the "silent failure" problem. Your guard clause checks for null filter values, but not for filters that produce zero rows. This can happen when the filter value is non-null but wrong (e.g., "CONT" vs "CONT " — note the trailing space). Add a row count sanity check (as shown in the test suite) and consider using Text.Trim on filter values when loading from configuration files.
This means your cleansing function is somehow producing rows with unexpected tenant IDs. Most often, this happens when fn_CleanseOrders joins to a lookup table that contains data from multiple tenants. If your shared cleansing functions do any joins, ensure the join targets are themselves tenant-filtered or are genuinely tenant-agnostic reference tables. The lesson on combining data from multiple sources with append and merge queries discusses merge safety patterns that apply here.
The fan-out pattern (invoking a function for each tenant row) evaluates serially in most contexts. If you have 50+ tenants, consider: (1) moving the aggregation step to SQL rather than Power Query; (2) using Power BI's dataflow Gen2 with computed entities, which supports parallel evaluation; or (3) rearchitecting so that each tenant has their own dataset, with refresh schedules staggered across the day rather than running simultaneously.
Refresh failure error messages in Power BI Service are sometimes truncated or genericized. To preserve your custom error details, structure your Error.Record calls to put the most important information in the Reason field (the first argument), since that tends to survive the most truncation. Also check the gateway logs for the full error details — they're more verbose than the Service UI.
Multi-tenant data isolation in Power Query is a genuinely complex engineering problem that intersects data engineering, security architecture, and platform operations. The key principles we've covered:
Parameterization architecture: Drive your tenant configuration from a centralized config table, not hardcoded values. Use function-based query generation to apply tenant context consistently and safely. Always include guard clauses that fail loudly on missing or null filter values.
Query folding discipline: Understand which M patterns break folding and avoid them in your isolation-critical filter steps. For highest assurance, use Value.NativeQuery with parameter binding to guarantee that filtering happens at the database layer. Never construct SQL through string concatenation.
Credential scoping: Know how Power Query matches credentials to data source definitions. For shared-credential, row-filtered architectures, pre-register credentials for the shared data source. For per-tenant credential architectures, pre-register all tenant endpoints before scheduling refreshes.
Defense in depth: Apply your tenant filter at the source, validate isolation after transformations, and rely on database-level security as a backstop when your pipeline credentials allow it. No single layer should be the only thing standing between correct isolation and a data breach.
Test everything: Build explicit isolation test queries and run them after every pipeline change. Zero-row tests, foreign-row tests, and null-filter guard tests are the minimum viable test suite.
To continue building on these skills, explore implementing row-level security data preparation in Power Query, which extends these isolation patterns into Power BI's security model for delivering tenant-specific reports. For the function library patterns we used here, building reusable Power Query function libraries covers recursion, error handling, and modular M code in depth. And for handling the inevitable evolution of your tenants' source schemas over time, handling dynamic schema changes in Power Query is essential reading.
Done right, a multi-tenant Power Query pipeline is a significant piece of engineering infrastructure — one that your organization can depend on safely as tenant count grows, as source systems change, and as compliance requirements evolve.