
Te Cli
- 14 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Use the cross-platform Tabular Editor CLI (te) to scaffold, edit, validate, run BPA on, query, deploy, refresh, and test semantic models on macOS, Linux, and Windows.
About
Guidance for the cross-platform te binary (built on TE3) that loads, edits, validates, deploys, refreshes, and tests semantic models against TMDL/BIM, PBI Desktop, and cloud workspaces. A developer uses it as the primary terminal tool for model operations across OSes.
- Cross-platform te binary for full model operations
- Edits, validates, deploys, refreshes, and BPA-checks models
Te Cli by the numbers
- 14 all-time installs (skills.sh)
- Ranked #1,390 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/data-goblin/power-bi-agentic-development --skill te-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Use the cross-platform Tabular Editor CLI (te) to scaffold, edit, validate, run BPA on, query, deploy, refresh, and test semantic models on macOS, Linux, and Windows.
Files
Tabular Editor CLI (te)
To get the te CLI yourself (as the agent), see references/get-te-cli.md.
The te CLI is a single self-contained binary that loads, edits, validates, deploys, refreshes, and tests semantic models against TMDL/BIM files, Power BI Desktop, and cloud workspaces (Power BI, Fabric, Azure AS, SSAS). It is built on the same TOMWrapper that powers Tabular Editor 3, so model edits behave like the desktop app.
Always pass `--output-format json` when driving te programmatically. The default text/table output uses tables and ANSI styling that mangle in agent transcripts; JSON is parseable and avoids rendering issues.
Limited public preview. Preview builds stop functioning after 2026-09-30. No license is required during preview. Issues and feedback: https://github.com/TabularEditor/CLI
Not the TE2 CLI. This is a different product from the legacy Windows-only TabularEditor.exe (TE2). If the user invokes TE2 flag syntax (-D, -S, -A, -B, -TMDL, -O, -C, -V, -G), route it through the compat layer or invoke TabularEditor.exe directly. See references/te2-migration.md.
When to use this skill
- The user mentions "te CLI", "the new Tabular Editor CLI", or runs a
te <command>in a terminal - The user wants to scaffold, inspect, edit, validate, deploy, refresh, query, or test a semantic model from the terminal on any OS
- The user wants to convert TMDL, BIM, or PBIP, run BPA, or format DAX from the command line
- The user is migrating CI/CD pipelines from
TabularEditor.exe(TE2) tote
When NOT to use this skill
- The user explicitly wants to run
TabularEditor.exenatively (TE2); use that product directly - The user asks about Tabular Editor 3 desktop UI features (Preferences.json, MacroActions.json, Layouts.json); consult https://docs.tabulareditor.com/
- The user wants help authoring a C# script body or a BPA rule expression itself rather than running it; use the
c-sharp-scriptingandbpa-rulesskills
Critical general rules
- First use in a session: run
te --versionandte auth status. If not authenticated, ask the user to runte auth login. - Run
te --helpandte <command> --helpthe first time composing a command; flags are still evolving during preview. te connectstate is per-shell-session and does NOT survive across separate Bash tool calls (each call is a fresh shell). Pass-m <model>(and-s/-dfor remote) on every command, or setTE_SESSION=<name>before the first call to share state.- MPartition path asymmetry:
te addfor an M partition uses<Table>/<Partition>, but every other command (te rm,te get,te ls,te mv,te set) uses<Table>/Partitions/<Partition>. - Mutations stage in memory by default.
te set,te add,te rm,te mv,te replace,te format,te script,te macro run,te incremental-refresh set/removeneed--saveto persist (unlessinteractiveEditModeis set tosave). - The BPA gate is ON by default for
te deployandte save. Bypass deliberately:--skip-bpa,--fix-bpa, orbpa.onDeploy/bpa.onSaveconfig (keys are nested underbpa., not flat). - In CI: pass
--non-interactiveand--force.te deployprompts withnas the safe default and hangs pipelines without--force. - Never put secrets on the command line (visible in
psand shell history). Use--auth envwithAZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID, stdin (-), or--auth managed-identity. - Avoid destructive operations without explicit direction:
te rm,te mv,te deploy --create-only,te save --force,te connect --clear. If a command is blocked by permissions, stop and ask.
Staging model (--save / --stage / --revert)
Every mutating command runs through a staging dispatcher: edits (set, add, rm, mv, replace), DAX/M (format), TOM (script, macro run), refresh policy, and BPA --fix.
By default edits stage in memory and are discarded on exit. Pass --save to persist. The default is configurable with te config set interactiveEditMode <mode>:
stage(default): keep changes in memory; persist with explicit--savesave: auto-persist after each successful mutationrevert: auto-roll-back after each mutation (safe audit/dry-run style)
Inside te interactive, --save, --stage, and --revert are available per command and mutually exclusive. --save-to <path> writes the mutation to a different location without overwriting the source. --force on te script / te save lets a mutation persist even when it introduces NEW DAX validation errors; the default save gate refuses to persist if the mutation introduces new errors (pre-existing errors do not block).
Quickstart
te --version && te auth status # 0. check install + auth
te auth login # 1. authenticate (browser); cached
te init ./my-model # 2. scaffold (PowerBI mode, TMDL, compat 1702)
te load ./model # 3. load + summary; then `te ls`, `te ls Sales`
te find "Revenue" --in names -m ./model # 4. search (names | expressions | descriptions | all)
te get Sales/Revenue -q expression -m ./model # 5. read a measure's DAX
te bpa run --fail-on error --ci github -m ./model # 6. BPA gate
te format --save -m ./model # 7. format all DAX
te query -q "EVALUATE TOPN(5, 'Sales')" -s ws -d model # 8. query
te save -o ./out --serialization tmdl -m ./model # 9. save / convert (tmdl|bim|pbip|te-folder)
te deploy ./model -s ws -d model --force --ci github # 10. deploy
te refresh --type full -s ws -d model # 11. refreshte connect <ws> <model> sets an active connection for interactive terminals, but it does not persist across separate Bash tool calls. In agentic or scripted use, pass -m/-s/-d explicitly every command (or set TE_SESSION).
Common operations
The highest-frequency tasks in their most concise form. Full flags are in references/command-reference.md; flags are still moving in preview, so confirm with te <command> --help.
1. Summarize a model (most concise): te load ./model prints a model summary. For a structural inventory, te ls (tables), te ls Measures (every measure across the model). Relationships do not list via te ls (a known gap, see references/gotchas.md); enumerate them with te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()". Add --output-format json for a machine-readable dump. 2. Search the model (fastest): te find "<text>" --in names --paths-only -m ./model. Scope --in to names, expressions, descriptions, displayFolders, ...; --in expressions walks every DAX and M expression. --paths-only is the fast, pipeable form. Structural lookups use wildcards (te ls "Sales/*Amount"). Relationships are not te ls-enumerable (known gap); list them with te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()". 3. Query the model:
- Inline DAX:
te query -q "EVALUATE TOPN(10, Sales)" -m ./model - From a
.daxfile:te query -f query.dax -m ./model - Save results (format picked by extension):
--output-file out.csv(csv/tsv/json/dax); machine-readable stdout:--output-format json.
4. Make a change (stages in memory; --save persists): te set Sales/Revenue -q expression -i "SUM(Sales[Amount])" --save. Also te add, te rm, te mv. Read the current value first with te get Sales/Revenue -q expression. 5. Make bulk changes:
- Text find/replace across the whole model:
te replace "Old" "New" --in expressions --save(previews unless--save). - Arbitrary bulk logic in one pass (the model loads once, avoiding ~1-2s per-call startup):
te script -S bulk.csx --save, or inlineecho '<C# foreach over Model.AllMeasures>' | te script -e - --save. Predefined macros:te macro run "<name>" --on "Sales/A,Sales/B" --save.
6. Validate and optimize:
- Validate DAX, schema, and relationships:
te validate -m ./model --errors-only. - Best-practice gate:
te bpa run --fail-on warning -m ./model(--fixauto-applies fixes); format DAX withte format --save -m ./model. - Size and storage:
te vertipaq --columns --detail --top 20 -m ./modelsurfaces the largest columns first;references/semantic-modeling-practices.mdcovers what to do about them.
Global options
Abbreviated; the full table (including --recent, server and database detail) is in references/command-reference.md.
| Option | Description |
|---|---|
-m, --model <path> | TMDL folder, .bim, or TE folder |
-s, --server / -d, --database | Workspace/endpoint and semantic model name |
--local | Running Power BI Desktop (Windows only) |
--auth <method> | auto \ |
--output-format <fmt> | auto \ |
--non-interactive | Disable prompts; fail if input missing (set in CI) |
--debug | Debug logs to stderr |
Note: --output-format (how stdout renders) and --serialization (how a model is written to disk on init/save) are different flags. Do not conflate them.
Semantic modeling checklist
Driving the CLI correctly is not the same as building a good model. After te add creates an object, apply the modeling decision that makes it correct and usable. The highest-value practices, each with its te command:
| Practice | Why | te command |
|---|---|---|
summarizeBy = none on key/ID columns | stops Power BI silently summing keys into meaningless totals | te set Sales/ProductKey -q summarizeBy -i none --save |
| Hide foreign-key and surrogate-key columns | keys serve relationships, not visuals; keeps the field list clean | te set Sales/ProductKey -q isHidden -i true --save |
| Mark the date table | unlocks reliable time intelligence | te set Date -q dataCategory -i Time --save |
| Single cross-filter direction by default | avoids ambiguous filter paths and double counting | list with te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()" (te ls cannot enumerate relationships; see gotchas), read one with te get Relationships/<name> (the -> shorthand is for te add only); enable bidirectional only for a deliberate bridge |
| Format string on every measure | unformatted measures render raw floats | te set "_Measures/Revenue" -q formatString -i "#,0.00" --save |
| Display folder + description on measures | a flat field pane is unusable past a few dozen measures; descriptions feed tooltips and Copilot | te set "_Measures/Revenue" -q displayFolder -i "Revenue" --save |
| Minimal correct data types; integer surrogate keys | high-cardinality and oversized types bloat VertiPaq | te set Sales/CustomerKey -q dataType -i int64 --save |
| Prefer measures over calculated columns | calculated columns cost storage and break some DirectQuery/DirectLake paths | te add "_Measures/Margin" -t Measure -i "[Revenue]-[COGS]" --save |
| Calculation groups over measure sprawl | turns N measures x K variants into N + K objects | see references/semantic-modeling-practices.md |
| Gate every batch with validate + BPA | catches broken references and antipatterns while the change is fresh | te validate -m ./model && te bpa run --fail-on warning -m ./model |
Full rationale, citations, and worked workflows (RLS roles, calculation groups, date tables, VertiPaq tuning): references/semantic-modeling-practices.md.
Command index
Ten command families. Full flags and examples in references/command-reference.md.
- Model I/O:
te load,te save,te open,te init - Editing:
te set,te add,te rm,te mv,te replace - Inspection:
te ls,te get,te find,te diff,te deps - Analysis & quality:
te validate,te bpa run,te vertipaq,te format - Execution:
te query,te script,te macro - Deploy & refresh:
te deploy,te refresh,te incremental-refresh - Testing:
te test - Connection & auth:
te connect,te auth,te profile,te session - Configuration:
te config,te migrate,te completion - Shell:
te interactive(model-aware REPL; subcommands work without theteprefix)
The authoring loop
Run quality gates continuously, not only at deploy:
te validate -m ./model --errors-only # after each batch of edits
te bpa run --fail-on warning -m ./model # antipattern gate during development
te format --save -m ./model # consistent DAX layout before commitFor build scripts that issue many te calls, set te config set bpa.onSave false first (skip the per-save BPA pass), run BPA once at the end, and set te config set spinner false for cleaner logs. Each invocation has ~1-2s of startup; prefer one te script with a C# loop over N te set calls for bulk edits.
Using te with other Power BI CLIs
te owns the semantic model. Two sibling CLIs own the layers around it, and the highest-value workflows cross the boundary:
pbir(the Power BI report layer): renaming or moving a model object leaves the report bound to the oldTable.Field. Rename in the model (te mv, thente replace --in expressions --save), then repair the report bindings (pbir fields replace,pbir validate --fields). Seereferences/pbir-cli-tandem.md.fab(the Fabric / Power BI service): export a model from a workspace, edit and gate it locally withte, then deploy over XMLA (te deploy) or import it back (fab import). Seereferences/fabric-cli-tandem.md.
Gate any cross-tool refactor with te validate before touching the report or the service, and remember every te mutation stages in memory until --save.
References
Bundled (load as needed):
references/command-reference.md- object path grammar, global options, all 10 command families, authentication, connections/profiles/sessionsreferences/semantic-modeling-practices.md- modeling best practices tied totecommands, with sourcesreferences/workflows.md- multi-step recipes (table + M partition, format conversions, deploy, refresh, perspectives, translations, incremental refresh, field parameters)references/gotchas.md- path/property asymmetries, output shapes, behavior trapsreferences/config-cicd-env.md- config keys, speed knobs, CI/CD (GitHub Actions, Azure DevOps), output formats, exit codes, environment variablesreferences/te2-migration.md- TE2 compat activation and full flag mappingreferences/pbir-cli-tandem.md- usingtewith thepbirCLI (rename and refactor propagation, thin reports, validation pairing)references/fabric-cli-tandem.md- usingtewith thefabCLI (export/edit/deploy round-trip, discovery, refresh, promotion)
Authoritative docs:
- Command reference: https://docs.tabulareditor.com/en/features/te-cli/te-cli-commands.html
- Overview: https://docs.tabulareditor.com/en/features/te-cli/te-cli.html
- CI/CD: https://docs.tabulareditor.com/en/features/te-cli/te-cli-cicd.html
- Known limitations: https://docs.tabulareditor.com/en/features/te-cli/te-cli-limitations.html
- GitHub (issues, releases): https://github.com/TabularEditor/CLI
te command reference
Full command surface for the te CLI. Companion to the te-cli skill (SKILL.md). Configuration keys, CI/CD, output formats, and environment variables live in config-cicd-env.md.
Installation
Download from https://tabulareditor.com (signed in with a TE account). Single self-contained binary; no .NET / runtime install needed.
| Platform | Archive | Install location (suggested) |
|---|---|---|
| Windows x64 / ARM64 | te-win-x64.zip / te-win-arm64.zip | %LOCALAPPDATA%\Programs\te |
| macOS Intel / Apple Silicon | te-osx-x64.zip / te-osx-arm64.zip | ~/.local/bin |
| Linux x64 / ARM64 | te-linux-x64.zip / te-linux-arm64.zip | ~/.local/bin |
Add the install dir to PATH. On macOS, allow first-run network access for Gatekeeper notarization check. Update by overwriting the binary; config and credentials persist.
Shell completion:
te completion bash > /etc/bash_completion.d/te
te completion zsh > "${fpath[1]}/_te"
te completion pwsh | Out-String | Invoke-ExpressionCross-platform limits: local SSAS connections (TCP) and Power BI Desktop connections (named pipe) are Windows-only. All cloud workflows work on every platform.
Authentication
Backed by Azure Identity's full credential chain.
| Method | Flag | When to use |
|---|---|---|
| Interactive browser | --auth interactive (default) | Local dev |
| Service principal (secret) | --auth spn -u <appId> -p <secret> -t <tenant> | Avoid; secret on cmd line |
| Service principal (cert) | --auth spn -u <appId> -t <tenant> --certificate <path> | Cert-based CI |
| Environment vars | --auth env (reads AZURE_CLIENT_ID/SECRET/TENANT_ID) | Preferred for CI |
| Managed identity | --auth managed-identity | Azure-hosted runners |
te auth login # browser
te auth login --identity # managed identity
te auth status # exit 0 if authenticated, 1 otherwise
te auth logout # clear cached credentialsCredential cache locations (all file-mode 0600 / DPAPI on Windows):
- Windows:
%USERPROFILE%\.te-cli\(DPAPI-encrypted) - Linux:
~/.te-cli/(libsecret via Azure.Identity) - macOS:
~/.te-cli/token-cache.bin
Connections and profiles
te connect sets a per-terminal active connection so subsequent commands don't need -s/-d/-m repeated.
te connect # show active connection (or open picker in interactive)
te connect MyWorkspace MyModel # remote workspace
te connect ./my-model # local TMDL/BIM
te connect --local # running Power BI Desktop (Windows)
te connect --clear # resetWorkspace mirroring (bidirectional sync between local TMDL folder ↔ remote workspace):
te connect Finance "Revenue Model" -w ./revenue-model # remote primary, mirror to local
te connect ./revenue-model -w Finance "Revenue Model" # local primary, mirror to remote
# --workspace-format <bim|tmdl|te-folder> # on-disk format for the mirror
# --workspace-auth <method> # auth for the remote side when primary is localProfiles save named connection + behavior overrides:
te profile set prod -s MyWorkspace -d MyModel --auto-format true
te profile set dev -s DevWorkspace -d MyModel --bpa-on-deploy false
te profile list
te profile show prod
te profile remove old
te connect --profile prodObject path syntax
Backed by a formal grammar (PathParser); paths come in two flavors with subtly different rules:
Object paths; used by te get, te set, te add, te rm, te mv. Resolve to one object. Wildcards rejected.
Filter paths; used by te ls, te find, te deps, te bpa run --path. Resolve to a set of objects. Wildcards allowed.
Slash-form (works on both)
Sales; tableSales/Revenue; measure or column on the Sales tableSales/Measures,Sales/Columns,Sales/Partitions,Sales/Hierarchies; sub-containersSales/Geography/Levels; hierarchy levelsMeasures/<name>/KPI; KPI sub-object on a measure (resolves through the KPI wrapper)Roles/<role>/Members,Roles/<role>/TablePermissions; role childrenPerspectives/<persp>/<table>; perspective membership (usete add Perspectives/Default/Salesto add a table)Tables,Measures,Roles,Perspectives,Cultures,Hierarchies,Annotations; model-level containers (pivot viate ls Measuresfor cross-table view)Relationshipsis not enumerable viate ls, despiterelationshipappearing inte ls --type's help. The keyword falls through to a literal path match and errors withNo objects match path 'Relationships', even when relationships exist (recognized-but-empty containers sayNo objects match 'X'without the wordpath). List relationships with DAXEVALUATE INFO.VIEW.RELATIONSHIPS()(orINFO.RELATIONSHIPS()on older compat), orte saveto TMDL and readrelationships.tmdl. A single relationship is still addressable once you know its name:te get Relationships/<name>.
Container-keyword table names (a table called Tables, Roles, etc.) resolve correctly via the path parser; the parser disambiguates by position.
DAX-form (object paths)
DAX-style quoting and bracket-suffix follow DAX conventions; doubled quote char escapes itself ('Bob''s' = Bob's, [foo]]bar] = foo]bar):
'Sales'[Amount]; same asSales/Amount"Net Sales"[Sales Amount]; same as"Net Sales"/"Sales Amount", double-quoted form[Total Sales]; model-wide measure-or-column lookup (no table prefix; resolver searches every table)"Sales[ProdKey]->Product[ProdKey]"; relationship shorthand (used byte addonly)"Sales 2024"/Revenue,"_Measures/Total Revenue"; quote any segment that contains a space,/,[, or]
Wildcards (filter paths only)
Single * matches any run of characters within one segment (case-insensitive). Multi-segment globs and ? are not supported.
te ls Sa* # tables starting with "Sa"
te ls Sales/*Amount # any child of Sales ending in "Amount"
te ls */Amount # an "Amount" column/measure across every table
te ls Roles/Re*/Members # members of every role matching Re*
te bpa run --path "Sales/*" # run BPA only on objects under SalesPassing a wildcard to an object-path command (te get Sa*, te set Sa*) fails fast with a parser error; wildcards on those would resolve to many objects, and the command needs exactly one.
Global options
Work with every command:
| Option | Description |
|---|---|
-m, --model <path> | TMDL folder, .bim, or TE folder |
-s, --server <endpoint> | Workspace name, powerbi://..., asazure://..., localhost:PORT |
-d, --database <name> | Semantic model name on workspace |
--local | Connect to running Power BI Desktop (Windows only) |
--auth <method> | auto \ |
--output-format <fmt> | auto \ |
--recent [N] | Use recently-used model (no value = picker, N = Nth most recent) |
--non-interactive | Disable prompts; fail if input missing; set in CI |
--debug | Debug logs to stderr |
Note: --output-format (how stdout is rendered) and --serialization (how models are written to disk on init/save/etc.) are two different flags. Don't conflate them; passing one when the other was meant gives a confusing error or silent wrong output.
Command reference (10 families)
Model I/O
| Command | Purpose | Key flags |
|---|---|---|
te load <path> | Load model and show summary | global -m/-s/-d |
te save | Save / convert / persist edits | -o, --output-path <path>, `--serialization tmdl\ |
te open <path> | Open in TE3 Desktop (TE3 must be installed) | n/a |
te init [path] | Create new empty model. Path is optional; falls back to global --model when omitted | `--compatibility-mode PowerBI\ |
te load ./model # local TMDL folder
te load model.bim # local BIM file
te load -s MyWorkspace -d MyModel # remote
te save # write back to source
te save ./model.bim -o ./tmdl-out # convert BIM → TMDL
te save -o ./project --serialization pbip --supporting-files
te save -o ./out -s ws -d model --skip-validation # fast passthrough
te init ./my-model # PowerBI mode, TMDL, compat 1702 (default)
te init ./my-model --compatibility-mode AnalysisServices # AS mode, compat 1500
te init ./my-model --compatibility-level 1604 # specific compat level
te init ./my-model --serialization bim # single-file .bim model
te init ./my-model --serialization pbip # full Power BI project structure
te --model ./new.bim init # path via global --modelModel Editing
| Command | Purpose | Key flags |
|---|---|---|
te set <obj> | Set property | -q <prop> (e.g. expression, formatString, description, isHidden), -i <value> (or - for stdin), --save, --save-to <path> |
te add <obj> | Add object | -t <type> (Table, Measure, Column, CalculatedColumn, CalculatedTable, Hierarchy, Role, Perspective, Culture, CalculationGroup, CalculationItem, MPartition, Partition, EntityPartition, PolicyRangePartition, KPI, NamedExpression, ...), -i <value>, --if-not-exists (idempotent), --save. Data-bound tables: `--mode import\ |
te rm <obj> | Remove object | --force, --if-exists, --dry-run, --save |
te mv <src> <dst> | Move/rename | --save |
te replace <find> <repl> | Find+replace text | `--in names\ |
te set Sales/Amount -q expression -i "SUM(Sales[Amt])" --save
te set Sales -q isHidden -i true --save
te add Sales/Revenue -t Measure -i "SUM(Sales[Amount])" --save
te add Sales -t Table --save # empty M partition (PowerBI default)
te add "Sales[ProdKey]->Product[ProdKey]" --save # relationship shorthand
te add Sales/MarketingFlag -t CalculatedColumn -i "..." --if-not-exists --save
te rm Sales/OldMeasure --if-exists --save
te rm Sales/Revenue --dry-run # preview impact
te mv Sales/Revenue Finance/Revenue --save # cross-table move
te replace "OldTable" "NewTable" --in expressions --save
te replace "SUM" "SUMX" --regex --in expressions --saveCommon -q properties
Property names are case-insensitive and match TOM. When in doubt, run te get <obj> to see what's already on the object, or check te set <obj> -q for the settable list. The most-used ones:
| Object | Common properties |
|---|---|
| Measure | expression (DAX), formatString, displayFolder, description, isHidden |
| DataColumn | dataType (int64/string/double/decimal/dateTime/boolean), sourceColumn, summarizeBy (none/sum/count/average/max/min/distinctCount/automatic), isKey, isHidden, formatString, sortByColumn, dataCategory, displayFolder, description |
| CalculatedColumn | expression (DAX) plus most DataColumn properties |
| MPartition | `MExpression` (NOT expression; that's what te get displays, but te set rejects it), Mode (Import/DirectQuery/DirectLake/Default), description |
| QueryPartition | QueryDefinition (alias Query), Mode, description |
| Table | isHidden, dataCategory (use Time to mark a date table), description, name (rename) |
| Hierarchy | displayFolder, description, isHidden; Levels take column (the source column name) |
| ModelRole | modelPermission (None/Read/ReadRefresh/Refresh/Administrator); TablePermissions take filterExpression (DAX) |
KPI (on a Measure path Measures/<name>/KPI) | statusExpression, trendExpression, targetExpression, statusGraphic, trendGraphic |
| CalculationItem | expression (DAX), ordinal (int), formatStringDefinition |
| Annotations / Translations | Annotations[<key>], TranslatedNames[<culture>], TranslatedDescriptions[<culture>]; bracket-indexed property names |
Properties not in the list are still usable; these are the most error-prone and frequently needed ones. `te get <obj>` is always the authoritative discovery tool for what an existing object exposes.
Inspection
| Command | Purpose | Key flags |
|---|---|---|
te ls [filter-path] | List objects, FS-style (filter-path: wildcards allowed) | --type <type>, --paths-only, --no-multiline (collapse multi-line cells; text output only) |
te get <obj> | Get properties (object-path: no wildcards) | -q <prop> (single property), `--output-format tmdl\ |
te find <text> | Search across model | `--in names\ |
te diff <m1> <m2> | Structural diff | exit 0 identical, 2 models differ, 1 error |
te deps [obj] | Dependency analysis | --unused (no DAX refs, not in relationships/hierarchies/sort-by/variations/time roles), --hidden (narrow to hidden), --deep, --upstream, --downstream, --max-depth <N> |
te ls # tables
te ls Sales # columns + measures in Sales
te ls Sales/Measures # measures only
te ls Measures # all measures across model
te ls --type measure --paths-only # pipeable
te get Sales/Revenue -q expression
te get Model -q description
te find "CALCULATE" --in expressions # covers DAX, calc-columns, KPI exprs, partition M, role filters, calc-group selection
te find "Revenue" --in names
te find "TODO" --in descriptions --no-multiline # single-line cells, easy to grep
te find 123 --in expressions --paths-only # pipeable, e.g. for finding a KPI TargetExpression value
te diff ./model-v1 ./model-v2
te deps "Sales/Revenue" # upstream + downstream
te deps --unused # unused everywhere
te deps --unused --hidden # hidden + unusedAnalysis & Quality
| Command | Purpose | Key flags |
|---|---|---|
te validate | Expressions + schema + TOM errors | --ci <fmt> (see below), --trx <file>, --no-multiline, --no-warnings, --no-antipatterns, --errors-only |
te bpa run [model] | Run BPA (optional positional model path) | -r/--rules <file-or-url> (repeatable; URLs supported), --fix, --save, --save-to <path>, --serialization, `--fail-on error\ |
te bpa rules list | Inspect active rules | --all (incl. disabled+ignored), --ignored, --no-multiline |
te vertipaq [path] | VertiPaq stats (optional positional object path, e.g. Sales or Sales/Amount) | --columns, --relationships, --partitions, --all, --detail (encoding/segments breakdown), --fields <csv> (custom column set), --export <vpax>, --import <vpax> (offline), --obfuscate (writes .vpax.dict sidecar), --top <N>, --stats (DAX-queried details), --annotate, --save |
te format | Format DAX or M | -e <text> (inline), -p <obj> (single), `--lang dax\ |
te validate ./model --ci github --trx results.trx
te validate ./model --errors-only # hide warnings + anti-patterns
te bpa run --fail-on error --ci github
te bpa run --fix --save
te bpa run --rule PERF_UNUSED_HIDDEN_COLUMN
te bpa rules list --all
te vertipaq --all --export stats.vpax
te vertipaq Sales # filter to one table
te vertipaq Sales/Amount # filter to one column
te vertipaq --columns --detail # encoding/segment breakdown
te vertipaq --fields name,card,size,%tbl,%db,bar # custom column set
te vertipaq --import stats.vpax # offline analysis from VPAX
te format --save # all DAX
te format -p Sales/Amount --save # single measure
te format --lang m --save # all M
te format -e "SUM ( Sales[Amount] )" # inline previewExecution
| Command | Purpose | Key flags |
|---|---|---|
te query | DAX query | -q <dax> or -f <file.dax>, --limit <N> (default 100), -o, --output-file <file> (extension picks format: `.csv\ |
te script | Run C# script (TOM) | -S <file> (repeatable, .cs/.csx), -e <code> (inline, - = stdin), --save, --save-to, --serialization, --dry-run, --timeout <s> |
te macro <sub> | TE3 macros | list, run <name-or-id> (with --on <obj-paths>, --save), add, set, rm, sort |
te query -q "EVALUATE TOPN(5, 'Sales')" -s ws -d model
te query -f query.dax --output-format json # global --output-format controls stdout format
te query -q "EVALUATE Sales" --output-file results.csv # writes CSV/TSV/JSON/DAX based on extension
te query -q "EVALUATE Sales" --runs 5 --cold --plan
te script -S fix.cs --save
te script -e "Info(Model.Tables.Count)"
echo "Info(Model.Name);" | te script -e -
te macro list
te macro run "Hide all measures"
te macro run "Format DAX" --on "Sales/Revenue,Sales/Margin" --saveDeployment & Refresh
| Command | Purpose | Key flags |
|---|---|---|
te deploy | Deploy model | -s/-d, --deploy-full (overwrite + connections + partitions + roles + members + shared exprs), --deploy-connections, --deploy-partitions, --skip-refresh-policy, --deploy-roles, --deploy-role-members, --deploy-shared-expressions, --create-only (fail if exists), --xmla <file> (TMSL only, - for stdout), --skip-bpa, --fix-bpa, --bpa-rules <file> (repeatable), --force (required for CI), --ci <fmt>, -p, --profile <name> |
te refresh | Trigger refresh | `--type full\ |
te incremental-refresh <sub> <table> | Manage IR policies | show, set, remove, apply (re-evaluate policy and create/expand partitions) |
te deploy ./model -s ws -d model --force --ci github
te deploy ./model --xmla script.tmsl # generate TMSL only
te deploy ./model --xmla - # TMSL to stdout
te deploy ./model --profile staging --force
te refresh --type full
te refresh --table Sales --partition "Sales.2024" --type full
te refresh --type full --dry-run > refresh.tmsl
te refresh --type full --trace # XMLA trace events to stderr
te refresh --type full --trace refresh.log # XMLA trace events to log file
te incremental-refresh show Sales
te incremental-refresh apply Sales # re-evaluate policy, create/expand partitionsTesting
| Command | Purpose | Key flags |
|---|---|---|
te test run | Run DAX assertion tests | --suite <path> (default .te-tests/), --tag <tag>, `--fail-on error\ |
te test init | Scaffold suite | --example, --from-model --model <path> |
te test spec | Print assertion format | n/a |
te test use <suite> | Activate suite (session-scoped) | n/a |
te test list | List test cases | n/a |
te test snapshot | Capture model snapshot | n/a |
te test compare | Compare snapshots | n/a |
te test init --example
te test init --from-model --model ./my-model # generate stubs from model
te test run --ci github --trx results.trx
te test run --tag revenue
te test snapshot
te test compareConnection & Auth
(Covered above under Authentication and Connections and profiles.) Full subcommands:
te connect [<server> <database>] [--local | -w/--workspace <path-or-server-db> | --workspace-format bim|tmdl|te-folder | --workspace-auth <method> | --force | -p/--profile <name> | --clear]
te auth login [-u <appId>] [-p <secret>|-] [-t <tenant>] [--identity|-I] [--certificate <path>] [--certificate-password <pw>] [--save] [--auth interactive|spn|env|managed-identity]
te auth status
te auth logout
te profile {set|show|list|remove} <name> [...]
te session [show | list | clear | prune [--all] [--dry-run]]Sessions
Every shell process gets its own session file under ~/.config/te/sessions/<id>.json, holding the active connection, active profile, active test suite, and timestamps. Default session ID is derived from the parent shell PID; set TE_SESSION=<name> to name a session and share it across multiple shells or scripts. Sessions for dead PIDs are auto-cleaned on each invocation; te session prune triggers cleanup manually (or with --all, drop every session except the current one).
te session # show current session (id, file, active state)
te session list # all session files on this machine
te session clear # reset active connection / profile / test suite for this shell
te session prune # delete sessions whose shell process is dead
te session prune --dry-run # preview what would be deleted
te session prune --all # delete every session except current (incl. named TE_SESSION ones)
TE_SESSION=ci-deploy te connect ws md # share session under name "ci-deploy"Why it matters: te connect, te test use, and --profile all mutate the session file, not the global config. Two terminals can hold different active connections without stepping on each other.
Configuration commands and the full key table: see config-cicd-env.md.
Shell
| Command | Purpose |
|---|---|
te interactive [model] | Model-aware REPL; prompt is te [MyModel]> or te>. All subcommands work without te prefix. Built-ins: help/?, status/pwd, clear/cls, exit/quit/q |
te completion <shell> | Print completion script (bash, zsh, pwsh) |
The REPL's argv splitter is bracket-aware, so DAX-style refs work without escaping the brackets; handy for paste-from-DAX-editor workflows:
te interactive
te interactive ./model
te interactive -s MyWorkspace -d MyModel
te> ls Sales
te> ls Sa* # wildcard filter-paths
te> get "Sales/Revenue" -q expression
te> get [Total Sales] # lone-bracket: model-wide measure/column lookup
te> get 'Sales'[Amount] # DAX-quoted form
te> ls Roles/Reader/Members # role members
te> add Perspectives/Default/Sales # add Sales table to the Default perspective
te> bpa run --fail-on error
te> exitte configuration, CI/CD, and environment
Companion to the te-cli skill (SKILL.md).
Configuration
| Command | Purpose |
|---|---|
te config show [--output-format json] | Show all settings |
te config paths | Resolved file paths (macros, BPA rules, config) |
te config init [--force] | Create default config |
te config set <key> <value> | Update setting |
te license … | Hidden during preview. Subcommands (activate, status, deactivate) are parseable so existing scripts don't fail at parse time, but any invocation prints "`te license` is not available in this preview build" and exits 1. Don't pipeline this. |
| `te migrate [-A] [--output-format text\ | json]` |
Config file: ~/.config/te/config.json (Windows: %USERPROFILE%\.config\te\config.json). Resolution order: $TE_CONFIG → default path → built-in defaults.
Configurable keys (the keys accepted by te config set):
| Key | Type | Default | Purpose |
|---|---|---|---|
macros | path | _(none)_ | Override path to a MacroActions.json file |
queryLog | path | _(none)_ | Path to the DAX query log file |
te3ExePath | path | _(none)_ | Override path to the TE3 desktop executable (for te open) |
autoFormat | bool | false | Apply DAX Formatter after mutations |
validateOnMutation | bool | true | Verify Table[Column] references after edits |
vertipaqOnRefresh | bool | false | Capture VertiPaq stats post-refresh |
bpa.rules | string[] | _(none)_ | Path(s)/URL(s) to BPA rule file(s); repeatable; comma-separated on te config set |
bpa.onMutation | bool | false | Run BPA after every mutation |
bpa.onDeploy | bool | true | BPA gate before deploy (bypass: --skip-bpa) |
bpa.onSave | bool | true | BPA gate before save (bypass: --skip-bpa) |
bpa.builtInRules | bool | true | Include built-in default rules in scans |
bpa.disabledBuiltInRuleIds | string[] | _(none)_ | Suppress specific built-in rule IDs |
formatOptions.useSemicolons | bool | false | Use Euro separator (;) in DAX output |
formatOptions.shortFormat | bool | true | Compact DAX layout (vs --long) |
formatOptions.skipSpaceAfterFunction | bool | false | SUM(x) instead of SUM (x) |
formatOptions.useSqlBiDaxFormatter | bool | false | Use SQLBI's online formatter instead of the in-house one |
interactiveEditMode | enum | stage | Default for mutating commands: stage (in-memory only), save (auto-persist), revert (auto-roll-back). Overridden per-command by --save/--stage/--revert |
hidePreviewNotice | bool | false | Suppress yellow preview banner |
spinner | bool | true | Animated progress (disable for CI) |
debug | bool | false | Debug logs to stderr |
disableTelemetry | bool | false | Opt out of anonymous usage telemetry |
Note: BPA keys are nested under `bpa.`; te config set bpa.onDeploy false, not bpaOnDeploy. Same for formatOptions.*. Active connection / profile / test-suite are session-scoped (see te session) and explicitly rejected by te config set; use te connect, te profile, te test use instead.
Speed knobs for batch / demo / CI runs: each te invocation has ~1-2 s of process startup + model load. For pipelines that issue many sequential te calls (build scripts, live demos, mass-edit loops), set these once before the run:
te config set bpa.onSave false # skip the BPA gate on every --save; run BPA once at the end instead
te config set spinner false # disable the animated progress widget (cleaner CI logs, slightly faster)
te config set hidePreviewNotice true # suppress the yellow preview bannerbpa.onSave: false is by far the biggest win; without it, BPA runs on every saved mutation, which on a typical model-build script means dozens of redundant passes.
Project-local BPA gate: drop a .te-bpa.json in repo root (or set via TE_BPA_CONFIG) to override gate behavior per project.
CI/CD integration
GitHub Actions
- name: Validate model
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
run: |
te validate ./model --ci github --trx validate.trx --non-interactive
- name: BPA gate
run: te bpa run --rules ./rules/BPARules.json --fail-on error --ci github --non-interactive
- name: Deploy
run: |
te deploy ./model \
-s "${{ vars.WORKSPACE }}" \
-d "${{ vars.SEMANTIC_MODEL }}" \
--auth env --force --ci github --non-interactive
- name: Run tests
run: te test run --ci github --trx test.trx --non-interactive
- name: Publish TRX
if: always()
uses: dorny/test-reporter@v1
with:
name: TE tests
path: '*.trx'
reporter: dotnet-trxAzure DevOps Pipelines
Same commands, swap --ci github for --ci azdo (or vsts/azure-devops; all aliases). Pipeline annotations come back as native ##vso[...] markers; --trx integrates with the PublishTestResults@2 task.
Patterns
- Always pass
--non-interactiveand--auth env(withAZURE_CLIENT_*env vars) and--force(onte deploy) - Stable annotations:
--ci azdoor--ci githubonvalidate,bpa run,deploy,test run,script - Test publishing:
--trx <file>onvalidate,bpa run,test runfor VSTEST-compatible XML - Promotion (dev → test → prod): build once, deploy with
--profile dev,--profile test,--profile prodagainst the same TMDL artifact - Disable spinner in CI:
te config set spinner falsein setup step
Output formats and exit codes
`--output-format` (global stdout format):
auto(default): text on TTY, JSON when stdout is piped/redirectedtext: forces human-readablejson: always valid JSON to stdout; errors/warnings to stderr (won't contaminate)csv: tabular results (onlyquery,bpa run,vertipaq)tmsl(aliasbim): emit the resolved object(s) as TMSL/BIM JSON; supported onte getandte lstmdl: emit the resolved object as TMDL; supported onte get(single named object only) andte ls
te get Sales --output-format tmdl # Sales table as TMDL
te get "Sales/Revenue" --output-format bim # Single measure as TMSL fragment
te ls Tables --output-format bim # All tables as TMSL/BIM
te ls Measures --output-format tmdl # Every measure across the model, in TMDL`--ci` formats (orthogonal to --output-format; emits CI-system logging commands to stderr on validate, bpa run, deploy, test run, script):
| Value | Effect |
|---|---|
vsts, azdo, azure-devops | Azure DevOps: ##vso[task.logissue type=error/warning;...]message + ##vso[task.complete result=...] summary |
github, gh | GitHub Actions: ::error file=…,line=…::message / ::warning::message |
| anything else | No CI output |
Errors and warnings are accumulated, so a non-zero exit code reflects total error count for the run.
Exit codes:
0; success1; generic failure: invalid args, validation errors, auth failure, BPA gate2;te diffonly: models differ
# JSON-safe pipeline
te ls --type measure --output-format json | jq -r '.[].path'
# Bash conditional on diff
if te diff old.bim new.bim --output-format json > /dev/null; then
echo "Identical"
elif [ $? -eq 2 ]; then
echo "Models differ"
fiEnvironment variables
| Var | Purpose |
|---|---|
TE_CONFIG | Override config file path (otherwise ~/.config/te/config.json) |
TE_DEBUG | Set 1 or true for debug logging to stderr |
TE_COMPAT | Set te2 to force legacy compat mode |
TE_SESSION | Name the current session (instead of parent-PID-derived ID). Lets multiple shells share active state; named sessions are never auto-cleaned |
TE_MACROS_PATH | Override path to a MacroActions.json (highest priority for te macro) |
TE_BPA_RULES | Override path to a BPA rules file (precedence: explicit --rules > TE_BPA_RULES > bpa.rules config > CWD BPARules.json) |
TE_BPA_CONFIG | Override path to a .te-bpa.json gate-config (for deploy/save BPA gating) |
AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID | SPN credentials (used with --auth env) |
te + fab tandem workflows
te (Tabular Editor CLI) and fab (Fabric CLI) split cleanly along a single seam. te owns the semantic model itself: it parses TMDL/BIM locally, edits objects (set, add, rm, mv, format), runs validation, BPA, and VertiPaq, generates TMSL, deploys over XMLA, and runs DAX queries, refreshes, and tests against a live XMLA endpoint. fab owns everything around the model in the service: discovering workspaces and items, resolving display names and GUIDs, exporting and importing item definitions over the Fabric REST API, managing permissions and capacities, driving deployment pipelines, and triggering refreshes via the Power BI REST API. Neither tool crosses into the other's half: fab has no DAX parser, no BPA engine, and no XMLA/TOM layer, so it cannot validate or BPA-check a model; te has no Fabric REST client, so it cannot enumerate workspaces, resolve item GUIDs, or trigger a REST refresh. The two share Azure AD identity but cache credentials independently, so authenticate both at the start of any tandem session.
Before each workflow: fab auth status and te auth status. If either is unauthenticated, ask the user to run fab auth login / te auth login. During preview, run te <command> --help and fab <command> --help the first time a command is composed; both surfaces are still moving.
1. Export, edit, deploy back (the common round-trip)
Pull a production model to local TMDL with fab, edit and quality-gate with te, push it back over XMLA with te deploy.
# 0. Confirm the item path is real before exporting
fab exists "Production.Workspace/Sales.SemanticModel"
# 1. Pre-create the output dir (fab export will not create parents)
mkdir -p ./sales-export
# 2. Download the model definition as a TMDL folder
fab export "Production.Workspace/Sales.SemanticModel" -o ./sales-export -f
# -> ./sales-export/Sales.SemanticModel/definition/ holds model.tmdl, tables/, etc.
# 3. Confirm te parses the exported TMDL; settle on the path that loads
te load ./sales-export/Sales.SemanticModel/definition
# 4. Baseline gates BEFORE editing (separate pre-existing issues from yours)
te validate -m ./sales-export/Sales.SemanticModel/definition --errors-only
te bpa run --fail-on error -m ./sales-export/Sales.SemanticModel/definition
# 5. Edit (each mutation needs --save to persist)
te set "_Measures/Revenue" -q formatString -i "#,0.00" -m ./sales-export/Sales.SemanticModel/definition --save
te format --save -m ./sales-export/Sales.SemanticModel/definition
# 6. Re-gate: no new errors or violations introduced
te validate -m ./sales-export/Sales.SemanticModel/definition --errors-only
te bpa run --fail-on error -m ./sales-export/Sales.SemanticModel/definition
# 7. Deploy back over XMLA (BPA gate runs by default; --force required non-interactively)
te deploy ./sales-export/Sales.SemanticModel/definition -s "Production" -d "Sales" --forcePer-step purpose: fab exists fails fast on a wrong name; mkdir -p avoids [InvalidPath]; te load confirms the parse and pins the path; the baseline gates separate pre-existing breakage from edits being introduced; --save persists each mutation (staged in memory otherwise); the second gate is the quality bar before deploy; te deploy writes the definition back through XMLA, re-running BPA on the way out.
Notes that bite:
fab exportnests TMDL under<Model>.SemanticModel/definition/. Runte loadonce to confirm whether the binary wants thedefinition/folder or its.SemanticModelparent, then use that exact path on every latertecall.te connectstate does not survive across separate shell calls. Pass-m(and-s/-dfor remote) every time, or setTE_SESSION=<name>before the first call.- The BPA gate is ON by default for
te deploy. If pre-existing violations block the deploy, use--skip-bpaonce and log it, or--fix-bpato auto-remediate; do not silently bypass. te deployoverwrites by default. Add--create-onlyto refuse if the model already exists (it errors if it does), to prevent clobbering.
Variant: import back with fab instead of te deploy
Use this when XMLA write is blocked (Pro/PPU without XMLA, or tenant policy). fab import goes through the Fabric item-definition REST API and works on any SKU. It does NOT run BPA or validation, so the te gate before it is the only safety net.
te validate -m ./sales-export/Sales.SemanticModel/definition --errors-only
te bpa run --fail-on error -m ./sales-export/Sales.SemanticModel/definition
fab import "Production.Workspace/Sales.SemanticModel" -i ./sales-export/Sales.SemanticModel -ffab import overwrites the whole item definition (no partial update) and does not refresh data. Pass the .SemanticModel folder (the one containing .platform and definition/), not the inner definition/. Trigger a refresh afterward if needed (workflow 5).
Variant: te-only pull (no fab)
te save can read a remote model and write it to local disk, so the round-trip does not strictly require fab export when XMLA is available:
te save -s "Production" -d "Sales" -o ./sales-export --serialization tmdlUse fab export when XMLA is blocked or when the .platform/PBIP scaffolding is also wanted; use te save when an XMLA connection already exists and only the model source is wanted.
2. Discover with fab, connect te by display name
When the exact workspace or model name is unknown or its casing is uncertain, resolve it with fab first, then feed the canonical display name into te -s/-d.
fab ls # list visible workspaces
fab find 'sales' -P type=SemanticModel -l # substring search; -l adds id + workspace_id
fab exists "Sales Analytics.Workspace/Sales Model.SemanticModel" # confirm the exact path
te load -s "Sales Analytics" -d "Sales Model" # te takes the bare display name (no .Workspace suffix)
te bpa run --fail-on error -s "Sales Analytics" -d "Sales Model" # gate the live model, no local exportBoundary: fab resolves and confirms names against the Fabric REST API and OneLake catalog; te connects to the XMLA endpoint using the display name directly. Watch the path-shape mismatch: fab uses dot-extension syntax ("Name.Workspace", "Model.SemanticModel"), but te -s takes the bare workspace display name and te -d the bare model name. Strip the .SemanticModel / .Workspace suffixes when handing off. Quote any name with spaces in both CLIs.
3. Extract GUIDs with fab, refresh after a te change
The Power BI REST API needs raw GUIDs that only fab can resolve from display names. te validates the model; fab triggers the refresh.
te validate -s "Production" -d "Sales Model" --errors-only # don't refresh a broken model
WS_ID=$(fab get "Production.Workspace" -q "id" | tr -d '"')
MODEL_ID=$(fab get "Production.Workspace/Sales Model.SemanticModel" -q "id" | tr -d '"')
fab api -A powerbi "groups/$WS_ID/datasets/$MODEL_ID/refreshes" -X post -i '{"type":"Full"}'
fab api -A powerbi "groups/$WS_ID/datasets/$MODEL_ID/refreshes?\$top=1" -q "value[0].{status:status,started:startTime}"fab get -q "id" returns a quoted JSON string; always pipe through tr -d '"' or the GUID carries literal quotes that break URL construction. te refresh --type full -s ws -d model also triggers a refresh over XMLA; use fab api when the GUIDs are already in scope or when XMLA refresh is not available. The refresh is async; the ?$top=1 check is a sanity probe, not a completion wait.
4. Promote dev to test/prod with quality gates
Export from dev, diff against the target, gate, then deploy. The local round-trip is deliberate: fab cp can copy workspace-to-workspace faster but skips every te gate.
mkdir -p ./promote
fab export "Dev.Workspace/Sales.SemanticModel" -o ./promote -f
# Structural diff against the live prod model over XMLA
te diff ./promote/Sales.SemanticModel/definition -s "Production" -d "Sales"
te validate -m ./promote/Sales.SemanticModel/definition --errors-only
te bpa run --fail-on error -m ./promote/Sales.SemanticModel/definition
# Deploy, including RLS roles and members
te deploy ./promote/Sales.SemanticModel/definition \
-s "Production" -d "Sales" \
--deploy-roles --deploy-role-members --force
fab exists "Production.Workspace/Sales.SemanticModel" # confirm it landed--deploy-roles / --deploy-role-members are opt-in; omit them when RLS is managed independently in prod. If XMLA is blocked, te diff cannot run against a live -s/-d target; instead fab export prod separately and diff two local folders: te diff ./promote/Sales.SemanticModel/definition ./prod-export/Sales.SemanticModel/definition.
Variant: governed promotion via deployment pipeline
te provides the quality gate and an optional TMSL audit artifact; fab drives the Fabric deployment pipeline (which preserves item IDs across stages, so thin reports do not need rebinding).
te bpa run --fail-on error --ci azdo --non-interactive -m ./dev-export/Sales.SemanticModel/definition
# Optional: emit the TMSL a direct XMLA deploy WOULD run, as an audit artifact (does not execute)
te deploy ./dev-export/Sales.SemanticModel/definition -s "Dev" -d "Sales" --xmla - > ./audit/deploy.tmsl
PIPELINE_ID=$(fab api "deploymentPipelines" -q "value[?displayName=='Sales Pipeline'].id | [0]" | tr -d '"')
DEV_STAGE=$(fab api "deploymentPipelines/$PIPELINE_ID/stages" -q "value[?order==\`0\`].id | [0]" | tr -d '"')
TEST_STAGE=$(fab api "deploymentPipelines/$PIPELINE_ID/stages" -q "value[?order==\`1\`].id | [0]" | tr -d '"')
# Promote; capture the LRO id from the response header
fab api -X post "deploymentPipelines/$PIPELINE_ID/deploy" \
-i "{\"sourceStageId\":\"$DEV_STAGE\",\"targetStageId\":\"$TEST_STAGE\",\"note\":\"BPA-gated\"}" \
--show_headers
# Poll the LRO to completion
until s=$(fab api "operations/$OPERATION_ID" -q "status" | tr -d '"'); [ "$s" = "Succeeded" ] || [ "$s" = "Failed" ]; do sleep 30; doneThe TMSL audit artifact describes a direct XMLA deploy from the local source, not what the pipeline promotion will do; treat it as a reference, not a contract. Capture OPERATION_ID from the x-ms-operation-id header immediately; it is not reliably retrievable later. Pipelines copy definitions only; refresh separately (workflow 5).
5. Post-deploy: refresh, then DAX regression tests
A pipeline or XMLA deploy moves definitions, not data. Refresh with fab, wait, then run the te test suite against the live model.
WS_ID=$(fab get "Test.Workspace" -q "id" | tr -d '"')
MODEL_ID=$(fab get "Test.Workspace/Sales.SemanticModel" -q "id" | tr -d '"')
fab api -A powerbi "groups/$WS_ID/datasets/$MODEL_ID/refreshes" -X post -i '{"type":"Full"}'
# Wait for the async refresh before asserting (neither tool has a blocking wait; poll in a loop)
until s=$(fab api -A powerbi "groups/$WS_ID/datasets/$MODEL_ID/refreshes?\$top=1" -q "value[0].status" | tr -d '"'); [ "$s" = "Completed" ] || [ "$s" = "Failed" ]; do sleep 30; done
te test run --suite ./.te-tests/ --ci azdo --trx test-results.trx --non-interactive -s "Test Workspace" -d "Sales"Do not run te test before the refresh finishes; stale or empty data causes false failures. te test run needs a .te-tests/ suite; scaffold one first with te test init --example. In CI, set AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID and pass --auth env.
6. Governance: enumerate with fab, audit with te
Discovery only comes from fab; te has no workspace or item listing surface. Pair them for tenant-wide BPA audits, VertiPaq profiling, and unused-column sweeps.
# Enumerate semantic models across all visible workspaces
fab find '' -P type=SemanticModel -l --output_format json > /tmp/models.json
jq -r '.[] | "\(.workspace)/\(.name)"' /tmp/models.json
# Per model: export, then analyze locally
mkdir -p /tmp/audit
fab export "Production.Workspace/Sales.SemanticModel" -o /tmp/audit -f
te bpa run /tmp/audit/Sales.SemanticModel/definition --rules ./BPARules.json --fail-on error --ci github
te deps --unused --hidden -m /tmp/audit/Sales.SemanticModel/definition --output-format json
te vertipaq /tmp/audit/Sales.SemanticModel/definition --columns --detail --top 20For governance fields fab find does not expose (last refresh, storage mode, owner, capacity SKU), use the fabric-cli skill's scripts/search_across_workspaces.py (note its filter is --type Model, not SemanticModel). VertiPaq stats from an offline TMDL export give column/relationship structure but not live row counts or cardinality; for those, point te vertipaq at a live -s/-d XMLA endpoint with --stats. te deps --unused flags objects with no DAX references, but a relationship key column can show as "unused" despite being load-bearing; inspect with te get before removing.
Boundaries and gotchas
- Path layout after `fab export`. TMDL lands in
<Model>.SemanticModel/definition/.te validate/bpa run/vertipaq/deployoperate on the model source;fab importwants the.SemanticModelfolder (with.platform). Confirm the exacttetarget withte loadfirst. - Return path: XMLA vs REST.
te deploywrites via XMLA and runs BPA inline but needs XMLA write (Premium/Fabric capacity, PPU with XMLA, or Trial).fab importwrites via the Fabric REST API on any SKU but runs no gate. Pick by SKU and by whether the inline BPA gate is wanted. - `te diff` exit codes are documented inconsistently in the te-cli skill. The exit-codes table (config-cicd-env.md) says
2= models differ,1= generic failure; the command-reference table says1= differs,2= error. Do not branch on the exact code without verifying against the installed binary (te diff a b; echo $?). Both agree0= identical. - Two-document JSON from `te bpa run --fix`. With
--output-format json, a--fixrun emits two concatenated JSON documents (scan result, then fix summary). Pipe throughjq --slurp(jq -s '.[0]'/.[1]) or drop--output-format jsonand read text. A single-document parser fails with trailing-token errors. - Quoted GUIDs from `fab get`.
fab get -q "id"returns a quoted string; always| tr -d '"'before interpolating into an API path. - `fab` REST refresh path shape.
fab api -A powerbiuses the Power BIgroups/<ws-id>/datasets/<model-id>shape; the Fabric REST API usesworkspaces/<ws-id>/semanticModels/<model-id>. Same GUIDs, different URL. - No native pipe between the CLIs.
fab find/search_across_workspaces.pyproduce paths; a shell intermediary (jq,while read) feeds them into ateloop.tehas no multi-model loop and no service-discovery command of its own. - Always `mkdir -p` before `fab export` (it does not create parents), and always `-f` on
fab export/import(skips the sensitivity-label / overwrite prompt). If sensitivity labels or DLP policies are in play, confirm with the user before exporting;-fstrips the label on export. - CI flags on `te`. Pass
--non-interactiveand--force(onte deploy) or the confirmation prompt defaults tonand hangs the pipeline. Use--auth envwithAZURE_CLIENT_*; never put a secret on the command line. - Thin-report rebinding is a `fab` job. After a
fab importorfab cpof a thin report to a new workspace, rebind withfab set "<ws>/<Report>.Report" -q semanticModelId -i "<target-model-id>". Deployment pipelines preserve IDs and skip this; manual import/copy does not.
Get the te CLI binary (agent self-service)
The te (Tabular Editor CLI) binary is published per platform on a public CDN, no account or auth needed:
https://cdn.tabulareditor.com/files/cli/latest/te-<os>-<arch>.tar.gz (.zip on Windows)
Pick the asset for the platform:
- macOS arm64 (Apple Silicon):
te-osx-arm64.tar.gz - macOS x64 (Intel):
te-osx-x64.tar.gz - Linux x64:
te-linux-x64.tar.gz - Linux arm64:
te-linux-arm64.tar.gz - Windows x64:
te-win-x64.zip - Windows arm64:
te-win-arm64.zip
macOS / Linux: detect, download, put on PATH
os=$(uname -s | tr 'A-Z' 'a-z'); [ "$os" = darwin ] && os=osx
arch=$(uname -m); case "$arch" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=x64 ;; esac
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://cdn.tabulareditor.com/files/cli/latest/te-$os-$arch.tar.gz" \
| tar xz -C "$HOME/.local/bin" te
chmod +x "$HOME/.local/bin/te"
export PATH="$HOME/.local/bin:$PATH" # this shell
"$HOME/.local/bin/te" --versionPersist PATH across shells once (skip if ~/.local/bin is already on PATH):
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrcWindows (PowerShell): download, put on PATH
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
$dir = "$env:LOCALAPPDATA\Programs\te"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Invoke-WebRequest "https://cdn.tabulareditor.com/files/cli/latest/te-win-$arch.zip" -OutFile "$env:TEMP\te.zip"
Expand-Archive -Force "$env:TEMP\te.zip" -DestinationPath $dir
[Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$dir", 'User')
& "$dir\te.exe" --versionVerify and stay current
te --version prints the build. The CDN latest path is always the newest at download time; for a binary that keeps itself current, use the self-updating te wrapper instead (te --update, plus a daily check on te --version).
te gotchas
Sharp edges and non-obvious behavior. Companion to the te-cli skill (SKILL.md).
Gotchas
Path & property-name asymmetries
- MPartition path asymmetry:
te addfor an MPartition uses<Table>/<PartitionName>(no/Partitions/segment). Every other partition command;te rm,te get,te ls,te mv,te set; uses<Table>/Partitions/<PartitionName>. Mixing these up errors with "Cannot add a MPartition at path … Check that -t matches the path shape." - Partition M property: `MExpression` for `te set`, `expression` for `te get`:
te get <Table>/Partitions/<P>displays the M asexpression, butte set -q expression …errors with "Property 'expression' not found on MPartition. Did you mean: MExpression?"; use-q MExpression -i "<M>" --saveto update. - `te mv` cannot rename a partition to its parent table's name:
te mv <Table>/Partitions/<Table>_m <Table>/Partitions/<Table>errors with either "Destination '<Table>/<Table>' already exists" or "Partition '<Table>' not found in table '<Table>'"; the destination path<Table>/<Table>resolves to the table object itself, not a partition slot inside the table. Passing-t Partitiondoes not disambiguate. Workaround: rename via theNameproperty;te set <Table>/Partitions/<Table>_m -q Name -i <Table> --save. - Object paths use `/` as separator, not
\or.. Quote paths with spaces:"Sales 2024"/Revenue,"_Measures/Total Revenue","Date/Date Hierarchy/Year". - `te ls` cannot list relationships: both
te ls Relationshipsandte ls --type relationshiperror withError: No objects match path 'Relationships'even when the model has relationships (confirmed against a 23-relationship model, local TMDL and live XMLA alike).relationshipis advertised inte ls --type's help, but unlike every other container (Tables,Measures,Roles,Perspectives,Cultures,Hierarchies) the keyword is not wired to the collection, so it falls through to a literal path match. Tell: recognized-but-empty containers sayNo objects match 'X'; the broken keyword saysNo objects match path 'X'(note the wordpath). Enumerate relationships with DAX instead:te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()"(friendlyfrom ∞←1 to, active flag, cross-filter direction), orINFO.RELATIONSHIPS()on older compat, orte saveto TMDL and readrelationships.tmdl. A single relationship is addressable by name viate get Relationships/<name>, but auto-assigned names (Relationship 2) make DAX enumeration the practical discovery path.
Object types and creation
- `-t DataColumn` is not accepted by `te add`: data columns are declared at table creation via
--columns "Name:Type,..."(auto-creates withSourceColumn = Name). Tune additional properties (IsKey,IsHidden,FormatString,SummarizeBy,SortByColumn,Description) afterwards withte set. OnlyCalculatedColumncan be added individually with-t CalculatedColumn -i "<DAX>". - Workflow ordering; relationships before measures: measures that use
RELATED()or cross-tableCALCULATE()are validated at save time. If the relationship doesn't exist yet, the save gate rejects withDAX0002: Column '<T>'[<C>] doesn't have a relationship to any table available in the current context. Add relationships before authoring dependent measures. If that order is impossible (scripted batch creation), use--forceonly as a last resort and runte validateimmediately after to confirm no DAX errors remain before committing. - `te rm` on the last partition fails with "Cannot remove last partition from table". Always add a replacement partition first, then remove the original.
- Silent M syntax errors during partition authoring: a typo in the partition M (unbalanced braces, an unquoted token) doesn't always raise at save time. After creating a data-bound table, sanity-check with
te get <Table>/Partitions/<Table>to confirmsourceType: Mand that the expression matches what was passed in.
Output shapes
- `te bpa run --output-format json`: top-level
violationsis the count (integer), top-levelresultsis the list of violation objects withruleId,severityLabel,objectName,canFix, etc. Don't confuse the two when piping intojq. - `te bpa run --fix [--save] --output-format json` emits TWO concatenated JSON documents to stdout, not one. Never pipe it into a single-document JSON parser. First comes the scan result (
{model, rulesEvaluated, violations, results, errors, ...}), then a fix summary ({fixed, fixErrors, skipped, fixedItems, fixErrorItems}). Strict single-document parsers fail with an "extra data" / "trailing tokens" error on the second document. Prefer in this order:
1. `jq --slurp` (shell-native, no interpreter dependency): te bpa run --fix --save --output-format json | jq -s '.[0].violations' (scan count) or jq -s '.[1].fixed' (fix count). --slurp reads concatenated documents into an array. 2. Drop `--output-format json` entirely and rely on the human-readable text for one-shot summaries; simplest and works without any extra tooling. Pair with --ci github/--ci azdo when machine-actionable annotations are needed. 3. Tail-grep for the summary lines (grep -E '"violations":|"fixed":' | head -2) when only the counts are needed.
Behavior
- `te ls` is filesystem-style, not workspace-style.
te ls Saleslists Sales' children (columns + measures), not "find Sales". Usete findfor full-text search. - `--save` is opt-in for editing commands (when
interactiveEditModeis the defaultstage). Without it,te set,te add,te rm,te mv,te replace,te format,te script,te macro runoperate in memory only and don't persist. See the Staging model section in SKILL.md for the full picture and thesave/revertalternatives. - `te validate` does not exercise partition M; it checks structural/DAX validity, not whether
Table.FromRowsliterals parse or SQL endpoints respond. A model with broken partitions still passeste validatecleanly. Verify partitions explicitly after table creation. - `te connect` is session-scoped (per shell PID). Each fresh shell (each Claude Code
Bashtool call spawns one) starts a new session without the active connection. Either setTE_SESSION=<name>to share state between shells, or pass-m <model>(and-s/-dwhere needed) explicitly to every command. - BPA config keys are nested under `bpa.`.
te config set bpaOnDeploy falsewill fail with "Unknown key"; the correct form iste config set bpa.onDeploy false. Same forbpa.onSave,bpa.onMutation,bpa.rules,bpa.builtInRules,bpa.disabledBuiltInRuleIds. - `--output-format` (stdout) vs `--serialization` (on-disk) are different flags. The first picks how stdout is rendered (text/json/csv/tmsl/tmdl); the second picks the model file format (tmdl/bim/te-folder/pbip/database.json). Passing one when the other was meant gives a confusing error or silent wrong output.
- `te deploy --create-only` fails if model exists. Use without
--create-onlyto overwrite (the default), or check with a probe call first. - `te deploy` confirmation prompt hangs CI without
--force. Default answer isn(safe), so non-interactive runs need--force --non-interactive. - BPA gate on deploy/save can mask sloppy commits. If bypassing with
--skip-bpa, log it loudly. Prefer--fix-bpaor address violations. - TE2 compat mode auto-detects when args contain TE2-style flags but no
tesubcommand. Don't rely on this in scripts; be explicit withTE_COMPAT=te2so behavior is reproducible. - Local SSAS / Power BI Desktop are Windows-only:
te connect --localandte connect "localhost:PORT"won't work on macOS/Linux even though the binary runs there. - Preview banner reappears 14 days before the 2026-09-30 cutoff regardless of
hidePreviewNotice. Plan for the hard expiry. - Secrets on the cmd line leak into
ps, shell history, CI logs. Use--auth envwith env vars, stdin (-), or--auth managed-identity.
te + pbir tandem workflows
te (Tabular Editor CLI) owns the semantic model: tables, columns, measures, relationships, DAX expressions, BPA, validation, and deployment to a workspace. pbir (pbir CLI) owns the Power BI report layer (.pbir): pages, visuals, field references and bindings, filters, themes, bookmarks, and extension measures. Neither tool crosses the line. te has no visibility into report JSON, and pbir cannot mutate TMDL or run DAX as a model edit. The contract that joins them is the Table.Field string: te changes an object's identity in the model, then pbir rewrites every report binding that still points at the old Table.Field. Run both halves, or ship a broken model or a broken report.
Two rules before every command: model edits in te stage in memory and need --save to persist; te replace and pbir fields replace are dry-run-leaning (te replace previews unless --save is passed; pbir fields replace runs for real unless --dry-run is passed). When a flag or argument order is unfamiliar, run te <command> --help or pbir <command> --help first; both CLIs are evolving and the help text is authoritative.
1. Rename a measure (model) and repair report bindings
The most common refactor. te mv renames the object only; it does not rewrite DAX that calls the old name, so te replace --in expressions is a mandatory second step.
# te: confirm the object and find DAX that calls it by name
te find "OldRevenue" --in names --paths-only -m ./Model.SemanticModel
te find "OldRevenue" --in expressions -m ./Model.SemanticModel # measures, calc columns, KPIs, role filters, calc-group selection
te deps "_Measures/OldRevenue" --downstream -m ./Model.SemanticModel # blast radius
# te: rename the object, then fix every DAX reference to the old name
te mv "_Measures/OldRevenue" "_Measures/Revenue" --save -m ./Model.SemanticModel
te replace "OldRevenue" "Revenue" --in expressions --save -m ./Model.SemanticModel
# te: gate before touching the report
te validate -m ./Model.SemanticModel --errors-only
# pbir: find report bindings on the old reference (run --help to confirm arg order)
pbir fields find "Report.Report" -f "_Measures.OldRevenue"
# pbir: preview, then apply the report-side rewrite
pbir fields replace "Report.Report" --from "_Measures.OldRevenue" --to "_Measures.Revenue" --dry-run
pbir fields replace "Report.Report" --from "_Measures.OldRevenue" --to "_Measures.Revenue"
# pbir: confirm every binding resolves against the model
pbir validate "Report.Report" --fieldsPer-step purpose:
te find --in names: confirm the measure exists and get its path before touching anything
te find --in expressions: discover DAX that calls the old name by value; te mv will NOT fix these
te deps --downstream: list measures/columns downstream to show the full impact
te mv: rename the TOM object (the name property only)
te replace --in expressions: rewrite every DAX call of the old name across the model; needs --save
te validate --errors-only: confirm no broken DAX references remain
pbir fields find: locate visuals, filters, and CF entries bound to the old reference
pbir fields replace --dry-run: preview the report rewrite
pbir fields replace: rewrite queryState projections, queryRefs, and nativeQueryRefs in one pass
pbir validate --fields: confirm all bindings resolve; zero broken references expected2. Rename a column (model) and repair report bindings
Same shape as a measure rename, plus column-only metadata to check after te mv.
te find "OldColumnName" --in names --paths-only -m ./Model.SemanticModel # check for same name on other tables
te deps "Date/OldColumnName" --downstream -m ./Model.SemanticModel
te mv "Date/OldColumnName" "Date/NewColumnName" --save -m ./Model.SemanticModel
te replace "OldColumnName" "NewColumnName" --in expressions --save -m ./Model.SemanticModel
te validate -m ./Model.SemanticModel --errors-only
pbir fields find "Report.Report" -f "Date.OldColumnName"
pbir fields replace "Report.Report" --from "Date.OldColumnName" --to "Date.NewColumnName" --dry-run
pbir fields replace "Report.Report" --from "Date.OldColumnName" --to "Date.NewColumnName"
pbir validate "Report.Report" --fieldsAfter the rename, verify two column relationships that store the old name as a property, not as a reference te replace would catch:
te get Date/SomeOtherColumn -q sortByColumn -m ./Model.SemanticModel # update with te set if it broke
te ls "Date/Geography/Levels" -m ./Model.SemanticModel # hierarchy levels take a column property3. Rename a table (model) and repair all report bindings
pbir fields replace works per Table.Field, not per table. There is no bulk table-prefix rewrite. Enumerate the affected fields first, then loop. Run the model-internal DAX rewrite before the rename so expressions are consistent at save time.
te find "FACT_Sales" --in names -m ./Model.SemanticModel
te find "FACT_Sales" --in expressions -m ./Model.SemanticModel
te replace "FACT_Sales" "Sales" --in expressions --save -m ./Model.SemanticModel
te mv FACT_Sales Sales --save -m ./Model.SemanticModel
te validate -m ./Model.SemanticModel --errors-only
# pbir: list fields, then replace each FACT_Sales.* binding individually
pbir fields list "Report.Report" --json
pbir fields replace "Report.Report" --from "FACT_Sales.Amount" --to "Sales.Amount" --dry-run
pbir fields replace "Report.Report" --from "FACT_Sales.Amount" --to "Sales.Amount"
# repeat the replace for every distinct FACT_Sales.<field> in the report
pbir validate "Report.Report" --fieldste replace --in expressions: rewrites DAX text only; apostrophe-quoted refs need the quotes in the find term, e.g. te replace "'FACT_Sales'" "'Sales'" --in expressions --save
te replace substring risk: if the old name is a substring of another identifier, add --case-sensitive or --regex with anchors and review the preview
relationship endpoints: te mv renames the table object; confirm relationship integrity with te validate4. Move a measure to a different table and update bindings
te mv across tables is the only way to change an object's table ownership. Only fully table-qualified DAX (SourceTable[Measure]) breaks; unqualified [Measure] keeps resolving model-wide.
te deps "SourceTable/MeasureName" --downstream -m ./Model.SemanticModel
te mv "SourceTable/MeasureName" "TargetTable/MeasureName" --save -m ./Model.SemanticModel
te find "SourceTable" --in expressions --paths-only -m ./Model.SemanticModel # decide if te replace is needed
te validate -m ./Model.SemanticModel --errors-only
pbir fields find "Report.Report" -f "SourceTable.MeasureName"
pbir fields replace "Report.Report" --from "SourceTable.MeasureName" --to "TargetTable.MeasureName"
pbir validate "Report.Report" --fieldsDisplay folder does not move with te mv; reset it on the moved measure if it should match the new table's folder structure (te set "TargetTable/MeasureName" -q displayFolder -i "<folder>" --save).
5. Scaffold a model, then create a thin report against it
te builds the TMDL model and deploys it; pbir creates a thin report bound to the published model byConnection. Deploy is a hard prerequisite for the -c workspace binding to resolve.
# te: scaffold and author
te init ./Model.SemanticModel --serialization tmdl # PowerBI mode, compat 1702 default
te add Sales -t Table --columns "OrderID:Int64,Amount:Decimal,OrderDate:DateTime" --save -m ./Model.SemanticModel
te add "_Measures/Revenue" -t Measure -i "SUM(Sales[Amount])" --save -m ./Model.SemanticModel
te set "_Measures/Revenue" -q formatString -i "#,0.00" --save -m ./Model.SemanticModel
te set "_Measures/Revenue" -q displayFolder -i "Revenue" --save -m ./Model.SemanticModel
# te: gate and deploy
te validate -m ./Model.SemanticModel --errors-only
te bpa run --fail-on error -m ./Model.SemanticModel
te deploy ./Model.SemanticModel -s "MyWorkspace" -d "Sales Model" --force --non-interactive
# pbir: create thin report bound to the published model, build, validate
pbir new report "Sales.Report" -c "MyWorkspace/Sales Model.SemanticModel"
pbir pages rename "Sales.Report/Page 1.Page" "Overview"
pbir model "Sales.Report" -d # introspect tables/measures before binding
pbir add visual card "Sales.Report/Overview.Page" --title "Revenue" -d "Values:_Measures.Revenue" -t Measure --y 120
pbir validate "Sales.Report" --fieldste deploy --force --non-interactive: deploy prompts with n as the default and hangs scripts without --force; set both in CI
pbir new report -c: a workspace target produces a byConnection (thin) report; the model must be reachable in the workspace first
pbir add visual -t Measure: pass the type or -d defaults to a Column binding, which fails at runtime even though validate passes the JSON
pbir model -d: schema comes via TMDL, not DMV; -q runs EVALUATE DAX only
te validate scope: does not exercise M partitions; broken M surfaces only on refreshTo bind to a local model on disk instead of a workspace, create the report and then rebind with the documented local form:
pbir report rebind "Sales.Report" --local "../Sales.SemanticModel"6. Add a measure to a live model, then surface it in a bound report
te ls Measures -m ./Model.SemanticModel # check naming conventions, avoid duplicates
te add "_Measures/Revenue YoY" -t Measure -i "DIVIDE([Revenue], CALCULATE([Revenue], SAMEPERIODLASTYEAR('Date'[Date]))) - 1" --save -m ./Model.SemanticModel
te set "_Measures/Revenue YoY" -q formatString -i "0.0%" --save -m ./Model.SemanticModel
te set "_Measures/Revenue YoY" -q displayFolder -i "Revenue" --save -m ./Model.SemanticModel
te set "_Measures/Revenue YoY" -q description -i "Year-over-year revenue growth" --save -m ./Model.SemanticModel
te validate -m ./Model.SemanticModel --errors-only
te deploy ./Model.SemanticModel -s "MyWorkspace" -d "Sales Model" --force --non-interactive
pbir model "Sales.Report" --cache # refresh the report's cached model definition
pbir model "Sales.Report" -d -t _Measures | grep -i "YoY"
pbir add visual card "Sales.Report/Overview.Page" --title "Revenue YoY" -d "Values:_Measures.Revenue YoY" -t Measure --y 120
pbir validate "Sales.Report" --fieldsThe Date table must be marked (te set Date -q dataCategory -i Time --save) for SAMEPERIODLASTYEAR to evaluate; te validate catches a missing mark.
7. Remove a column: clear report references first, then delete
Report-first, model-second. Clear the report bindings while the column still exists so validation can still resolve the type, then delete in the model. Reversing the order breaks the report on deploy.
te deps Sales/OldRegionCode --downstream -m ./Model.SemanticModel # model-side dependents
# pbir: find and remove the report references first
pbir fields find "Report.Report" -f "Sales.OldRegionCode"
pbir validate "Report.Report"
# surgical removal per visual is safer than a broad clear:
pbir visuals bind "Report.Report/Page.Page/Visual.Visual" -r "Category:Sales.OldRegionCode"
# te: delete only after the report is clean
te rm Sales/OldRegionCode --dry-run -m ./Model.SemanticModel
te rm Sales/OldRegionCode --if-exists --save -m ./Model.SemanticModel
te validate -m ./Model.SemanticModel --errors-only
te deploy ./Model.SemanticModel -s "MyWorkspace" -d "Sales Model" --force --non-interactiveremoval granularity: pbir visuals bind -r removes one role binding on one visual; prefer it over a report- or page-wide pbir fields clear, which strips bindings broadly and can leave visuals with empty roles
sort-by dependency: te rm fails if the column is another column's sortByColumn target; clear that first with te set OtherColumn -q sortByColumn -i "" --save8. Split a thick PBIP, edit the model, keep the report in sync
pbir owns the structural split and the definition.pbir connection record; once the model is in the workspace, te edits it directly over the workspace endpoint.
pbir model "ThickReport.Report" # confirm byPath (thick)
pbir report split-from-thick ThickProject --target "MyWorkspace.Workspace/Sales Model.SemanticModel" -F pbir
pbir model "ThickReport.Report" # confirm byConnection (thin)
te load -s "MyWorkspace" -d "Sales Model" # confirm te reaches the published model
te set "_Measures/Revenue" -q description -i "Total net revenue" -s "MyWorkspace" -d "Sales Model" --save
te bpa run --fail-on error -s "MyWorkspace" -d "Sales Model"
pbir validate "ThickReport.Report" --fieldsAfter split-from-thick there is no local TMDL to pass to -m; use -s/-d for all later te commands. The split's publish step needs the Fabric CLI (fab) authenticated, separate from te auth.
9. Deploy and publish together
te validate -m ./Model.SemanticModel --errors-only && te bpa run --fail-on error -m ./Model.SemanticModel
te deploy ./Model.SemanticModel -s "MyWorkspace" -d "Sales" --force --non-interactive
pbir report rebind "Sales.Report" "MyWorkspace/Sales.SemanticModel" # byPath -> byConnection
pbir validate "Sales.Report" --fields # validate against the remote model
pbir publish "Sales.Report" "MyWorkspace/Sales" -f # positional args, not --workspacepbir report rebind must come after te deploy completes, or validation at publish time fails. pbir publish takes positional source and destination, never --workspace.
Boundaries and gotchas
te owns:
- object identity (te mv / te set -q name) and DAX expression repair (te replace --in expressions)
- dependency analysis (te deps), validation (te validate), BPA (te bpa run), deploy (te deploy)
- te mv renames the object only; it does NOT rewrite DAX that calls the old name
- te replace previews by default; pass --save to persist; it is literal text find-replace (use --regex / --case-sensitive for substring or quoted-name cases)
- te find --in expressions covers measure DAX, calc columns, KPI expressions, partition M, role filters, and calc-group selection; it does NOT see report JSON
pbir owns:
- report-layer references: visual queryState projections, filters, CF, slicer bindings
- pbir fields replace works per Table.Field; there is no table-level bulk rewrite, so a table rename is one replace per affected field (enumerate with pbir fields list first)
- pbir validate --fields resolves against the connected model; if the report is byPath it validates against the local TMDL, if byConnection against the workspace model (confirm with pbir model first)
- pbir add visual / visuals bind: pass -t Measure or the binding defaults to Column and fails at runtime
Not covered by pbir fields replace (handle separately):
- extension measures in reportExtensions.json: inspect with pbir dax measures list / json; rename the object with pbir dax measures rename, but the DAX body must be re-authored manually
- visual calculations: locate with pbir dax viscalcs json and update the DAX separately
- bookmark data states: pbir validate --fields surfaces broken refs but does not repair captured slicer/filter state; re-test bookmarks after a rename
Argument-order caveat:
- the pbir skill documents two forms for pbir fields find (report-first with -f, and search-term-first); run pbir fields find --help to confirm the build in use before scripting itSemantic modeling practices for te
Companion to the te-cli skill. The skill teaches how to drive the CLI; this file teaches what to build with it. Each practice pairs a modeling decision with the te command that applies it, and cites the authoritative source (Microsoft Learn, SQLBI, or the Tabular Editor blog). Apply these after te add creates an object, and gate every batch with te validate and te bpa run.
Three cross-cutting reminders that change every command below:
- Mutations stage in memory by default. Pass
--saveto persist, or sette config set interactiveEditMode savefor a build script. - Each
Bashcall is a fresh shell, sote connectdoes not carry over. Pass-m <path>(and-s/-dfor remote) on every command, or setTE_SESSION=<name>first. - Property names are case-insensitive and match TOM. When unsure what a property is called or whether it is settable, run
te get <obj>andte set <obj> -qfirst; those are the authoritative discovery tools. The less common properties below (isAvailableInMDX,discourageImplicitMeasures,metadataPermission, relationshipcrossFilteringBehavior) are real TOM properties but are not in the skill's documented-qlist; confirm them on a real object before scripting them in a pipeline, or fall back tote script(TOM).
Dimensional design (star schema)
Build a star: one fact table per business process at a single grain, surrounded by conformed dimensions joined dimension-to-fact by one-to-many relationships. The VertiPaq storage engine and the DAX engine are both optimized for this shape; dimensions filter and group, facts summarize, which is exactly how visuals generate DAX. Reserve a flat single-table model for small prototypes or a deliberate DirectQuery denormalization.
| Practice | Why | te command |
|---|---|---|
| Model a star, not a flat table | flat models measured up to ~2.65x larger in RAM and up to 2x slower on complex queries; where flat wins it is marginal | inspect topology first: te ls, te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()" (relationships; te ls cannot list them), te deps "Sales/Revenue" |
| One consistent grain per fact table; never join fact-to-fact (header/detail) directly | mixing grains gives wrong aggregations; fact-to-fact becomes a limited relationship (SQLBI's 1.4B-row test: ~15x CPU, 17 minutes vs seconds for distinct count) | add a shared dimension and relate both facts to it rather than to each other |
| Flatten snowflake sub-dimensions into one dimension per business entity | extra hops cost CPU on every filter pass and a hierarchy cannot span tables; run-length encoding makes the duplication cheap. Keep a sub-dimension separate only when it is large and reused across parents | do the join in Power Query upstream, then relate the single dimension |
| Keep degenerate dimensions (order/invoice number) as hidden fact columns | a one-column table for a high-cardinality attribute adds size and clutter for no gain | te set Sales/OrderNumber -q isHidden -i true --save |
| Host model measures on a dedicated hidden measure table; prefix to sort it to the top | separates calculations from data and keeps the field list clean (organizational, not an engine requirement; below ~20 measures, display folders inside the source table are equally valid) | te add _Measures -t Table --columns "_Measures:String" --partition-expression 'let Source = #table({"_Measures"}, {{""}}) in Source' --save, then te set _Measures -q isHidden -i true --save, then te mv Sales/Revenue "_Measures/Revenue" --save |
| Use plain business names (Customer, Sales); drop DIM_/FACT_ prefixes | a table's role comes from cardinality, not its name; prefixes degrade Q&A and Copilot interpretation (organizational, bends to warehouse naming constraints) | te mv FACT_Sales Sales --save |
Nuance to preserve: SQLBI shows a flat model can beat star for some simple low-cardinality groupings, so "star" is the default rather than a universal absolute; when flat wins it is marginal, when it loses it is far worse.
Sources: Understand star schema (MS Learn), The importance of star schemas (SQLBI), Star schema or single table (SQLBI), Header/Detail vs Star Schema (SQLBI), Naming conventions (Tabular Editor).
Relationships
Default every relationship to one-to-many cardinality and single cross-filter direction, flowing from the dimension to the fact. This is predictable, lets the storage engine pre-build join indexes, and avoids ambiguous filter paths.
| Practice | Why | te command |
|---|---|---|
| Add the dimension-to-fact relationship before authoring measures that cross it | measures using RELATED() or cross-table CALCULATE() validate at save time and the gate rejects with DAX0002 if the relationship is missing | te add "Sales[ProductKey]->Product[ProductKey]" --save |
| Keep single cross-filter direction; never set model-level bidirectional just to sync slicers | bidirectional on a model with 3+ related tables creates two propagation paths and subtly wrong numbers; scope the rare genuine need to one measure with CROSSFILTER(..., BOTH). Since 2019 a visual-level "is not blank" measure filter replaces the slicer-sync use case | list relationships with te query -q "EVALUATE INFO.VIEW.RELATIONSHIPS()" (te ls cannot enumerate them; see gotchas), read one with te get Relationships/<name> (the -> shorthand is for te add only); change crossFilteringBehavior only after confirming the property name with te set <rel> -q, or use te script |
| Treat many-to-many cardinality as a last resort; bridge dimension-to-dimension via two one-to-many relationships (exactly one leg bidirectional) | one-to-many gives a regular relationship with a blank row for RI violations; many-to-many cardinality gives a limited relationship with no join index, multiple passes, and silent drops of unmatched values | model the bridge with te add relationships rather than a direct many-to-many |
| When many-to-many is genuinely required, document that totals are non-additive | with shared membership the total counts each item once and will not equal the sum of visible rows; this is correct by design, the risk is silent misreading | add a te set <measure> -q description note on affected measures |
Role-playing dimensions: duplicate the physical dimension with an active relationship per role; use one shared table with inactive relationships + USERELATIONSHIP only when simultaneous multi-role filtering is not needed | active relationships give correct drag-and-drop results with no DAX; inactive ones force a USERELATIONSHIP wrapper in every measure and break Q&A. Never use USERELATIONSHIP in a calculated column (use LOOKUPVALUE) | load the dimension twice in Power Query (Order Date, Ship Date), each with its own active relationship |
| Avoid one-to-one; merge the tables in Power Query and use display folders | one-to-one splits fields users expect together, blocks cross-table hierarchies, and adds blank rows | merge upstream, then te set <col> -q displayFolder to regroup |
| Prefer regular over limited relationships; validate referential integrity before deploy | regular relationships propagate in one engine pass; limited relationships force multiple passes and degrade above low-cardinality joins | te validate -m ./model and check for unmatched keys before te deploy |
Sources: Relationships (SQLBI), Bi-directional guidance (MS Learn), Bidirectional ambiguity (SQLBI), Many-to-many guidance (MS Learn), Regular and limited relationships (SQLBI), Active vs inactive (MS Learn), Ambiguous paths (Tabular Editor).
VertiPaq, cardinality, and data types
Column cardinality (distinct value count), not row count, is the primary driver of model size. VertiPaq stores a per-column dictionary plus a compressed per-row index, and compression is governed almost entirely by distinct values. Profile with te vertipaq and attack the highest "% of DB" columns first.
| Practice | Why | te command |
|---|---|---|
| Profile by size and fix the biggest columns first | a high-cardinality column's dictionary alone can exceed 90% of its storage | te vertipaq --columns --detail --top 20 -m ./model; scope with te vertipaq Sales |
| Split a high-cardinality datetime into Date and Time parts (in Power Query, not a calculated column) | a sub-second datetime is near-unique; one real case went from 38.1% of DB to 0.3% (>99% reduction); a calculated column over the original reclaims nothing | fix in Power Query, then verify with te vertipaq Sales/OrderDate |
| Prefer narrow integer surrogate keys for relationship columns | the win is a smaller dictionary for the same distinct count, NOT an encoding switch (VertiPaq always hash-encodes relationship columns); SQLBI measured a relationship dropping from 4 MB to under 50 KB | te set Sales/CustomerKey -q dataType -i int64 --save |
| Use a Date data type (not integer YYYYMMDD) for the date key | SQLBI's 2B-row test found storage and scan essentially identical, so usability decides: Date enables native arithmetic and classic time intelligence | te set Date/Date -q dataType -i dateTime --save |
| Use Fixed Decimal (Currency) for monetary values; avoid Double/Single for aggregated columns | VertiPaq scans segments in parallel and floating-point addition is non-associative, so Double sums can vary between runs; Fixed Decimal is a deterministic scaled integer | te set Sales/Amount -q dataType -i decimal --save |
| Remove unused columns before deployment, not after | every imported column costs dictionary, data-segment, and attribute-hierarchy memory; removing one 50M-value identity column cut a model from 1.3 GB to 490 MB | te deps Sales/SomeCol --downstream to check, then te rm Sales/SomeCol --dry-run and te rm Sales/SomeCol --if-exists --save |
Set isAvailableInMDX = false on high-cardinality hidden columns (keep it true for Sort By Column targets when MDX/Excel clients exist) | the per-column attribute hierarchy is pure overhead on hidden columns unreachable from MDX (one case: 1.1 GB of hierarchy) | confirm the property name first with te set <col> -q, then te set Sales/Key -q isAvailableInMDX -i false --save (or te script) |
| Reduce numeric precision to what analysis needs when the business agrees | fewer distinct values means smaller dictionaries; rounding loses auditability, so validate with owners and consider keeping a hidden full-precision copy | round in Power Query, then re-profile with te vertipaq |
Nuance to preserve: integer-key superiority is about dictionary size, not a value-vs-hash encoding switch (the Tabular Editor blog rightly pushes back on "integer keys are mandatory" as relational dogma); state the mechanism correctly. The Date-vs-integer-key choice is usability-driven, not a performance win.
Sources: Optimizing high cardinality columns (SQLBI), Data reduction techniques (MS Learn), Optimizing semantic model size (Tabular Editor), Date or Integer for dates (SQLBI), Choosing numeric data types (SQLBI), Costs of relationships (SQLBI).
Usability and AI-readiness metadata
A model is consumer-ready and Copilot-ready only once measures and columns carry format strings, folders, descriptions, and the right summarization and visibility flags. Copilot and data agents read this metadata to ground DAX, so the same work that helps humans helps AI.
| Practice | Why | te command |
|---|---|---|
| Set a format string on every visible measure and numeric/date column | unformatted values render raw and inconsistently; Copilot reads format strings when grounding DAX | te set "_Measures/Revenue" -q formatString -i "#,0.00" --save |
Hide surrogate/foreign-key columns and set summarizeBy = none on numeric columns that should not aggregate | keys are scaffolding, not attributes; none removes the implicit-aggregation sigma so a key cannot be summed into nonsense, and Copilot/Q&A exclude hidden columns | te set Sales/CustomerKey -q isHidden -i true --save and te set Sales/CustomerKey -q summarizeBy -i none --save |
| Apply Sort By Column to display columns with non-alphabetical order (month, weekday, fiscal period) and hide the helper | without it "Month Name" sorts April, August, December; the sort column must be 1:1 with the display column | te set Date/MonthName -q sortByColumn -i MonthNumber --save then te set Date/MonthNumber -q isHidden -i true --save |
| Add descriptions to visible tables, columns, and measures; front-load the key guidance in the first 200 characters | descriptions feed Copilot/data-agent DAX (which reads the first 200 chars) and surface as service tooltips for humans | te set "_Measures/Revenue" -q description -i "Net revenue after returns and discounts" --save |
| Use human-readable, unique, business-aligned names; organize measures into display folders; avoid duplicate column names across tables | ambiguous or duplicated names make Copilot/Q&A misroute; folders keep a large field list navigable (purely cosmetic) | te set "_Measures/Revenue" -q displayFolder -i "Revenue" --save; qualify clashes as "Customer Name" vs "Store Name" with te mv |
Mark isKey on dimension primary keys, then hide them | signals the unique grain the engine builds filters from, and keeps the field list clean | te set Product/ProductKey -q isKey -i true --save then te set Product/ProductKey -q isHidden -i true --save |
| Set data categories on geographic, URL, and image columns | enables map visuals, clickable links, and image rendering, and Copilot reads them for grounding | te set Geo/City -q dataCategory -i City --save, te set Customer/Photo -q dataCategory -i ImageUrl --save (confirm category values with te get) |
For bulk metadata across many objects, prefer one te script pass over N te set calls; the model loads once, avoiding the ~1-2s startup per invocation:
echo 'foreach (var m in Model.AllMeasures) if (string.IsNullOrEmpty(m.DisplayFolder)) m.DisplayFolder = "Uncategorized";' | te script -e - -m ./model --saveSources: Star schema - Measures (MS Learn), Prepare a model for Copilot (MS Learn), Semantic model best practices for data agent (MS Learn), Sort By Column side effects (SQLBI), Built-in BPA rules (Tabular Editor), Naming conventions (Tabular Editor).
Date tables and time intelligence
Time intelligence needs one dedicated, shared Date dimension that all facts relate to, marked as a date table, with a contiguous date column at day grain spanning complete years.
| Practice | Why | te command |
|---|---|---|
| Build one shared Date dimension; never drive time intelligence off a fact datetime column | classic time-intelligence needs a separate date spine to apply REMOVEFILTERS against, and one shared table lets a single slicer drive every fact | relate each fact date to Date[Date] with te add "Sales[OrderDate]->Date[Date]" --save |
Mark the Date table (dataCategory = Time) and designate the date key | marking designates the date spine so time-intelligence functions (DATESYTD, SAMEPERIODLASTYEAR) know which column to clear and enumerate against, and it suppresses auto date/time; without it those functions intersect the current filter context and return silent wrong results | te set Date -q dataCategory -i Time --save then te set Date/Date -q isKey -i true --save |
Remove auto date/time tables (LocalDateTable_*, DateTableTemplate_*) | auto date/time creates a hidden calculated table per date column, inflating size and refresh, cannot be shared, and is invisible to Excel/paginated/XMLA clients | find them with te ls, remove with te rm <table> --save; disable the toggle at the report/source so they do not regenerate on the next author save |
Keep blank/null dates in the fact rather than inflating the Date table; guard comparisons with NOT ISBLANK() | blanks on the many side add a (Blank) slicer row and distort totals, and comparison operators treat BLANK as a pre-1900 date | author guarded measures with te add/te set -q expression |
| Standardize one org-wide Date definition (warehouse DimDate, dataflow, or parameterized DAX) | a shared definition prevents fiscal-year, week-numbering, and naming drift across models | import the warehouse DimDate where one exists |
te validate checks structural and DAX validity but does not check date contiguity. Probe for gaps with a query:
te query -q "EVALUATE ROW(\"Gap\", COUNTROWS(Date) - (MAX(Date[Date]) - MIN(Date[Date]) + 1))" -m ./modelA nonzero Gap means the date column is not contiguous.
Sources: Date tables guidance (MS Learn), Mark as Date table (SQLBI), Auto date/time guidance (MS Learn), Blank in date columns (SQLBI).
Measures, calculated columns, and calculation groups
Default to explicit DAX measures for all aggregated logic. Push row-level computation upstream, and use calculation groups to kill measure sprawl.
| Practice | Why | te command |
|---|---|---|
| Author aggregations as explicit measures, not implicit sigma-column measures | measures are metadata-only and respond to filter context; implicit measures break MDX clients, let authors pick wrong aggregations, and cannot join calculation groups | te add "_Measures/Total Sales" -t Measure -i "SUM(Sales[Amount])" --save |
| Push row-level columns upstream: source/warehouse, then Power Query, then DAX calculated column, then measure | DAX calculated columns compress worse (SQLBI: ~4x larger in one case) because they skip the sort-order search and they extend refresh; Power Query columns can fold to the source | compute in Power Query; reserve DAX calculated columns for RELATED, PATH, or COMBINEVALUES keys |
| Remember calculated columns and tables are unsupported in DirectQuery and (as of mid-2026) Direct Lake | DirectQuery prohibits them (use COMBINEVALUES for multi-column keys); Direct Lake transcodes from Delta and does not materialize them, so push to the Lakehouse | for a calc column where unavoidable: te add Sales/Bucket -t CalculatedColumn -i "<DAX>" --save |
Use calculation groups for time-intelligence or currency-conversion families with SELECTEDMEASURE(); set discourageImplicitMeasures = true | N base measures x M variants becomes N + M objects; calculation groups only fire on explicit measures, so discouraging implicit measures keeps them consistent | see the worked example below; confirm the discourageImplicitMeasures property name with te set <group> -q, then set via te set or te script |
Scope items with ISSELECTEDMEASURE() rather than SELECTEDMEASURENAME() string comparison | ISSELECTEDMEASURE participates in rename fixup; a string literal silently breaks on rename | author the item DAX accordingly |
| Set a Format String Expression on any item that changes a measure's meaning (percentages, currency) | a YOY% item returning 0.12 displays "0" under a #,##0 base format | te set "Time Intelligence/YOY%" -q formatStringDefinition -i "\"0.0%\"" --save |
| Set Precedence deliberately when multiple calculation groups coexist, and test with a trivial measure first | active items apply in precedence order (highest is outermost), which also decides whose format string wins | confirm the property name with te set <group> -q |
| Consider parameterized DAX UDF measures over calculation groups when calculations must be opt-in (tooltip pages, filter panes, flexible matrices) | calculation items apply to every measure in context and can corrupt non-numeric measures; UDF measures are called explicitly per measure | see When DAX UDF measures beat calculation groups (Tabular Editor) |
Worked example, a Time Intelligence calculation group:
te add "Time Intelligence" -t CalculationGroup -m ./model --save
te add "Time Intelligence/YTD" -t CalculationItem -i "CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))" -m ./model --save
te set "Time Intelligence/YTD" -q ordinal -i 0 --save
te set "Time Intelligence/YTD" -q formatStringDefinition -i "SELECTEDMEASUREFORMATSTRING()" --saveexpression, ordinal, and formatStringDefinition are the documented CalculationItem properties. Confirm the calculation-item child-path form with te ls "Time Intelligence" after creating the group, before scripting many items in a pipeline.
Sources: Calculated columns and measures (SQLBI), DAX calculated columns vs Power Query (SQLBI), Data reduction - custom columns (MS Learn), Calculation groups (MS Learn), Understanding calculation groups (SQLBI), Controlling format strings in calculation groups (SQLBI).
Security and governance (RLS, OLS, BPA)
Define roles, set permissions, attach filters, then validate and deploy. Prefer dynamic RLS, assign Entra ID groups rather than individuals, and treat BPA as the governance gate.
te add Roles/RegionManagers -t Role -m ./model --save
te set Roles/RegionManagers -q modelPermission -i Read --save
# Dynamic filter: confirm the TablePermission add/path form with `te ls Roles/RegionManagers/TablePermissions`
te set Roles/RegionManagers/TablePermissions/Sales -q filterExpression -i "[Region] = USERPRINCIPALNAME()" --save
te validate -m ./model
te deploy ./model -s ws -d model --deploy-roles --deploy-role-members --force --ci github| Practice | Why |
|---|---|
Prefer dynamic RLS with USERPRINCIPALNAME() against a mapping table; assign Entra ID groups, not users | one dynamic role scales with master data; group membership changes in Entra ID without republishing. Use USERPRINCIPALNAME (not USERNAME, which returns DOMAIN\user in Desktop) |
Filter on the dimension and let active relationships propagate; move the filter to the fact when the dimension exceeds ~131K rows or carries USERELATIONSHIP paths | small dimensions get a reusable bitmap index; above the threshold the cache never activates and dimension filtering becomes worse than fact filtering |
| One role per access tier; membership is additive (OR), not subtractive | a user in a FALSE() role and a TRUE() role sees everything; combining OLS and RLS for the same user across roles throws a query-time error, so keep them in one combined role |
| Give consumers only Viewer/Read; Admin/Member/Contributor and model-editor SPNs bypass RLS and OLS entirely | any Edit-level identity silently removes all security; keep ETL/XMLA identities separate and least-privileged |
Avoid LOOKUPVALUE in RLS filters; propagate via relationships | LOOKUPVALUE in a security filter runs in the formula engine on every query and blocks storage-engine caching |
Do not rely on USERELATIONSHIP/CROSSFILTER to override an RLS-carrying relationship; relocate the filter | RLS propagates only through active relationships, and the engine blocks USERELATIONSHIP on an RLS-carrying relationship; TREATAS workarounds need explicit semantic review |
Set object-level security with te: per-role metadataPermission on a table or column (= None hides both the data and the object name). Use te script for TOM-level access if the property is not directly settable | DAX security targets tables and columns, not measures, so hiding a measure needs a sentinel-table workaround. Confirm the property name with te set <obj> -q before scripting it |
| Run BPA as a governance gate (Microsoft Analysis Services rule set as baseline + org rules); remember BPA detects whether roles exist, not whether they are correct | te bpa run loads rules from a URL and gates deploy on error-severity violations |
te bpa run --rules https://raw.githubusercontent.com/microsoft/Analysis-Services/master/BestPracticeRules/BPARules.json --fail-on error --ci github -m ./modelSources: RLS guidance (MS Learn), Security cost (SQLBI), RLS with inactive relationships (SQLBI), Object-level security (MS Learn), OLS in Power BI (Tabular Editor), RLS in Power BI semantic models (Tabular Editor), Using the BPA (Tabular Editor).
The validate-then-BPA-then-format authoring loop and the per-save BPA speed knob are in SKILL.md; run those gates continuously, not only at deploy.
Scope notes
These practices are grounded in the sources above. The research did not cover, and this file deliberately does not assert detailed guidance on: user-defined aggregation design (grain, agg-awareness precedence), incremental-refresh and partitioning strategy, composite/Direct Lake-plus-Import tuning, or absolute size thresholds at which star beats flat. For those, consult the live docs and verify against real VertiPaq measurements with te vertipaq.
Migrating from TE2 (TabularEditor.exe) to te
Companion to the te-cli skill (SKILL.md).
Migration from TE2
Activate TE2 compatibility three ways:
mv te te2 && ./te2 Model.bim -S fix.csx -D server db -O # 1. binary rename
TE_COMPAT=te2 te Model.bim -S fix.csx -D server db -O # 2. env var
te Model.bim -S fix.csx -D server db -O # 3. auto-detect from flagsFor the full mapping table (and an interactive single-flag lookup), run:
te migrate # full table
te migrate -A # prompt for a TE2 flag, get equivalent
te migrate --output-format json # machine-readable for codemodsMost-used mappings:
| TE2 flag | New CLI |
|---|---|
<file> (positional) | te <command> <path> or --model <path> |
-S <file.csx> / -S "code" | te script -S <file> / -e "code" |
-A <rules> / -AX <rules> | te bpa run --rules <rules> (-AX = no model rules, which is default) |
-D <server> <db> | te deploy <model> -s <server> -d <db> |
-O | (default; overwrite) |
-C | --deploy-connections |
-P | --deploy-partitions |
-R / -M / -SHARED | --deploy-roles / --deploy-role-members / --deploy-shared-expressions |
-FULL | --deploy-full |
-X <file> | --xmla <file> (use - for stdout) |
-V / -G | --ci vsts (also azdo/azure-devops) / --ci github (also gh); on validate, bpa run, deploy, script, test run |
-T <file> | --trx <file> |
-B <file> | te save -o <file> --serialization bim |
-TMDL <dir> | te save -o <dir> --serialization tmdl (default format) |
-F <dir> | te save -o <dir> --serialization te-folder (or --deploy-full after -D) |
-Y | --deploy-partitions --skip-refresh-policy |
-W / -E | (default) |
-L <user> <pass> (after -D) | te auth login -u <id> -p <secret> -t <tenant> (prefer env vars) |
-SC | _Not yet implemented_ |
Behavioral differences from TE2:
te deployruns BPA as pre-flight gate by default (TE2 didn't).--skip-bpato disable,--fix-bpato auto-fix.te deployprompts for confirmation. CI must pass--force.- All commands support
--output-format jsonfor machine-readable output. - No
start /waitwrapper needed on Windows; it's a normal console binary.
te common workflows
Multi-step recipes for the te CLI. Modeling-driven workflows (RLS roles, calculation groups, date tables) live in semantic-modeling-practices.md.
Common workflows
Build a new table with an M partition
te add <Table> -t Table on a model with no provider data source creates an MPartition by default. --partition-expression "<M>" delivers the M to that partition. Combine with --columns for a one-shot data-bound table:
te add Sales -t Table \
--columns "OrderID:Int64,Amount:Decimal,OrderDate:DateTime" \
--partition-expression "$(cat <<'EOF'
let
Source = Sql.Database("server", "db"),
Sales = Source{[Schema="dbo", Item="Sales"]}[Data]
in
Sales
EOF
)" -m ./model --saveResult: one table, one MPartition with the M in place, columns typed. te get Sales/Partitions/Sales -q sourceType returns M.
Variants:
# Columns + placeholder M (filled later via te set <Table>/Partitions/<Name> -q MExpression …)
te add Sales -t Table --columns "Id:Int64,Amount:Decimal" -m ./model --save
# M only, no explicit column schema (columns auto-discovered at refresh from the M output)
te add Sales -t Table --partition-expression "<M>" -m ./model --save
# Force M explicitly when the model has a legacy provider DS that would otherwise win
te add Sales -t Table --source-type m --columns "..." --partition-expression "<M>" -m ./model --save
# Opt into a legacy Query partition on a modern model
te add LegacyT -t Table --source-type query -q query -i "SELECT * FROM dbo.Foo" -m ./model --saveInline-data partitions (demo models, placeholder tables, calc-group hosts): use #table({...}, {{...}}) inside a single-quoted heredoc. Example for a _Measures placeholder table:
te add _Measures -t Table --columns "_Measures:String" \
--partition-expression 'let Source = #table({"_Measures"}, {{""}}) in Source' \
-m ./model --saveHeuristic for `-q expression -i "<value>"` (when neither --source-type nor --partition-expression is set): the M.Analyzer lexer tokenises the value and reports M if the first token is (, [, the let keyword, or an identifier starting with # (#table, #date, #shared, ...). Everything else (including bare identifiers and SQL-shaped strings) reports as Query, with a stderr hint pointing at --source-type m if the user meant M. Leading comments are skipped.
Pre-validation errors (fail before mutation):
--source-type calculatedpaired with-t Table→ use-t CalculatedTable--source-type mon a model with a provider data source → remove the DS, or use--source-type query--source-type mon Compatibility Level < 1400 → upgrade the model--source-typecombined with--mode directlake→ DL/Entity partitions are picked automatically
Updating an existing partition's M after creation: te set Sales/Partitions/Sales -q MExpression -i "<M>" --save (note MExpression, not expression, despite te get displaying the property as expression).
Convert TMDL ↔ BIM ↔ PBIP
te save ./model.bim -o ./tmdl-out # BIM → TMDL folder
te save ./tmdl-folder -o ./model.bim --serialization bim # TMDL → BIM
te save ./model.bim -o ./project --serialization pbip --supporting-files # BIM → PBIP (.platform / definition.pbism)Deploy from local TMDL with BPA gate + CI annotations
te deploy ./model \
-s "powerbi://api.powerbi.com/v1.0/myorg/MyWorkspace" -d "MySemanticModel" \
--force --ci github # BPA gate runs by default; pipeline fails on violationsFor CI without strict BPA gating: add --skip-bpa (one-shot) or te config set bpa.onDeploy false. To auto-fix instead of failing: --fix-bpa.
Generate TMSL/XMLA without deploying
te deploy ./model -s ws -d model --xmla deploy.tmsl
te deploy ./model -s ws -d model --xmla - > deploy.tmsl # to stdoutRefresh single partition with dry-run safety
te refresh --table Sales --partition "Sales.2024" --type full --dry-run > refresh.tmsl
# Review TMSL, then drop --dry-run to executeFind unused measures and remove with preview
te deps --unused --hidden # discover candidates
te rm Sales/UnusedMeasure --dry-run # confirm impact
te rm Sales/UnusedMeasure --if-exists --save # idempotent removalMirror remote workspace for local editing
te connect MyWorkspace MyModel -w ./local-mirror # remote → local TMDL
# Edit locally, push commits, experiment with `te set`, `te add`, etc.
te save # intended to write to both source (remote) and mirror (local); verify with `te connect --help` before relying on bidirectional mirroringBulk DAX format and BPA fix as one batch
te format --save && te bpa run --fix --saveRun a TE3 C# script against a remote model
te script -S ./scripts/format-all-dax.csx -s ws -d model --save
echo "foreach (var t in Model.Tables) t.Name = t.Name.Replace(\"_\", \" \");" | te script -e - --saveSnapshot + compare for regression testing
te test snapshot # capture baseline
# … make changes …
te test compare # detect driftAdditional authoring workflows
Modeling-driven recipes (mark a date table, calculation groups, RLS roles) live in semantic-modeling-practices.md, paired with the rationale for each. The recipes below are the remaining structural-object workflows. The te CLI is in preview; confirm any flag or path shape below with te <command> --help (or te ls <container> to see the exact child-path form) before scripting it in a pipeline.
Perspectives
Perspectives are saved field-list views. Create the perspective, then add tables and objects to it through the Perspectives/<perspective>/... path.
te add "Perspectives/Sales View" -t Perspective -m ./model --save
te add "Perspectives/Sales View/Sales" -m ./model --save # add the Sales table to the perspective
te add "Perspectives/Sales View/_Measures/Revenue" -m ./model --save # add a single measure
te ls "Perspectives/Sales View" # confirm membershipTranslations and cultures
Translations live on a culture object; the per-object translated strings are bracket-indexed properties (TranslatedNames[<culture>], TranslatedDescriptions[<culture>]).
te add Cultures/fr-FR -t Culture -m ./model --save
te set "_Measures/Revenue" -q "TranslatedNames[fr-FR]" -i "Revenu" -m ./model --save
te set "_Measures/Revenue" -q "TranslatedDescriptions[fr-FR]" -i "Revenu net" -m ./model --saveIncremental refresh setup
te incremental-refresh manages the policy on a table (show, set, remove, apply). A policy requires the RangeStart and RangeEnd NamedExpression parameters in the model first; the partition M must filter on them. The exact flags for set (granularity, rolling/archive window, detect-data-changes) are not pinned in this skill, so read them from the binary before use:
te incremental-refresh set --help # confirm the granularity / window / detect-changes flag names
te incremental-refresh show Sales -m ./model
te incremental-refresh apply Sales -m ./model # re-evaluate the policy, create/expand partitionsField parameters
A field parameter is a CalculatedTable whose DAX uses the NAMEOF(...) pattern plus specific annotations that Power BI Desktop expects. Hand-authoring the exact DAX and annotations through te add/te set is error-prone; prefer the field-parameter macro in the c-sharp-scripting skill (run via te macro run or te script), then verify with te get <Table> --output-format tmdl.