TMDL Authoring Rules
Syntax Rules (MUST follow)
- TMDL uses tab indentation — every nesting level is exactly one tab (
\t), NOT spaces. Spaces cause validation errors.
- PowerShell: use
`t
- Bash: use
$'\t' or literal tabs
- Objects declared by type + name:
table Customer, column ProductId, measure 'Total Sales'
- Names with spaces or special chars (
., =, :, ') must be in single quotes: column 'Order Date'
- Descriptions use
/// placed ABOVE the object — do NOT use the description property
// comments are NOT supported in TMDL
- Do NOT add
lineageTag on new objects — it is auto-generated
- Multi-line DAX must be enclosed in triple backticks (
```)
- Place measures before columns in table definitions
formatString is required on every measure
- Never set
dataType on measures — it is inferred from DAX
Naming Conventions
- Tables: business-friendly, no
Fact/Dim prefixes. Plural for facts (Sales), singular for dimensions (Product)
- Columns: readable with spaces (
Order Date, Unit Price)
- Measures: clear patterns (
Total Sales, # Customers). Time intelligence: [measure], [measure (ly)], [measure (ytd)])
Column Rules
| Property |
Rule |
dataType |
Required. Use int64, decimal, string, dateTime, boolean. Avoid double |
sourceColumn |
Must match partition source column name exactly |
isHidden |
Set for ID columns, foreign keys, system columns |
summarizeBy |
none for non-aggregatable numerics (IDs, postal codes, year numbers) |
isAvailableInMdx |
false for hidden columns not used in sort-by or hierarchies |
sortByColumn |
For text needing non-alphabetical sort (month names → month number) |
Measure & DAX Rules
- Always set
formatString — Currency: $#,##0.00 | Percentage: 0.00% | Integer: #,##0 | Decimal: #,##0.00
- Use
DIVIDE() instead of / for safe division
- Never use
IFERROR — causes performance degradation
- Prefix
VAR names with _: VAR _totalSales = ...
- Use
displayFolder to organize measures into logical groups
- Add
/// descriptions to explain business logic
Relationship Rules
fromColumn: = many-side (fact); toColumn: = one-side (dimension)
- Create relationships BEFORE measures that depend on them
- Default:
crossFilteringBehavior: oneDirection; add bothDirections only when needed
- Role-playing dimensions: duplicate the table by default —
Date,
Ship Date, Delivery Date, each with one active relationship.
Microsoft recommends "defining active relationships whenever possible",
which "means that role-playing dimension tables should be duplicated in
your model"; the cost is model size, "rarely a concern" for dimensions.
isActive: false + USERELATIONSHIP() is the conditional case —
only when no visual needs two roles at once and you write the measures.
In Direct Lake, confirm duplication is available first (see below).
- Both sides must have matching
dataType
- Set
isKey: true on dimension primary key columns
- Hide foreign keys on fact tables (
isHidden: true)
- No composite keys — use a single surrogate integer key
Calculation Groups
table 'Time Intelligence'
calculationGroup
calculationItem Current = SELECTEDMEASURE()
calculationItem YTD = CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))
column 'Time Intelligence'
dataType: string
partition 'Partition_Time Intelligence' = calculationGroup
calculationGroup keyword has NO name — just the keyword indented under the table
- Partition type must be
= calculationGroup (not = m or = calculated)
- Use
formatStringDefinition (not formatString) for calc items that override measure format
Security Roles
role RegionalManager
modelPermission: read
tablePermission Sales = [Region] = "East"
modelPermission: required — use read or readRefresh
- Assign users via Power BI REST API, not TMDL:
POST .../datasets/{id}/users with roles array
- Do NOT use
INFO.ROLES() / INFO.ROLEMEMBERSHIPS() via DAX — unreliable. Use the REST API.
Annotations
- Do NOT add
PBI_* annotations manually — they are Power BI internal metadata
- Custom annotations are fine for documentation/tooling
- Syntax: blank line before the first annotation; blank line between annotations; same indent as peer properties
column 'Product Name'
dataType: string
sourceColumn: Product Name
annotation MyTool_Owner = analytics-team
model.tmdl Required Properties
model.tmdl is the root of the definition/ folder alongside database.tmdl, expressions.tmdl, functions.tmdl, relationships.tmdl, roles/, perspectives/, cultures/, and tables/.
model Model
culture: en-US
defaultPowerBIDataSourceVersion: powerBI_V3
discourageImplicitMeasures
sourceQueryCulture: en-US
dataAccessOptions
legacyRedirects
returnErrorValuesAsNull
defaultPowerBIDataSourceVersion: powerBI_V3 is required for Import-mode models — without it, Import from JSON supported for V3 models only.
Direct Lake Configuration
ALL partitions must use EntityPartitionSource — no M/Power Query
A named expression pointing to the Lakehouse/Warehouse must be defined before tables:
expression DL_Lakehouse =
let
Source = AzureStorage.DataLake("https://onelake.dfs.fabric.microsoft.com/<WorkspaceId>/<LakehouseId>", [HierarchicalNavigation=true])
in
Source
Each table partition references the expression:
partition Sales = entity
mode: directLake
source
entityName: Sales
schemaName: dbo
expressionSource: DL_Lakehouse
dataType: binary columns are NOT supported in Direct Lake
Columns map directly via sourceColumn — no transforms
Adding the same source table twice is not supported in Power BI
Desktop or web modeling — XMLA tools can, but Edit tables and
refresh then error. So the role-playing fix above usually has to
happen upstream: add the role table to the lakehouse and bind it.
Calculated tables are preview on Direct Lake on OneLake and unsupported
on Direct Lake on SQL; Direct Lake calculated columns are unmaterialized
and so can't be used in relationships.
Cross-environment rebinding (deployment pipelines): Direct Lake on OneLake does not
support data source rules — the dropdowns are simply greyed out. Only the Direct Lake
overview limitations table
says so; the create-rules page doesn't. Instead, declare the workspace/lakehouse GUIDs as
IsParameterQuery Text expressions and build the URL from them — concatenation works
(AzureStorage.DataLake("https://onelake.dfs.fabric.microsoft.com/" & WorkspaceId & "/" & LakehouseId, ...))
— then rebind each target stage with parameter rules (verified live 2026-08-24).
Gotchas
| Issue |
Cause |
Fix |
InvalidLineType: Property! in database.tmdl |
Bare compatibilityLevel: without database declaration |
Start the file with database <Name> on line 1 |
Import from JSON supported for V3 models only |
Missing defaultPowerBIDataSourceVersion |
Add powerBI_V3 to model.tmdl |
| Spaces-for-tabs validation errors |
Editor converted tabs |
Force literal tabs; configure editor not to expand |
// comment ignored or invalid |
Not supported |
Use /// on line above the object (descriptions only) |
| Measure has wrong inferred type |
dataType was set manually |
Remove dataType from measures — always inferred |
Missing formatString errors |
Measure without formatString |
Always set per measure; use formatStringDefinition for dynamic |
| Calc item format ignored |
Used formatString instead of formatStringDefinition |
formatStringDefinition is DAX-based; only it overrides the selected measure's format |
| Broken report binding after column rename |
Stale lineageTag left in place |
Never edit lineageTag; let Power BI regenerate only on creation |
| Role members ignored |
Authored member statically |
Assign via Power BI REST API (POST datasets/{id}/users) |
INFO.ROLES() returns stale/missing data |
Known DAX surface unreliability |
Query membership via REST API |
| Calendar name collision |
Name unique per-table but not per-model |
Calendar names must be globally unique across the model |
| Direct Lake partition errors |
binary column in source |
Cast away in upstream Lakehouse/Warehouse; drop the column |
...transformations that can't be used for DirectQuery refreshing a parameterized Direct Lake model |
Model-page ribbon schema-and-data refresh re-evaluates the M; fires on any parameterized source shape |
False alarm — data-only, workspace-page, scheduled, and pipeline refreshes just reframe and work (observed 2026-08-24, undocumented) |
| Perspective appears empty in Power BI |
No perspectiveTable children |
Add at least one table + column/measure, or includeAll on a table |
model.bim and definition/ both present |
Forgot to delete .bim after TMDL conversion |
Remove model.bim; they are mutually exclusive |
| TMDL conversion fails |
Old Microsoft.AnalysisServices.retail.amd64 |
Upgrade NuGet package for TmdlSerializer |
| Hierarchy level references missing column |
Column removed or renamed without updating level |
level.column: must reference an existing same-table column |
PBI_* annotation edits revert |
Power BI rewrites on save |
Do not hand-author PBI internal annotations |
Additional reference
- Microsoft Learn: TMDL language overview
- Microsoft Learn: TMDL view in Power BI Desktop
- Microsoft Learn: Power BI Desktop project semantic model folder (PBIP)
- Companion references/REFERENCE.md: per-object property tables (
database, model, table, column, measure, relationship, hierarchy, partition, calculationGroup, role, perspective, cultureInfo, expression, function, dataSource, refreshPolicy, calendar, queryGroup), BIM ↔ TMDL conversion procedure, enum value lists, and a comprehensive MS Learn link bundle (TMDL syntax / TMDL view / PBIP folder / calculation groups / Direct Lake / DAX / TMSL / TOM).
1---2name: fabric-tmdl3description: TMDL (Tabular Model Definition Language) authoring rules for Fabric and Power BI semantic models. Use when editing .tmdl files, adding measures or columns to a semantic model, defining relationships or calculation groups, working in a PBIP definition/ folder, configuring Direct Lake partitions, or debugging TMDL validation errors. Covers syntax (tabs not spaces, /// descriptions, single-quoting names), DAX measure patterns, row-level security roles, calendar groups, and common gotchas.4---56## TMDL Authoring Rules78### Syntax Rules (MUST follow)910- **TMDL uses tab indentation** — every nesting level is exactly one tab (`\t`), NOT spaces. Spaces cause validation errors.11 - PowerShell: use `` `t ``12 - Bash: use `$'\t'` or literal tabs13- Objects declared by type + name: `table Customer`, `column ProductId`, `measure 'Total Sales'`14- Names with spaces or special chars (`.`, `=`, `:`, `'`) must be in **single quotes**: `column 'Order Date'`15- Descriptions use `///` placed ABOVE the object — do NOT use the `description` property16- `//` comments are **NOT supported** in TMDL17- Do NOT add `lineageTag` on new objects — it is auto-generated18- Multi-line DAX must be enclosed in triple backticks (` ``` `)19- Place **measures before columns** in table definitions20- `formatString` is required on every measure21- Never set `dataType` on measures — it is inferred from DAX2223### Naming Conventions2425- **Tables**: business-friendly, no `Fact`/`Dim` prefixes. Plural for facts (`Sales`), singular for dimensions (`Product`)26- **Columns**: readable with spaces (`Order Date`, `Unit Price`)27- **Measures**: clear patterns (`Total Sales`, `# Customers`). Time intelligence: `[measure]`, `[measure (ly)]`, `[measure (ytd)]`)2829### Column Rules3031| Property | Rule |32|---|---|33| `dataType` | Required. Use `int64`, `decimal`, `string`, `dateTime`, `boolean`. Avoid `double` |34| `sourceColumn` | Must match partition source column name exactly |35| `isHidden` | Set for ID columns, foreign keys, system columns |36| `summarizeBy` | `none` for non-aggregatable numerics (IDs, postal codes, year numbers) |37| `isAvailableInMdx` | `false` for hidden columns not used in sort-by or hierarchies |38| `sortByColumn` | For text needing non-alphabetical sort (month names → month number) |3940### Measure & DAX Rules4142- Always set `formatString` — Currency: `$#,##0.00` | Percentage: `0.00%` | Integer: `#,##0` | Decimal: `#,##0.00`43- Use `DIVIDE()` instead of `/` for safe division44- **Never** use `IFERROR` — causes performance degradation45- Prefix `VAR` names with `_`: `VAR _totalSales = ...`46- Use `displayFolder` to organize measures into logical groups47- Add `///` descriptions to explain business logic4849### Relationship Rules5051- `fromColumn:` = many-side (fact); `toColumn:` = one-side (dimension)52- Create relationships BEFORE measures that depend on them53- Default: `crossFilteringBehavior: oneDirection`; add `bothDirections` only when needed54- **Role-playing dimensions: duplicate the table by default** — `Date`,55 `Ship Date`, `Delivery Date`, each with one active relationship.56 Microsoft recommends "defining active relationships whenever possible",57 which "means that role-playing dimension tables should be duplicated in58 your model"; the cost is model size, "rarely a concern" for dimensions.59 `isActive: false` + `USERELATIONSHIP()` is the *conditional* case —60 only when no visual needs two roles at once and you write the measures.61 In Direct Lake, confirm duplication is available first (see below).62- Both sides must have matching `dataType`63- Set `isKey: true` on dimension primary key columns64- Hide foreign keys on fact tables (`isHidden: true`)65- No composite keys — use a single surrogate integer key6667### Calculation Groups6869```tmdl70table 'Time Intelligence'71 calculationGroup72 calculationItem Current = SELECTEDMEASURE()73 calculationItem YTD = CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))74 column 'Time Intelligence'75 dataType: string76 partition 'Partition_Time Intelligence' = calculationGroup77```7879- `calculationGroup` keyword has NO name — just the keyword indented under the table80- Partition type must be `= calculationGroup` (not `= m` or `= calculated`)81- Use `formatStringDefinition` (not `formatString`) for calc items that override measure format8283### Security Roles8485```tmdl86role RegionalManager87 modelPermission: read88 tablePermission Sales = [Region] = "East"89```9091- `modelPermission:` required — use `read` or `readRefresh`92- Assign users via Power BI REST API, not TMDL: `POST .../datasets/{id}/users` with `roles` array93- Do NOT use `INFO.ROLES()` / `INFO.ROLEMEMBERSHIPS()` via DAX — unreliable. Use the REST API.9495### Annotations9697- Do NOT add `PBI_*` annotations manually — they are Power BI internal metadata98- Custom annotations are fine for documentation/tooling99- Syntax: blank line before the first annotation; blank line between annotations; same indent as peer properties100101```tmdl102column 'Product Name'103 dataType: string104 sourceColumn: Product Name105106 annotation MyTool_Owner = analytics-team107```108109110---111112113## model.tmdl Required Properties114115`model.tmdl` is the root of the `definition/` folder alongside `database.tmdl`, `expressions.tmdl`, `functions.tmdl`, `relationships.tmdl`, `roles/`, `perspectives/`, `cultures/`, and `tables/`.116117```tmdl118model Model119 culture: en-US120 defaultPowerBIDataSourceVersion: powerBI_V3121 discourageImplicitMeasures122 sourceQueryCulture: en-US123 dataAccessOptions124 legacyRedirects125 returnErrorValuesAsNull126```127128`defaultPowerBIDataSourceVersion: powerBI_V3` is required for Import-mode models — without it, `Import from JSON supported for V3 models only`.129130131---132133134## Direct Lake Configuration135136- ALL partitions must use `EntityPartitionSource` — no M/Power Query137- A named expression pointing to the Lakehouse/Warehouse must be defined before tables:138139 ```tmdl140 expression DL_Lakehouse =141 let142 Source = AzureStorage.DataLake("https://onelake.dfs.fabric.microsoft.com/<WorkspaceId>/<LakehouseId>", [HierarchicalNavigation=true])143 in144 Source145 ```146147- Each table partition references the expression:148149 ```tmdl150 partition Sales = entity151 mode: directLake152 source153 entityName: Sales154 schemaName: dbo155 expressionSource: DL_Lakehouse156 ```157158- `dataType: binary` columns are NOT supported in Direct Lake159- Columns map directly via `sourceColumn` — no transforms160- **Adding the same source table twice is not supported** in Power BI161 Desktop or web modeling — XMLA tools can, but **Edit tables** and162 **refresh** then error. So the role-playing fix above usually has to163 happen upstream: add the role table to the lakehouse and bind it.164 Calculated tables are preview on Direct Lake on OneLake and unsupported165 on Direct Lake on SQL; Direct Lake calculated columns are unmaterialized166 and so [can't be used in relationships](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns).167- **Cross-environment rebinding (deployment pipelines):** Direct Lake on OneLake does **not**168 support data source rules — the dropdowns are simply greyed out. Only the [Direct Lake169 overview limitations table](https://learn.microsoft.com/fabric/fundamentals/direct-lake-overview#considerations-and-limitations)170 says so; the create-rules page doesn't. Instead, declare the workspace/lakehouse GUIDs as171 `IsParameterQuery` Text expressions and build the URL from them — concatenation works172 (`AzureStorage.DataLake("https://onelake.dfs.fabric.microsoft.com/" & WorkspaceId & "/" & LakehouseId, ...)`)173 — then rebind each target stage with **parameter rules** (verified live 2026-08-24).174175176---177178179## Gotchas180181| Issue | Cause | Fix |182|---|---|---|183| `InvalidLineType: Property!` in `database.tmdl` | Bare `compatibilityLevel:` without `database` declaration | Start the file with `database <Name>` on line 1 |184| `Import from JSON supported for V3 models only` | Missing `defaultPowerBIDataSourceVersion` | Add `powerBI_V3` to `model.tmdl` |185| Spaces-for-tabs validation errors | Editor converted tabs | Force literal tabs; configure editor not to expand |186| `//` comment ignored or invalid | Not supported | Use `///` on line above the object (descriptions only) |187| Measure has wrong inferred type | `dataType` was set manually | Remove `dataType` from measures — always inferred |188| Missing `formatString` errors | Measure without `formatString` | Always set per measure; use `formatStringDefinition` for dynamic |189| Calc item format ignored | Used `formatString` instead of `formatStringDefinition` | `formatStringDefinition` is DAX-based; only it overrides the selected measure's format |190| Broken report binding after column rename | Stale `lineageTag` left in place | Never edit `lineageTag`; let Power BI regenerate only on creation |191| Role members ignored | Authored `member` statically | Assign via Power BI REST API (`POST datasets/{id}/users`) |192| `INFO.ROLES()` returns stale/missing data | Known DAX surface unreliability | Query membership via REST API |193| Calendar name collision | Name unique per-table but not per-model | Calendar names must be globally unique across the model |194| Direct Lake partition errors | `binary` column in source | Cast away in upstream Lakehouse/Warehouse; drop the column |195| `...transformations that can't be used for DirectQuery` refreshing a parameterized Direct Lake model | Model-page ribbon **schema-and-data** refresh re-evaluates the M; fires on any parameterized source shape | False alarm — data-only, workspace-page, scheduled, and pipeline refreshes just reframe and work (observed 2026-08-24, undocumented) |196| Perspective appears empty in Power BI | No `perspectiveTable` children | Add at least one table + column/measure, or `includeAll` on a table |197| `model.bim` and `definition/` both present | Forgot to delete `.bim` after TMDL conversion | Remove `model.bim`; they are mutually exclusive |198| TMDL conversion fails | Old `Microsoft.AnalysisServices.retail.amd64` | Upgrade NuGet package for `TmdlSerializer` |199| Hierarchy level references missing column | Column removed or renamed without updating level | `level.column:` must reference an existing same-table column |200| `PBI_*` annotation edits revert | Power BI rewrites on save | Do not hand-author PBI internal annotations |201202203---204205## Additional reference206207- Microsoft Learn: [TMDL language overview](https://learn.microsoft.com/analysis-services/tmdl/tmdl-overview)208- Microsoft Learn: [TMDL view in Power BI Desktop](https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view)209- Microsoft Learn: [Power BI Desktop project semantic model folder (PBIP)](https://learn.microsoft.com/power-bi/developer/projects/projects-dataset)210- Companion [references/REFERENCE.md](references/REFERENCE.md): per-object property tables (`database`, `model`, `table`, `column`, `measure`, `relationship`, `hierarchy`, `partition`, `calculationGroup`, `role`, `perspective`, `cultureInfo`, `expression`, `function`, `dataSource`, `refreshPolicy`, `calendar`, `queryGroup`), BIM ↔ TMDL conversion procedure, enum value lists, and a comprehensive MS Learn link bundle (TMDL syntax / TMDL view / PBIP folder / calculation groups / Direct Lake / DAX / TMSL / TOM).