Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Query

Implementing Custom Environment and Credential Management with M Language let Scoping: Structuring Query Context for Multi-Source, Multi-Tenant Data Pipelines

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.

🌱 Foundation16 min readSep 8, 2026Updated Sep 23, 2026
Implementing Custom Environment and Credential Management with M Language let Scoping: Structuring Query Context for Multi-Source, Multi-Tenant Data Pipelines
On this page
  • Introduction
  • Prerequisites
  • How `let` Scoping Actually Works
  • Building the Environment Configuration Record
  • Structuring Tenant-Aware Connection Builders
  • Handling Credentials with `let` Scoping
  • Composing Multi-Tenant Pipelines from Context Objects
  • Layered Context: Overrides Without Repetition
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps

Implementing Custom Environment and Credential Management with M Language `let` Scoping: Structuring Query Context for Multi-Source, Multi-Tenant Data Pipelines

Introduction

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:

  • How M's let scoping rules determine variable visibility and how to exploit them for context isolation
  • How to build an environment configuration record that drives all downstream connection decisions
  • How to structure tenant-aware query templates that resolve credentials and endpoints at evaluation time
  • How to use record-based context objects to pass environment state across dependent query steps
  • Common patterns and pitfalls when managing secrets and multi-tenant context in Power Query

Prerequisites

You 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.


How `let` Scoping Actually Works

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 let block 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.


Building the Environment Configuration Record

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-else chains 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.


Structuring Tenant-Aware Connection Builders

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 GetTenantConnection multiple 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 a List.Transform or Table.AddColumn if you can avoid it. Keeping connection evaluations explicit makes it far easier to reason about performance patterns and troubleshoot refresh failures.


Handling Credentials with `let` Scoping

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.


Composing Multi-Tenant Pipelines from Context Objects

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.Combine on 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.


Layered Context: Overrides Without Repetition

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.Combine is 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}.


Hands-On Exercise

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.


Common Mistakes & Troubleshooting

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.NativeQuery workaround 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.


Summary & Next Steps

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.
  • One config query, one activation point. All environment variation should trace back to a single ActiveEnvironment selector. Everything else is derivation.
  • Record.Combine enables layered configuration. Defaults plus overrides, applied in composition order, gives you flexibility without repetition.
  • Functions as first-class context resolvers. A query that returns a function lets you encapsulate credential-sensitive logic in the tightest possible scope.
  • Tenant iteration should be data-driven. Drive 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.

Work With Us

From insight to implementation

Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

Let's Build

Advanced M Language

Previous

Implementing Custom Culture-Aware Text Classification and Regex Engine Pipelines in Power Query M: Building Reusable Pattern Libraries for Multilingual Data Standardization

Next

Implementing Custom Delta Load and Change Data Capture Patterns in Power Query M: Watermark Tracking, Hash-Based Diff Detection, and Incremental Table Synchronization Without Native CDC Support

Related Insights

Power QueryExpert

Implementing Custom OData Feed Clients in Power Query M: Handling Service Metadata, Entity Expansion, and Filter Pushdown for Enterprise API Integration

27 min
Power QueryExpert

Implementing Table Relationship Validation and Referential Integrity Checks in Power Query M: Detecting Orphaned Keys and Broken Joins Before Load

26 min
Power QueryPractitioner

Implementing Custom Environment Promotion and Deployment Pipelines for Power Query M Solutions

24 min

On this page

  • Introduction
  • Prerequisites
  • How `let` Scoping Actually Works
  • Building the Environment Configuration Record
  • Structuring Tenant-Aware Connection Builders
  • Handling Credentials with `let` Scoping
  • Composing Multi-Tenant Pipelines from Context Objects
  • Layered Context: Overrides Without Repetition
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps