Chapter 7 · Engineering
The Model as Code
A .pbix file is a zip of binary parts: you can't diff it, review it or merge it. A Power BI Project (.pbip) saves the same model and report as text files. Once they are text, they can live in git, every change can be reviewed in a pull request, and scripts can read and even write them. AdventureWorksPL was written as text from the start and opened in Desktop only to check it.
From PBIX to PBIP
In Power BI Desktop, choose File › Save as and pick Power BI project (*.pbip). Desktop writes a folder instead of one file. This is the AdventureWorksPL project in the site's repository:
AdventureWorksPL/
├── AdventureWorksPL.pbip pointer file: open this in Desktop
├── .gitignore excludes the data cache and local settings
├── AdventureWorksPL.SemanticModel/
│ ├── definition.pbism model settings (version 4.2 = TMDL folder)
│ └── definition/
│ ├── database.tmdl compatibility level
│ ├── model.tmdl model options, table order
│ ├── expressions.tmdl parameters + shared queries (SalesSource, FiscalCalendarSql)
│ ├── relationships.tmdl 14 relationships
│ ├── roles/Territory Access.tmdl
│ └── tables/ one file per table: Sales.tmdl, Date.tmdl, _Measures.tmdl, …
└── AdventureWorksPL.Report/
├── definition.pbir which model the report uses (byPath)
├── StaticResources/… the base theme
└── definition/ PBIR: one JSON file per page and per visual
├── report.json
└── pages/plOverview/
├── page.json
└── visuals/plMatrix/visual.json, trendChart/visual.json, …
The data cache is never committed: the repository holds the definition, and data comes from refreshing it. When Desktop saves a project it creates .pbi/cache.abf and .pbi/localSettings.json next to the definition, and the .gitignore excludes both.
What is inside the definition
The model is stored in TMDL (Tabular Model Definition Language): indented text, one file per table. Everything from Chapters 2–6 is in it. A measure, with its description, format and folder:
table _Measures
/// Gross sales less discounts, in USD unless one other currency is selected.
measure '03 - Net Sales' = [01 - Gross Sales] - [02 - Discounts]
formatString: #,0
displayFolder: P&L Actual
measure 'Gross Margin %' = DIVIDE ( [05 - Gross Profit], [03 - Net Sales] )
formatString: 0.0%
displayFolder: Ratios
A table, with hidden fact columns and one of its partitions:
/// Grain: one order line, internet or reseller. Amounts are in USD; LocalCurrencyRate converts them to the order's currency.
table Sales
column GrossSales
dataType: decimal
isHidden
formatString: #,0.00
summarizeBy: sum
sourceColumn: GrossSales
partition 'Sales FY2013' = m
mode: import
source =
let
Source = SalesSource,
FY2013 = Table.SelectRows(Source, each [DateKey] >= 20120701 and [DateKey] < 20130630)
in
FY2013
Notice what is not there. A relationship with no crossFilteringBehavior or cardinality properties is many-to-one and single-direction, because those are the defaults. A bi-directional relationship shows up as a line crossFilteringBehavior: bothDirections, which is easy to catch in a review.
The report is stored in PBIR: one JSON file per page and per visual, each with a published schema. A card is a few lines:
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.13.0/schema.json",
"name": "cardNetSales",
"position": { "x": 20, "y": 86, "width": 296, "height": 96 },
"visual": {
"visualType": "card",
"query": { "queryState": { "Values": { "projections": [ {
"field": { "Measure": { "Expression": { "SourceRef": { "Entity": "_Measures" } }, "Property": "03 - Net Sales" } }
} ] } } }
}
}
TMDL, model.bim and why the format matters
Older projects store the whole model in one JSON file, model.bim. It holds the same objects, and Desktop still opens it, but every change lands in one large file. With TMDL each measure is a separate block of lines in its table's file, so diffs are small and two people adding measures rarely conflict. If a tool needs model.bim, the Tabular Object Model can convert one to the other (TmdlSerializer and JsonSerializer).
The workflow
- Branch. One branch per change:
feature/freight-income-line. - Edit. In Power BI Desktop for visuals and simple measures; in Tabular Editor or a text editor for bulk changes, calculation groups, partitions and roles. They all edit the same files. If the files change while Desktop has the project open, Desktop shows "This project's files were changed externally" with an Apply external changes button: apply them, and don't save Desktop's copy over them.
- Validate. Before anything else, load the definition with the Tabular Object Model. A syntax error in a TMDL file fails here, with the file and line, instead of in someone's Desktop.
- Commit and open a pull request. The diff shows exactly which measures, columns, relationships, queries and visuals changed.
- Automated checks. The pipeline runs Best Practice Analyzer rules and regenerates the measure documentation (Chapter 8), so the reviewer sees the new lineage too.
- Review and merge. A second person approves.
- Deploy from main. A deployment pipeline (dev → test → prod workspaces, with parameter rules for
ServerName) or a script against the XMLA endpoint. Then refresh only the partitions that changed.
Add-Type -Path 'C:\Program Files\Microsoft Power BI Desktop\bin\Microsoft.PowerBI.Tabular.dll'
$db = [Microsoft.AnalysisServices.Tabular.TmdlSerializer]::DeserializeDatabaseFromFolder(
'.\AdventureWorksPL\AdventureWorksPL.SemanticModel\definition')
"{0} tables, {1} relationships, {2} roles" -f $db.Model.Tables.Count, $db.Model.Relationships.Count, $db.Model.Roles.Count
# 15 tables, 14 relationships, 1 roles
That check catches TMDL syntax, not DAX mistakes. To check DAX, open the project in Desktop (or deploy it to a workspace) and read each measure's state from the engine; Chapter 9 shows the query.
What to look for in a review
| In the diff | Ask |
|---|---|
| A measure expression changed | Do the reconciliation tests (Chapter 9) still pass? Which measures depend on it? |
| A derived or analysis measure references a column | Should it read line measures or a helper instead (layer rule, Chapter 5)? |
Table.SelectRows with literal dates | Is this a partition boundary, or a development filter about to reach production? Are the boundaries half-open? |
crossFilteringBehavior: bothDirections | Why? Is there a measure or visual-filter alternative? |
A new visible measure without formatString or a /// description | Add them before merging. |
| A new table | Is it related to Sales Territory, so row-level security covers it? |
| A role's filter expression | Was it tested with "View as"? |
| A change to a source query | Did the row counts per partition and the reconciliation change? Should they have? |
Best Practice Analyzer
Tabular Editor's Best Practice Analyzer (BPA) checks a model against a list of rules, and its command-line version can fail a build. Start with Microsoft's published rule set, then add your own. Rules worth having for a P&L model:
| Rule | Catches |
|---|---|
| Fact table columns must be hidden | Users summing raw amounts and bypassing currency logic |
| Visible measures must have a format string and a description | Inconsistent decimals, unexplained numbers |
| No bi-directional relationships | Ambiguous filter paths |
| No calculated columns | Logic that belongs in SQL |
Measures live in _Measures | New measures added to random tables |
Avoid FILTER over a fact table inside CALCULATE | Slow measures (Chapter 9) |
Amount columns are decimal, not double | Float rounding (Chapter 3, trap 3) |
Exercise: a merge conflict
Two developers each added a measure to _Measures on separate branches. With model.bim the second merge conflicts; with TMDL it usually doesn't. Why?
In model.bim both measures were inserted into the same JSON array, often at the same position, so git sees overlapping edits. In TMDL each measure is a separate block of lines in _Measures.tmdl, and when they don't touch the same lines git merges them automatically. Fewer conflicts is the main practical reason to prefer TMDL for teams.
Key takeaways
- Save the model and report as a PBIP project so their definitions are text; never commit the data cache.
- TMDL holds every table, column, measure, partition query, relationship and role; PBIR holds every page and visual.
- Validate, branch, pull request, automated checks, review, deploy from main: the same workflow as any other code.
- Encode your modelling rules as Best Practice Analyzer rules so the build enforces them.