Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Implementing Power BI Semantic Model Version Control with TMDL and Git for Enterprise Dataset Lifecycle Management

Implementing Power BI Semantic Model Version Control with TMDL and Git for Enterprise Dataset Lifecycle Management

Power BI🔥 Expert26 min readAug 5, 2026Updated Aug 5, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Understanding TMDL: What It Is and Why It Changes Everything
  • Extracting Your Existing Model to TMDL
  • Using Tabular Editor 3
  • Using pbi-tools for Automation
Structuring Your Git Repository
  • Branch Strategy for Data Models
  • Building the Deployment Pipeline
  • Service Principal Setup
  • Azure DevOps Pipeline
  • The Deployment Script
  • Managing Environment-Specific Configuration
  • Parameterized Data Sources
  • Handling Incremental Refresh Policies
  • The Pull Request Workflow in Practice
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Treating the PBIX as the Source of Truth Alongside Git
  • Mistake 2: Committing the Entire PBIX File
  • Mistake 3: Deployment Overwrites Incremental Refresh Partitions
  • Mistake 4: lineageTag Collisions After Manual Editing
  • Mistake 5: Broken TMDL After Merge Conflicts
  • Mistake 6: Pipeline Credentials Expiring Silently
  • Troubleshooting: XMLA Endpoint Returning 403
  • Troubleshooting: Measures Showing Wrong Values After Deployment
  • Advanced Patterns: Multi-Model Repositories and Shared Dimensions
  • Summary & Next Steps
  • Implementing Power BI Semantic Model Version Control with TMDL and Git for Enterprise Dataset Lifecycle Management

    Introduction

    Picture this: your analytics team has spent six weeks building a mission-critical financial reporting semantic model in Power BI. Fifty calculated measures, custom time intelligence, row-level security roles, and carefully crafted relationships between eight source tables. Then, one Friday afternoon, a well-meaning developer makes a "quick change" to a DAX measure, publishes directly to the shared workspace, and by Monday morning the CFO is asking why revenue figures in every report are off by 23%. You have no rollback mechanism, no change history, and no way to know exactly what changed. Your only option is to rebuild from memory or a weeks-old backup.

    This scenario plays out at enterprises every week. Power BI's browser-based development model was designed for self-service agility, but that same accessibility becomes a liability at enterprise scale. What development teams need is what software engineers have had for decades: proper version control with branching, pull requests, code review, and the ability to roll back to any point in history. The good news is that Microsoft's Tabular Model Definition Language (TMDL) combined with Git makes this not just possible but genuinely elegant. TMDL serializes your entire semantic model — tables, columns, measures, relationships, security roles, everything — into human-readable text files that Git handles beautifully.

    By the end of this lesson, you will have a complete understanding of how to implement production-grade version control for Power BI semantic models. You will be able to take an existing enterprise model, extract it to TMDL, structure a Git repository with a proper branching strategy, automate deployment through CI/CD pipelines, and manage the full dataset lifecycle from development through to production.

    What you'll learn:

    • How TMDL serializes tabular model metadata and why the file structure matters for meaningful Git diffs
    • How to set up a Git repository structure optimized for semantic model development workflows
    • How to implement branching strategies and pull request policies that enforce code review for data models
    • How to build Azure DevOps or GitHub Actions pipelines that deploy TMDL to Power BI workspaces automatically
    • How to handle the edge cases that break naive implementations: incremental refresh configurations, deployment parameters, and multi-developer conflicts

    Prerequisites

    Before working through this lesson, you should be comfortable with:

    • Power BI Desktop at an intermediate-to-advanced level, including DAX and data modeling
    • Git fundamentals — commits, branches, merges, and pull requests. You don't need to be a Git expert, but you should know what a rebase is.
    • Azure DevOps or GitHub — basic pipeline/actions familiarity
    • Power BI Premium or Fabric capacity — XMLA endpoint access, which is required for programmatic deployment, is a Premium/Fabric feature
    • Command-line comfort — several operations happen in the terminal

    You'll need the following tools installed:

    • Power BI Desktop (current version)
    • Tabular Editor 3 (the commercial version; the free Tabular Editor 2 also works for most operations but TE3 has native TMDL support)
    • Git
    • The Power BI CLI (pbicli) or the pbi-tools command-line tool
    • Azure CLI (for service principal authentication in pipelines)

    Understanding TMDL: What It Is and Why It Changes Everything

    Before TMDL, the canonical serialization format for tabular models was TMSL (Tabular Model Scripting Language), which is JSON. If you've ever tried to review a TMSL diff in a pull request, you know the problem: a single measure change might touch hundreds of lines because the JSON structure wraps everything in deeply nested objects with redundant property declarations. Reviewers can't meaningfully evaluate what changed, and merge conflicts in JSON are painful to resolve correctly.

    TMDL (Tabular Model Definition Language) is Microsoft's answer. It's a domain-specific language purpose-built for tabular model serialization that's both human-readable and diff-friendly. More importantly, TMDL serializes the model as a folder structure rather than a single file, with each logical component living in its own file.

    Here's what a TMDL folder structure looks like for a sales reporting model:

    SalesModel/
    ├── database.tmdl
    ├── model.tmdl
    ├── tables/
    │   ├── Sales.tmdl
    │   ├── Product.tmdl
    │   ├── Customer.tmdl
    │   ├── Date.tmdl
    │   └── _Measures.tmdl        ← dedicated measures table
    ├── relationships.tmdl
    ├── roles.tmdl
    └── cultures/
        └── en-US.tmdl
    

    The database.tmdl file holds model-level properties: compatibility level, default mode, and linguistic schema references. The model.tmdl file contains model-scoped configurations. Each table gets its own .tmdl file, and this is where the magic happens for Git workflows.

    Here's what the Sales.tmdl file looks like for a real table definition:

    table Sales
    	lineageTag: a1b2c3d4-e5f6-7890-abcd-ef1234567890
    
    	column SalesOrderNumber
    		dataType: string
    		lineageTag: b2c3d4e5-f6a7-8901-bcde-f12345678901
    		sourceColumn: SalesOrderNumber
    		summarizeBy: none
    
    	column OrderDate
    		dataType: dateTime
    		formatString: Short Date
    		lineageTag: c3d4e5f6-a7b8-9012-cdef-123456789012
    		sourceColumn: OrderDate
    		summarizeBy: none
    
    	column SalesAmount
    		dataType: decimal
    		lineageTag: d4e5f6a7-b8c9-0123-defa-234567890123
    		sourceColumn: SalesAmount
    		formatString: \$#,0.00;(\$#,0.00);\$#,0.00
    		summarizeBy: sum
    
    	measure 'Total Sales' =
    			SUMX(
    			    Sales,
    			    Sales[SalesAmount] * Sales[Quantity]
    			)
    		formatString: \$#,0.00;(\$#,0.00);\$#,0.00
    		lineageTag: e5f6a7b8-c9d0-1234-efab-345678901234
    
    	measure 'YTD Sales' =
    			CALCULATE(
    			    [Total Sales],
    			    DATESYTD('Date'[Date])
    			)
    		formatString: \$#,0.00;(\$#,0.00);\$#,0.00
    		lineageTag: f6a7b8c9-d0e1-2345-fabc-456789012345
    

    Notice how DAX expressions live directly in the file with sensible indentation. When a developer changes the YTD Sales measure, the Git diff shows exactly those lines — not 200 lines of JSON noise. A pull request reviewer can immediately understand what changed and whether it's correct.

    The lineageTag values are GUIDs that Power BI uses to track object identity across renames. You should never manually modify these; they're generated once when an object is created and remain stable.

    Critical concept: TMDL is not just an export format. It's the authoritative source of truth in a version-controlled workflow. When you commit TMDL to Git, the repository is your model definition. The Power BI service workspace becomes an artifact derived from Git, not the other way around.


    Extracting Your Existing Model to TMDL

    The first practical step is getting your existing semantic model into TMDL format. There are two primary paths: using Tabular Editor 3 interactively, or using pbi-tools from the command line. For automation purposes, you'll ultimately want the command-line approach, but starting with Tabular Editor 3 builds intuition.

    Using Tabular Editor 3

    Open Tabular Editor 3 and connect to your model. You can connect to a published dataset via the XMLA endpoint, or you can open a .pbix file directly (TE3 can read the model from .pbix through the Analysis Services instance that Power BI Desktop runs locally).

    To connect to Power BI Desktop's local instance: while your .pbix is open in Power BI Desktop, go to File → Options and Settings → Options → Preview Features and enable "Store datasets using enhanced metadata format" if it isn't already enabled (it should be on by default in recent versions). Then in Tabular Editor 3, use File → Open → From DB and connect to localhost:PORT where PORT is the Analysis Services port that Power BI Desktop is running on. You can find this port by looking at the msmdsrv.port.txt file in %LocalAppData%\Microsoft\Power BI Desktop\AnalysisServicesWorkspaces.

    Once connected, go to File → Save to Folder and choose a destination directory. Tabular Editor writes the complete TMDL folder structure.

    Using pbi-tools for Automation

    pbi-tools is an open-source command-line tool that's become the standard for Power BI DevOps workflows. Install it:

    # Install via .NET tool
    dotnet tool install --global pbi-tools
    
    # Or download the standalone executable from github.com/pbi-tools/pbi-tools
    

    To extract a .pbix file to TMDL:

    pbi-tools extract `
      -pbixPath "C:\Projects\SalesReporting\SalesModel.pbix" `
      -extractFolder "C:\Projects\SalesReporting\model-source" `
      -modelSerialization tmdl
    

    This creates the full TMDL folder structure plus additional metadata about Power Query queries, report pages, and custom visuals. For semantic model version control specifically, you'll primarily work with the Model subdirectory that pbi-tools creates.

    Architecture decision: You have a choice about whether to version control the full .pbix (including report layout) or just the semantic model TMDL. For enterprise scenarios, separating report development from model development is a best practice. Models and reports have different release cadences and different teams responsible for them. Keep them in separate repositories or at minimum separate directories with separate pipelines.


    Structuring Your Git Repository

    Repository structure is not a minor detail. A poorly structured repository creates friction in daily workflows and makes CI/CD pipelines unnecessarily complex. Here's a production-tested structure for an enterprise semantic model repository:

    financial-reporting-model/
    ├── .gitignore
    ├── README.md
    ├── CHANGELOG.md
    ├── pipeline/
    │   ├── azure-pipelines.yml          ← main CI/CD pipeline
    │   ├── deploy-dev.yml
    │   ├── deploy-staging.yml
    │   └── deploy-prod.yml
    ├── scripts/
    │   ├── deploy-model.ps1
    │   ├── run-validation.ps1
    │   └── update-connection-strings.ps1
    ├── model/
    │   ├── database.tmdl
    │   ├── model.tmdl
    │   ├── tables/
    │   ├── relationships.tmdl
    │   └── roles.tmdl
    ├── tests/
    │   ├── dax-tests.xml               ← DAX test definitions (DAX Studio format)
    │   └── schema-tests.json
    └── docs/
        ├── measure-catalog.md
        └── rls-design.md
    

    The .gitignore file needs careful thought. You want to exclude artifacts that are regenerated during deployment but commit everything that defines model behavior:

    # Power BI Desktop temporary files
    *.pbix.tmp
    *.pbix.lock
    
    # Local environment configuration (never commit these)
    .env
    local.config.json
    connection-strings.local.json
    
    # Deployment artifacts
    /deploy-output/
    *.deploymentlog
    
    # OS files
    .DS_Store
    Thumbs.db
    

    Branch Strategy for Data Models

    The branching strategy needs to account for the reality that semantic model changes often have downstream effects on reports. A modified measure affects every report consuming it. This is why a robust branching strategy matters more for semantic models than for most software.

    The strategy that works best for enterprise Power BI follows a modified GitFlow pattern:

    main                    ← production-deployed state
    │
    ├── release/2024-Q1     ← release candidate, staging deployed
    │
    ├── develop             ← integration branch, dev workspace deployed
    │   │
    │   ├── feature/revenue-recognition-measures
    │   ├── feature/new-rls-roles-emea
    │   └── fix/ytd-calculation-incorrect-weekend
    │
    └── hotfix/urgent-revenue-fix
    

    The key insight is that main always reflects what's in the production Power BI workspace. When you deploy to production, you tag the commit with the version number. When something is broken in production, you branch from that tag, not from develop.

    Configure branch protection rules (in Azure DevOps: Branch Policies; in GitHub: Branch Protection Rules) for both main and develop:

    • Require pull request reviews (minimum 2 reviewers for main)
    • Require the CI pipeline to pass before merging
    • Prohibit direct pushes (force reviewers to use PRs)
    • Require linear history (no merge commits, use squash or rebase)

    Warning: Linear history requirements and TMDL can sometimes conflict when merge tools auto-resolve TMDL conflicts incorrectly. We'll cover conflict resolution in the troubleshooting section. The point is that a human reviewer should always look at TMDL diffs before merging, especially for relationships and roles files.


    Building the Deployment Pipeline

    This is where theory becomes operational reality. Your pipeline needs to do several things: validate that the TMDL is well-formed, optionally run DAX tests, deploy to the target workspace, and optionally refresh the dataset.

    Service Principal Setup

    Deployment pipelines should never use human credentials. Create a service principal in Azure Active Directory and grant it the appropriate permissions:

    # In Azure CLI
    az ad sp create-for-rbac `
      --name "pbi-deployment-sp" `
      --role contributor `
      --scopes /subscriptions/{subscription-id}
    
    # Note the appId, password, and tenant values output
    

    In the Power BI Admin portal, you need to explicitly enable service principal access: Tenant Settings → Developer Settings → Allow service principals to use Power BI APIs. Add your service principal to an allowed security group there.

    Then add the service principal as a workspace member with at least Contributor role in each workspace (Dev, Staging, Production).

    Azure DevOps Pipeline

    Here's a complete pipeline definition that handles the full deployment lifecycle. This is a real pipeline, not pseudocode:

    # azure-pipelines.yml
    trigger:
      branches:
        include:
          - develop
          - release/*
          - main
      paths:
        include:
          - model/**
    
    variables:
      - group: PowerBI-ServicePrincipal       # Variable group with CLIENT_ID, CLIENT_SECRET, TENANT_ID
      - name: dotnetVersion
        value: '7.x'
    
    stages:
      - stage: Validate
        displayName: 'Validate TMDL'
        jobs:
          - job: ValidateModel
            pool:
              vmImage: 'windows-latest'
            steps:
              - checkout: self
    
              - task: UseDotNet@2
                inputs:
                  version: $(dotnetVersion)
    
              - powershell: |
                  dotnet tool install --global pbi-tools
                  dotnet tool install --global TabularEditor.CLI
                displayName: 'Install Tools'
    
              - powershell: |
                  # Validate TMDL can be parsed without errors
                  te3cli.exe "$(Build.SourcesDirectory)/model" -validate
                displayName: 'Validate TMDL Syntax'
    
              - powershell: |
                  # Run DAX tests using DAX test framework
                  Invoke-Expression "$(Build.SourcesDirectory)/scripts/run-validation.ps1"
                displayName: 'Run Schema Validation'
                env:
                  PBI_CLIENT_ID: $(CLIENT_ID)
                  PBI_CLIENT_SECRET: $(CLIENT_SECRET)
                  PBI_TENANT_ID: $(TENANT_ID)
    
      - stage: DeployDev
        displayName: 'Deploy to Development'
        condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
        dependsOn: Validate
        jobs:
          - deployment: DeployToDev
            environment: 'PowerBI-Development'
            pool:
              vmImage: 'windows-latest'
            strategy:
              runOnce:
                deploy:
                  steps:
                    - checkout: self
    
                    - powershell: |
                        .\scripts\deploy-model.ps1 `
                          -TmdlPath "$(Build.SourcesDirectory)/model" `
                          -WorkspaceId "$(DEV_WORKSPACE_ID)" `
                          -DatasetName "Financial Reporting Model" `
                          -ClientId "$(CLIENT_ID)" `
                          -ClientSecret "$(CLIENT_SECRET)" `
                          -TenantId "$(TENANT_ID)" `
                          -Environment "development"
                      displayName: 'Deploy Model to Dev Workspace'
    
      - stage: DeployStaging
        displayName: 'Deploy to Staging'
        condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'))
        dependsOn: Validate
        jobs:
          - deployment: DeployToStaging
            environment: 'PowerBI-Staging'
            pool:
              vmImage: 'windows-latest'
            strategy:
              runOnce:
                deploy:
                  steps:
                    - checkout: self
    
                    - powershell: |
                        .\scripts\deploy-model.ps1 `
                          -TmdlPath "$(Build.SourcesDirectory)/model" `
                          -WorkspaceId "$(STAGING_WORKSPACE_ID)" `
                          -DatasetName "Financial Reporting Model" `
                          -ClientId "$(CLIENT_ID)" `
                          -ClientSecret "$(CLIENT_SECRET)" `
                          -TenantId "$(TENANT_ID)" `
                          -Environment "staging"
                      displayName: 'Deploy Model to Staging Workspace'
    
      - stage: DeployProduction
        displayName: 'Deploy to Production'
        condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
        dependsOn: Validate
        jobs:
          - deployment: DeployToProduction
            environment: 'PowerBI-Production'    # This environment requires manual approval gate
            pool:
              vmImage: 'windows-latest'
            strategy:
              runOnce:
                deploy:
                  steps:
                    - checkout: self
    
                    - powershell: |
                        .\scripts\deploy-model.ps1 `
                          -TmdlPath "$(Build.SourcesDirectory)/model" `
                          -WorkspaceId "$(PROD_WORKSPACE_ID)" `
                          -DatasetName "Financial Reporting Model" `
                          -ClientId "$(CLIENT_ID)" `
                          -ClientSecret "$(CLIENT_SECRET)" `
                          -TenantId "$(TENANT_ID)" `
                          -Environment "production" `
                          -TriggerRefresh
                      displayName: 'Deploy Model to Production Workspace'
    

    The Deployment Script

    The pipeline calls a PowerShell deployment script that handles the actual XMLA deployment. This script deserves careful attention because it handles several production concerns that naive implementations miss:

    # deploy-model.ps1
    param(
        [string]$TmdlPath,
        [string]$WorkspaceId,
        [string]$DatasetName,
        [string]$ClientId,
        [string]$ClientSecret,
        [string]$TenantId,
        [string]$Environment,
        [switch]$TriggerRefresh
    )
    
    $ErrorActionPreference = "Stop"
    
    # Install Analysis Services PowerShell module if not present
    if (-not (Get-Module -ListAvailable -Name SqlServer)) {
        Install-Module -Name SqlServer -Force -AllowClobber -Scope CurrentUser
    }
    Import-Module SqlServer
    
    # Construct the XMLA endpoint for the workspace
    $xmlaEndpoint = "powerbi://api.powerbi.com/v1.0/myorg/$WorkspaceId"
    
    # Get access token for service principal
    $tokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $tokenBody = @{
        grant_type    = "client_credentials"
        client_id     = $ClientId
        client_secret = $ClientSecret
        scope         = "https://analysis.windows.net/powerbi/api/.default"
    }
    $tokenResponse = Invoke-RestMethod -Uri $tokenUrl -Method POST -Body $tokenBody
    $accessToken = $tokenResponse.access_token
    
    Write-Host "Successfully obtained access token"
    
    # Use Tabular Editor CLI to deploy TMDL to the workspace
    # TE CLI handles the TMDL-to-TMSL conversion and XMLA deployment
    $deployCommand = @"
    TabularEditor.CLI.exe "$TmdlPath" `
      -deploy "$xmlaEndpoint" "$DatasetName" `
      -override "DataSource.connectionString=..." `
      -access-token "$accessToken" `
      -overwrite `
      -v
    "@
    
    # Before deploying, apply environment-specific overrides
    # This is critical: you don't want dev connection strings in production
    $overrideFile = "$PSScriptRoot/../config/overrides.$Environment.json"
    if (Test-Path $overrideFile) {
        Write-Host "Applying environment overrides from $overrideFile"
        $overrides = Get-Content $overrideFile | ConvertFrom-Json
    }
    
    # Deploy using XMLA endpoint
    try {
        $connectionString = "Provider=MSOLAP;Data Source=$xmlaEndpoint;User ID=app:$ClientId@$TenantId;Password=$accessToken"
        
        # Read TMDL and convert to deployment script
        # In practice, Tabular Editor CLI handles this entire block
        Invoke-TabularCmd `
            -ConnectionString $connectionString `
            -TmdlFolder $TmdlPath `
            -DatabaseName $DatasetName `
            -CreateOrReplace
        
        Write-Host "Model deployed successfully to $Environment"
    } catch {
        Write-Error "Deployment failed: $_"
        exit 1
    }
    
    # Optionally trigger a dataset refresh after deployment
    if ($TriggerRefresh) {
        Write-Host "Triggering dataset refresh..."
        
        $refreshUrl = "https://api.powerbi.com/v1.0/myorg/groups/$WorkspaceId/datasets"
        
        # Get the dataset ID by name
        $headers = @{ Authorization = "Bearer $accessToken" }
        $datasets = Invoke-RestMethod -Uri $refreshUrl -Headers $headers
        $dataset = $datasets.value | Where-Object { $_.name -eq $DatasetName }
        
        if ($dataset) {
            $refreshTriggerUrl = "https://api.powerbi.com/v1.0/myorg/groups/$WorkspaceId/datasets/$($dataset.id)/refreshes"
            $refreshBody = @{ notifyOption = "MailOnFailure" } | ConvertTo-Json
            Invoke-RestMethod -Uri $refreshTriggerUrl -Method POST `
                -Headers $headers -Body $refreshBody -ContentType "application/json"
            Write-Host "Refresh triggered for dataset: $($dataset.id)"
        }
    }
    

    Managing Environment-Specific Configuration

    One of the trickiest aspects of semantic model version control is that connection strings, data source credentials, and sometimes measure behavior differ between environments. The TMDL in Git should represent the structure of the model, not environment-specific values. Here's how to handle this properly.

    Parameterized Data Sources

    In Tabular Editor, you can use deployment parameters to abstract connection strings out of the model metadata. But a more Git-friendly approach is to use expression-based data sources with variables that get overridden at deployment time.

    Create an overrides.dev.json, overrides.staging.json, and overrides.prod.json in your config/ directory (which is in .gitignore for connection strings but tracked for structural config):

    // overrides.prod.json
    {
      "dataSources": {
        "AzureSynapse_DW": {
          "connectionString": "Server=tcp:prod-synapse.sql.azuresynapse.net,1433;Database=DataWarehouse;Authentication=ActiveDirectoryServicePrincipal"
        }
      },
      "model": {
        "defaultMode": "import"
      }
    }
    

    Security note: Never commit connection strings containing passwords or secrets to Git, even in a private repository. Use Azure Key Vault references in your pipeline variables and inject them at deployment time. The pipeline variable groups in Azure DevOps support Key Vault-backed variables, which means the secret is retrieved fresh at each pipeline run and never stored in pipeline logs.

    Handling Incremental Refresh Policies

    Incremental refresh is where many TMDL deployment implementations break. When you enable incremental refresh on a table, Power BI creates a refreshPolicy object in the model metadata. The TMDL for a table with incremental refresh looks like this:

    table Sales
    	lineageTag: a1b2c3d4-...
    
    	refreshPolicy
    		incrementalGranularity: day
    		rollingWindowGranularity: year
    		rollingWindowPeriods: 2
    		incrementalPeriods: 10
    		sourceExpression: >
    			let
    			    Source = AzureSynapse.Database(...),
    			    SalesTable = Source{[Schema="dbo",Item="FactSales"]}[Data],
    			    Filtered = Table.SelectRows(SalesTable, each 
    			        [OrderDate] >= RangeStart and [OrderDate] < RangeEnd)
    			in
    			    Filtered
    

    The critical issue: if you deploy a model with an incremental refresh policy to a workspace that already has partitions, a naive CreateOrReplace deployment will wipe out all existing partitions and reset the incremental refresh history. This is catastrophic for production.

    The solution is to use the AlterOrCreate deployment mode instead of CreateOrReplace for incremental refresh tables, and to explicitly skip the partition deployment. In Tabular Editor CLI:

    # For production deployments where incremental refresh is active,
    # use a targeted deployment that excludes partition changes
    te3cli.exe "$TmdlPath" `
      -deploy "$xmlaEndpoint" "$DatasetName" `
      -mode AlterOrCreate `
      -skip Partitions `
      -access-token "$accessToken"
    

    You'll want to detect this condition in your deployment script by checking whether the target workspace already has the dataset with active partitions before choosing the deployment mode.


    The Pull Request Workflow in Practice

    Theory is one thing; let's walk through what a real change looks like end-to-end in this workflow.

    A developer is adding a new margin calculation to the financial model. Here's the complete workflow:

    Step 1: Create a feature branch

    git checkout develop
    git pull origin develop
    git checkout -b feature/gross-margin-measures
    

    Step 2: Make changes in Tabular Editor

    The developer opens Tabular Editor 3, connects to the dev workspace model (or a local .pbix copy), and adds three new measures to the _Measures table:

    • Gross Margin Amount
    • Gross Margin %
    • Gross Margin % LY (for year-over-year comparison)

    Step 3: Save to folder (update TMDL)

    In Tabular Editor 3, File → Save to Folder overwrites the TMDL files. The developer then runs:

    git diff model/tables/_Measures.tmdl
    

    The diff shows exactly and only the three new measures — no noise, no unrelated changes. This is what good TMDL diffs look like:

     table _Measures
     
    +	measure 'Gross Margin Amount' =
    +			[Total Sales] - [Total Cost]
    +		formatString: \$#,0.00;(\$#,0.00);\$#,0.00
    +		lineageTag: 9a8b7c6d-5e4f-3210-9876-543210fedcba
    +
    +	measure 'Gross Margin %' =
    +			DIVIDE([Gross Margin Amount], [Total Sales])
    +		formatString: 0.0%;-0.0%;0.0%
    +		lineageTag: 8b7c6d5e-4f3e-2109-8765-432109edcba9
    +
    +	measure 'Gross Margin % LY' =
    +			CALCULATE(
    +			    [Gross Margin %],
    +			    SAMEPERIODLASTYEAR('Date'[Date])
    +			)
    +		formatString: 0.0%;-0.0%;0.0%
    +		lineageTag: 7c6d5e4f-3e2d-1098-7654-321098dcba98
    +
    

    Step 4: Commit and push

    git add model/tables/_Measures.tmdl
    git commit -m "feat: add gross margin measures (amount, %, and YOY)
    
    Adds three measures for gross margin analysis:
    - Gross Margin Amount: simple subtraction of cost from revenue
    - Gross Margin %: using DIVIDE to handle zero-revenue edge case
    - Gross Margin % LY: SAMEPERIODLASTYEAR for YOY comparison
    
    Relates to JIRA ticket FIN-247"
    
    git push origin feature/gross-margin-measures
    

    Step 5: Create pull request

    The PR triggers the CI pipeline's Validate stage. If validation passes (TMDL parses correctly, schema validation passes), reviewers are notified. Reviewers look at the diff — just those measure definitions — and can comment directly on specific lines. This is a genuine code review for DAX, something that wasn't possible before TMDL.


    Hands-On Exercise

    This exercise takes approximately 2-3 hours and gives you hands-on experience with the complete workflow.

    Scenario: You're taking over version control for the Contoso HR Analytics model. The model currently lives only in a Power BI workspace with no version history. You need to establish the Git repository, make a controlled change, and validate the round-trip.

    Part 1: Initial Repository Setup (30 minutes)

    1. Create a new Git repository named contoso-hr-model. Initialize with a README and add a .gitignore using the template above.

    2. In Power BI Desktop, create a simplified HR model with three tables:

      • Employee (EmployeeID, Name, Department, HireDate, Salary, ManagerID)
      • Department (DepartmentID, DepartmentName, Region)
      • Date (standard date table with Year, Quarter, Month, DateKey)
    3. Add these measures to a dedicated _Measures table:

      • Headcount = COUNTROWS(Employee)
      • Average Salary = AVERAGE(Employee[Salary])
      • New Hires This Period = CALCULATE([Headcount], DATESINPERIOD('Date'[Date], LASTDATE('Date'[Date]), -90, DAY))
    4. Use pbi-tools extract to extract the model to TMDL in your repository under model/.

    5. Commit the initial TMDL with message "chore: initial TMDL extraction from existing model".

    Part 2: Controlled Change and Review (45 minutes)

    1. Create a feature branch: git checkout -b feature/tenure-calculation

    2. Open Tabular Editor 3 and connect to your local model. Add two new measures:

      • Average Tenure Years = AVERAGEX(Employee, DATEDIFF(Employee[HireDate], TODAY(), YEAR))
      • Attrition Risk Count = CALCULATE([Headcount], Employee[Salary] < PERCENTILEX.INC(Employee, Employee[Salary], 0.25))
    3. Save to folder, examine the diff with git diff, verify it shows only the two new measures.

    4. Commit with a meaningful message explaining the business purpose.

    5. Create a mock PR review: run git log --oneline to confirm your history, then merge to a local develop branch and examine the merge commit.

    Part 3: Conflict Resolution (45 minutes)

    Simulate a realistic conflict scenario:

    1. From develop, create two branches: feature/salary-bands and feature/remote-work-flag.

    2. In feature/salary-bands, add a column to Employee table: SalaryBand (computed via a calculated column).

    3. In feature/remote-work-flag, add a different column to the same Employee table: IsRemote.

    4. Merge feature/salary-bands into develop first.

    5. Try to merge feature/remote-work-flag. Observe the conflict in model/tables/Employee.tmdl.

    6. Resolve the conflict manually by ensuring both columns appear in the merged file with correct TMDL syntax, and that lineageTag values remain unchanged.

    7. Verify the resolved TMDL is valid by opening it in Tabular Editor.

    Part 4: Pipeline Validation (30 minutes)

    If you have access to Azure DevOps or GitHub Actions:

    1. Push your repository.
    2. Set up a simple validation pipeline that runs pbi-tools extract --check against the committed TMDL to verify it's parseable.
    3. Introduce an intentional syntax error in a TMDL file and verify the pipeline catches it.
    4. Fix the error and verify the pipeline passes.

    Common Mistakes & Troubleshooting

    Mistake 1: Treating the PBIX as the Source of Truth Alongside Git

    The most common failure mode is developers sometimes editing in Power BI Desktop and sometimes working through Tabular Editor/TMDL. This creates drift. You must establish an unambiguous rule: the TMDL in Git is the source of truth. Direct edits to the published workspace dataset are prohibited outside of emergency hotfix procedures. Power BI Desktop development is only allowed if the developer immediately re-extracts to TMDL and commits.

    Consider enforcing this technically by removing workspace edit permissions from developers and only allowing writes through the pipeline service principal.

    Mistake 2: Committing the Entire PBIX File

    A .pbix file is a ZIP archive containing binary data. Git handles it poorly — you get large binary diffs, repository bloat, and no meaningful history. Don't commit .pbix files. If you need to share a development .pbix for onboarding purposes, use a SharePoint/OneDrive link and reference it in the README. Your Git repository should contain only TMDL and supporting scripts.

    Mistake 3: Deployment Overwrites Incremental Refresh Partitions

    This is covered in detail above, but the troubleshooting signal is clear: after a deployment, users report that reports are taking much longer to load (full refresh triggered) or data appears to be missing for recent periods. Your deployment script must use AlterOrCreate with -skip Partitions for any table that has an incremental refresh policy in production.

    Mistake 4: lineageTag Collisions After Manual Editing

    If you hand-edit TMDL files and accidentally duplicate a lineageTag GUID (copy-paste error), the model will either fail to deploy or will exhibit bizarre behavior where report bookmarks and saved states break. The fix: delete the duplicate lineageTag line from the manually-added object and let Tabular Editor regenerate it when it next opens the model. Never copy lineageTags between objects.

    Mistake 5: Broken TMDL After Merge Conflicts

    Merge conflicts in TMDL are handled by standard Git text conflict markers (<<<<<<<, =======, >>>>>>>). The problem is that if you accept the merged file without verifying the TMDL syntax, you may end up with structurally invalid TMDL that looks fine as text but fails to parse.

    Always validate TMDL after resolving conflicts:

    # Quick validation - will throw on parse errors
    TabularEditor.CLI.exe "model/" -validate
    
    # Alternative with pbi-tools
    pbi-tools info "model/"
    

    If the conflict is in relationships.tmdl, be especially careful. Relationship definitions include both ends' column references, and a conflict might leave you with a relationship that references a table that was renamed in one branch. Tabular Editor's validation will catch this.

    Mistake 6: Pipeline Credentials Expiring Silently

    Service principal client secrets have expiration dates (typically 1-2 years in Azure AD defaults). When a secret expires, your deployment pipeline fails with an authentication error — often at the worst possible time. Set up Azure Monitor alerts to notify you 30 days before service principal secret expiration, and document the renewal procedure in your runbook.

    Troubleshooting: XMLA Endpoint Returning 403

    If your deployment script gets a 403 from the XMLA endpoint, work through this checklist in order:

    1. Verify the service principal is in an AAD security group that's whitelisted in Power BI Tenant Settings
    2. Verify the service principal is a workspace member with at least Contributor role
    3. Verify the workspace is on a Premium/Fabric capacity (XMLA endpoints don't work on shared capacity)
    4. Verify the access token scope is https://analysis.windows.net/powerbi/api/.default (not the Power BI REST API scope, which is different)
    5. Check whether the service principal's secret has expired

    Troubleshooting: Measures Showing Wrong Values After Deployment

    If measures calculate correctly in the source environment but give wrong results in the deployed environment, the most common causes are:

    1. Data source query differences — the dev data source has different data than staging/prod. This is a data issue, not a model issue.
    2. Calculation group precedence — if you have calculation groups, their precedence order must be verified in the TMDL and may differ from what the UI showed.
    3. Implicit measures overriding explicit ones — verify that isHidden is correctly set on base columns if you're replacing implicit measures with explicit ones.

    Advanced Patterns: Multi-Model Repositories and Shared Dimensions

    At enterprise scale, you often have multiple semantic models that share common dimensions — a Date table, a Customer dimension, or organizational hierarchy dimensions. Keeping these in sync across multiple models is a significant operational challenge.

    A mature solution is to maintain shared dimensions as a separate "foundation model" repository with its own release cycle. Individual subject-area models (Sales Analytics, HR Analytics, Finance) declare a dependency on the foundation model version and import dimension tables from a composite model or shared dataset connection.

    In TMDL, shared dataset connections appear as dataSource objects with a connectionType of directQuery pointing to another Power BI dataset. When your CI/CD pipeline deploys the Sales Analytics model, it can first verify that the foundation model is at the expected version by checking a version file in the foundation model repository.

    dataSource 'Foundation Model'
    	connectionType: datawarehouse
    	connectionDetails
    		protocol: sharepoint
    		address
    			url: powerbi://api.powerbi.com/v1.0/myorg/FoundationDatasets
    

    This approach gives you centralized governance of shared dimensions (one team owns them, changes go through their PR process) while allowing subject-area teams independence for their own measures and business logic.


    Summary & Next Steps

    You've covered a complete implementation of enterprise semantic model version control — from the conceptual model of TMDL as a diff-friendly serialization format, through repository structure and branching strategy, to production CI/CD pipelines with environment-specific configuration management.

    The critical principles that make this system work are:

    TMDL as source of truth. The moment you allow the Power BI service workspace to diverge from Git without tracking, you've lost the system's integrity. Enforce this with tooling, not just process.

    Meaningful diffs enable meaningful review. The entire point of TMDL over TMSL/JSON is that humans can read and review changes. Build a pull request culture where DAX changes get the same scrutiny as application code.

    Environments are deployment targets, not sources. Your development workspace reflects develop branch. Staging reflects release/* branches. Production reflects main. Nothing flows backward.

    Automation handles the boring parts. Deployment, refresh triggering, and validation should never require a human to click through the Power BI portal. Pipelines handle all of this, and humans focus on reviewing what changed and whether the logic is correct.

    For your next steps, consider exploring:

    • DAX unit testing with DAX Studio's test framework — you can define expected outputs for measures given controlled inputs, and run these tests as part of your CI pipeline before deployment
    • Power BI Deployment Pipelines API — Microsoft's native deployment pipelines can be orchestrated via REST API and may complement (or partially replace) custom XMLA deployment scripts for simpler scenarios
    • Fabric Git Integration — Microsoft Fabric has first-class Git integration built into the service, which handles some of the deployment automation covered here natively. Understanding the TMDL/Git foundation covered in this lesson is essential context for working effectively with Fabric's native integration
    • Automated semantic model documentation — tools like dax.guide integration and automated measure catalog generation from TMDL metadata can be triggered as part of your pipeline to keep documentation synchronized with model changes

    The investment in building this infrastructure pays dividends immediately through faster, safer iteration on your most critical analytical assets.

    Learning Path: Enterprise Power BI

    Previous

    Implementing Power BI Writeback Solutions with Power Automate to Enable Enterprise Planning and What-If Scenario Management

    Related Articles

    Power BI🔥 Expert

    DAX Decomposition Trees and Drill-Through Measures: Building Self-Explaining KPIs with PATH, PATHITEM, and Parent-Child Hierarchies

    26 min
    Power BI🔥 Expert

    Mastering Power BI Dataflows: Building Reusable ETL Pipelines in Power BI Service

    28 min
    Power BI⚡ Practitioner

    Implementing Power BI Writeback Solutions with Power Automate to Enable Enterprise Planning and What-If Scenario Management

    24 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding TMDL: What It Is and Why It Changes Everything
    • Extracting Your Existing Model to TMDL
    • Using Tabular Editor 3
    • Using pbi-tools for Automation
    • Structuring Your Git Repository
    • Branch Strategy for Data Models
    • Building the Deployment Pipeline
    • Service Principal Setup
    • Azure DevOps Pipeline
    • The Deployment Script
    • Managing Environment-Specific Configuration
    • Parameterized Data Sources
    • Handling Incremental Refresh Policies
    • The Pull Request Workflow in Practice
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Treating the PBIX as the Source of Truth Alongside Git
    • Mistake 2: Committing the Entire PBIX File
    • Mistake 3: Deployment Overwrites Incremental Refresh Partitions
    • Mistake 4: lineageTag Collisions After Manual Editing
    • Mistake 5: Broken TMDL After Merge Conflicts
    • Mistake 6: Pipeline Credentials Expiring Silently
    • Troubleshooting: XMLA Endpoint Returning 403
    • Troubleshooting: Measures Showing Wrong Values After Deployment
    • Advanced Patterns: Multi-Model Repositories and Shared Dimensions
    • Summary & Next Steps