Hard-coded connection strings scattered across dozens of queries are a deployment disaster waiting to happen. This lesson shows you how to use M's let scoping and record-based context objects to build multi-environment, multi-tenant Power Query pipelines where a single configuration change propagates correctly to every downstream query.

Picture this: you're building a Power Query solution that connects to three different CRM databases — one for North America, one for Europe, and one for Asia-Pacific. Each region uses a different server URL, a different schema prefix, and different API keys for supplemental REST calls. On top of that, you need the same pipeline to work correctly in both your development sandbox and the production environment without manual find-and-replace operations before every deployment.
If you've ever handled this by scattering hard-coded connection strings across twenty queries, you already know where that road leads: brittle pipelines, deployment-night panic, and the special joy of hunting down which query still points at the dev server after you thought you fixed them all. M's let scoping mechanism, combined with deliberate context design, gives you a structured way out of that mess. You can centralize environment configuration, derive connection details dynamically, and isolate credential-sensitive logic so that swapping between tenants or environments is a single-point change rather than a hunt-and-fix expedition.
By the end of this lesson, you'll be able to architect multi-source, multi-tenant Power Query solutions where environment and credential context flows cleanly from a single configuration layer down through every dependent query — with no repeated magic strings and no accidental environment cross-contamination.
What you'll learn:
let scoping rules determine variable visibility and how to exploit them for context isolationYou should be comfortable writing basic M expressions and understand how let...in blocks work at a surface level. Familiarity with M Language Fundamentals: Syntax, Types, and Expressions for Power Query is strongly recommended. You should also understand how Power Query evaluates queries lazily — if that's new to you, Understanding M Language Query Evaluation: Lazy Evaluation, Dependency Graphs, and Step Ordering in Power Query is the right foundation to read first.
Before we architect anything, let's get precise about what let scoping means in M, because it's the mechanism everything else depends on.
In M, a let expression defines a set of named bindings and then evaluates a final expression (the in clause) within the context of those bindings. Each binding can reference other bindings defined earlier in the same let block. Critically, bindings defined inside a let block are not visible outside of it. They exist only within the scope of that particular let...in expression.
let
ServerUrl = "https://crm-prod.example.com",
ApiVersion = "v3",
BaseEndpoint = ServerUrl & "/api/" & ApiVersion
in
BaseEndpoint
// Result: "https://crm-prod.example.com/api/v3"
This is straightforward in a single query. The interesting design question is: how do you share context across queries in a way that respects scoping boundaries and doesn't create hidden coupling?
The answer lies in treating your configuration as a first-class record value that can be referenced by other queries. In Power Query, a query that produces a record value can be referenced by other queries, and those queries can extract fields from it. This is how you bridge the scoping gap between separate query definitions — not by leaking bindings across boundaries, but by passing explicit context objects.
Key insight: M scoping is intentionally strict. Variables from one
letblock never implicitly bleed into another. This is a feature, not a limitation — it means you can reason about each query in isolation and design explicit, traceable data flow for your configuration context.
The first structural piece of a well-designed multi-environment pipeline is a dedicated configuration query — let's call it EnvironmentConfig. This query produces a single record that describes the current deployment context.
Here's a realistic starting point for a three-environment, three-tenant setup:
// Query name: EnvironmentConfig
let
// --- Environment selector ---
// Change this one value to switch all downstream behavior.
// Values: "Development", "Staging", "Production"
ActiveEnvironment = "Production",
// --- Environment definitions ---
Environments = [
Development = [
CrmBaseUrl = "https://crm-dev.internal.example.com",
DataLakeUrl = "https://datalake-dev.example.com",
SchemaPrefix = "dev_",
LogLevel = "Verbose",
EnableCache = false
],
Staging = [
CrmBaseUrl = "https://crm-staging.example.com",
DataLakeUrl = "https://datalake-staging.example.com",
SchemaPrefix = "stg_",
LogLevel = "Info",
EnableCache = true
],
Production = [
CrmBaseUrl = "https://crm-prod.example.com",
DataLakeUrl = "https://datalake-prod.example.com",
SchemaPrefix = "prd_",
LogLevel = "Error",
EnableCache = true
]
],
// --- Resolve the active environment record ---
ActiveConfig = Record.Field(Environments, ActiveEnvironment),
// --- Tenant definitions ---
// Tenant-specific overrides layered on top of environment defaults
Tenants = [
NorthAmerica = [
Region = "us-east-1",
CrmDatabase = "crm_na",
Currency = "USD",
Locale = "en-US"
],
Europe = [
Region = "eu-west-1",
CrmDatabase = "crm_eu",
Currency = "EUR",
Locale = "de-DE"
],
AsiaPacific = [
Region = "ap-southeast-1",
CrmDatabase = "crm_apac",
Currency = "SGD",
Locale = "en-SG"
]
]
in
// Return a record with both environment config and the tenant map
[
Environment = ActiveConfig,
Tenants = Tenants,
ActiveEnv = ActiveEnvironment
]
Notice a few deliberate design decisions here. First, the environment selector is a single named binding at the top of the let block — it's the only thing you ever touch when deploying to a new environment. Second, the environment definitions themselves are nested records inside a parent record, so you look up the active one with Record.Field. Third, tenants are stored separately because they represent a different axis of variation — tenants don't change based on environment; the connection details for a tenant do.
Tip: Storing your environment definitions as nested records rather than parallel
if-then-elsechains makes it far easier to add a new environment later. Adding "DisasterRecovery" or "QA" is a matter of adding one more record field, not rewriting branching logic throughout multiple queries.
This approach connects directly to the Cross-Query State Management and Shared Parameter Tables in Power Query M: Centralizing Configuration for Multi-Report Deployments pattern, but extends it by nesting context hierarchically rather than maintaining a flat parameter table.
With the config record in place, the next layer is a set of connection builder functions — M functions that accept a tenant name and return fully resolved connection details for that tenant in the current environment.
// Query name: GetTenantConnection
let
// Reference the shared config query
Config = EnvironmentConfig,
// Define a function that resolves full connection details for one tenant
GetConnection = (tenantName as text) as record =>
let
EnvConfig = Config[Environment],
TenantConfig = Record.Field(Config[Tenants], tenantName),
// Merge environment and tenant configs into one flat record
Merged = Record.Combine({EnvConfig, TenantConfig}),
// Derive the full database connection string
ConnectionString =
EnvConfig[CrmBaseUrl] &
"/" &
TenantConfig[CrmDatabase],
// Build a complete connection context record
ConnectionContext = Record.AddFields(
Merged,
{
{"ConnectionString", ConnectionString},
{"TenantName", tenantName},
{"FullSchemaName", EnvConfig[SchemaPrefix] & TenantConfig[CrmDatabase]}
}
)
in
ConnectionContext
in
GetConnection
This query produces a function, not a table or record. Any other query can call it like this:
// In another query — fetching North America CRM data
let
NaContext = GetTenantConnection("NorthAmerica"),
Source = Sql.Database(
NaContext[CrmBaseUrl],
NaContext[CrmDatabase],
[Query = "SELECT * FROM " & NaContext[FullSchemaName] & ".orders"]
),
Filtered = Table.SelectRows(Source, each [Status] = "Active")
in
Filtered
The query that actually fetches data never hard-codes a server name, database name, or schema prefix. All of that is resolved at evaluation time from the context object. If you promote the solution to production, you change ActiveEnvironment in EnvironmentConfig, and every downstream query automatically picks up the right values.
Warning: Be careful about calling
GetTenantConnectionmultiple times in the same query with different tenant names. Each call triggers a separate evaluation and a separate potential connection. If you need data from multiple tenants in a single query, call the function once per tenant, store the results in named bindings, then combine them — don't call it inline inside aList.TransformorTable.AddColumnif you can avoid it. Keeping connection evaluations explicit makes it far easier to reason about performance patterns and troubleshoot refresh failures.
Credentials are where M's scoping rules become genuinely important for security. The core principle: credential-sensitive values should live in the innermost let scope that needs them, not hoisted to a shared outer scope where unrelated query steps could theoretically access them.
Power Query itself manages credentials through its credential store (accessible via Data Source Settings in Power BI Desktop, or through the Power Query editor's datasource credentials panel). For most connectors, you don't embed raw secrets in M — the connector handles authentication handshakes behind the scenes. But for REST APIs using API keys in headers, or for dynamic data sources where you're constructing connection parameters programmatically, you need to be deliberate about where credential-derived values live.
Here's a pattern for API key management that uses let scoping to limit exposure:
// Query name: ApiKeyedRequest
let
Config = EnvironmentConfig,
// This function isolates all credential-sensitive logic inside its own scope.
// The API key never appears in a shared binding visible to the broader query chain.
FetchTenantData = (tenantName as text, resourcePath as text) as table =>
let
TenantCtx = GetTenantConnection(tenantName),
// In a real scenario, you'd retrieve tenant-specific API keys
// from a secure store or Power Query's credential mechanism.
// Here we show the structural pattern with a placeholder.
RequestUrl = TenantCtx[CrmBaseUrl] & resourcePath,
RequestHeaders = [
#"Accept" = "application/json",
#"X-Region" = TenantCtx[Region],
#"X-Locale" = TenantCtx[Locale]
],
RawResponse = Web.Contents(
RequestUrl,
[Headers = RequestHeaders, Timeout = #duration(0, 0, 0, 30)]
),
ParsedJson = Json.Document(RawResponse),
ResultTable = Table.FromRecords(ParsedJson[data])
in
ResultTable
in
FetchTenantData
The RequestHeaders record is scoped inside the inner let block of the function body. It exists only for the duration of that function call and is garbage-collected afterward. This isn't perfect isolation by cryptographic standards, but it is good architectural hygiene — you're not building a global Credentials record that every query in your workbook can see and accidentally surface in a preview.
For scenarios where you need to work with authentication tokens or OAuth flows in more depth, the Implementing Custom Data Connector Authentication and OAuth Flows in Power Query M Language lesson covers the full landscape.
Now let's put all these pieces together in a realistic orchestration pattern. Suppose you need to produce a unified orders table that pulls data from all three regional tenants and tags each row with its origin.
// Query name: UnifiedOrders
let
Config = EnvironmentConfig,
TenantNames = Record.FieldNames(Config[Tenants]),
// Build one context record per tenant
TenantContexts = List.Transform(
TenantNames,
each GetTenantConnection(_)
),
// Fetch orders for a single tenant — returns a typed table
FetchOrders = (ctx as record) as table =>
let
Source = Sql.Database(
ctx[CrmBaseUrl],
ctx[CrmDatabase],
[
Query =
"SELECT OrderId, CustomerId, Amount, OrderDate " &
"FROM " & ctx[FullSchemaName] & ".orders " &
"WHERE OrderDate >= '2024-01-01'"
]
),
Tagged = Table.AddColumn(
Source,
"TenantName",
each ctx[TenantName],
type text
),
LocalizedAmount = Table.AddColumn(
Tagged,
"Currency",
each ctx[Currency],
type text
)
in
LocalizedAmount,
// Map fetch function across all tenants and combine
AllTenantTables = List.Transform(TenantContexts, FetchOrders),
Combined = Table.Combine(AllTenantTables),
// Final type enforcement
Typed = Table.TransformColumnTypes(
Combined,
{
{"OrderId", type text},
{"CustomerId", type text},
{"Amount", type number},
{"OrderDate", type date},
{"TenantName", type text},
{"Currency", type text}
}
)
in
Typed
This is where the architectural investment pays off. The UnifiedOrders query doesn't know or care which tenants exist. It reads TenantNames from the configuration record, maps a fetch function over them, and combines the results. Adding a fourth tenant (say, "LatinAmerica") requires only one change: adding a tenant record to EnvironmentConfig. The orchestration query updates automatically.
Note:
Table.Combineon data from multiple live SQL connections can produce a query that Power Query evaluates as multiple separate source queries rather than one folded query. This is expected and correct for multi-tenant scenarios — you're intentionally hitting different databases. Just be aware that query folding operates at the individual source level; post-combination transformations won't fold back to any source.
A common real-world complication: one tenant needs a non-standard configuration for a specific environment. Maybe Europe's staging environment uses a different URL than the standard staging pattern, because the EU team manages their own staging infrastructure.
You can handle this with an override layer without restructuring your entire config. The key is Record.Combine, which merges two records and lets the second one win on any key conflicts:
// Inside EnvironmentConfig, add an overrides section:
let
// ... (same as before) ...
TenantEnvironmentOverrides = [
Europe = [
Staging = [
CrmBaseUrl = "https://crm-staging-eu.euteam.example.com"
]
]
],
// Modify GetActiveConfig to apply overrides
ApplyOverride = (tenantName as text, baseConfig as record) as record =>
let
HasTenantOverride =
Record.HasFields(TenantEnvironmentOverrides, {tenantName}),
TenantOverrides =
if HasTenantOverride
then Record.Field(TenantEnvironmentOverrides, tenantName)
else [],
HasEnvOverride =
Record.HasFields(TenantOverrides, {ActiveEnvironment}),
EnvOverride =
if HasEnvOverride
then Record.Field(TenantOverrides, ActiveEnvironment)
else [],
MergedConfig = Record.Combine({baseConfig, EnvOverride})
in
MergedConfig
in
[
Environment = ActiveConfig,
Tenants = Tenants,
TenantOverrides = TenantEnvironmentOverrides,
ActiveEnv = ActiveEnvironment,
ApplyTenantOverride = ApplyOverride
]
Now GetTenantConnection can call EnvironmentConfig[ApplyTenantOverride](tenantName, baseConfig) before returning the final context record. The override mechanism is composable and lives in one place. If you ever need environment-specific overrides for a different tenant, you add one nested record to TenantEnvironmentOverrides.
This kind of layered configuration composition mirrors patterns used in professional configuration management systems — the same principle behind how Kubernetes ConfigMaps and Helm value overrides work.
Key insight:
Record.Combineis your friend for layered configuration. It merges records left-to-right with later records winning on key conflicts. This lets you define sensible defaults and then apply narrowly scoped overrides without touching the defaults. Think of it as the M equivalent of object spread in JavaScript:{...defaults, ...overrides}.
Build a three-environment, two-tenant pipeline in Power BI Desktop (or Power Query in Excel) using the patterns from this lesson. Here's your scenario:
Scenario: You're building a sales analysis solution that connects to two retail brands — "BrandAlpha" and "BrandBeta" — each with their own database. The solution needs to work in "Development" and "Production" environments with different base URLs.
Step 1: Create a new query called EnvironmentConfig. Set ActiveEnvironment = "Development". Define development and production environments with a BaseApiUrl and SchemaVersion field. Define two tenants — BrandAlpha and BrandBeta — each with a DatabaseName, Region, and PrimaryContact field.
Step 2: Create a query called GetBrandConnection that takes a brand name as text and returns a record combining the active environment config with the brand-specific config. Use Record.Combine to merge them. Add a derived field FullEndpoint that concatenates the BaseApiUrl with the DatabaseName.
Step 3: Create a query called AllBrandContexts that calls GetBrandConnection for each brand and returns a list of context records. Use Record.FieldNames(EnvironmentConfig[Tenants]) to drive the iteration — don't hard-code the brand names.
Step 4: Switch ActiveEnvironment to "Production" in EnvironmentConfig and verify that AllBrandContexts reflects the production URLs without any changes to GetBrandConnection or AllBrandContexts.
Challenge extension: Add a third environment called "UAT" (User Acceptance Testing) with its own URL. Verify that only EnvironmentConfig needs to change to activate it.
Mistake: Circular reference between config and connection queries
If EnvironmentConfig references GetTenantConnection and GetTenantConnection references EnvironmentConfig, you'll get a circular dependency error. The rule is: config flows down, never up. Configuration queries should have no dependencies on query logic — they're pure data.
Mistake: Using a flat parameter table when you need hierarchical config
A flat parameter table (Name/Value pairs) works well for simple scenarios, but becomes unwieldy with multi-tenant, multi-environment context. Once you have more than two dimensions of variation, the nested record approach from this lesson is more maintainable. See the tradeoffs discussed in Cross-Query State Management and Shared Parameter Tables.
Mistake: Storing credential values as plain text in config records
Never put actual passwords, API keys, or connection tokens in your M code as string literals in a shared config query. Power Query's credential store exists precisely for this purpose. Use it. If your architecture requires runtime credential injection, use Power Query parameters for the values and mark them as sensitive — or use a gateway with stored credentials for data sources that support it.
Mistake: Calling connection builder functions inside row-level operations
Calling GetTenantConnection inside a Table.AddColumn or List.Transform that operates row-by-row can force re-evaluation of the function for every row. This is expensive and usually wrong. Call connection builder functions once, store the result in a named binding, and pass the result into downstream operations.
Warning: When building dynamic data source paths at runtime — constructing URLs or server names as M expressions — Power Query may flag the query as having a "dynamically generated data source" and refuse to refresh it in certain gateway configurations. This is a known limitation related to Power Query's privacy and query folding analysis. If you hit this, explore the
Value.NativeQueryworkaround or restructure the dynamic portion to be driven by a Power Query parameter rather than an M expression. The Implementing Incremental Refresh Logic in Power Query M lesson touches on similar gateway-compatibility considerations.
Mistake: Not testing environment switches before deployment
Build a simple validation query that reads EnvironmentConfig[ActiveEnv] and asserts it equals the expected value. It takes five minutes to write and will save you from the classic "forgot to switch to Production" deployment failure.
You've built the conceptual and practical foundation for environment-aware, tenant-aware Power Query pipelines. The core ideas to carry forward:
let scoping is strict by design. Use record values as explicit context objects to pass configuration across query boundaries rather than trying to share bindings globally.ActiveEnvironment selector. Everything else is derivation.List.Transform calls from Record.FieldNames(Config[Tenants]) rather than hard-coding tenant names in orchestration logic.From here, two natural directions to explore: First, if your pipelines are hitting REST APIs with pagination or rate-limiting concerns, the patterns in Streaming and Pagination Patterns in M: Handling Large APIs and Multi-Page Data Sources with Custom Iterators will extend what you've built here. Second, if you want to enforce data quality contracts on the output of your multi-tenant pipeline — ensuring that each tenant's data conforms to a shared schema — Implementing Custom Table.Schema Validation and Type Enforcement Pipelines in Power Query M picks up exactly where this lesson leaves off.