
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:
Before working through this lesson, you should be comfortable with:
You'll need the following tools installed:
pbicli) or the pbi-tools command-line toolBefore 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.
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.
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.
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.
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
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:
main)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.
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.
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).
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 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)"
}
}
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.
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.
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.
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 AmountGross 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.
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)
Create a new Git repository named contoso-hr-model. Initialize with a README and add a .gitignore using the template above.
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)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))Use pbi-tools extract to extract the model to TMDL in your repository under model/.
Commit the initial TMDL with message "chore: initial TMDL extraction from existing model".
Part 2: Controlled Change and Review (45 minutes)
Create a feature branch: git checkout -b feature/tenure-calculation
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))Save to folder, examine the diff with git diff, verify it shows only the two new measures.
Commit with a meaningful message explaining the business purpose.
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:
From develop, create two branches: feature/salary-bands and feature/remote-work-flag.
In feature/salary-bands, add a column to Employee table: SalaryBand (computed via a calculated column).
In feature/remote-work-flag, add a different column to the same Employee table: IsRemote.
Merge feature/salary-bands into develop first.
Try to merge feature/remote-work-flag. Observe the conflict in model/tables/Employee.tmdl.
Resolve the conflict manually by ensuring both columns appear in the merged file with correct TMDL syntax, and that lineageTag values remain unchanged.
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:
pbi-tools extract --check against the committed TMDL to verify it's parseable.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.
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.
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.
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.
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.
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.
If your deployment script gets a 403 from the XMLA endpoint, work through this checklist in order:
https://analysis.windows.net/powerbi/api/.default (not the Power BI REST API scope, which is different)If measures calculate correctly in the source environment but give wrong results in the deployed environment, the most common causes are:
isHidden is correctly set on base columns if you're replacing implicit measures with explicit ones.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.
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.guide integration and automated measure catalog generation from TMDL metadata can be triggered as part of your pipeline to keep documentation synchronized with model changesThe investment in building this infrastructure pays dividends immediately through faster, safer iteration on your most critical analytical assets.
Learning Path: Enterprise Power BI