TMDL Authoring
Expert guidance for authoring and editing TMDL (Tabular Model Definition Language) files directly in PBIP projects.
This skill is a last resort. Direct TMDL file editing lacks the validation, atomicity, and DAX query capabilities of the Tabular Editor CLI, Power BI MCP server, or the connect-pbid skill (TOM via PowerShell). Use those tools when available. TMDL editing is appropriate when:
- Working with PBIP files in a Git repo without Power BI Desktop open
- No Tabular Editor CLI or MCP server is installed
- Making quick text-level fixes (descriptions, format strings, display folders) where a full tool chain is overkill
Direct TMDL editing does not validate DAX syntax, check referential integrity, or verify that property values are valid. Errors will only surface when the model is next loaded in Power BI Desktop or deployed via XMLA. The pbip-validator agent can review TMDL before a Desktop open, but it inspects the files by reading them: its deterministic validators do not parse TMDL. Treat its report as a review, not a parse. For the checks that do settle a TMDL change, and for the failure modes that pass every cheap check, see references/authoring-gotchas.md.
When to Use This Skill
Activate only when the Tabular Editor CLI, Power BI MCP server, or connect-pbid skill are not available, and tasks involve:
- Editing
.tmdl files directly (measures, columns, tables, relationships)
- Adding or modifying measure definitions in TMDL
- Adding descriptions to columns, measures, or tables
- Fixing
summarizeBy or formatString values
- Understanding TMDL syntax rules (indentation, quoting, property ordering)
- Writing multi-line DAX in TMDL format
- Understanding the difference between
/// descriptions and // comments
Critical
/// (triple-slash) sets the Description property on the object that immediately follows it. A /// line must be immediately followed by a declaration (measure, column, table, etc.), at the same indent as that declaration, and never by a blank line or another ///. // is not a TMDL line at all: keep it inside a DAX or M expression body (references/authoring-gotchas.md).
- Indentation is semantic. TMDL uses whitespace indentation where depth equals nesting level (TMDL spec: Indentation). PBIP files use a single tab per level because Power BI Desktop and the TOM
TmdlSerializer default to IndentationMode.Tabs. Spaces are also valid (IndentationMode.Spaces, default 4 per level), but be consistent within a file. Mixed or incorrect indentation breaks the model, and some depth mistakes corrupt it silently rather than failing the parse (see references/authoring-gotchas.md). Properties of a table are indented one level; properties of a column (which belongs to a table) are indented two levels.
- Name quoting rules: Only quote names that contain spaces, special characters, or start with a digit. Simple names and underscore-prefixed names are unquoted. See the Name Quoting section for details.
- A calculation group requires
discourageImplicitMeasures on the model. Add the flag to the model object in model.tmdl in the same edit that adds the calculation group. Without it Power BI Desktop refuses to load the whole project, not just the calculation group. The flag also turns implicit aggregation off model-wide, so any report visual bound to a bare column projection needs an explicit measure.
- Some TMDL mistakes parse clean and corrupt the model silently. A measure expression written at the depth of its properties deletes the format string; a generator that slices by line position duplicates half a table. Read
references/authoring-gotchas.md before hand-authoring or generating TMDL, and again when a project will not open.
- M expressions and tables share a namespace. A name declared by
expression <name> in expressions.tmdl and a name declared by table <name> in tables/*.tmdl collide; Power BI Desktop fails the load with 'duplicate member <name>'. Pick distinct names; the conventional fix is to suffix the M expression with Query or Source and have partitions reference it via source = #"<Name> Query". validate_pbip.py enforces this as an ERROR.
TMDL File Types
| File |
Contents |
Location |
model.tmdl |
Model configuration, ref table entries, query groups, annotations |
definition/ |
database.tmdl |
Compatibility level, model ID |
definition/ |
relationships.tmdl |
All relationships between tables |
definition/ |
expressions.tmdl |
Shared M expressions and parameters |
definition/ |
functions.tmdl |
DAX user-defined functions (reusable parameterized DAX); requires compatibilityLevel: 1702 in database.tmdl and Power BI Desktop 26.06+, see references/authoring-gotchas.md |
definition/ |
roles/<RoleName>.tmdl |
One file per security role (RLS filters, role members, OLS) |
definition/roles/ |
perspectives/<Name>.tmdl |
One file per perspective (object membership) |
definition/perspectives/ |
dataSources.tmdl |
Legacy data source definitions (if present) |
definition/ |
tables/<Name>.tmdl |
Table definition with columns, measures, hierarchies, partitions |
definition/tables/ |
cultures/<locale>.tmdl |
Linguistic metadata and translations |
definition/cultures/ |
Object Nesting Rules
Objects must be nested inside their correct parent. The validator enforces these rules:
| Object |
Allowed Parent(s) |
column, measure, hierarchy, partition, calculationGroup |
table |
level |
hierarchy |
calculationItem |
calculationGroup |
tablePermission |
role |
columnPermission |
tablePermission |
perspectiveTable |
perspective |
perspectiveColumn, perspectiveMeasure, perspectiveHierarchy |
perspectiveTable |
linguisticMetadata, translation |
cultureInfo |
dataAccessOptions |
model |
formatStringDefinition |
measure, calculationItem |
detailRowsDefinition |
measure, table |
alternateOf |
column |
member |
role |
annotation, extendedProperty |
any object (including queryGroup, function, member) |
ref |
model, table |
Root-level objects (indent 0 only): model, database, table, relationship, role, cultureInfo, perspective, dataSource, expression, queryGroup, function.
Syntax Rules
Indentation
TMDL uses whitespace indentation where depth equals nesting level. PBIP files use a single tab per level (the TOM TmdlSerializer default), so all examples below use tabs:
table Product
lineageTag: abc-123
measure '# Products' =
COUNTROWS (
VALUES ( Product[Name] )
)
formatString: #,##0
displayFolder: Measures
lineageTag: def-456
column 'Product Name'
dataType: string
lineageTag: ghi-789
summarizeBy: none
sourceColumn: Product Name
annotation SummarizationSetBy = Automatic
Depth of each line above. The depths are listed here rather than annotated in the snippet because
TMDL has no comment line of its own: a trailing // note on a declaration line fails the parse with
Unexpected line type: Other! (see references/authoring-gotchas.md).
| Line |
Depth |
What it is |
table Product |
0 |
top-level declaration |
lineageTag: abc-123 |
1 |
table property |
measure '# Products' = |
1 |
measure declaration |
COUNTROWS ( through ) |
3 |
DAX expression body, one level deeper than the measure's properties |
formatString, displayFolder, lineageTag |
2 |
measure properties |
column 'Product Name' |
1 |
column declaration |
dataType, lineageTag, summarizeBy, sourceColumn |
2 |
column properties |
annotation SummarizationSetBy = Automatic |
2 |
column annotation |
Key rules:
- Use tabs in PBIP files (Power BI Desktop's default); see the Critical section above for the spaces alternative
- Table-level objects (columns, measures, hierarchies, partitions) are at depth 1
- Properties of those objects are at depth 2
- Multi-line DAX expression bodies are always 2 levels deeper than the enclosing declaration (depth 3 for measures/columns inside a table; depth 2 for top-level functions; depth 4 for calculationItems)
- Annotations are at the same depth as properties of their parent object, separated by a blank line
Descriptions (///)
Triple-slash sets the Description property on the next declaration. This is native TMDL syntax (not a Tabular Editor extension); the TMDL spec treats /// as first-class description support.
The snippets below are fragments inside a table block, shown at real depth: the /// line and the declaration at one tab, properties at two, the DAX body at three.
/// Count of distinct products in the current filter context.
measure '# Products' =
COUNTROWS ( VALUES ( Product[Product Name] ) )
formatString: #,##0
lineageTag: abc-123
Rules:
/// must be immediately followed by a declaration on the next line
/// carries the same indent as the declaration it describes; a /// at column 0 above a tab-indented measure fails the parse and the error names the measure line
- No blank line between
/// and the declaration
- Multiple
/// lines concatenate into a single description
/// applies to the next measure, column, table, hierarchy, or level
Common mistakes. Wrong, a blank line between the /// and the declaration (Unexpected line type: Empty!):
/// This is a description.
measure 'My Measure' = 1
Wrong, /// used as a standalone separator:
///
measure 'My Measure' = 1
Right, /// directly above the declaration and at the same indent:
/// This is a description.
measure 'My Measure' = 1
Do not answer Unexpected line type: Empty! by stripping blank lines wholesale. Blank lines between
sibling objects are canonical and Power BI Desktop emits them on save; only two placements break. See
references/authoring-gotchas.md, ## Blank lines: only two spots break, for the triage.
Comments (//)
// is not a TMDL construct. It is the comment syntax of the embedded expression languages, DAX and M,
so it is safe inside an expression body and nowhere else, and it travels with the expression text:
measure 'My Measure' =
// a DAX comment, stored as part of the expression
1
A // line of its own between TMDL declarations is a different thing, and the TOM deserializer rejects
it with Unexpected line type: Other! at every indent, column 0 included. A // trailing a declaration
fails the same way. Use /// for a description and keep commentary inside the expression body.
references/authoring-gotchas.md carries the full placement table and the retest.
Property Ordering
Properties should follow a consistent order, though TMDL is not strict about it. The conventional order is:
For columns: dataType, isHidden, isKey, displayFolder, lineageTag, summarizeBy, isNameInferred, sourceColumn, sortByColumn, then annotations.
For measures: DAX expression (on the = line or multi-line), formatString or formatStringDefinition, displayFolder, lineageTag, then annotations.
Name Quoting
When to Quote
Use single quotes around names that contain any of these characters:
- Spaces:
'Product Name'
- Dots:
'Sales.Amount'
- Equals:
'Price = Target'
- Colons:
'Date:Key'
- Single quotes (escape by doubling):
'Customer''s Name'
- Other special characters:
'Sales ($)', 'OTD % (Value)', '1) Selected Metric'
- Names starting with a digit:
'4) Selected Period'
When NOT to Quote
Do not quote names that are simple identifiers:
Product (simple word)
_Measures (underscore prefix, no spaces)
Date (simple word)
CgMetricQuantity (PascalCase, no spaces)
Examples
Annotated in a table rather than in a snippet, because a trailing // note on a declaration line fails
the parse with Unexpected line type: Other! (see Comments above).
| Declaration |
Why |
table Product |
unquoted: simple name |
table _Measures |
unquoted: underscore prefix |
table 'Budget Rate' |
quoted: contains a space |
table 'Invoice Document Type' |
quoted: contains spaces |
table '1) Selected Metric' |
quoted: starts with a digit |
table 'On-Time Delivery' |
quoted: contains a space |
Column Definitions
For complete column examples (basic, hidden, key, sortByColumn, description), see references/tmdl-file-examples.md. For full property reference, see references/column-properties.md.
Key column pattern (a fragment inside a table block):
column 'Product Name'
dataType: string
displayFolder: 1. Product Hierarchy
lineageTag: abc-123
summarizeBy: none
sourceColumn: Product Name
annotation SummarizationSetBy = Automatic
Measure Definitions
The examples below are fragments inside a table block: the measure declaration at one tab, its properties at two, a multi-line DAX body at three, a formatStringDefinition body at four.
Single-Line DAX
measure '# Products' = COUNTROWS ( VALUES ( Product[Product Name] ) )
formatString: #,##0
displayFolder: Measures
lineageTag: abc-123
Multi-Line DAX
Two syntaxes for multi-line DAX:
1. Indented block (most common) -- expression body indented two levels deeper than the declaration:
2. Triple-backtick block -- DAX enclosed in ``` fences, useful for expressions with complex indentation:
measure Percentage = ```
VAR _Total = CALCULATE( SUM ( 'Table'[Quantitative] ), REMOVEFILTERS ( ) )
RETURN
DIVIDE ( SUM ( 'Table'[Quantitative] ), _Total )
```
formatString: 0.0%;-0.0%;0.0%
lineageTag: abc-123
Indented block syntax (standard approach) -- indented two extra tabs from the measure's parent (table) level:
measure 'Actuals MTD' =
CALCULATE (
[Actuals],
CALCULATETABLE (
DATESMTD ( 'Date'[Date] ),
'Date'[IsDateInScope]
)
)
formatString: #,##0
displayFolder: 2. MTD\Actuals
lineageTag: abc-123
Measure with Description
/// Number of workdays elapsed month-to-date, considering only dates in scope.
measure '# Workdays MTD' =
CALCULATE(
MAX( 'Date'[Workdays MTD] ),
'Date'[IsDateInScope] = TRUE
)
formatString: #,##0
displayFolder: 5. Weekday / Workday\Measures\# Workdays
lineageTag: abc-123
Measure with formatStringDefinition (Dynamic Format)
measure 'Sales Target MTD vs. Actuals (%)' =
Comparison.RelativeToTarget (
[Actuals MTD],
[Sales Target MTD]
)
displayFolder: 2. MTD\Sales Target
lineageTag: abc-123
formatStringDefinition =
FormatString.Comparison.RelativeToTarget (
"SUFFIX",
1,
"ARROWS",
"",
""
)
A measure may not carry both formatString and formatStringDefinition. Power BI Desktop refuses the whole project with not supported scenario when it finds both. formatStringDefinition is a child object holding the DAX that computes the format (often a calculation group format function): when you add it, delete the static formatString: in the same edit, and when you revert to a static format, delete the formatStringDefinition. Placement and the three valid body shapes are in references/authoring-gotchas.md.
Other Object Types
For complete examples of calculated columns, roles (RLS/OLS), calculation groups, date table marking, hierarchies, partitions, relationships, shared expressions, and model configuration, see references/tmdl-file-examples.md. A calculation group also needs discourageImplicitMeasures on the model object in model.tmdl, in the same edit; without it Power BI Desktop refuses to load the whole project (references/authoring-gotchas.md).
Common Data Quality Patterns
summarizeBy Rules
| Column Type |
Correct summarizeBy |
Reason |
| Keys (surrogate/natural) |
none |
Keys are never aggregated |
| Attributes (names, codes, types) |
none |
Text attributes are never summed |
| Dates |
none |
Dates are never summed |
| Boolean flags |
none |
Flags are never summed |
| Additive numeric facts (amounts, quantities) |
sum |
Default aggregation is SUM |
| Non-additive numeric facts (rates, percentages) |
none |
Cannot be meaningfully summed |
Common fix pattern, changing summarizeBy: sum to summarizeBy: none for key columns:
Before, wrong, a key column set to sum:
column 'Customer Key'
dataType: int64
isHidden
lineageTag: abc-123
summarizeBy: sum
sourceColumn: Customer Key
After, correct:
column 'Customer Key'
dataType: int64
isHidden
lineageTag: abc-123
summarizeBy: none
sourceColumn: Customer Key
formatString Patterns
| Data Type |
Pattern |
Example |
| Integer |
#,##0 |
1,234 |
| Decimal (2 places) |
#,##0.00 |
1,234.56 |
| Percentage |
#,##0% or 0.00% |
85% or 85.00% |
| Currency |
$#,##0.00 |
$1,234.56 |
| Date |
mm/dd/yyyy or dd/mm/yyyy |
01/15/2024 |
PBI_FormatHint Annotation
Power BI Desktop may add a PBI_FormatHint annotation alongside formatString:
column Amount
dataType: decimal
formatString: #,##0.00
lineageTag: abc-123
summarizeBy: sum
sourceColumn: Amount
annotation SummarizationSetBy = Automatic
annotation PBI_FormatHint = {"isGeneralNumber":true}
Do not fight this annotation. Power BI tooling re-adds it automatically. When setting a formatString, leave any existing PBI_FormatHint in place. If Power BI re-adds a removed PBI_FormatHint, accept it.
Quick Reference
Property Cheat Sheet
For the complete property reference for every object type, see references/object-properties.md.
| Object |
Property |
Values |
Notes |
| Column |
dataType |
string, int64, double, decimal, dateTime, boolean, binary, unknown, variant, automatic |
Required for data columns |
| Column |
summarizeBy |
default, none, sum, min, max, count, average, distinctCount |
Use none for keys/attributes |
| Column |
type |
data, calculated, rowNumber, calculatedTableColumn |
Column type variant |
| Column |
isHidden |
(flag, no value) |
Boolean flags: write the keyword alone on its own line |
| Column |
isKey |
(flag, no value) |
Marks the column as the table's key |
| Column |
isNullable |
(flag, no value) |
Column allows nulls |
| Column |
isUnique |
(flag, no value) |
Column values are unique |
| Column |
isNameInferred |
(flag, no value) |
Name inferred from source |
| Column |
isDefaultLabel |
(flag, no value) |
Default label for the table |
| Column |
isDefaultImage |
(flag, no value) |
Default image for the table |
| Column |
isDataTypeInferred |
(flag, no value) |
Data type inferred from source |
| Column |
isAvailableInMdx |
(flag, no value) |
Available in MDX queries |
| Column |
keepUniqueRows |
(flag, no value) |
Keep unique rows |
| Column |
encodingHint |
default, hash, value |
Storage encoding hint |
| Column |
alignment |
default, left, right, center |
Column alignment |
| Column |
displayFolder |
folder path string |
Use \ for nesting: 1. Year\Quarter |
| Column |
sourceColumn |
source column name |
Must match the Power Query output column |
| Column |
sortByColumn |
column name reference |
Column to sort by (e.g., month name sorted by month number) |
| Column |
expression |
DAX expression |
For calculated columns |
| Measure |
formatString |
format pattern |
e.g., #,##0, 0.00% |
| Measure |
displayFolder |
folder path string |
Use \ for nesting |
| Measure |
formatStringDefinition |
DAX expression block |
Dynamic format string; the measure must not also carry formatString |
| Measure |
isHidden |
(flag, no value) |
Hide the measure |
| Measure |
isSimpleMeasure |
(flag, no value) |
Simple implicit-style measure |
| Measure |
dataCategory |
string |
Semantic data category |
| Partition |
mode |
import, directQuery, default, push, dual, directLake |
Storage mode |
| Partition |
sourceType |
query, calculated, none, m, entity, policyRange, calculationGroup, inferred |
Source type |
| Relationship |
crossFilteringBehavior |
oneDirection, bothDirections, automatic |
Cross-filter direction |
| Relationship |
securityFilteringBehavior |
oneDirection, bothDirections, none |
RLS filter direction |
| Relationship |
fromCardinality / toCardinality |
none, one, many |
Cardinality ends |
| Relationship |
isActive |
(flag, no value) |
Active relationship |
| Role |
modelPermission |
none, read, readRefresh, refresh, administrator |
Role permission level |
| Model |
discourageImplicitMeasures |
(flag, no value) |
Disables implicit measures |
| Model |
defaultPowerBIDataSourceVersion |
powerBI_V1, powerBI_V2, powerBI_V3 |
PBI data source version |
| Model |
directLakeBehavior |
automatic, directLakeOnly, directQueryOnly |
Direct Lake mode |
| All |
lineageTag |
GUID |
Unique identifier, do not change existing values |
Indentation Depth Summary
Rule: a multi-line DAX body is always 2 levels deeper than its enclosing object declaration.
| Context |
Depth |
Tabs |
Top-level declaration (table, relationship, expression) |
0 |
0 |
| Table properties, column/measure/hierarchy declarations |
1 |
1 |
| Column/measure properties, hierarchy levels |
2 |
2 |
| DAX body for measure/column declared at depth 1 (inside table) |
3 |
3 |
| Level properties |
3 |
3 |
DAX body for top-level function declared at depth 0 |
2 |
2 |
calculationItem inside calculationGroup (depth 1) |
2 |
2 |
DAX body for calculationItem at depth 2 |
4 |
4 |
Additional Resources
Reference Files
references/object-properties.md - Complete property reference for all 30+ TMDL object types with valid enum values for every property type (dataType, summarizeBy, modeType, crossFilteringBehavior, etc.)
references/column-properties.md - Column-specific property guide with summarizeBy rules, formatString patterns, PBI_FormatHint behavior
references/naming-conventions.md - SQLBI naming conventions, display folder conventions, measure table conventions, and calculation group naming
references/bim-to-tmdl.md - Converting between model.bim (TMSL) and definition/ (TMDL) via Tabular Editor CLI or TOM TmdlSerializer
references/authoring-gotchas.md - Failure modes a syntax check does not catch: the calculation group flag, the /// indent trap, which blank lines are safe and which two placements break, the expression depth that silently deletes format strings, formatStringDefinition placement, generator idempotency, what offline validation proves, and DAX UDFs at compatibility level 1702. It is also the in-repo home for new TMDL failure modes: its closing ## Adding to this file section sets the shape a new entry takes
references/tmdl-file-examples.md - Complete examples for every TMDL file type (model, database, expressions, relationships, roles, perspectives, tables, cultures) including backtick-enclosed expressions, field parameters, calculation groups, and date tables
Fetching Docs
To retrieve current TMDL reference docs, use microsoft_docs_search + microsoft_docs_fetch (MCP) if available, otherwise mslearn search + mslearn fetch (CLI). Search based on the user's request and run multiple searches as needed to ensure sufficient context before proceeding.
Example Model
examples/SpaceParts.SemanticModel/ -- Complete real-world TMDL model (SpaceParts) with 40 tables, 152 measures, 8 calculation groups, 8 RLS roles, 2 perspectives, DAX UDFs (functions.tmdl), shared M expressions, relationships, and cultures. Covers every TMDL file type. Key files to study:
definition/functions.tmdl -- DAX user-defined functions with parameters, types, and multi-line expressions. The model sets compatibilityLevel: 1702 for these; an older AMO/TOM assembly throws UnsupportedObjectType on function when reading it (references/authoring-gotchas.md)
definition/tables/Z04CG1 - Time Intelligence.tmdl -- Calculation group with triple-backtick DAX
definition/tables/__Measures.tmdl -- Measures table with calculation group references
definition/tables/Invoices.tmdl -- Large fact table (51 measures, 18 columns)
definition/tables/Date.tmdl -- Calculated date table with 42 columns
definition/roles/Account Managers.tmdl -- RLS role with DAX filter expression
definition/relationships.tmdl -- 27 relationships including inactive
definition/expressions.tmdl -- Shared M/Power Query expressions and parameters
definition/perspectives/Measure Selection.tmdl -- Perspective definition
External References
1---2name: tmdl3description: Direct TMDL file authoring and BIM-to-TMDL conversion for semantic models in PBIP projects. Automatically invoke when the user asks to "edit TMDL", "add a measure in TMDL", "TMDL syntax", "fix formatString", "fix summarizeBy", "TMDL indentation", "convert BIM to TMDL", "add a column description", "create a calculated column in TMDL", or mentions .tmdl file editing or BIM-to-TMDL migration.4---56# TMDL Authoring78Expert guidance for authoring and editing TMDL (Tabular Model Definition Language) files directly in PBIP projects.910> **This skill is a last resort.** Direct TMDL file editing lacks the validation, atomicity, and DAX query capabilities of the Tabular Editor CLI, Power BI MCP server, or the `connect-pbid` skill (TOM via PowerShell). Use those tools when available. TMDL editing is appropriate when:11>12> - Working with PBIP files in a Git repo without Power BI Desktop open13> - No Tabular Editor CLI or MCP server is installed14> - Making quick text-level fixes (descriptions, format strings, display folders) where a full tool chain is overkill15>16> Direct TMDL editing does not validate DAX syntax, check referential integrity, or verify that property values are valid. Errors will only surface when the model is next loaded in Power BI Desktop or deployed via XMLA. The **`pbip-validator`** agent can review TMDL before a Desktop open, but it inspects the files by reading them: its deterministic validators do not parse TMDL. Treat its report as a review, not a parse. For the checks that do settle a TMDL change, and for the failure modes that pass every cheap check, see **`references/authoring-gotchas.md`**.1718## When to Use This Skill1920Activate only when the Tabular Editor CLI, Power BI MCP server, or `connect-pbid` skill are not available, and tasks involve:2122- Editing `.tmdl` files directly (measures, columns, tables, relationships)23- Adding or modifying measure definitions in TMDL24- Adding descriptions to columns, measures, or tables25- Fixing `summarizeBy` or `formatString` values26- Understanding TMDL syntax rules (indentation, quoting, property ordering)27- Writing multi-line DAX in TMDL format28- Understanding the difference between `///` descriptions and `//` comments2930## Critical3132- **`///` (triple-slash) sets the `Description` property** on the object that immediately follows it. A `///` line must be immediately followed by a declaration (`measure`, `column`, `table`, etc.), at the same indent as that declaration, and never by a blank line or another `///`. `//` is not a TMDL line at all: keep it inside a DAX or M expression body (`references/authoring-gotchas.md`).33- **Indentation is semantic.** TMDL uses whitespace indentation where depth equals nesting level ([TMDL spec: Indentation](https://learn.microsoft.com/en-us/analysis-services/tmdl/tmdl-overview#indentation)). PBIP files use a single tab per level because Power BI Desktop and the TOM `TmdlSerializer` default to `IndentationMode.Tabs`. Spaces are also valid (`IndentationMode.Spaces`, default 4 per level), but be consistent within a file. Mixed or incorrect indentation breaks the model, and some depth mistakes corrupt it silently rather than failing the parse (see `references/authoring-gotchas.md`). Properties of a table are indented one level; properties of a column (which belongs to a table) are indented two levels.34- **Name quoting rules:** Only quote names that contain spaces, special characters, or start with a digit. Simple names and underscore-prefixed names are unquoted. See the Name Quoting section for details.35- **A calculation group requires `discourageImplicitMeasures` on the model.** Add the flag to the `model` object in `model.tmdl` in the same edit that adds the calculation group. Without it Power BI Desktop refuses to load the **whole project**, not just the calculation group. The flag also turns implicit aggregation off model-wide, so any report visual bound to a bare column projection needs an explicit measure.36- **Some TMDL mistakes parse clean and corrupt the model silently.** A measure expression written at the depth of its properties deletes the format string; a generator that slices by line position duplicates half a table. Read **`references/authoring-gotchas.md`** before hand-authoring or generating TMDL, and again when a project will not open.37- **M expressions and tables share a namespace.** A name declared by `expression <name>` in `expressions.tmdl` and a name declared by `table <name>` in `tables/*.tmdl` collide; Power BI Desktop fails the load with `'duplicate member <name>'`. Pick distinct names; the conventional fix is to suffix the M expression with ` Query` or ` Source` and have partitions reference it via `source = #"<Name> Query"`. `validate_pbip.py` enforces this as an ERROR.3839## TMDL File Types4041| File | Contents | Location |42|------|----------|----------|43| `model.tmdl` | Model configuration, `ref table` entries, query groups, annotations | `definition/` |44| `database.tmdl` | Compatibility level, model ID | `definition/` |45| `relationships.tmdl` | All relationships between tables | `definition/` |46| `expressions.tmdl` | Shared M expressions and parameters | `definition/` |47| `functions.tmdl` | DAX user-defined functions (reusable parameterized DAX); requires `compatibilityLevel: 1702` in `database.tmdl` and Power BI Desktop 26.06+, see `references/authoring-gotchas.md` | `definition/` |48| `roles/<RoleName>.tmdl` | One file per security role (RLS filters, role members, OLS) | `definition/roles/` |49| `perspectives/<Name>.tmdl` | One file per perspective (object membership) | `definition/perspectives/` |50| `dataSources.tmdl` | Legacy data source definitions (if present) | `definition/` |51| `tables/<Name>.tmdl` | Table definition with columns, measures, hierarchies, partitions | `definition/tables/` |52| `cultures/<locale>.tmdl` | Linguistic metadata and translations | `definition/cultures/` |5354## Object Nesting Rules5556Objects must be nested inside their correct parent. The validator enforces these rules:5758| Object | Allowed Parent(s) |59|--------|-------------------|60| `column`, `measure`, `hierarchy`, `partition`, `calculationGroup` | `table` |61| `level` | `hierarchy` |62| `calculationItem` | `calculationGroup` |63| `tablePermission` | `role` |64| `columnPermission` | `tablePermission` |65| `perspectiveTable` | `perspective` |66| `perspectiveColumn`, `perspectiveMeasure`, `perspectiveHierarchy` | `perspectiveTable` |67| `linguisticMetadata`, `translation` | `cultureInfo` |68| `dataAccessOptions` | `model` |69| `formatStringDefinition` | `measure`, `calculationItem` |70| `detailRowsDefinition` | `measure`, `table` |71| `alternateOf` | `column` |72| `member` | `role` |73| `annotation`, `extendedProperty` | any object (including `queryGroup`, `function`, `member`) |74| `ref` | `model`, `table` |7576Root-level objects (indent 0 only): `model`, `database`, `table`, `relationship`, `role`, `cultureInfo`, `perspective`, `dataSource`, `expression`, `queryGroup`, `function`.7778## Syntax Rules7980### Indentation8182TMDL uses **whitespace indentation** where depth equals nesting level. PBIP files use a single tab per level (the TOM `TmdlSerializer` default), so all examples below use tabs:8384```tmdl85table Product86 lineageTag: abc-1238788 measure '# Products' =89 COUNTROWS (90 VALUES ( Product[Name] )91 )92 formatString: #,##093 displayFolder: Measures94 lineageTag: def-4569596 column 'Product Name'97 dataType: string98 lineageTag: ghi-78999 summarizeBy: none100 sourceColumn: Product Name101102 annotation SummarizationSetBy = Automatic103```104105Depth of each line above. The depths are listed here rather than annotated in the snippet because106TMDL has no comment line of its own: a trailing `// note` on a declaration line fails the parse with107`Unexpected line type: Other!` (see `references/authoring-gotchas.md`).108109| Line | Depth | What it is |110|------|-------|------------|111| `table Product` | 0 | top-level declaration |112| `lineageTag: abc-123` | 1 | table property |113| `measure '# Products' =` | 1 | measure declaration |114| `COUNTROWS (` through `)` | 3 | DAX expression body, one level deeper than the measure's properties |115| `formatString`, `displayFolder`, `lineageTag` | 2 | measure properties |116| `column 'Product Name'` | 1 | column declaration |117| `dataType`, `lineageTag`, `summarizeBy`, `sourceColumn` | 2 | column properties |118| `annotation SummarizationSetBy = Automatic` | 2 | column annotation |119120**Key rules:**121- Use tabs in PBIP files (Power BI Desktop's default); see the Critical section above for the spaces alternative122- Table-level objects (columns, measures, hierarchies, partitions) are at depth 1123- Properties of those objects are at depth 2124- Multi-line DAX expression bodies are always **2 levels deeper than the enclosing declaration** (depth 3 for measures/columns inside a table; depth 2 for top-level functions; depth 4 for calculationItems)125- Annotations are at the same depth as properties of their parent object, separated by a blank line126127### Descriptions (`///`)128129Triple-slash sets the `Description` property on the **next** declaration. This is native TMDL syntax (not a Tabular Editor extension); the TMDL spec treats `///` as first-class description support.130131The snippets below are fragments inside a `table` block, shown at real depth: the `///` line and the declaration at one tab, properties at two, the DAX body at three.132133```tmdl134 /// Count of distinct products in the current filter context.135 measure '# Products' =136 COUNTROWS ( VALUES ( Product[Product Name] ) )137 formatString: #,##0138 lineageTag: abc-123139```140141**Rules:**142- `///` must be immediately followed by a declaration on the next line143- `///` carries the **same indent** as the declaration it describes; a `///` at column 0 above a tab-indented `measure` fails the parse and the error names the `measure` line144- No blank line between `///` and the declaration145- Multiple `///` lines concatenate into a single description146- `///` applies to the next `measure`, `column`, `table`, `hierarchy`, or `level`147148**Common mistakes.** Wrong, a blank line between the `///` and the declaration (`Unexpected line type: Empty!`):149150```tmdl151 /// This is a description.152153 measure 'My Measure' = 1154```155156Wrong, `///` used as a standalone separator:157158```tmdl159 ///160 measure 'My Measure' = 1161```162163Right, `///` directly above the declaration and at the same indent:164165```tmdl166 /// This is a description.167 measure 'My Measure' = 1168```169170Do not answer `Unexpected line type: Empty!` by stripping blank lines wholesale. Blank lines between171sibling objects are canonical and Power BI Desktop emits them on save; only two placements break. See172`references/authoring-gotchas.md`, `## Blank lines: only two spots break`, for the triage.173174### Comments (`//`)175176`//` is not a TMDL construct. It is the comment syntax of the embedded expression languages, DAX and M,177so it is safe **inside an expression body** and nowhere else, and it travels with the expression text:178179```tmdl180 measure 'My Measure' =181 // a DAX comment, stored as part of the expression182 1183```184185A `//` line of its own between TMDL declarations is a different thing, and the TOM deserializer rejects186it with `Unexpected line type: Other!` at every indent, column 0 included. A `//` trailing a declaration187fails the same way. Use `///` for a description and keep commentary inside the expression body.188`references/authoring-gotchas.md` carries the full placement table and the retest.189190### Property Ordering191192Properties should follow a consistent order, though TMDL is not strict about it. The conventional order is:193194**For columns:** `dataType`, `isHidden`, `isKey`, `displayFolder`, `lineageTag`, `summarizeBy`, `isNameInferred`, `sourceColumn`, `sortByColumn`, then annotations.195196**For measures:** DAX expression (on the `=` line or multi-line), `formatString` or `formatStringDefinition`, `displayFolder`, `lineageTag`, then annotations.197198## Name Quoting199200### When to Quote201202Use single quotes around names that contain any of these characters:203- Spaces: `'Product Name'`204- Dots: `'Sales.Amount'`205- Equals: `'Price = Target'`206- Colons: `'Date:Key'`207- Single quotes (escape by doubling): `'Customer''s Name'`208- Other special characters: `'Sales ($)'`, `'OTD % (Value)'`, `'1) Selected Metric'`209- Names starting with a digit: `'4) Selected Period'`210211### When NOT to Quote212213Do not quote names that are simple identifiers:214- `Product` (simple word)215- `_Measures` (underscore prefix, no spaces)216- `Date` (simple word)217- `CgMetricQuantity` (PascalCase, no spaces)218219### Examples220221Annotated in a table rather than in a snippet, because a trailing `// note` on a declaration line fails222the parse with `Unexpected line type: Other!` (see Comments above).223224| Declaration | Why |225|-------------|-----|226| `table Product` | unquoted: simple name |227| `table _Measures` | unquoted: underscore prefix |228| `table 'Budget Rate'` | quoted: contains a space |229| `table 'Invoice Document Type'` | quoted: contains spaces |230| `table '1) Selected Metric'` | quoted: starts with a digit |231| `table 'On-Time Delivery'` | quoted: contains a space |232233## Column Definitions234235For complete column examples (basic, hidden, key, sortByColumn, description), see **`references/tmdl-file-examples.md`**. For full property reference, see **`references/column-properties.md`**.236237Key column pattern (a fragment inside a `table` block):238239```tmdl240 column 'Product Name'241 dataType: string242 displayFolder: 1. Product Hierarchy243 lineageTag: abc-123244 summarizeBy: none245 sourceColumn: Product Name246247 annotation SummarizationSetBy = Automatic248```249250## Measure Definitions251252The examples below are fragments inside a `table` block: the `measure` declaration at one tab, its properties at two, a multi-line DAX body at three, a `formatStringDefinition` body at four.253254### Single-Line DAX255256```tmdl257 measure '# Products' = COUNTROWS ( VALUES ( Product[Product Name] ) )258 formatString: #,##0259 displayFolder: Measures260 lineageTag: abc-123261```262263### Multi-Line DAX264265Two syntaxes for multi-line DAX:266267**1. Indented block** (most common) -- expression body indented two levels deeper than the declaration:268269**2. Triple-backtick block** -- DAX enclosed in `` ``` `` fences, useful for expressions with complex indentation:270271```tmdl272 measure Percentage = ```273 VAR _Total = CALCULATE( SUM ( 'Table'[Quantitative] ), REMOVEFILTERS ( ) )274 RETURN275 DIVIDE ( SUM ( 'Table'[Quantitative] ), _Total )276 ```277 formatString: 0.0%;-0.0%;0.0%278 lineageTag: abc-123279```280281**Indented block syntax** (standard approach) -- indented two extra tabs from the measure's parent (table) level:282283```tmdl284 measure 'Actuals MTD' =285 CALCULATE (286 [Actuals],287 CALCULATETABLE (288 DATESMTD ( 'Date'[Date] ),289 'Date'[IsDateInScope]290 )291 )292 formatString: #,##0293 displayFolder: 2. MTD\Actuals294 lineageTag: abc-123295```296297### Measure with Description298299```tmdl300 /// Number of workdays elapsed month-to-date, considering only dates in scope.301 measure '# Workdays MTD' =302 CALCULATE(303 MAX( 'Date'[Workdays MTD] ),304 'Date'[IsDateInScope] = TRUE305 )306 formatString: #,##0307 displayFolder: 5. Weekday / Workday\Measures\# Workdays308 lineageTag: abc-123309```310311### Measure with formatStringDefinition (Dynamic Format)312313```tmdl314 measure 'Sales Target MTD vs. Actuals (%)' =315 Comparison.RelativeToTarget (316 [Actuals MTD],317 [Sales Target MTD]318 )319 displayFolder: 2. MTD\Sales Target320 lineageTag: abc-123321322 formatStringDefinition =323 FormatString.Comparison.RelativeToTarget (324 "SUFFIX",325 1,326 "ARROWS",327 "",328 ""329 )330```331332**A measure may not carry both `formatString` and `formatStringDefinition`.** Power BI Desktop refuses the whole project with `not supported scenario` when it finds both. `formatStringDefinition` is a child object holding the DAX that computes the format (often a calculation group format function): when you add it, delete the static `formatString:` in the same edit, and when you revert to a static format, delete the `formatStringDefinition`. Placement and the three valid body shapes are in `references/authoring-gotchas.md`.333334## Other Object Types335336For complete examples of calculated columns, roles (RLS/OLS), calculation groups, date table marking, hierarchies, partitions, relationships, shared expressions, and model configuration, see **`references/tmdl-file-examples.md`**. A calculation group also needs `discourageImplicitMeasures` on the `model` object in `model.tmdl`, in the same edit; without it Power BI Desktop refuses to load the whole project (`references/authoring-gotchas.md`).337338339## Common Data Quality Patterns340341### summarizeBy Rules342343| Column Type | Correct `summarizeBy` | Reason |344|-------------|----------------------|--------|345| Keys (surrogate/natural) | `none` | Keys are never aggregated |346| Attributes (names, codes, types) | `none` | Text attributes are never summed |347| Dates | `none` | Dates are never summed |348| Boolean flags | `none` | Flags are never summed |349| Additive numeric facts (amounts, quantities) | `sum` | Default aggregation is SUM |350| Non-additive numeric facts (rates, percentages) | `none` | Cannot be meaningfully summed |351352**Common fix pattern**, changing `summarizeBy: sum` to `summarizeBy: none` for key columns:353354Before, wrong, a key column set to sum:355356```tmdl357 column 'Customer Key'358 dataType: int64359 isHidden360 lineageTag: abc-123361 summarizeBy: sum362 sourceColumn: Customer Key363```364365After, correct:366367```tmdl368 column 'Customer Key'369 dataType: int64370 isHidden371 lineageTag: abc-123372 summarizeBy: none373 sourceColumn: Customer Key374```375376### formatString Patterns377378| Data Type | Pattern | Example |379|-----------|---------|---------|380| Integer | `#,##0` | 1,234 |381| Decimal (2 places) | `#,##0.00` | 1,234.56 |382| Percentage | `#,##0%` or `0.00%` | 85% or 85.00% |383| Currency | `$#,##0.00` | $1,234.56 |384| Date | `mm/dd/yyyy` or `dd/mm/yyyy` | 01/15/2024 |385386### PBI_FormatHint Annotation387388Power BI Desktop may add a `PBI_FormatHint` annotation alongside `formatString`:389390```tmdl391 column Amount392 dataType: decimal393 formatString: #,##0.00394 lineageTag: abc-123395 summarizeBy: sum396 sourceColumn: Amount397398 annotation SummarizationSetBy = Automatic399400 annotation PBI_FormatHint = {"isGeneralNumber":true}401```402403**Do not fight this annotation.** Power BI tooling re-adds it automatically. When setting a `formatString`, leave any existing `PBI_FormatHint` in place. If Power BI re-adds a removed `PBI_FormatHint`, accept it.404405406## Quick Reference407408### Property Cheat Sheet409410For the complete property reference for every object type, see **`references/object-properties.md`**.411412| Object | Property | Values | Notes |413|--------|----------|--------|-------|414| Column | `dataType` | `string`, `int64`, `double`, `decimal`, `dateTime`, `boolean`, `binary`, `unknown`, `variant`, `automatic` | Required for data columns |415| Column | `summarizeBy` | `default`, `none`, `sum`, `min`, `max`, `count`, `average`, `distinctCount` | Use `none` for keys/attributes |416| Column | `type` | `data`, `calculated`, `rowNumber`, `calculatedTableColumn` | Column type variant |417| Column | `isHidden` | (flag, no value) | Boolean flags: write the keyword alone on its own line |418| Column | `isKey` | (flag, no value) | Marks the column as the table's key |419| Column | `isNullable` | (flag, no value) | Column allows nulls |420| Column | `isUnique` | (flag, no value) | Column values are unique |421| Column | `isNameInferred` | (flag, no value) | Name inferred from source |422| Column | `isDefaultLabel` | (flag, no value) | Default label for the table |423| Column | `isDefaultImage` | (flag, no value) | Default image for the table |424| Column | `isDataTypeInferred` | (flag, no value) | Data type inferred from source |425| Column | `isAvailableInMdx` | (flag, no value) | Available in MDX queries |426| Column | `keepUniqueRows` | (flag, no value) | Keep unique rows |427| Column | `encodingHint` | `default`, `hash`, `value` | Storage encoding hint |428| Column | `alignment` | `default`, `left`, `right`, `center` | Column alignment |429| Column | `displayFolder` | folder path string | Use `\` for nesting: `1. Year\Quarter` |430| Column | `sourceColumn` | source column name | Must match the Power Query output column |431| Column | `sortByColumn` | column name reference | Column to sort by (e.g., month name sorted by month number) |432| Column | `expression` | DAX expression | For calculated columns |433| Measure | `formatString` | format pattern | e.g., `#,##0`, `0.00%` |434| Measure | `displayFolder` | folder path string | Use `\` for nesting |435| Measure | `formatStringDefinition` | DAX expression block | Dynamic format string; the measure must not also carry `formatString` |436| Measure | `isHidden` | (flag, no value) | Hide the measure |437| Measure | `isSimpleMeasure` | (flag, no value) | Simple implicit-style measure |438| Measure | `dataCategory` | string | Semantic data category |439| Partition | `mode` | `import`, `directQuery`, `default`, `push`, `dual`, `directLake` | Storage mode |440| Partition | `sourceType` | `query`, `calculated`, `none`, `m`, `entity`, `policyRange`, `calculationGroup`, `inferred` | Source type |441| Relationship | `crossFilteringBehavior` | `oneDirection`, `bothDirections`, `automatic` | Cross-filter direction |442| Relationship | `securityFilteringBehavior` | `oneDirection`, `bothDirections`, `none` | RLS filter direction |443| Relationship | `fromCardinality` / `toCardinality` | `none`, `one`, `many` | Cardinality ends |444| Relationship | `isActive` | (flag, no value) | Active relationship |445| Role | `modelPermission` | `none`, `read`, `readRefresh`, `refresh`, `administrator` | Role permission level |446| Model | `discourageImplicitMeasures` | (flag, no value) | Disables implicit measures |447| Model | `defaultPowerBIDataSourceVersion` | `powerBI_V1`, `powerBI_V2`, `powerBI_V3` | PBI data source version |448| Model | `directLakeBehavior` | `automatic`, `directLakeOnly`, `directQueryOnly` | Direct Lake mode |449| All | `lineageTag` | GUID | Unique identifier, do not change existing values |450451### Indentation Depth Summary452453**Rule: a multi-line DAX body is always 2 levels deeper than its enclosing object declaration.**454455| Context | Depth | Tabs |456|---------|-------|------|457| Top-level declaration (`table`, `relationship`, `expression`) | 0 | 0 |458| Table properties, column/measure/hierarchy declarations | 1 | 1 |459| Column/measure properties, hierarchy levels | 2 | 2 |460| DAX body for measure/column declared at depth 1 (inside table) | 3 | 3 |461| Level properties | 3 | 3 |462| DAX body for top-level `function` declared at depth 0 | 2 | 2 |463| `calculationItem` inside `calculationGroup` (depth 1) | 2 | 2 |464| DAX body for `calculationItem` at depth 2 | 4 | 4 |465466## Additional Resources467468### Reference Files469470- **`references/object-properties.md`** - Complete property reference for all 30+ TMDL object types with valid enum values for every property type (dataType, summarizeBy, modeType, crossFilteringBehavior, etc.)471- **`references/column-properties.md`** - Column-specific property guide with `summarizeBy` rules, `formatString` patterns, `PBI_FormatHint` behavior472- **`references/naming-conventions.md`** - SQLBI naming conventions, display folder conventions, measure table conventions, and calculation group naming473- **`references/bim-to-tmdl.md`** - Converting between `model.bim` (TMSL) and `definition/` (TMDL) via Tabular Editor CLI or TOM TmdlSerializer474- **`references/authoring-gotchas.md`** - Failure modes a syntax check does not catch: the calculation group flag, the `///` indent trap, which blank lines are safe and which two placements break, the expression depth that silently deletes format strings, `formatStringDefinition` placement, generator idempotency, what offline validation proves, and DAX UDFs at compatibility level 1702. It is also the in-repo home for new TMDL failure modes: its closing `## Adding to this file` section sets the shape a new entry takes475- **`references/tmdl-file-examples.md`** - Complete examples for every TMDL file type (model, database, expressions, relationships, roles, perspectives, tables, cultures) including backtick-enclosed expressions, field parameters, calculation groups, and date tables476477### Fetching Docs478479To retrieve current TMDL reference docs, use `microsoft_docs_search` + `microsoft_docs_fetch` (MCP) if available, otherwise `mslearn search` + `mslearn fetch` (CLI). Search based on the user's request and run multiple searches as needed to ensure sufficient context before proceeding.480481### Example Model482483- **`examples/SpaceParts.SemanticModel/`** -- Complete real-world TMDL model (SpaceParts) with 40 tables, 152 measures, 8 calculation groups, 8 RLS roles, 2 perspectives, DAX UDFs (functions.tmdl), shared M expressions, relationships, and cultures. Covers every TMDL file type. Key files to study:484 - `definition/functions.tmdl` -- DAX user-defined functions with parameters, types, and multi-line expressions. The model sets `compatibilityLevel: 1702` for these; an older AMO/TOM assembly throws `UnsupportedObjectType` on `function` when reading it (`references/authoring-gotchas.md`)485 - `definition/tables/Z04CG1 - Time Intelligence.tmdl` -- Calculation group with triple-backtick DAX486 - `definition/tables/__Measures.tmdl` -- Measures table with calculation group references487 - `definition/tables/Invoices.tmdl` -- Large fact table (51 measures, 18 columns)488 - `definition/tables/Date.tmdl` -- Calculated date table with 42 columns489 - `definition/roles/Account Managers.tmdl` -- RLS role with DAX filter expression490 - `definition/relationships.tmdl` -- 27 relationships including inactive491 - `definition/expressions.tmdl` -- Shared M/Power Query expressions and parameters492 - `definition/perspectives/Measure Selection.tmdl` -- Perspective definition493494### External References495496- [TMDL overview (Microsoft Learn)](https://learn.microsoft.com/en-us/analysis-services/tmdl/tmdl-overview)497- [TMDL syntax reference (Microsoft Learn)](https://learn.microsoft.com/en-us/analysis-services/tmdl/tmdl-how-to)498- [SQLBI naming conventions](https://www.sqlbi.com/articles/rules-of-the-game-how-to-name-things-in-your-data-model/)