
Bpa Rules
- 39 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Interactively create and improve Best Practice Analyzer (BPA) rules for Power BI semantic models in Tabular Editor, including FixExpression auto-remediation.
About
Provides expert guidance for authoring and improving BPA rules that validate Power BI semantic models in Tabular Editor. A developer uses it to create, recommend, debug, or auto-remediate best-practice rules for a model.
- Guided discovery, model investigation, and rule authoring
- Writes FixExpression to auto-remediate rule violations
Bpa Rules by the numbers
- 39 all-time installs (skills.sh)
- Ranked #1,006 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 bpa-rulesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 39 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Interactively create and improve Best Practice Analyzer (BPA) rules for Power BI semantic models in Tabular Editor, including FixExpression auto-remediation.
Files
Best Practice Analyzer Rules
Expert guidance for creating and improving BPA (Best Practice Analyzer) rules for Tabular Editor and Power BI semantic models.
When to Use This Skill
Activate automatically when tasks involve:
- Creating new BPA rules for semantic model validation
- Recommending or choosing BPA rules for a model, team, or organization
- Improving or debugging BPA rule expressions
- Writing FixExpression to auto-remediate rule violations
- Understanding BPA annotations in TMDL files
- Analyzing a semantic model against best practices
- Converting ad-hoc checks into reusable BPA rules
- Auditing or discovering all BPA rules across sources (built-in, URL, model, user, machine)
Primary Workflow: Interactive Q&A Discovery (Double Diamond)
CRITICAL: Do NOT generate BPA rules immediately. This is a requirements-gathering exercise. Use the AskUserQuestion tool to conduct an iterative, back-and-forth conversation with the user across multiple rounds. Continue asking questions until sufficient context about the user's business, team, model, and priorities has been gathered. Only then move to rule generation.
The workflow follows a double-diamond pattern: 1. Diverge -- broadly explore the user's context, organization, and goals 2. Converge -- narrow down to specific priorities and constraints 3. Diverge -- explore the model structure and identify candidate rule areas 4. Converge -- select and generate the final tailored rule set
Diamond 1: Requirements Gathering (Phases 1-2)
Phase 1: Understand the User and Organization
Call AskUserQuestion with 2-4 questions per round. After each round, review the answers and ask follow-up questions. Do not proceed to Phase 2 until the organizational context is clear. Continue rounds until satisfied.
Round 1 -- Goal and audience:
Ask about the primary goal and who will use the rules. Example AskUserQuestion call:
- Question 1: "What is the primary goal for these BPA rules?" -- options: "Set up BPA for my team", "Improve a specific model", "Create governance/compliance rules", (Other)
- Question 2: "Who will use these rules?" -- options: "Solo developer", "Small team (2-5)", "Large org / multiple teams", (Other)
- Question 3: "What tooling do you use?" -- options: "Tabular Editor 3", "Tabular Editor 2", "TE CLI in CI/CD", "Fabric notebooks"
Round 2 -- Standards and existing rules:
Based on Round 1 answers, ask about conventions and existing rules. Example:
- Question 1: "Do you have existing naming conventions?" -- options: "Yes, documented", "Yes, informal/ad-hoc", "No conventions yet", (Other)
- Question 2: "Are there BPA rules already in use?" -- options: "Yes, in Tabular Editor", "Yes, from a URL/repo", "No existing rules", (Other)
- Question 3: "Which categories matter most?" -- multiSelect: true -- options: "Performance", "Metadata/Documentation", "DAX quality", "Naming conventions"
Round 3+ -- Follow-ups as needed:
If the user has existing rules, ask for the file path or URL and read them. If they have naming conventions, ask for specifics. If they mentioned CI/CD, ask about the pipeline setup. Keep calling AskUserQuestion until the organizational picture is clear.
Phase 2: Investigate the Model
After Phase 1, use AskUserQuestion to determine how to access the model:
- Question: "How is your semantic model available?" -- options: "Published to Fabric / Power BI Service", "Local as PBIP (.tmdl files)", "Local as .pbix file", "I have a model.bim file"
Then investigate the model based on the answer:
| Answer | Action |
|---|---|
| Published to Fabric | Use AskUserQuestion to get workspace and model name. Then use fab CLI to inspect remotely -- load the fabric-cli skill and read references/model-investigation.md for specific commands. |
| Local as PBIP | Use AskUserQuestion to get the path to the .SemanticModel/definition/ folder. Then read TMDL files directly with Read/Grep tools. |
| Local as .pbix only | Guide the user to save as PBIP: *File > Save as > Power BI Project (.pbip)** in Power BI Desktop. Then ask for the resulting folder path. See references/model-investigation.md for detailed steps. |
| model.bim file | Use AskUserQuestion to get the file path. Then parse with jq or read directly. |
| No model yet | Skip model investigation; generate general-purpose rules based on organizational context only. |
What to extract from the model (read files, grep patterns, count objects):
- Table count, measure count, column count
- Storage mode (Import, DirectQuery, Direct Lake, mixed)
- Metadata completeness (descriptions, display folders, format strings)
- DAX patterns in use (CALCULATE, FILTER/ALL, calculation groups, UDFs)
- Relationship patterns (bi-directional, many-to-many, inactive)
- RLS roles defined
- Naming conventions currently in use
- Existing BPA annotations already embedded in model.tmdl
After model investigation, summarize findings to the user and use AskUserQuestion to confirm the analysis is accurate and ask if anything was missed.
Diamond 2: Rule Generation (Phases 3-4)
Phase 3: Prioritize and Recommend
Based on everything gathered, present a prioritized recommendation of rule categories. Use AskUserQuestion to let the user confirm or adjust before generating any rules.
Present categories ranked by relevance to the user's context. For example:
If the model has many measures without descriptions and the user cares about governance: 1. Metadata (high priority -- many objects lack descriptions) 2. Governance (high priority -- user goal) 3. Performance (medium -- some unused hidden columns detected) 4. DAX Expressions (medium -- some FILTER/ALL patterns found) 5. Naming Conventions (low -- model already follows consistent naming)
Use AskUserQuestion to ask:
- Which categories to include (multiSelect)
- What severity level to assign to each category (or let the skill decide)
- Whether FixExpressions should be included (some teams prefer manual fixes)
Do not generate rules until the user confirms the priorities.
Phase 4: Generate Rules
Only after Phases 1-3, generate tailored BPA rules. For each rule:
- Explain why it is relevant to the user's specific model and context
- Include a FixExpression where safe and practical (if the user opted in)
- Set severity based on the user's stated priorities
- Use the organization's naming conventions in rule IDs and names
After generating rules, use AskUserQuestion to ask:
- Whether any rules should be adjusted, removed, or added
- Where to save the output (user-level, machine-level, model-embedded, or URL)
- Whether to validate with
scripts/validate_rules.py
Iterate on the rule set until the user is satisfied. Continue calling AskUserQuestion for refinements.
When to Skip Q&A
Skip the full Q&A workflow only when:
- The user asks for a specific, well-defined rule (e.g., "write a rule that checks for measures without descriptions")
- The user asks to improve or debug an existing expression
- The user asks to audit existing rules
- The user provides all context upfront in their request
Even in these cases, ask clarifying questions with AskUserQuestion if the request is ambiguous.
Critical
- Always validate rule expressions before suggesting them
- Test expressions against the target scope (Measure, Column, Table, etc.)
- Ensure FixExpression does not cause data loss or break the model
- Consider CompatibilityLevel when using newer TOM properties
Tabular Editor Compatibility
BPA rule files must follow specific formatting requirements for Tabular Editor to load them correctly. Files that don't follow these rules may show empty rule collections or fail to load entirely.
Line Endings (CRLF Required)
Tabular Editor on Windows requires Windows line endings (CRLF, `\r\n`). Files with Unix line endings (LF only) will fail to load or show empty rule collections.
To convert a file to CRLF:
# macOS/Linux
sed -i 's/$/\r/' rules.json
# Or use the validation script
python scripts/validate_rules.py --fix rules.jsonFile Paths
When adding rule files in Tabular Editor:
- Use absolute paths (e.g.,
C:\BPARules\my-rules.json) - Avoid relative paths with
..\..\..- TE may fail to resolve these - URLs work reliably (e.g.,
https://raw.githubusercontent.com/...)
JSON Format Requirements
No extra properties: TE's JSON parser is strict. Only use allowed fields:
ID,Name,Category,Description,Severity,Scope,ExpressionFixExpression,CompatibilityLevel,Source,Remarks
Avoid these patterns:
// BAD: _comment fields not allowed
{ "_comment": "Section header", "ID": "RULE1", ... }
// BAD: Runtime fields (TE adds these, don't include them)
{ "ID": "RULE1", "ObjectCount": 0, "ErrorMessage": null, ... }
// GOOD: FixExpression can be null or omitted
{ "ID": "RULE1", "FixExpression": null, ... }
{ "ID": "RULE1", "Name": "...", "Severity": 2, "Scope": "Measure", "Expression": "..." }Note: FixExpression: null is valid. ErrorMessage and ObjectCount are runtime fields that TE adds - do not include them in rule definitions.
Regex Expression Syntax
When using RegEx.IsMatch() in expressions:
No `@` prefix: Do not use C# verbatim string prefix
// BAD: @ prefix not supported
RegEx.IsMatch(Expression, @"FILTER\s*\(\s*ALL")
// GOOD: Standard escaping
RegEx.IsMatch(Expression, "FILTER\\s*\\(\\s*ALL")No RegexOptions parameter: TE doesn't support the options parameter
// BAD: RegexOptions not supported
RegEx.IsMatch(Name, "^DATE$", RegexOptions.IgnoreCase)
// GOOD: Use inline flag or pattern only
RegEx.IsMatch(Name, "(?i)^DATE$")
RegEx.IsMatch(Name, "^(DATE|date|Date)$")Correct Scope Names
Use the exact scope names from the TOM enum. Common mistakes:
| Wrong | Correct |
|---|---|
Role | ModelRole |
Member | ModelRoleMember |
Expression | NamedExpression |
DataSource | ProviderDataSource or StructuredDataSource |
Note: Column is valid as a backwards-compatible alias for DataColumn, CalculatedColumn, CalculatedTableColumn.
Validation Script
Use the validation script to check and fix TE compatibility issues:
# Check for issues
python scripts/validate_rules.py rules.json
# Auto-fix issues (CRLF, remove nulls, remove _comment)
python scripts/validate_rules.py --fix rules.jsonThe script checks:
- Line endings (CRLF)
- No
_commentfields - No
nullvalues for optional fields - Valid scope names
- Expression syntax warnings
About BPA rules
- BPA rules define automatic tests for semantic models in Power BI and Fabric for QA/QC
- BPA rules are used by Tabular Editor 2, 3, CLI, or Fabric notebooks
- Rule expressions are defined in C# for Tabular Editor or Python for Fabric Notebooks
- BPA rules are better defined and used by Tabular Editor because they are actionable with ability to ignore or fix, and they are integrated with the IDE
File Locations
BPA rules can exist in multiple locations (evaluated in order of priority):
| Location | Path / Source | Description |
|---|---|---|
| Built-in Best Practices | Internal to TE3 | Default rules bundled with Tabular Editor 3 |
| URL | Any valid URL (e.g., https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules-standard.json) | Remote rule collections loaded from web |
| Rules within current model | See below | Rules embedded in model metadata |
| Rules for local user | %LocalAppData%\TabularEditor3\BPARules.json | User-specific rules on Windows |
| Rules on local machine | %ProgramData%\TabularEditor3\BPARules.json | Machine-wide rules for all users |
For built-in rule IDs (27 rules in TE3), model-embedded rule formats, cross-platform file access, and all file location details, see `references/te-compatibility.md`.
Quick Reference
For rule JSON structure, valid scope values, severity levels, compatibility levels, and category prefixes, see `references/quick-reference.md`.
For expression syntax (Dynamic LINQ, TOM properties, string/boolean/collection checks, Tokenize(), DependsOn, ReferencedBy), see `references/expression-syntax.md`.
TMDL Annotations
BPA rules can be embedded in TMDL files via annotations:
annotation BestPracticeAnalyzer = [{ "ID": "...", ... }]
annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["RULE1","RULE2"]}
annotation BestPracticeAnalyzer_ExternalRuleFiles = ["https://..."]For complete annotation patterns, see references/tmdl-annotations.md.
Workflow
Recommended: Interactive Rule Generation
Follow the Primary Workflow: Interactive Q&A Discovery (above) for the best results. Use AskUserQuestion iteratively to gather context, investigate the model, then generate targeted rules.
Creating a Single Rule (Direct)
When the user requests a specific rule without needing full discovery:
1. Identify the best practice to enforce 2. Determine the appropriate Scope 3. Write the Expression to detect violations 4. Optionally write a FixExpression for auto-remediation 5. Test against sample models 6. Add to rule collection
Improving an Existing Rule
1. Understand the current rule's intent 2. Identify false positives or missed cases 3. Refine the Expression logic 4. Verify FixExpression doesn't cause side effects 5. Test thoroughly
Additional Resources
Reference Files
For detailed syntax and patterns, consult:
- `references/model-investigation.md` - Investigating models via Fabric CLI or local .bim/.tmdl files; guiding users to save as PBIP; model analysis checklist
- `references/te-compatibility.md` - Tabular Editor compatibility (CRLF, file paths, JSON format, regex, scope names, validation, built-in rules, file locations, cross-platform access)
- `references/quick-reference.md` - Rule JSON structure, valid scopes, severity levels, compatibility levels, category prefixes, expression syntax overview
- `schema/bparules-schema.json` - JSON Schema for validating BPA rule files (Draft-07) (temporary location)
- `references/rule-schema.md` - Human-readable BPA rule field descriptions
- `references/expression-syntax.md` - Dynamic LINQ expression syntax, TOM properties, Tokenize(), DependsOn, ReferencedBy
- `references/tmdl-annotations.md` - BPA annotations in TMDL format
Example Files
Working examples in examples/:
- `examples/comprehensive-rules.json` - 30+ production-ready rules across all categories
- `examples/model-with-bpa-annotations.tmdl` - TMDL file showing all annotation patterns
Scripts
Utility scripts:
- `/scripts/bpa_rules_audit.py` - Comprehensive BPA rules audit across all sources (built-in, URL, model, user, machine). Supports Windows, WSL, and macOS with Parallels. Outputs ASCII report and JSON export.
- `scripts/validate_rules.py` - Validate BPA rule JSON files for schema compliance
Audit Script Usage:
# Basic audit
python scripts/bpa_rules_audit.py /path/to/model
# Export to JSON
python scripts/bpa_rules_audit.py /path/to/model --json output.json
# Quiet mode (summary only)
python scripts/bpa_rules_audit.py /path/to/model --quietRelated Commands
- `/suggest-rule` - Generate BPA rules from descriptions
Related Agents
- `bpa-expression-helper` - Debug and improve BPA expressions
Fetching Docs
To retrieve current BPA and TOM reference docs, use microsoft_docs_search + microsoft_docs_fetch (MCP) if available, otherwise mslearn search + mslearn fetch (CLI). Search based on the user's request and run multiple searches as needed to ensure sufficient context before proceeding.
External References
- Tabular Editor BPA Getting Started
- Tabular Editor BPA View
- BPA Sample Rule Expressions
- TabularEditor BPA Source Code
- BPA Rules Repository
- TabularEditor Docs Repository
- Power BI Semantic Model Checklist
Example Rules
Measure Without Description
{
"ID": "META_MEASURE_NO_DESCRIPTION",
"Name": "Measure has no description",
"Category": "Metadata",
"Description": "All measures should have descriptions for documentation.",
"Severity": 2,
"Scope": "Measure",
"Expression": "string.IsNullOrWhitespace(Description)"
}Hidden Unused Column
{
"ID": "PERF_UNUSED_HIDDEN_COLUMN",
"Name": "Remove hidden columns not used",
"Category": "Performance",
"Description": "Hidden columns with no references waste memory.",
"Severity": 3,
"Scope": "Column",
"Expression": "IsHidden and ReferencedBy.Count = 0 and not UsedInRelationships.Any()",
"FixExpression": "Delete()"
}[
{
"ID": "DAX_COLUMNS_FULLY_QUALIFIED",
"Name": "Column references should be fully qualified",
"Category": "DAX Expressions",
"Description": "Using fully qualified column references makes it easier to distinguish between column and measure references, and also helps avoid certain errors.",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculatedTable, KPI",
"Expression": "DependsOn.Any(Key.ObjectType = \"Column\" and Value.Any(not FullyQualified))",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_MEASURES_UNQUALIFIED",
"Name": "Measure references should be unqualified",
"Category": "DAX Expressions",
"Description": "Measure references should not include the table name prefix. This makes DAX more readable and distinguishes measures from columns.",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculatedTable, KPI",
"Expression": "DependsOn.Any(Key.ObjectType = \"Measure\" and Value.Any(FullyQualified))",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_DIVISION_COLUMNS",
"Name": "Avoid division (use DIVIDE function instead)",
"Category": "DAX Expressions",
"Description": "Using the DIVIDE function handles division by zero gracefully. The / operator will return an error when dividing by zero.",
"Severity": 3,
"Scope": "Measure, CalculatedColumn, CalculatedTable",
"Expression": "Tokenize().Any(Type = DIV and Next.Type <> INTEGER_LITERAL and Next.Type <> REAL_LITERAL)",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_AVOID_IFERROR",
"Name": "Avoid using IFERROR",
"Category": "DAX Expressions",
"Description": "IFERROR can mask real errors. Use specific error handling or fix the root cause instead.",
"Severity": 2,
"Scope": "Measure, CalculatedColumn",
"Expression": "Expression.IndexOf(\"IFERROR\", StringComparison.OrdinalIgnoreCase) >= 0",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_TODO",
"Name": "Revisit TODO expressions",
"Category": "DAX Expressions",
"Description": "DAX expressions containing TODO comments should be reviewed and completed before deployment.",
"Severity": 1,
"Scope": "Measure, Partition, CalculatedColumn, CalculatedTable",
"Expression": "Expression.IndexOf(\"TODO\", StringComparison.OrdinalIgnoreCase) >= 0",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_AVOID_FILTER_ALL",
"Name": "Avoid FILTER with ALL in iterators",
"Category": "DAX Expressions",
"Description": "Using FILTER(ALL(...)) in iterators like SUMX can cause performance issues. Consider using CALCULATE with filters instead.",
"Severity": 2,
"Scope": "Measure, CalculatedColumn",
"Expression": "RegEx.IsMatch(Expression, \"FILTER\\s*\\(\\s*ALL\\s*\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "META_MEASURE_NO_DESCRIPTION",
"Name": "Measure has no description",
"Category": "Metadata",
"Description": "All measures should have descriptions to help report authors understand their purpose.",
"Severity": 2,
"Scope": "Measure",
"Expression": "string.IsNullOrWhitespace(Description)"
},
{
"ID": "META_COLUMN_NO_DESCRIPTION",
"Name": "Visible column has no description",
"Category": "Metadata",
"Description": "Visible columns should have descriptions to help users understand the data.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn",
"Expression": "IsVisible and string.IsNullOrWhitespace(Description)"
},
{
"ID": "META_AVOID_FLOAT",
"Name": "Do not use floating point data types",
"Category": "Metadata",
"Description": "Floating point (Double) data types can cause precision issues. Use Decimal for financial calculations.",
"Severity": 3,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "DataType = \"Double\"",
"FixExpression": "DataType = DataType.Decimal",
"CompatibilityLevel": 1200
},
{
"ID": "META_SUMMARIZE_NONE",
"Name": "Don't summarize numeric columns",
"Category": "Metadata",
"Description": "Numeric columns should have SummarizeBy set to None to prevent implicit measures.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "IsVisible and SummarizeBy <> \"None\" and (DataType = \"Double\" or DataType = \"Decimal\" or DataType = \"Int64\")",
"FixExpression": "SummarizeBy = AggregateFunction.None",
"CompatibilityLevel": 1200
},
{
"ID": "META_DISABLE_ATTRIBUTE_HIERARCHIES",
"Name": "Disable attribute hierarchies to decrease processing",
"Category": "Metadata",
"Description": "Hidden columns not used in hierarchies, sort-by, or variations should have IsAvailableInMDX set to false.",
"Severity": 2,
"Scope": "DataColumn",
"Expression": "not IsVisible and IsAvailableInMDX and not UsedInHierarchies.Any() and not UsedInVariations.Any() and not UsedInSortBy.Any()",
"FixExpression": "IsAvailableInMDX = false",
"CompatibilityLevel": 1400
},
{
"ID": "META_TABLE_NO_DESCRIPTION",
"Name": "Table has no description",
"Category": "Metadata",
"Description": "All tables should have descriptions documenting their purpose and data source.",
"Severity": 1,
"Scope": "Table",
"Expression": "IsVisible and string.IsNullOrWhitespace(Description)"
},
{
"ID": "PERF_UNUSED_COLUMNS",
"Name": "Remove unused columns",
"Category": "Performance",
"Description": "Hidden columns with no references consume memory without benefit. Review before deletion.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "not IsVisible and ReferencedBy.Count = 0 and (not UsedInRelationships.Any()) and (not UsedInSortBy.Any()) and (not UsedInHierarchies.Any()) and (not UsedInVariations.Any())",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "PERF_UNUSED_MEASURES",
"Name": "Remove unused measures",
"Category": "Performance",
"Description": "Hidden measures with no references may be candidates for removal.",
"Severity": 1,
"Scope": "Measure",
"Expression": "not IsVisible and ReferencedBy.Count = 0",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "PERF_AVOID_BIDIR_RELATIONSHIPS",
"Name": "Avoid bi-directional relationships",
"Category": "Performance",
"Description": "Bi-directional relationships can cause ambiguity and performance issues. Use single direction when possible.",
"Severity": 2,
"Scope": "Relationship",
"Expression": "CrossFilteringBehavior = CrossFilteringBehavior.BothDirections",
"CompatibilityLevel": 1200
},
{
"ID": "PERF_TOO_MANY_COLUMNS",
"Name": "Table has too many columns",
"Category": "Performance",
"Description": "Tables with more than 100 columns may indicate over-denormalization or need for refactoring.",
"Severity": 2,
"Scope": "Table",
"Expression": "Columns.Count > 100"
},
{
"ID": "PERF_TOO_MANY_CALC_COLUMNS",
"Name": "Table has many calculated columns",
"Category": "Performance",
"Description": "Calculated columns are computed during processing and stored in memory. Consider using measures instead.",
"Severity": 2,
"Scope": "Table",
"Expression": "Columns.Count(ObjectType = \"CalculatedColumn\") > 10"
},
{
"ID": "LAYOUT_HIDE_FK_COLUMNS",
"Name": "Hide foreign key columns",
"Category": "Model Layout",
"Description": "Foreign key columns used in relationships should be hidden from end users.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "IsVisible and Model.Relationships.Any(FromColumn = outerIt)",
"FixExpression": "IsHidden = true",
"CompatibilityLevel": 1200
},
{
"ID": "LAYOUT_MEASURES_IN_DISPLAY_FOLDERS",
"Name": "Organize measures in display folders",
"Category": "Model Layout",
"Description": "Tables with more than 10 visible measures should organize them in display folders.",
"Severity": 1,
"Scope": "Table",
"Expression": "Measures.Count(IsVisible and string.IsNullOrEmpty(DisplayFolder)) > 10",
"CompatibilityLevel": 1200
},
{
"ID": "LAYOUT_COLUMNS_IN_DISPLAY_FOLDERS",
"Name": "Organize columns and hierarchies in display folders",
"Category": "Model Layout",
"Description": "Tables with more than 10 visible columns/hierarchies should organize them in display folders.",
"Severity": 1,
"Scope": "Table",
"Expression": "Columns.Count(IsVisible and string.IsNullOrEmpty(DisplayFolder)) + Hierarchies.Count(IsVisible and string.IsNullOrEmpty(DisplayFolder)) > 10",
"CompatibilityLevel": 1200
},
{
"ID": "LAYOUT_VISIBLE_MEASURE_NO_FOLDER",
"Name": "Visible measure not in display folder",
"Category": "Model Layout",
"Description": "Visible measures should be organized in display folders for easier navigation.",
"Severity": 1,
"Scope": "Measure",
"Expression": "IsVisible and string.IsNullOrEmpty(DisplayFolder)"
},
{
"ID": "NAME_UPPERCASE_FIRST_LETTER",
"Name": "Names must start with uppercase letter",
"Category": "Naming Conventions",
"Description": "Visible objects should have names starting with an uppercase letter for consistency.",
"Severity": 2,
"Scope": "Table, Measure, DataColumn, CalculatedColumn, Hierarchy",
"Expression": "IsVisible and char.IsLower(Name[0])",
"CompatibilityLevel": 1200
},
{
"ID": "NAME_NO_SPECIAL_CHARS",
"Name": "Names should not contain special characters",
"Category": "Naming Conventions",
"Description": "Object names should avoid special characters except spaces, underscores, and hyphens.",
"Severity": 2,
"Scope": "Table, Measure, DataColumn, CalculatedColumn",
"Expression": "RegEx.IsMatch(Name, \"[^a-zA-Z0-9 _\\-()%]\")",
"CompatibilityLevel": 1200
},
{
"ID": "NAME_RELATIONSHIP_COLUMNS_MATCH",
"Name": "Relationship column names should match",
"Category": "Naming Conventions",
"Description": "For clarity, columns on both sides of a relationship should have the same or similar names.",
"Severity": 2,
"Scope": "Relationship",
"Expression": "(Model.Relationships.Count(FromTable = OuterIt.FromTable and ToTable = OuterIt.ToTable) = 1 and FromColumn.Name <> ToColumn.Name)",
"CompatibilityLevel": 1200
},
{
"ID": "NAME_AVOID_RESERVED_WORDS",
"Name": "Avoid DAX reserved words in names",
"Category": "Naming Conventions",
"Description": "Using DAX reserved words in object names can cause confusion and require escaping.",
"Severity": 2,
"Scope": "Table, Measure, DataColumn, CalculatedColumn",
"Expression": "RegEx.IsMatch(Name, \"^(DATE|TIME|YEAR|MONTH|DAY|HOUR|MINUTE|SECOND|NOW|TODAY|TRUE|FALSE|BLANK)$\")",
"CompatibilityLevel": 1200
},
{
"ID": "FORMAT_NUMERIC_COLUMNS",
"Name": "Provide format string for visible numeric columns",
"Category": "Formatting",
"Description": "Numeric columns should have format strings to control display in reports.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "IsVisible and string.IsNullOrWhitespace(FormatString) and (DataType = \"Int64\" or DataType = \"Double\" or DataType = \"Decimal\")",
"CompatibilityLevel": 1200
},
{
"ID": "FORMAT_NUMERIC_MEASURES",
"Name": "Provide format string for visible numeric measures",
"Category": "Formatting",
"Description": "Numeric measures should have format strings to control display in reports.",
"Severity": 2,
"Scope": "Measure",
"Expression": "IsVisible and string.IsNullOrWhitespace(FormatString) and (DataType = \"Int64\" or DataType = \"Double\" or DataType = \"Decimal\")",
"CompatibilityLevel": 1200
},
{
"ID": "FORMAT_PERCENTAGE_MEASURES",
"Name": "Percentage measures should use percentage format",
"Category": "Formatting",
"Description": "Measures representing percentages should use a percentage format string.",
"Severity": 1,
"Scope": "Measure",
"Expression": "(Name.EndsWith(\"%\") or Name.EndsWith(\"Percent\") or Name.EndsWith(\"Percentage\") or Name.EndsWith(\"Rate\")) and not FormatString.Contains(\"%\")",
"CompatibilityLevel": 1200
},
{
"ID": "GOV_NO_HARDCODED_VALUES",
"Name": "Avoid hardcoded values in DAX",
"Category": "Governance",
"Description": "Hardcoded numeric values in DAX should be moved to parameters or calculated tables for maintainability.",
"Severity": 1,
"Scope": "Measure",
"Expression": "RegEx.IsMatch(Expression, \"[^\\d]\\d{4,}[^\\d]\")",
"Remarks": "Checks for numbers with 4+ digits that might be hardcoded years, thresholds, etc."
},
{
"ID": "GOV_ROLE_HAS_MEMBERS",
"Name": "Security role has no members defined",
"Category": "Governance",
"Description": "Security roles without members may indicate incomplete configuration.",
"Severity": 1,
"Scope": "ModelRole",
"Expression": "Model.Roles.Any() and TablePermissions.Count = 0",
"Remarks": "Review role configuration before deployment."
},
{
"ID": "GOV_MODEL_HAS_DATA_SOURCE",
"Name": "Model has no data source configured",
"Category": "Governance",
"Description": "Model should have at least one data source defined.",
"Severity": 3,
"Scope": "Model",
"Expression": "DataSources.Count = 0 and Tables.Any(Partitions.Any(SourceType = \"M\"))",
"CompatibilityLevel": 1200
}
]
[
{
"ID": "RULE1",
"Name": "Table name should be the same as the view name.",
"Category": "Formatting",
"Description": "This rule checks if the table's name is in the list of allowed names according to the business rules.",
"Severity": 4,
"Scope": "Table",
"Expression": " Name != \"Brands\"\r\nand Name != \"Budget Rate\"\r\nand Name != \"Customers\"\r\nand Name != \"Employees\"\r\nand Name != \"Exchange Rate\"\r\nand Name != \"Invoice Document Type\"\r\nand Name != \"Order Document Type\"\r\nand Name != \"Order Status\"\r\nand Name != \"Products\"\r\nand Name != \"Regions\"\r\nand Name != \"Budget\"\r\nand Name != \"Forecast\"\r\nand Name != \"Invoices\"\r\nand Name != \"Orders\"",
"CompatibilityLevel": 1200
},
{
"ID": "RULE2",
"Name": "Avoid 'Remove Duplicates' in Power Query",
"Category": "Data quality",
"Description": "'Remove Duplicates' can result in unintentional filtering of data.",
"Severity": 3,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Distinct\")",
"CompatibilityLevel": 1200
},
{
"ID": "RULE3",
"Name": "Avoid Auto-DateTime tables",
"Category": "Performance",
"Description": "Auto datetime tables should be removed as they take up unnecessary space. Instead, set up your own date table and date hierarchy fields.",
"Severity": 3,
"Scope": "CalculatedTable",
"Expression": "Name.Contains(\"DateTableTemplate_\") or Name.Contains(\"LocalDateTable_\")",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "RULE4",
"Name": "Limit the number of columns to < 12",
"Category": "Performance",
"Description": "Try to not include too many columns.",
"Severity": 2,
"Scope": "Table",
"Expression": "Columns.Count() > 11",
"CompatibilityLevel": 1200
},
{
"ID": "RULE5",
"Name": "Avoid bi-directional or many-to-many relationships",
"Category": "Performance",
"Description": "Many-to-Many and Bi-directional relationships can cause model ambiguity and should be used with caution.",
"Severity": 3,
"Scope": "Relationship",
"Expression": "(\r\nToCardinality == \"Many\"\r\nand\r\nFromCardinality == \"Many\"\r\n)\r\nor\r\nCrossFilteringBehavior == \"BothDirections\"",
"CompatibilityLevel": 1200
},
{
"ID": "RULE6",
"Name": "Do not use floating point data types",
"Category": "Performance",
"Description": "Floating point datatypes can cause unexpected results when evaluating values close to 0 or have excessive precision. Use Currency / Fixed Decimal Number (decimal) instead.",
"Severity": 3,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "DataType == \"Double\"",
"FixExpression": "DataType = DataType.Decimal",
"CompatibilityLevel": 1200
},
{
"ID": "RULE7",
"Name": "Redundant Columns",
"Category": "Performance",
"Description": "Avoid including multiple instances of the same columns in different tables.",
"Severity": 5,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any() == false\r\nand\r\n!(Name == \"Month\" or Name == \"Quarter\" or Name == \"Year\")\r\nand\r\nModel.AllColumns.Any(\r\n Name == current.Name\r\n and Table.Name != current.Table.Name\r\n and Table.UsedInRelationships.Any(\r\n FromTable.Name == current.Table.Name\r\n )\r\n)\r\n",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "RULE8",
"Name": "Avoid Joins / 'Merges' in Power Query",
"Category": "Performance",
"Description": "Merges are expensive operations which can greatly reduce refresh times in import models. Instead, consider how this can be addressed in the model design or data source.",
"Severity": 5,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.NestedJoin\")",
"CompatibilityLevel": 1200
},
{
"ID": "RULE9",
"Name": "Fields not organized in display folders",
"Category": "Formatting",
"Description": "Fields should be organized into display folders to make the model more organized and usable.",
"Severity": 2,
"Scope": "Measure, Hierarchy, DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "DisplayFolder = \"\"",
"CompatibilityLevel": 1200
},
{
"ID": "RULE10",
"Name": "Provide format string and description for all visible measures",
"Category": "Formatting",
"Description": "Visible measures should have their Format String property assigned and a concise Description explaining the measure's purpose.",
"Severity": 1,
"Scope": "Measure",
"Expression": "IsVisible \nand string.IsNullOrWhitespace(FormatString) and DataType != DataType.String or Description == \"\"",
"CompatibilityLevel": 1200
}
][
{
"ID": "AVOID_FLOATING_POINT_DATA_TYPES",
"Name": "[Performance] Do not use floating point data types",
"Category": "Performance",
"Description": "The \"Double\" floating point data type should be avoided, as it can result in unpredictable roundoff errors and decreased performance in certain scenarios. Use \"Int64\" or \"Decimal\" where appropriate (but note that \"Decimal\" is limited to 4 digits after the decimal sign).",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "DataType = \"Double\"",
"FixExpression": "DataType = DataType.Decimal",
"CompatibilityLevel": 1200
},
{
"ID": "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
"Name": "[Performance] Set IsAvailableInMdx to false on non-attribute columns",
"Category": "Performance",
"Description": "To speed up processing time and conserve memory after processing, attribute hierarchies should not be built for columns that are never used for slicing by MDX clients. In other words, all hidden columns that are not used as a Sort By Column or referenced in user hierarchies should have their IsAvailableInMdx property set to false.\r\nReference: https://blog.crossjoin.co.uk/2018/07/02/isavailableinmdx-ssas-tabular/",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "IsAvailableInMDX\r\nand\r\n\n(IsHidden or Table.IsHidden)\r\nand\r\n\nnot UsedInSortBy.Any() \r\nand\r\n\nnot UsedInHierarchies.Any()\r\nand\r\nnot UsedInVariations.Any()\r\nand\r\nSortByColumn = null",
"FixExpression": "IsAvailableInMDX = false",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS",
"Name": "[Performance] Avoid bi-directional relationships against high-cardinality columns",
"Category": "Performance",
"Description": "For best performance, it is recommended to avoid using bi-directional relationships against high-cardinality columns. In order to run this rule, you must first run the script shown here: https://www.elegantbi.com/post/vertipaqintabulareditor",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any(CrossFilteringBehavior == CrossFilteringBehavior.BothDirections)\n\nand\n\nConvert.ToInt64(GetAnnotation(\"Vertipaq_Cardinality\")) > 100000",
"CompatibilityLevel": 1200
},
{
"ID": "REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY",
"Name": "[Performance] Reduce usage of long-length columns with high cardinality",
"Category": "Performance",
"Description": "It is best to avoid lengthy text columns. This is especially true if the column has many unique values. These types of columns can cause longer processing times, bloated model sizes, as well as slower user queries. Long length is defined as more than 100 characters.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "Convert.ToInt64(GetAnnotation(\"LongLengthRowCount\")) > 500000",
"CompatibilityLevel": 1200
},
{
"ID": "SPLIT_DATE_AND_TIME",
"Name": "[Performance] Split date and time",
"Category": "Performance",
"Description": "This rule finds datetime columns that have values not at midnight. To maximize performance, the time element should be split from date element (or the time component should be rounded to midnight as this will reduce column cardinality).\r\nReference: https://www.sqlbi.com/articles/separate-date-and-time-in-powerpivot-and-bism-tabular/",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "Convert.ToInt32(GetAnnotation(\"DateTimeWithHourMinSec\")) > 0",
"CompatibilityLevel": 1200
},
{
"ID": "LARGE_TABLES_SHOULD_BE_PARTITIONED",
"Name": "[Performance] Large tables should be partitioned",
"Category": "Performance",
"Description": "Large tables should be partitioned in order to optimize processing. In order for this rule to run properly, you must run the script shown here: https://www.elegantbi.com/post/vertipaqintabulareditor",
"Severity": 2,
"Scope": "Table",
"Expression": "Convert.ToInt64(GetAnnotation(\"Vertipaq_RowCount\")) > 25000000\r\nand\r\nPartitions.Count = 1",
"CompatibilityLevel": 1200
},
{
"ID": "REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION",
"Name": "[Performance] Reduce usage of calculated columns that use the RELATED function",
"Category": "Performance",
"Description": "Calculated columns do not compress as well as data columns and may cause longer processing times. As such, calculated columns should be avoided if possible. One scenario where they may be easier to avoid is if they use the RELATED function.\r\nReference: https://www.sqlbi.com/articles/storage-differences-between-calculated-columns-and-calculated-tables/",
"Severity": 2,
"Scope": "CalculatedColumn",
"Expression": "RegEx.IsMatch(Expression,\"(?i)RELATED\\s*\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "SNOWFLAKE_SCHEMA_ARCHITECTURE",
"Name": "[Performance] Consider a star-schema instead of a snowflake architecture",
"Category": "Performance",
"Description": "Generally speaking, a star-schema is the optimal architecture for tabular models. That being the case, there are valid cases to use a snowflake approach. Please check your model and consider moving to a star-schema architecture.\r\nReference: https://docs.microsoft.com/power-bi/guidance/star-schema",
"Severity": 2,
"Scope": "Table, CalculatedTable",
"Expression": "UsedInRelationships.Any(current.Name == FromTable.Name)\r\nand\r\nUsedInRelationships.Any(current.Name == ToTable.Name)",
"CompatibilityLevel": 1200
},
{
"ID": "MODEL_SHOULD_HAVE_A_DATE_TABLE",
"Name": "[Performance] Model should have a date table",
"Category": "Performance",
"Description": "Generally speaking, models should generally have a date table. Models that do not have a date table generally are not taking advantage of features such as time intelligence or may not have a properly structured architecture.",
"Severity": 2,
"Scope": "Model",
"Expression": "Tables.Any(DataCategory == \"Time\" && Columns.Any(IsKey == true && DataType == \"DateTime\")) == false",
"CompatibilityLevel": 1200
},
{
"ID": "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
"Name": "[Performance] Date/calendar tables should be marked as a date table",
"Category": "Performance",
"Description": "This rule looks for tables that contain the words 'date' or 'calendar' as they should likely be marked as a date table.\r\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-date-tables",
"Severity": 2,
"Scope": "Table, CalculatedTable",
"Expression": "(Name.ToUpper().Contains(\"DATE\") or Name.ToUpper().Contains(\"CALENDAR\"))\n\nand\n\n(\nDataCategory <> \"Time\"\n\nor\n\nColumns.Any(IsKey == true && DataType == \"DateTime\") == false\n)",
"CompatibilityLevel": 1200
},
{
"ID": "REMOVE_AUTO-DATE_TABLE",
"Name": "[Performance] Remove auto-date table",
"Category": "Performance",
"Description": "Avoid using auto-date tables. Make sure to turn off auto-date table in the settings in Power BI Desktop. This will save memory resources. \r\nReference: https://www.youtube.com/watch?v=xu3uDEHtCrg",
"Severity": 2,
"Scope": "Table, CalculatedTable",
"Expression": "ObjectTypeName == \"Calculated Table\"\n\r\nand\r\n\n(\nName.StartsWith(\"DateTableTemplate_\") \n\nor \n\nName.StartsWith(\"LocalDateTable_\")\n)",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS",
"Name": "[Performance] Avoid excessive bi-directional or many-to-many relationships",
"Category": "Performance",
"Description": "Limit use of b-di and many-to-many relationships. This rule flags the model if more than 30% of relationships are bi-di or many-to-many.\r\nReference: https://www.sqlbi.com/articles/bidirectional-relationships-and-ambiguity-in-dax/",
"Severity": 2,
"Scope": "Model",
"Expression": "(\r\n\nRelationships.Where(CrossFilteringBehavior == CrossFilteringBehavior.BothDirections).Count()\r\n\n+\r\n\nRelationships.Where(FromCardinality.ToString() == \"Many\" && ToCardinality.ToString() == \"Many\").Count()\r\n\n)\r\n\n\n/\r\n\n\nMath.Max(Convert.ToDecimal(Relationships.Count)\n\n,1)> 0.3",
"CompatibilityLevel": 1200
},
{
"ID": "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC",
"Name": "[Performance] Limit row level security (RLS) logic",
"Category": "Performance",
"Description": "Try to simplify the DAX used for row level security. Usage of the functions within this rule can likely be offloaded to the upstream systems (data warehouse).",
"Severity": 2,
"Scope": "Table, CalculatedTable",
"Expression": "RowLevelSecurity.Any(RegEx.IsMatch(it.Replace(\" \",\"\"),\"(?i)RIGHT\\s*\\(\"))\r\nor\r\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(\" \",\"\"),\"(?i)LEFT\\s*\\(\"))\r\nor\r\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(\" \",\"\"),\"(?i)UPPER\\s*\\(\"))\r\nor\r\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(\" \",\"\"),\"(?i)LOWER\\s*\\(\"))\r\nor\r\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(\" \",\"\"),\"(?i)FIND\\s*\\(\"))\r\n",
"CompatibilityLevel": 1200
},
{
"ID": "MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS",
"Name": "[Performance] Consider using aggregations if using Direct Query in Power BI",
"Category": "Performance",
"Description": "If using Direct Query in Power BI Premium, you may want to consider using aggregations in order to boost performance.\r\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-aggregations",
"Severity": 1,
"Scope": "Model",
"Expression": "Tables.Any(ObjectTypeName == \"Table (DirectQuery)\")\r\nand\r\n\n\nAllColumns.Any(AlternateOf != null) == false\r\nand \r\nDefaultPowerBIDataSourceVersion.ToString() == \"PowerBI_V3\"",
"CompatibilityLevel": 1200
},
{
"ID": "MINIMIZE_POWER_QUERY_TRANSFORMATIONS",
"Name": "[Performance] Minimize Power Query transformations",
"Category": "Performance",
"Description": "Minimize Power Query transformations in order to improve model processing performance. It is a best practice to offload these transformations to the data warehouse if possible. Also, please check whether query folding is occurring within your model. Please reference the article below for more information on query folding.\r\nReference: https://docs.microsoft.com/power-query/power-query-folding",
"Severity": 2,
"Scope": "Partition",
"Expression": "\nSourceType.ToString() = \"M\"\r\nand\r\n(\r\nQuery.Contains(\"Table.Combine(\")\r\nor\r\n\nQuery.Contains(\"Table.Join(\")\r\nor\r\n\nQuery.Contains(\"Table.NestedJoin(\")\r\nor\r\nQuery.Contains(\"Table.AddColumn(\")\r\nor\r\nQuery.Contains(\"Table.Group(\")\r\nor\r\nQuery.Contains(\"Table.Sort(\")\r\nor\r\nQuery.Contains(\"Table.Pivot(\")\r\nor\r\nQuery.Contains(\"Table.Unpivot(\")\r\nor\r\nQuery.Contains(\"Table.UnpivotOtherColumns(\")\r\nor\r\nQuery.Contains(\"Table.Distinct(\")\r\nor\r\nQuery.Contains(\"[Query=\"\"SELECT\")\r\nor\r\nQuery.Contains(\"Value.NativeQuery\")\r\nor\r\nQuery.Contains(\"OleDb.Query\")\r\nor\r\nQuery.Contains(\"Odbc.Query\")\r\n)",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY",
"Name": "[Performance] Avoid using many-to-many relationships on tables used for dynamic row level security",
"Category": "Performance",
"Description": "Using many-to-many relationships on tables which use dynamic row level security can cause serious query performance degradation. This pattern's performance problems compound when snowflaking multiple many-to-many relationships against a table which contains row level security. Instead, use one of the patterns shown in the article below where a single dimension table relates many-to-one to a security table.\r\n\r\nReference: https://www.elegantbi.com/post/dynamicrlspatterns",
"Severity": 3,
"Scope": "Table",
"Expression": "UsedInRelationships.Any(FromCardinality == \"Many\" and ToCardinality== \"Many\")\r\nand\r\nRowLevelSecurity.Any(it.Length > 0)",
"CompatibilityLevel": 1200
},
{
"ID": "UNPIVOT_PIVOTED_(MONTH)_DATA",
"Name": "[Performance] Unpivot pivoted (month) data",
"Category": "Performance",
"Description": "Avoid using pivoted data in your tables. This rule checks specifically for pivoted data by month.\r\nReference: https://www.elegantbi.com/post/top10bestpractices",
"Severity": 2,
"Scope": "Table, CalculatedTable",
"Expression": "Columns.Any(Name.ToUpper().Contains(\"JAN\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains(\"FEB\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains(\"MAR\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains(\"APR\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains(\"MAY\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains(\"JUN\") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))",
"CompatibilityLevel": 1200
},
{
"ID": "MANY-TO-MANY_RELATIONSHIPS_SHOULD_BE_SINGLE-DIRECTION",
"Name": "[Performance] Many-to-many relationships should be single-direction",
"Category": "Performance",
"Severity": 2,
"Scope": "Relationship",
"Expression": "FromCardinality == \"Many\"\n\r\nand\r\n\nToCardinality == \"Many\"\r\n\nand\r\n\nCrossFilteringBehavior == \"BothDirections\"",
"CompatibilityLevel": 1200
},
{
"ID": "REDUCE_USAGE_OF_CALCULATED_TABLES",
"Name": "[Performance] Reduce usage of calculated tables",
"Category": "Performance",
"Description": "Migrate calculated table logic to your data warehouse. Reliance on calculated tables will lead to technical debt and potential misalignments if you have multiple models on your platform.",
"Severity": 2,
"Scope": "CalculatedTable",
"Expression": "1=1",
"CompatibilityLevel": 1200
},
{
"ID": "REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES",
"Name": "[Performance] Remove redundant columns in related tables",
"Category": "Performance",
"Description": "Removing unnecessary columns reduces model size and speeds up data loading.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any() == false \r\nand\r\nModel.AllColumns.Any(Name == current.Name and Table.Name != current.Table.Name and Table.UsedInRelationships.Any(FromTable.Name == current.Table.Name))",
"CompatibilityLevel": 1200
},
{
"ID": "MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY",
"Name": "[Performance] Measures using time intelligence and model is using Direct Query",
"Category": "Performance",
"Description": "At present, time intelligence functions are known to not perform as well when using Direct Query. If you are having performance issues, you may want to try alternative solutions such as adding columns in the fact table that show previous year or previous month data.",
"Severity": 2,
"Scope": "Measure, CalculationItem",
"Expression": "Model.Tables.Any(ObjectTypeName == \"Table (DirectQuery)\")\r\nand\r\n(\r\nRegEx.IsMatch(Expression,\"CLOSINGBALANCEMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"CLOSINGBALANCEQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"CLOSINGBALANCEYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATEADD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATESBETWEEN\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATESINPERIOD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATESMTD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATESQTD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"DATESYTD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"ENDOFMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"ENDOFQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"ENDOFYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"FIRSTDATE\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"FIRSTNONBLANK\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"FIRSTNONBLANKVALUE\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"LASTDATE\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"LASTNONBLANK\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"LASTNONBLANKVALUE\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"NEXTDAY\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"NEXTMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"NEXTQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"NEXTYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"OPENINGBALANCEMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"OPENINGBALANCEQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"OPENINGBALANCEYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"PARALLELPERIOD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"PREVIOUSDAY\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"PREVIOUSMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"PREVIOUSQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"PREVIOUSYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"SAMEPERIODLASTYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"STARTOFMONTH\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"STARTOFQUARTER\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"STARTOFYEAR\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"TOTALMTD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"TOTALQTD\\s*\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"TOTALYTD\\s*\\(\")\r\n)",
"CompatibilityLevel": 1200
},
{
"ID": "REDUCE_NUMBER_OF_CALCULATED_COLUMNS",
"Name": "[Performance] Reduce number of calculated columns",
"Category": "Performance",
"Description": "Calculated columns do not compress as well as data columns so they take up more memory. They also slow down processing times for both the table as well as process recalc. Offload calculated column logic to your data warehouse and turn these calculated columns into data columns.\r\nReference: https://www.elegantbi.com/post/top10bestpractices",
"Severity": 2,
"Scope": "Model",
"Expression": "AllColumns.Where(Type.ToString() == \"Calculated\").Count() > 5",
"CompatibilityLevel": 1200
},
{
"ID": "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID",
"Name": "[Performance] Check if bi-directional and many-to-many relationships are valid",
"Category": "Performance",
"Description": "Bi-directional and many-to-many relationships may cause performance degradation or even have unintended consequences. Make sure to check these specific relationships to ensure they are working as designed and are actually necessary.\r\nReference: https://www.sqlbi.com/articles/bidirectional-relationships-and-ambiguity-in-dax/",
"Severity": 1,
"Scope": "Relationship",
"Expression": "FromCardinality.ToString() = \"Many\" and ToCardinality.ToString() = \"Many\"\r\nor\r\nCrossFilteringBehavior == CrossFilteringBehavior.BothDirections",
"CompatibilityLevel": 1200
},
{
"ID": "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY",
"Name": "[Performance] Check if dynamic row level security (RLS) is necessary",
"Category": "Performance",
"Description": "Usage of dynamic row level security (RLS) can add memory and performance overhead. Please research the pros/cons of using it.\r\nReference: https://docs.microsoft.com/power-bi/admin/service-admin-rls",
"Severity": 1,
"Scope": "TablePermission",
"Expression": "RegEx.IsMatch(Expression,\"(?i)USERNAME\\(\")\r\nor\r\nRegEx.IsMatch(Expression,\"(?i)USERPRINCIPALNAME\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_COLUMNS_FULLY_QUALIFIED",
"Name": "[DAX Expressions] Column references should be fully qualified",
"Category": "DAX Expressions",
"Description": "Using fully qualified column references makes it easier to distinguish between column and measure references, and also helps avoid certain errors. When referencing a column in DAX, first specify the table name, then specify the column name in square brackets.\r\nReference: https://www.elegantbi.com/post/top10bestpractices",
"Severity": 3,
"Scope": "Measure, KPI, TablePermission, CalculationItem",
"Expression": "DependsOn.Any(Key.ObjectType = \"Column\" and Value.Any(not FullyQualified))",
"CompatibilityLevel": 1200
},
{
"ID": "DAX_MEASURES_UNQUALIFIED",
"Name": "[DAX Expressions] Measure references should be unqualified",
"Category": "DAX Expressions",
"Description": "Using unqualified measure references makes it easier to distinguish between column and measure references, and also helps avoid certain errors. When referencing a measure using DAX, do not specify the table name. Use only the measure name in square brackets.\r\nReference: https://www.elegantbi.com/post/top10bestpractices",
"Severity": 3,
"Scope": "Measure, CalculatedColumn, CalculatedTable, KPI, CalculationItem",
"Expression": "DependsOn.Any(Key.ObjectType = \"Measure\" and Value.Any(FullyQualified))",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_DUPLICATE_MEASURES",
"Name": "[DAX Expressions] No two measures should have the same definition",
"Category": "DAX Expressions",
"Description": "Two measures with different names and defined by the same DAX expression should be avoided to reduce redundancy.",
"Severity": 2,
"Scope": "Measure",
"Expression": "Model.AllMeasures.Any(Expression.Replace(\" \",\"\").Replace(\"\\n\",\"\").Replace(\"\\r\",\"\").Replace(\"\\t\",\"\") = outerIt.Expression.Replace(\" \",\"\").Replace(\"\\n\",\"\").Replace(\"\\r\",\"\").Replace(\"\\t\",\"\") and it <> outerIt)",
"CompatibilityLevel": 1200
},
{
"ID": "USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT",
"Name": "[DAX Expressions] Use the TREATAS function instead of INTERSECT for virtual relationships",
"Category": "DAX Expressions",
"Description": "The TREATAS function is more efficient and provides better performance than the INTERSECT function when used in virutal relationships.\r\nReference: https://www.sqlbi.com/articles/propagate-filters-using-treatas-in-dax/",
"Severity": 2,
"Scope": "Measure, CalculationItem",
"Expression": "RegEx.IsMatch(Expression,\"(?i)INTERSECT\\s*\\(\")",
"CompatibilityLevel": 1400
},
{
"ID": "USE_THE_DIVIDE_FUNCTION_FOR_DIVISION",
"Name": "[DAX Expressions] Use the DIVIDE function for division",
"Category": "DAX Expressions",
"Description": "Use the DIVIDE function instead of using \"/\". The DIVIDE function resolves divide-by-zero cases. As such, it is recommended to use to avoid errors.\r\n\r\nReference: https://docs.microsoft.com/power-bi/guidance/dax-divide-function-operator",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculationItem",
"Expression": "RegEx.IsMatch(Expression,\"\\]\\s*\\/(?!\\/)(?!\\*)\")\r\nor\r\nRegEx.IsMatch(Expression,\"\\)\\s*\\/(?!\\/)(?!\\*)\")",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_USING_THE_IFERROR_FUNCTION",
"Name": "[DAX Expressions] Avoid using the IFERROR function",
"Category": "DAX Expressions",
"Description": "Avoid using the IFERROR function as it may cause performance degradation. If you are concerned about a divide-by-zero error, use the DIVIDE function as it naturally resolves such errors as blank (or you can customize what should be shown in case of such an error).\r\nReference: https://www.elegantbi.com/post/top10bestpractices",
"Severity": 2,
"Scope": "Measure, CalculatedColumn",
"Expression": "RegEx.IsMatch(Expression,\"(?i)IFERROR\\s*\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES",
"Name": "[DAX Expressions] Measures should not be direct references of other measures",
"Category": "DAX Expressions",
"Description": "This rule identifies measures which are simply a reference to another measure. As an example, consider a model with two measures: [MeasureA] and [MeasureB]. This rule would be triggered for MeasureB if MeasureB's DAX was MeasureB:=[MeasureA]. Such duplicative measures should be removed.",
"Severity": 2,
"Scope": "Measure",
"Expression": "Model.AllMeasures.Any(DaxObjectName == current.Expression)",
"CompatibilityLevel": 1200
},
{
"ID": "FILTER_COLUMN_VALUES",
"Name": "[DAX Expressions] Filter column values with proper syntax",
"Category": "DAX Expressions",
"Description": "Instead of using this pattern FILTER('Table','Table'[Column]=\"Value\") for the filter parameters of a CALCULATE or CALCULATETABLE function, use one of the options below. As far as whether to use the KEEPFILTERS function, see the second reference link below.\r\n\r\nOption 1: KEEPFILTERS('Table'[Column]=\"Value\")\r\nOption 2: 'Table'[Column]=\"Value\"\r\n\r\nReference: https://docs.microsoft.com/power-bi/guidance/dax-avoid-avoid-filter-as-filter-argument\r\nReference: https://www.sqlbi.com/articles/using-keepfilters-in-dax/",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculationItem",
"Expression": "RegEx.IsMatch(Expression,\"(?i)CALCULATE\\s*\\(\\s*[^,]+,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+'*\\s*,\\s*\\'*[A-Za-z0-9 _]+\\'*\\[[A-Za-z0-9 _]+\\]\")\r\nor\r\nRegEx.IsMatch(Expression,\"(?i)CALCULATETABLE\\s*\\([^,]*,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*,\\s*\\'*[A-Za-z0-9 _]+\\'*\\[[A-Za-z0-9 _]+\\]\")",
"CompatibilityLevel": 1200
},
{
"ID": "FILTER_MEASURE_VALUES_BY_COLUMNS",
"Name": "[DAX Expressions] Filter measure values by columns, not tables",
"Category": "DAX Expressions",
"Description": "Instead of using this pattern FILTER('Table',[Measure]>Value) for the filter parameters of a CALCULATE or CALCULATETABLE function, use one of the options below (if possible). Filtering on a specific column will produce a smaller table for the engine to process, thereby enabling faster performance. Using the VALUES function or the ALL function depends on the desired measure result.\r\n\r\nOption 1: FILTER(VALUES('Table'[Column]),[Measure] > Value)\r\nOption 2: FILTER(ALL('Table'[Column]),[Measure] > Value)\r\n\r\nReference: https://docs.microsoft.com/power-bi/guidance/dax-avoid-avoid-filter-as-filter-argument",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculationItem",
"Expression": "RegEx.IsMatch(Expression,\"(?i)CALCULATE\\s*\\(\\s*[^,]+,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*\\s*,\\s*\\[[^\\]]+\\]\")\r\nor\r\nRegEx.IsMatch(Expression,\"(?i)CALCULATETABLE\\s*\\([^,]*,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*,\\s*\\[\")",
"CompatibilityLevel": 1200
},
{
"ID": "INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED",
"Name": "[DAX Expressions] Inactive relationships that are never activated",
"Category": "DAX Expressions",
"Description": "Inactive relationships are activated using the USERELATIONSHIP function. If an inactive relationship is not referenced in any measure via this function, the relationship will not be used. It should be determined whether the relationship is not necessary or to activate the relationship via this method.\r\n\r\nReference: https://docs.microsoft.com/power-bi/guidance/relationships-active-inactive\r\nReference: https://dax.guide/userelationship/",
"Severity": 2,
"Scope": "Relationship",
"Expression": "IsActive == false\r\nand not\r\n(\r\nModel.AllMeasures.Any(RegEx.IsMatch(Expression,\r\n\"(?i)USERELATIONSHIP\\s*\\(\\s*\\'*\" +\r\ncurrent.FromTable.Name + \"\\'*\\[\" + \r\ncurrent.FromColumn.Name + \"\\]\\s*,\\s*\\'*\" +\r\ncurrent.ToTable.Name + \"\\'*\\[\" +\r\ncurrent.ToColumn.Name + \"\\]\"))\r\nor\r\nModel.AllCalculationItems.Any(RegEx.IsMatch(Expression,\r\n\"(?i)USERELATIONSHIP\\s*\\(\\s*\\'*\" +\r\ncurrent.FromTable.Name + \"\\'*\\[\" + \r\ncurrent.FromColumn.Name + \"\\]\\s*,\\s*\\'*\" +\r\ncurrent.ToTable.Name + \"\\'*\\[\" +\r\ncurrent.ToColumn.Name + \"\\]\"))\r\n)",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_USING_'1-(X/Y)'_SYNTAX",
"Name": "[DAX Expressions] Avoid using '1-(x/y)' syntax",
"Category": "DAX Expressions",
"Description": "Instead of using the '1-(x/y)' or '1+(x/y)' syntax to achieve a percentage calculation, use the basic DAX functions (as shown below). Using the improved syntax will generally improve the performance. The '1+/-...' syntax always returns a value whereas the solution without the '1+/-...' does not (as the value may be 'blank'). Therefore the '1+/-...' syntax may return more rows/columns which may result in a slower query speed.\r\n\r\nLet's clarify with an example:\r\n\r\nAvoid this: 1 - SUM ( 'Sales'[CostAmount] ) / SUM( 'Sales'[SalesAmount] )\r\nBetter: DIVIDE ( SUM ( 'Sales'[SalesAmount] ) - SUM ( 'Sales'[CostAmount] ), SUM ( 'Sales'[SalesAmount] ) )\r\nBest: VAR x = SUM ( 'Sales'[SalesAmount] ) RETURN DIVIDE ( x - SUM ( 'Sales'[CostAmount] ), x )",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, CalculationItem",
"Expression": "RegEx.IsMatch(Expression,\"[0-9]+\\s*[-+]\\s*[\\(]*\\s*(?i)SUM\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*\\s*\\[[A-Za-z0-9 _]+\\]\\s*\\)\\s*\\/\")\r\nor\r\nRegEx.IsMatch(Expression,\"[0-9]+\\s*[-+]\\s*(?i)DIVIDE\\s*\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS",
"Name": "[DAX Expressions] The EVALUATEANDLOG function should not be used in production models",
"Category": "DAX Expressions",
"Description": "The EVALUATEANDLOG function is meant to be used only in development/test environments and should not be used in production models.\r\n\r\nReference: https://pbidax.wordpress.com/2022/08/16/introduce-the-dax-evaluateandlog-function/",
"Severity": 1,
"Scope": "Measure",
"Expression": "RegEx.IsMatch(Expression,\"(?i)EVALUATEANDLOG\\s*\\(\")",
"CompatibilityLevel": 1200
},
{
"ID": "DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
"Name": "[Error Prevention] Data columns must have a source column",
"Category": "Error Prevention",
"Description": "Data columns must have a source column. A data column without a source column will cause an error when processing the model.",
"Severity": 3,
"Scope": "DataColumn",
"Expression": "string.IsNullOrWhitespace(SourceColumn)",
"CompatibilityLevel": 1200
},
{
"ID": "EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION",
"Name": "[Error Prevention] Expression-reliant objects must have an expression",
"Category": "Error Prevention",
"Description": "Calculated columns, calculation items and measures must have an expression. Without an expression, these objects will not show any values.",
"Severity": 3,
"Scope": "Measure, CalculatedColumn, CalculationItem",
"Expression": "string.IsNullOrWhiteSpace(Expression)",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS",
"Name": "[Error Prevention] Avoid structured data sources with provider partitions",
"Category": "Error Prevention",
"Description": "Power BI does not support provider (a.k.a. 'legacy') partitions which reference structured data sources. Partitions which reference structured data sources must use the M-language. Otherwise, 'provider' partitions must reference a 'provider' data source. This can be resolved by converting the structured data source into a provider data source (see 2nd reference link below).\r\n\r\nReference: https://docs.microsoft.com/power-bi/admin/service-premium-connect-tools#data-source-declaration\r\nReference: https://www.elegantbi.com/post/convertdatasources",
"Severity": 2,
"Scope": "Partition",
"Expression": "SourceType == \"Query\"\r\nand\r\nDataSource.Type == \"Structured\"",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE",
"Name": "[Error Prevention] Avoid the USERELATIONSHIP function and RLS against the same table",
"Category": "Error Prevention",
"Description": "The USERELATIONSHIP function may not be used against a table which also leverages row-level security (RLS). This will generate an error when using the particular measure in a visual. This rule will highlight the table which is used in a measure's USERELATIONSHIP function as well as RLS.\r\n\r\nReference: https://blog.crossjoin.co.uk/2013/05/10/userelationship-and-tabular-row-security/",
"Severity": 3,
"Scope": "Table, CalculatedTable",
"Expression": "Model.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)USERELATIONSHIP\\s*\\(\\s*.+?(?=])\\]\\s*,\\s*'*\" + current.Name + \"'*\\[\"))\r\nand\r\nRowLevelSecurity.Any(it <> null)",
"CompatibilityLevel": 1200
},
{
"ID": "RELATIONSHIP_COLUMNS_SAME_DATA_TYPE",
"Name": "[Error Prevention] Relationship columns should be of the same data type",
"Category": "Error Prevention",
"Description": "Columns used in a relationship should be of the same data type. Ideally, they will be of integer data type (see the related rule '[Formatting] Relationship columns should be of integer data type'). Having columns within a relationship which are of different data types may lead to various issues.",
"Severity": 3,
"Scope": "Relationship",
"Expression": "FromColumn.DataType != ToColumn.DataType",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_INVALID_NAME_CHARACTERS",
"Name": "[Error Prevention] Avoid invalid characters in names",
"Category": "Error Prevention",
"Description": "This rule identifies if a name for a given object in your model (i.e. table/column/measure) which contains an invalid character. Invalid characters will cause an error when deploying the model (and failure to deploy). This rule has a fix expression which converts the invalid character into a space, resolving the issue.",
"Severity": 3,
"Scope": "Table, Measure, Hierarchy, Level, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, KPI, ModelRole, CalculationGroup, CalculationItem",
"Expression": "Name.ToCharArray().Any(char.IsControl(it) and !char.IsWhiteSpace(it))",
"FixExpression": "Name = string.Concat( it.Name.ToCharArray().Select( c => (char.IsControl(c) && !char.IsWhiteSpace(c)) ? ' ': c ))",
"CompatibilityLevel": 1200
},
{
"ID": "AVOID_INVALID_DESCRIPTION_CHARACTERS",
"Name": "[Error Prevention] Avoid invalid characters in descriptions",
"Category": "Error Prevention",
"Description": "This rule identifies if a description for a given object in your model (i.e. table/column/measure) which contains an invalid character. Invalid characters will cause an error when deploying the model (and failure to deploy). This rule has a fix expression which converts the invalid character into a space, resolving the issue.",
"Severity": 3,
"Scope": "Table, Measure, Hierarchy, Level, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, KPI, ModelRole, CalculationGroup, CalculationItem",
"Expression": "Description.ToCharArray().Any(char.IsControl(it) and !char.IsWhiteSpace(it))",
"FixExpression": "Description = string.Concat( it.Description.ToCharArray().Select( c => (char.IsControl(c) && !char.IsWhiteSpace(c)) ? ' ': c ))",
"CompatibilityLevel": 1200
},
{
"ID": "SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
"Name": "[Error Prevention] Set IsAvailableInMdx to true on necessary columns",
"Category": "Error Prevention",
"Description": "In order to avoid errors, ensure that attribute hierarchies are enabled if a column is used for sorting another column, used in a hierarchy, used in variations, or is sorted by another column.",
"Severity": 3,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "IsAvailableInMDX = false\r\n\r\nand\r\n(\r\nUsedInSortBy.Any()\r\nor\r\nUsedInHierarchies.Any()\r\nor\r\nUsedInVariations.Any()\r\nor\r\nSortByColumn != null\r\n)",
"FixExpression": "IsAvailableInMDX = true",
"CompatibilityLevel": 1200
},
{
"ID": "UNNECESSARY_COLUMNS",
"Name": "[Maintenance] Remove unnecessary columns",
"Category": "Maintenance",
"Description": "Hidden columns that are not referenced by any DAX expressions, relationships, hierarchy levels or Sort By-properties should be removed.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "(IsHidden or Table.IsHidden)\n\n\r\nand ReferencedBy.Count = 0\r\n\n\nand (not UsedInRelationships.Any())\n\n\r\nand (not UsedInSortBy.Any())\n\n\r\nand (not UsedInHierarchies.Any())\n\n\r\nand (not Table.RowLevelSecurity.Any(\nit <> null and it.IndexOf(\"[\" + current.Name + \"]\", \"OrdinalIgnoreCase\") >= 0\n))\n\n and (not Model.Roles.Any(RowLevelSecurity.Any(\nit <> null and \n(\nit.IndexOf(current.Table.Name + \"[\" + current.Name + \"]\", \"OrdinalIgnoreCase\") >= 0 or\n it.IndexOf(\"'\" + current.Table.Name + \"'[\" + current.Name + \"]\", \"OrdinalIgnoreCase\") >= 0\n )\n)))\r\nand not (\r\nObjectLevelSecurity.Any(it.ToString() == \"None\"))\r\nand not (\r\nTable.ObjectLevelSecurity.Any(it.ToString() == \"None\"))",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "UNNECESSARY_MEASURES",
"Name": "[Maintenance] Remove unnecessary measures",
"Category": "Maintenance",
"Description": "Hidden measures that are not referenced by any DAX expressions should be removed for maintainability",
"Severity": 2,
"Scope": "Measure",
"Expression": "(Table.IsHidden or IsHidden) \r\nand ReferencedBy.Count = 0",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "FIX_REFERENTIAL_INTEGRITY_VIOLATIONS",
"Name": "[Maintenance] Fix referential integrity violations",
"Category": "Maintenance",
"Description": "This rule highlights relationships which have referential integrity violations. This indicates that there are values in the table on the 'from' side of the relationship which do not exist in the table on the 'to' side of the relationship. Referential integrity violations will also produce the 'blank' member value in slicers. It is recommended to fix these issues by ensuring that the 'to' table's primary key column has all the values in the 'from' table's foreign key column.\r\n\r\nReference: https://blog.enterprisedna.co/vertipaq-analyzer-tutorial-relationships-referential-integrity/",
"Severity": 2,
"Scope": "Relationship",
"Expression": "Convert.ToInt64(GetAnnotation(\"Vertipaq_RIViolationInvalidRows\")) > 0",
"CompatibilityLevel": 1200
},
{
"ID": "REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
"Name": "[Maintenance] Remove data sources not referenced by any partitions",
"Category": "Maintenance",
"Description": "Data sources which are not referenced by any partitions may be removed.",
"Severity": 1,
"Scope": "ProviderDataSource, StructuredDataSource",
"Expression": "UsedByPartitions.Count() == 0\r\nand not Model.Tables.Any(SourceExpression.Contains(OuterIt.Name))\r\nand not Model.AllPartitions.Any(Query.Contains(OuterIt.Name))",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "REMOVE_ROLES_WITH_NO_MEMBERS",
"Name": "[Maintenance] Remove roles with no members",
"Category": "Maintenance",
"Description": "May remove roles with no members.",
"Severity": 1,
"Scope": "ModelRole",
"Expression": "Members.Count() == 0",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "ENSURE_TABLES_HAVE_RELATIONSHIPS",
"Name": "[Maintenance] Ensure tables have relationships",
"Category": "Maintenance",
"Description": "This rule highlights tables which are not connected to any other table in the model with a relationship.",
"Severity": 1,
"Scope": "Table, CalculatedTable",
"Expression": "UsedInRelationships.Count() == 0",
"CompatibilityLevel": 1200
},
{
"ID": "OBJECTS_WITH_NO_DESCRIPTION",
"Name": "[Maintenance] Visible objects with no description",
"Category": "Maintenance",
"Description": "Add descriptions to objects. These descriptions are shown on hover within the Field List in Power BI Desktop. Additionally, you can leverage these descriptions to create an automated data dictionary (see link below).\r\nReference: https://www.elegantbi.com/post/datadictionary",
"Severity": 1,
"Scope": "Table, Measure, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup",
"Expression": "string.IsNullOrWhitespace(Description)\r\nand\r\nIsHidden == false",
"CompatibilityLevel": 1200
},
{
"ID": "PERSPECTIVES_WITH_NO_OBJECTS",
"Name": "[Maintenance] Perspectives with no objects",
"Category": "Maintenance",
"Description": "Perspectives that contain no objects (tables) are most likely not necessary. In this rule, it is only necessary to check tables as adding a column/measure/hierarchy to a perspective also adds the table to the perspective. Additionally, tables in general covers calculated tables and calculation groups as well.",
"Severity": 1,
"Scope": "Perspective",
"Expression": "Model.Tables.Any(InPerspective[current.Name]) == false",
"FixExpression": "Delete()",
"CompatibilityLevel": 1200
},
{
"ID": "CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
"Name": "[Maintenance] Calculation groups with no calculation items",
"Category": "Maintenance",
"Description": "Calculation groups have no function unless they have calculation items.",
"Severity": 2,
"Scope": "CalculationGroup",
"Expression": "CalculationItems.Count == 0",
"CompatibilityLevel": 1200
},
{
"ID": "PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES",
"Name": "[Naming Conventions] Partition name should match table name for single partition tables",
"Category": "Naming Conventions",
"Description": "Tables with just one partition should match their table and partition names.Tables with more than one partition should have each partition name starting with the table name.",
"Severity": 1,
"Scope": "Table",
"Expression": "(Partitions.Count = 1 and Partitions[0].Name <> Name)",
"FixExpression": "Partitions[0].Name = it.Name",
"CompatibilityLevel": 1200
},
{
"ID": "SPECIAL_CHARS_IN_OBJECT_NAMES",
"Name": "[Naming Conventions] Object names must not contain special characters",
"Category": "Naming Conventions",
"Description": "Tabs, line breaks, etc.",
"Severity": 2,
"Scope": "Model, Table, Measure, Hierarchy, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup, CalculationItem",
"Expression": "Name.IndexOf(char(9)) > -1\r\nor\r\n\nName.IndexOf(char(10)) > -1 \r\nor\r\n\nName.IndexOf(char(13)) > -1",
"CompatibilityLevel": 1200
},
{
"ID": "TRIM_OBJECT_NAMES",
"Name": "[Naming Conventions] Trim object names",
"Category": "Naming Conventions",
"Description": "Unintentionally leaving a trailing space in an object name is a common occurrence when copying/duplicating objects in Tabular Editor.",
"Severity": 1,
"Scope": "Model, Table, Measure, Hierarchy, Level, Perspective, Partition, ProviderDataSource, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, StructuredDataSource, NamedExpression, ModelRole, CalculationGroup, CalculationItem",
"Expression": "Name.StartsWith(\" \") or Name.EndsWith(\" \")",
"CompatibilityLevel": 1200
},
{
"ID": "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
"Name": "[Formatting] Format flag columns as Yes/No value strings",
"Category": "Formatting",
"Description": "Flags must be properly formatted as Yes/No as this is easier to read than using 0/1 integer values.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "(\nName.StartsWith(\"Is\") and \nDataType = \"Int64\" and \nnot (IsHidden or Table.IsHidden)\n) \r\nor\r\n\n(\nName.EndsWith(\" Flag\") and \nDataType <> \"String\" and \nnot (IsHidden or Table.IsHidden)\n)",
"CompatibilityLevel": 1200
},
{
"ID": "OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
"Name": "[Formatting] Objects should not start or end with a space",
"Category": "Formatting",
"Description": "Objects should not start or end with a space",
"Severity": 3,
"Scope": "Model, Table, Measure, Hierarchy, Perspective, Partition, DataColumn, CalculatedColumn",
"Expression": "Name.StartsWith(\" \") or Name.EndsWith(\" \")",
"CompatibilityLevel": 1200
},
{
"ID": "DATECOLUMN_FORMATSTRING",
"Name": "[Formatting] Provide format string for \"Date\" columns",
"Category": "Formatting",
"Description": "Columns of type \"DateTime\" that have \"Month\" in their names should be formatted as \"mm/dd/yyyy\".",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "Name.IndexOf(\"Date\", \"OrdinalIgnoreCase\") >= 0 \r\nand \r\nDataType = \"DateTime\" \r\nand \r\nFormatString <> \"mm/dd/yyyy\"",
"FixExpression": "FormatString = \"mm/dd/yyyy\"",
"CompatibilityLevel": 1200
},
{
"ID": "MONTHCOLUMN_FORMATSTRING",
"Name": "[Formatting] Provide format string for \"Month\" columns",
"Category": "Formatting",
"Description": "Columns of type \"DateTime\" that have \"Month\" in their names should be formatted as \"MMMM yyyy\".",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "Name.IndexOf(\"Month\", \"OrdinalIgnoreCase\") >= 0 and DataType = \"DateTime\" and FormatString <> \"MMMM yyyy\"",
"FixExpression": "FormatString = \"MMMM yyyy\"",
"CompatibilityLevel": 1200
},
{
"ID": "PROVIDE_FORMAT_STRING_FOR_MEASURES",
"Name": "[Formatting] Provide format string for measures",
"Category": "Formatting",
"Description": "Visible measures should have their format string property assigned",
"Severity": 3,
"Scope": "Measure",
"Expression": "not IsHidden \r\nand not Table.IsHidden \r\nand string.IsNullOrWhitespace(FormatString)",
"CompatibilityLevel": 1200
},
{
"ID": "NUMERIC_COLUMN_SUMMARIZE_BY",
"Name": "[Formatting] Do not summarize numeric columns",
"Category": "Formatting",
"Description": "Numeric columns (integer, decimal, double) should have their SummarizeBy property set to \"None\" to avoid accidental summation in Power BI (create measures instead).",
"Severity": 3,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "(\r\nDataType = \"Int64\"\r\nor \r\nDataType=\"Decimal\" \r\nor \r\nDataType=\"Double\"\r\n)\n\r\nand \r\nSummarizeBy <> \"None\"\r\n\nand not (IsHidden or Table.IsHidden)",
"FixExpression": "SummarizeBy = AggregateFunction.None",
"CompatibilityLevel": 1200
},
{
"ID": "PERCENTAGE_FORMATTING",
"Name": "[Formatting] Percentages should be formatted with thousands separators and 1 decimal",
"Category": "Formatting",
"Severity": 2,
"Scope": "Measure",
"Expression": "FormatString.Contains(\"%\") and FormatString <> \"#,0.0%;-#,0.0%;#,0.0%\"",
"FixExpression": "FormatString = \"#,0.0%\\u003B-#,0.0%\\u003B#,0.0%\"",
"CompatibilityLevel": 1200
},
{
"ID": "INTEGER_FORMATTING",
"Name": "[Formatting] Whole numbers should be formatted with thousands separators and no decimals",
"Category": "Formatting",
"Severity": 2,
"Scope": "Measure",
"Expression": "not FormatString.Contains(\"$\") and not FormatString.Contains(\"%\") and not (FormatString = \"#,0\" or FormatString = \"#,0.0\")",
"FixExpression": "FormatString = \"#,0\"",
"CompatibilityLevel": 1200
},
{
"ID": "RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE",
"Name": "[Formatting] Relationship columns should be of integer data type",
"Category": "Formatting",
"Description": "It is a best practice for relationship columns to be of integer data type. This applies not only to data warehousing but data modeling as well.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any()\n\nand \n\nDataType != DataType.Int64",
"CompatibilityLevel": 1200
},
{
"ID": "ADD_DATA_CATEGORY_FOR_COLUMNS",
"Name": "[Formatting] Add data category for columns",
"Category": "Formatting",
"Description": "Add Data Category property for appropriate columns.\r\n\r\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-data-categorization",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "string.IsNullOrWhitespace(DataCategory)\r\nand\r\n(\r\n(\r\nName.ToLower().Contains(\"country\")\r\nor \r\n\nName.ToLower().Contains(\"continent\"\n)\r\nor\r\nName.ToLower().Contains(\"city\")\r\n)\r\nand DataType == \"String\"\r\n)\r\nor \r\n(\r\n(\nName.ToLower() == \"latitude\" \n or \nName.ToLower() == \"longitude\")\r\nand (DataType == DataType.Decimal or DataType == DataType.Double)\r\n)",
"CompatibilityLevel": 1200
},
{
"ID": "HIDE_FOREIGN_KEYS",
"Name": "[Formatting] Hide foreign keys",
"Category": "Formatting",
"Description": "Foreign keys should always be hidden.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any(FromColumn.Name == current.Name and FromCardinality == \"Many\")\n\r\nand\r\n\nIsHidden == false",
"FixExpression": "IsHidden = true",
"CompatibilityLevel": 1200
},
{
"ID": "MARK_PRIMARY_KEYS",
"Name": "[Formatting] Mark primary keys",
"Category": "Formatting",
"Description": "Set the 'Key' property to 'True' for primary key columns within the column properties.",
"Severity": 1,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "UsedInRelationships.Any(ToTable.Name == current.Table.Name and ToColumn.Name == current.Name and ToCardinality == \"One\")\r\n\nand\r\n\nIsKey == false\r\nand\r\ncurrent.Table.DataCategory != \"Time\"",
"FixExpression": "IsKey = true",
"CompatibilityLevel": 1200
},
{
"ID": "HIDE_FACT_TABLE_COLUMNS",
"Name": "[Formatting] Hide fact table columns",
"Category": "Formatting",
"Description": "It is a best practice to hide fact table columns that are used for aggregation in measures.",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "(\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)COUNT\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)COUNTBLANK\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)SUM\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)AVERAGE\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)VALUES\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)DISTINCT\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)DISTINCTCOUNT\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\n\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)MIN\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\n\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)MAX\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)COUNTA\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\n\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)AVERAGEA\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)MAXA\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n\nor\r\nReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,\"(?i)MINA\\s*\\(\\s*\\'*\" + outerit.Table.Name + \"\\'*\\[\" + outerit.Name + \"\\]\\s*\\)\"))\r\n)\r\n\nand IsHidden == false\r\n\nand (DataType == \"Int64\" || DataType == \"Decimal\" || DataType == \"Double\")",
"FixExpression": "IsHidden = true",
"CompatibilityLevel": 1200
},
{
"ID": "FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED",
"Name": "[Formatting] First letter of objects must be capitalized",
"Category": "Formatting",
"Severity": 1,
"Scope": "Table, Measure, Hierarchy, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup",
"Expression": "Name.Substring(0,1).ToUpper() != Name.Substring(0,1)",
"CompatibilityLevel": 1200
},
{
"ID": "MONTH_(AS_A_STRING)_MUST_BE_SORTED",
"Name": "[Formatting] Month (as a string) must be sorted",
"Category": "Formatting",
"Description": "This rule highlights month columns which are strings and are not sorted. If left unsorted, they will sort alphabetically (i.e. April, August...). Make sure to sort such columns so that they sort properly (January, February, March...).",
"Severity": 2,
"Scope": "DataColumn, CalculatedColumn, CalculatedTableColumn",
"Expression": "Name.ToUpper().Contains(\"MONTH\")\r\nand\r\n! Name.ToUpper().Contains(\"MONTHS\") \r\nand \r\n\n\nDataType == DataType.String \r\nand \r\nSortByColumn == null",
"CompatibilityLevel": 1200
}
]
/// Example TMDL model file showing BPA annotation patterns
/// This is a reference example - not a complete model definition
model ExampleModel
culture: en-US
defaultPowerBIDataSourceVersion: powerBI_V3
discourageImplicitMeasures
/// Extended properties from Tabular Editor
extendedProperty TabularEditor_DeploymentMetadata = {"User":"developer","Time":"2026-01-13T10:00:00"}
/// Serialization options
annotation TabularEditor_SerializeOptions = {"IgnoreInferredObjects":true,"IgnoreInferredProperties":true}
/// =============================================================
/// BPA ANNOTATION: Ignore specific rules for this model
/// =============================================================
/// Use when rules are not applicable or cause known false positives
/// Format: JSON object with RuleIDs array
annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["DAX_DIVISION_COLUMNS","META_AVOID_FLOAT","PERF_UNUSED_MEASURES"]}
/// =============================================================
/// BPA ANNOTATION: External rule files
/// =============================================================
/// Reference shared rule collections from URLs
/// Multiple URLs supported in the array
annotation BestPracticeAnalyzer_ExternalRuleFiles = ["https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules-standard-lax.json"]
/// =============================================================
/// BPA ANNOTATION: Inline custom rules
/// =============================================================
/// Define model-specific rules directly in the model
/// Full JSON array of rule definitions
annotation BestPracticeAnalyzer =
[
{
"ID": "CUSTOM_UDF_DESCRIPTION",
"Name": "User-defined functions must have descriptions",
"Category": "Governance",
"Description": "All UDFs require documentation for maintainability.",
"Severity": 2,
"Scope": "UserDefinedFunction",
"Expression": "string.IsNullOrWhitespace(Description)",
"FixExpression": "Description = \"TODO: Document this function\"",
"CompatibilityLevel": 1470
},
{
"ID": "CUSTOM_MEASURE_PREFIX",
"Name": "KPI measures should have prefix",
"Category": "Naming Conventions",
"Description": "Key performance indicator measures should start with 'KPI_' prefix.",
"Severity": 1,
"Scope": "Measure",
"Expression": "DisplayFolder.Contains(\"KPI\") and not Name.StartsWith(\"KPI_\")",
"FixExpression": null,
"CompatibilityLevel": 1200
},
{
"ID": "CUSTOM_NO_HARDCODED_DATES",
"Name": "Avoid hardcoded dates in DAX",
"Category": "Governance",
"Description": "Hardcoded dates should be replaced with parameters or TODAY()/NOW().",
"Severity": 2,
"Scope": "Measure, CalculatedColumn",
"Expression": "RegEx.IsMatch(Expression, @\"DATE\\s*\\(\\s*20[0-9]{2}\\s*,\")",
"FixExpression": null,
"Remarks": "Detects DATE(20XX, patterns which suggest hardcoded years"
}
]
/// =============================================================
/// OBJECT-LEVEL BPA ANNOTATION
/// =============================================================
/// Individual objects can ignore specific rules
/// Placed as annotation on the object itself
/// Example table definition
table Sales
lineageTag: 12345678-1234-1234-1234-123456789012
/// This measure intentionally has no description - ignore the rule
measure 'Internal Calculation' =
SUM(Sales[Amount])
isHidden
lineageTag: 87654321-4321-4321-4321-210987654321
annotation BestPracticeAnalyzer_Ignore = {"RuleIDs":["META_MEASURE_NO_DESCRIPTION"]}
/// Standard visible measure - rules apply normally
measure 'Total Sales' =
SUM(Sales[Amount])
description: Total sales amount across all transactions.
formatString: $#,##0
displayFolder: Revenue
lineageTag: 11111111-2222-3333-4444-555555555555
[
{
"ID": "PQ_AVOID_TABLE_DISTINCT",
"Name": "Avoid Table.Distinct in Power Query",
"Category": "Data Quality",
"Description": "Table.Distinct removes duplicate rows but can mask data quality issues in the source. Consider fixing duplicates at the source or implementing explicit duplicate handling logic.",
"Severity": 2,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Distinct\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_AVOID_REMOVE_DUPLICATES",
"Name": "Avoid Table.RemoveDuplicates in Power Query",
"Category": "Data Quality",
"Description": "Table.RemoveDuplicates can result in unintentional filtering of data and may mask underlying data quality issues.",
"Severity": 2,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.RemoveDuplicates\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_DUPLICATE_DETECTION_COMPREHENSIVE",
"Name": "Avoid all duplicate removal operations in Power Query",
"Category": "Data Quality",
"Description": "Duplicate removal operations (Table.Distinct, Table.RemoveDuplicates) can hide data quality issues. Consider fixing duplicates at the source.",
"Severity": 2,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Distinct\") or Expression.Contains(\"Table.RemoveDuplicates\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_AVOID_TABLE_NESTEDJOIN",
"Name": "Avoid Table.NestedJoin in Power Query",
"Category": "Performance",
"Description": "Table.NestedJoin is an expensive operation that can significantly increase refresh times in import models. Consider performing joins at the source or using model relationships.",
"Severity": 3,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.NestedJoin\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_AVOID_TABLE_JOIN",
"Name": "Avoid Table.Join in Power Query",
"Category": "Performance",
"Description": "Table.Join is an expensive operation that can greatly reduce refresh performance in import models. Consider pushing joins to the source query or using model relationships.",
"Severity": 3,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Join\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_AVOID_FUZZY_JOINS",
"Name": "Avoid fuzzy join operations in Power Query",
"Category": "Performance",
"Description": "Fuzzy join operations (Table.FuzzyJoin, Table.FuzzyNestedJoin) are extremely expensive and can cause severe refresh performance issues.",
"Severity": 3,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.FuzzyJoin\") or Expression.Contains(\"Table.FuzzyNestedJoin\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_MERGE_OPERATIONS_COMPREHENSIVE",
"Name": "Avoid all merge/join operations in Power Query",
"Category": "Performance",
"Description": "All merge and join operations are expensive and significantly impact refresh performance in import models. Push joins to the data source or use model relationships.",
"Severity": 3,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Join\") or Expression.Contains(\"Table.NestedJoin\") or Expression.Contains(\"Table.FuzzyJoin\") or Expression.Contains(\"Table.FuzzyNestedJoin\")",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_TABLE_COMBINE_WITH_DISTINCT",
"Name": "Review Table.Combine with subsequent distinct operations",
"Category": "Performance",
"Description": "Combining tables followed by duplicate removal may indicate overlapping data sources or data quality issues.",
"Severity": 2,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Combine\") and (Expression.Contains(\"Table.Distinct\") or Expression.Contains(\"Table.RemoveDuplicates\"))",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_BUFFER_BEFORE_JOINS",
"Name": "Table.Buffer used before join operations",
"Category": "Performance",
"Description": "Table.Buffer forces full table evaluation before joins. Review whether buffering is necessary and beneficial.",
"Severity": 1,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.Buffer\") and (Expression.Contains(\"Table.Join\") or Expression.Contains(\"Table.NestedJoin\"))",
"CompatibilityLevel": 1200
},
{
"ID": "PQ_EXPAND_AFTER_NESTEDJOIN",
"Name": "Table.ExpandTableColumn after nested joins",
"Category": "Performance",
"Description": "Table.ExpandTableColumn following Table.NestedJoin indicates a merge operation. Consider performing this join at the source or using model relationships.",
"Severity": 2,
"Scope": "Partition",
"Expression": "Expression.Contains(\"Table.NestedJoin\") and Expression.Contains(\"Table.ExpandTableColumn\")",
"CompatibilityLevel": 1200
}
]BPA Rule Examples
This directory contains example BPA rule sets for Tabular Editor.
Important: Tabular Editor Compatibility
Line Endings (CRLF)
Tabular Editor on Windows requires CRLF line endings (\r\n). Files with Unix line endings (LF only) may fail to load or show empty rule collections.
To convert a file to CRLF:
# macOS/Linux
sed -i 's/$/\r/' rules.json
# Or use the validation script with --fix flag
python validate_rules.py --fix rules.jsonFile Paths
When adding rule files in Tabular Editor:
- Use absolute paths (e.g.,
C:\BPARules\my-rules.json) - Avoid relative paths with
..\..\..- TE may fail to resolve these - URLs work fine (e.g.,
https://raw.githubusercontent.com/...)
JSON Format Requirements
- No
_commentfields - TE's JSON parser doesn't allow extra properties - Avoid
nullvalues for optional fields - omit the field entirely instead - Use standard JSON encoding (UTF-8, no BOM)
Files
power-query-operations-rules.json
Comprehensive BPA rules for detecting Power Query anti-patterns:
Duplicate Detection (3 rules):
PQ_AVOID_TABLE_DISTINCT- Detects Table.Distinct operationsPQ_AVOID_REMOVE_DUPLICATES- Detects Table.RemoveDuplicates operationsPQ_DUPLICATE_DETECTION_COMPREHENSIVE- Comprehensive rule covering all duplicate removal
Join/Merge Operations (4 rules):
PQ_AVOID_TABLE_NESTEDJOIN- Detects Table.NestedJoin (severity 3)PQ_AVOID_TABLE_JOIN- Detects Table.Join (severity 3)PQ_AVOID_FUZZY_JOINS- Detects fuzzy join operations (severity 3)PQ_MERGE_OPERATIONS_COMPREHENSIVE- Comprehensive rule covering all join/merge patterns
Advanced Patterns (3 rules):
PQ_TABLE_COMBINE_WITH_DISTINCT- Detects Table.Combine + duplicate removal patternsPQ_BUFFER_BEFORE_JOINS- Detects Table.Buffer usage before joinsPQ_EXPAND_AFTER_NESTEDJOIN- Detects expand column operations after nested joins
course-3-business-case-bpa-rules.json
Business case example rules from training materials demonstrating various BPA patterns including:
- Table naming conventions
- Duplicate removal detection
- Auto-DateTime table detection
- Column count limits
- Relationship best practices
- Data type recommendations
- Redundant column detection
- Display folder organization
- Format string requirements
comprehensive-rules.json
Complete reference set of BPA rules covering:
- DAX expressions
- Metadata
- Performance
- Model layout
- Naming conventions
- Formatting
- Governance
microsoft-analysis-services-rules.json
77 official BPA rules from the Microsoft Analysis Services repository, maintained by Michael Kovalsky (Fabric CAT team). This is the industry-standard rule set used by Power BI developers worldwide.
Source: microsoft/Analysis-Services/BestPracticeRules
Categories covered:
- Performance (25 rules): Data types, relationships, aggregations, query folding, Direct Query optimization
- DAX Expressions (17 rules): Column/measure references, DIVIDE function, IFERROR avoidance, filter syntax
- Error Prevention (9 rules): Source columns, expressions, data type mismatches, invalid characters
- Maintenance (9 rules): Unused columns/measures, referential integrity, descriptions
- Naming Conventions (4 rules): Object names, partition names, special characters
- Formatting (18 rules): Format strings, data categories, hiding conventions, primary/foreign keys
Note: Some rules require Vertipaq Analyzer annotations. Run the script at elegantbi.com/post/vertipaqintabulareditor first to enable cardinality-based rules.
Usage
Import these rule sets into Tabular Editor via: 1. Tools -> Manage BPA Rules 2. Click "Add..." 3. Enter the absolute file path or URL 4. Click "OK"
Or reference them in your BPARules.json file in the model directory.
Validation
Use the validation script to check rules before loading in TE:
# Validate rules
python scripts/validate_rules.py examples/my-rules.json
# Validate and fix line endings
python scripts/validate_rules.py --fix examples/my-rules.json
# Schema validation only
python scripts/validate_rules.py --schema-only examples/my-rules.json{
"DaxOptimizer": {
"ObfuscateVpax": false
},
"AppVersion": "3.25.0",
"Version": 3,
"Deployment": {
"DeployDataSources": true,
"DeployPartitions": true,
"DeployRefreshPolicyPartitions": true,
"DeployModelRoles": true,
"DeployModelRoleMembers": false,
"DeploySharedExpressions": true
},
"Defaults": {
"NewModelCL": 1571,
"UseLatestDefault": true,
"NewModelUseWorkspace": true
},
"DataBrowsing": {
"AutoRefreshDataPreview": false,
"AutoRefreshPivotGrid": true,
"PivotCustomizationShowAllFields": true,
"PivotHeaderWordWrap": true,
"WarnIfPivotGridFieldsMismatch": true,
"AlwaysShowPivotGridFieldList": true,
"PivotCustomizationDefaultLayout": "BottomPanelOnly2by2",
"AutoExecuteDaxQuery": false,
"DirectQueryMaxRows": 100,
"DaxQueryMaxRows": 1000,
"DaxQuerySmartSelection": true,
"KeepFilteringAndSortingDaxQuery": "WhenQueryUnchanged"
},
"PbiEndpointBaseUrl": "https://api.powerbi.com",
"FabricEndpointBaseUrl": "https://api.fabric.microsoft.com",
"PbiDefaultAuthMode": "Integrated",
"DefaultSaveMode": "TMDL",
"DaxFormatterRequestTimeout": 5000,
"DaxFormatterConsent": false,
"CheckForUpdatesOnStartup": true,
"CheckForUpdatesMajorOnly": false,
"VertiPaqAnalyzerIncludeTom": true,
"VertiPaqAnalyzerReadStatsFromData": true,
"VertiPaqAnalyzerDirectLakeExtractionMode": "Full",
"VertiPaqAnalyzerReadStatsFromDQ": false,
"VertiPaqAnalyzerSampleRI": 3,
"VertiPaqAnalyzerColumnBatchSize": 50,
"CollectTelemetry": false,
"FormulaFixup": true,
"FormulaFixupOnPaste": true,
"PbiModelsAddDaxLineBreak": true,
"PbiModelsAddDaxLineBreakMultiLineOnly": false,
"AllowUnsupportedPBIFeatures": false,
"HidePBIAutoDateTimeWarnings": false,
"WarnWhenMetadataOutOfSync": true,
"ChangeDetectionOnLocalServers": true,
"AutoRefreshTomTree": true,
"BackgroundBpa": true,
"BuiltInBpaRules": "Enable",
"DisabledBuiltInRuleIds": [
"TE3_BUILT_IN_DATA_COLUMN_SOURCE",
"TE3_BUILT_IN_EXPRESSION_REQUIRED",
"TE3_BUILT_IN_AVOID_PROVIDER_PARTITIONS_STRUCTURED",
"TE3_BUILT_IN_SET_ISAVAILABLEINMDX_FALSE",
"TE3_BUILT_IN_DATE_TABLE_EXISTS",
"TE3_BUILT_IN_MANY_TO_MANY_SINGLE_DIRECTION",
"TE3_BUILT_IN_RELATIONSHIP_SAME_DATATYPE",
"TE3_BUILT_IN_AVOID_INVALID_CHARACTERS_NAMES",
"TE3_BUILT_IN_AVOID_INVALID_CHARACTERS_DESCRIPTIONS",
"TE3_BUILT_IN_SET_ISAVAILABLEINMDX_TRUE_NECESSARY",
"TE3_BUILT_IN_REMOVE_UNUSED_DATA_SOURCES",
"TE3_BUILT_IN_VISIBLE_TABLES_NO_DESCRIPTION",
"TE3_BUILT_IN_VISIBLE_COLUMNS_NO_DESCRIPTION",
"TE3_BUILT_IN_VISIBLE_MEASURES_NO_DESCRIPTION",
"TE3_BUILT_IN_VISIBLE_CALCULATION_GROUPS_NO_DESCRIPTION",
"TE3_BUILT_IN_VISIBLE_UDF_NO_DESCRIPTION",
"TE3_BUILT_IN_PERSPECTIVES_NO_OBJECTS",
"TE3_BUILT_IN_CALCULATION_GROUPS_NO_ITEMS",
"TE3_BUILT_IN_TRIM_OBJECT_NAMES",
"TE3_BUILT_IN_FORMAT_STRING_COLUMNS",
"TE3_BUILT_IN_TRANSLATE_DISPLAY_FOLDERS",
"TE3_BUILT_IN_TRANSLATE_DESCRIPTIONS",
"TE3_BUILT_IN_TRANSLATE_VISIBLE_NAMES",
"TE3_BUILT_IN_TRANSLATE_HIERARCHY_LEVELS",
"TE3_BUILT_IN_TRANSLATE_PERSPECTIVES",
"TE3_BUILT_IN_SPECIFY_APPLICATION_NAME",
"TE3_BUILT_IN_POWERBI_LATEST_COMPATIBILITY"
],
"DataRefreshNotification": true,
"SaveBackupLocation": "",
"AnnotateDeploymentMetadata": true,
"SendErrorReports": false,
"SchemaCompare_IgnoreImport": false,
"SchemaCompare_IgnoreDataTypeChanges": false,
"SchemaCompare_IgnoreDescriptionChanges": false,
"SchemaCompare_IgnoreDecimalToDoubleChanges": false,
"DirectLake_AutoRefreshSave": true,
"PrioritizeAnalysisServicesSchemaDetector": true,
"IgnoreLineageTags": false,
"IgnoreInferredObjects": true,
"IgnoreInferredProperties": true,
"IgnoreTimestamps": true,
"IgnorePrivacySettings": false,
"IncludeSensitive": false,
"IgnoreIncrementalRefreshPartitions": false,
"SplitMultilineStrings": true,
"SortArrays": false,
"UsePbixFileNameWhenSavingToDisk": true,
"CreateUserOptionsForNewModels": true,
"ProxyType": "None",
"ProxyBypassList": "",
"ProxyUseDefaultCredentials": true,
"ProxyAddress": "",
"ProxyBypassOnLocal": true,
"ProxyUser": "",
"ProxyPasswordEncrypted": "",
"SaveToFolder_PrefixFiles": false,
"SaveToFolder_LocalRelationships": false,
"SaveToFolder_LocalPerspectives": false,
"SaveToFolder_LocalTranslations": false,
"SaveToFolder_UseRecommendedSettings": false,
"Copy_IncludeTranslations": false,
"Copy_IncludePerspectives": true,
"Copy_IncludeRLS": true,
"Copy_IncludeOLS": true,
"Perspectives_InheritForNewObjects": true,
"Perspectives_InheritForRelocatedObjects": true,
"Perspectives_InheritTableAdd": true,
"Perspectives_InheritTableRemove": true,
"View_DisplayFolders": true,
"View_HiddenObjects": true,
"View_AllObjectTypes": true,
"View_SortAlphabetically": false,
"View_Measures": true,
"View_Columns": true,
"View_Hierarchies": true,
"View_Partitions": true,
"View_MetadataInformation": true,
"SaveToFolder_Levels": [
"Data Sources",
"Shared Expressions",
"Perspectives",
"Relationships",
"Roles",
"Tables",
"Tables/Columns",
"Tables/Hierarchies",
"Tables/Measures",
"Tables/Partitions",
"Tables/CalculationItems",
"Translations"
],
"SaveToFolder_UseTmdl": true,
"TmdlOptions": {
"IncludeRefs": false,
"CasingStyle": "CamelCase",
"ExpressionTrimStyle": "TrimTrailingWhitespaces",
"NewLineStyle": "SystemDefault",
"Encoding": "UTF8",
"BaseIndentationLevel": 0,
"SpacesIndentation": 0
},
"View_ColumnPreferences": [
{
"Name": "Name",
"Width": 355,
"Visible": true
},
{
"Name": "Parent",
"Width": 100,
"Visible": true
},
{
"Name": "Type",
"Width": 152,
"Visible": true
},
{
"Name": "Format",
"Width": 50,
"Visible": true
},
{
"Name": "Data type",
"Width": 60,
"Visible": true
},
{
"Name": "Source",
"Width": 100,
"Visible": true
},
{
"Name": "Description",
"Width": 100,
"Visible": true
}
],
"BackupOnSave": false,
"ShouldBackupOnSave": false,
"BackupOnDeploy": true,
"ShouldBackupOnDeploy": false
}BPA Expression Syntax
Dynamic LINQ expression syntax for Best Practice Analyzer rules.
Overview
BPA expressions use Dynamic LINQ with access to the Tabular Object Model (TOM). Expressions return true for objects that violate the rule.
Basic Syntax
String Operations
// Null/empty checks
string.IsNullOrWhitespace(Description)
string.IsNullOrEmpty(Expression)
// Contains
Expression.Contains("CALCULATE")
Name.Contains(" ")
// StartsWith / EndsWith
Name.StartsWith("_")
Name.EndsWith("ID")
// Case-insensitive search
Expression.IndexOf("TODO", StringComparison.OrdinalIgnoreCase) >= 0
// Regex matching
RegEx.IsMatch(Name, "^[A-Z][a-z]+")
RegEx.IsMatch(Expression, @"\bSUM\s*\(")Boolean Logic
// Simple boolean
IsHidden
not IsHidden
IsVisible
// AND / OR
IsHidden and ReferencedBy.Count = 0
IsVisible or Table.IsVisible
// Complex conditions
(IsHidden or Table.IsHidden) and ReferencedBy.Count = 0Numeric Comparisons
// Equality
Columns.Count = 0
Severity = 3
// Inequality
ReferencedBy.Count > 0
Measures.Count < 5
// Range (use AND)
Columns.Count >= 10 and Columns.Count <= 50Collection Operations (LINQ)
// Any - at least one matches
DependsOn.Any()
DependsOn.Any(Key.ObjectType = "Column")
Columns.Any(IsHidden)
// All - every item matches
Columns.All(IsHidden)
Measures.All(string.IsNullOrWhitespace(Description))
// Count
ReferencedBy.Count = 0
Columns.Count(IsHidden) > 5
// Where (filter)
Columns.Where(not IsHidden).Count() > 100Nested Property Access
// Parent object
Table.Name
Table.IsHidden
Column.Table.Model.Name
// Child collections
Table.Columns.Count
Table.Measures.Any()Properties by Object Type
<!-- TODO: Expand with complete TOM property reference -->
Model
Name // Model name
Tables // Collection of tables
Relationships // Collection of relationships
Perspectives // Collection of perspectives
Cultures // Collection of cultures
Roles // Collection of roles
DataSources // Collection of data sourcesTable
Name // Table name
Description // Table description
IsHidden // Is table hidden
IsPrivate // Is table private (calculation group)
Columns // Collection of columns
Measures // Collection of measures
Hierarchies // Collection of hierarchies
Partitions // Collection of partitions
CalculationGroup // Calculation group (if calc group table)
ObjectType // "Table"Column
Name // Column name
Description // Column description
DataType // DataType enum
SourceColumn // Source column name (for data columns)
Expression // DAX expression (calculated columns)
FormatString // Format string
DisplayFolder // Display folder path
IsHidden // Is column hidden
IsKey // Is primary key
IsNullable // Allows nulls
IsAvailableInMDX // Available in MDX
SummarizeBy // Default aggregation
SortByColumn // Sort by column reference
Table // Parent table
ReferencedBy // Objects referencing this column
DependsOn // Objects this column depends on
UsedInRelationships // Relationships using this column
UsedInSortBy // Columns sorted by this
UsedInHierarchies // Hierarchies containing this
ObjectType // "Column", "CalculatedColumn", etc.Measure
Name // Measure name
Description // Measure description
Expression // DAX expression
FormatString // Format string
DisplayFolder // Display folder path
IsHidden // Is measure hidden
IsSimpleMeasure // Is implicit measure
Table // Parent table
ReferencedBy // Objects referencing this measure
DependsOn // Objects this measure depends on
KPI // KPI (if defined)
ObjectType // "Measure"Hierarchy
Name // Hierarchy name
Description // Hierarchy description
DisplayFolder // Display folder path
IsHidden // Is hierarchy hidden
Levels // Collection of levels
Table // Parent tableRelationship
FromTable // From table
FromColumn // From column
ToTable // To table
ToColumn // To column
FromCardinality // Cardinality (Many/One)
ToCardinality // Cardinality (Many/One)
CrossFilteringBehavior // OneDirection/BothDirections
IsActive // Is relationship active
SecurityFilteringBehavior // Security filteringPartition
Name // Partition name
Description // Partition description
Expression // M query or SQL
SourceType // Query/M/Calculated
Table // Parent table
Mode // Import/DirectQuery/DualCalculationItem
Name // Calculation item name
Description // Description
Expression // DAX expression
FormatStringExpression // Format string DAX
Ordinal // Sort order
CalculationGroup // Parent calculation groupCommon Patterns
Unused Objects
// Unused hidden column
IsHidden and ReferencedBy.Count = 0 and not UsedInRelationships.Any()
// Unused measure
ReferencedBy.Count = 0 and not string.IsNullOrEmpty(Expression)Missing Metadata
// No description
string.IsNullOrWhitespace(Description)
// No format string on numeric measure
DataType = DataType.Int64 and string.IsNullOrWhitespace(FormatString)
// Visible with no display folder
not IsHidden and string.IsNullOrWhitespace(DisplayFolder)DAX Anti-patterns
// Uses IFERROR
Expression.Contains("IFERROR")
// Uses implicit CALCULATE
DependsOn.Any(Key.ObjectType = "Column" and Value.Any(not FullyQualified))
// Division without DIVIDE
Expression.Contains("/") and not Expression.Contains("DIVIDE")Naming Conventions
// Starts with number
RegEx.IsMatch(Name, "^[0-9]")
// Contains special characters
RegEx.IsMatch(Name, "[^a-zA-Z0-9 _-]")
// Not in PascalCase
not RegEx.IsMatch(Name, "^[A-Z][a-z]+(?:[A-Z][a-z]+)*$")Performance Issues
// Table too wide
Columns.Count > 100
// Many calculated columns
Columns.Count(ObjectType = "CalculatedColumn") > 10
// Bi-directional relationship
CrossFilteringBehavior = CrossFilteringBehavior.BothDirectionsFix Expression Syntax
Fix expressions modify object properties or call methods.
Property Assignment
// Set string property
Description = "TODO: Add description"
DisplayFolder = "Measures\\Calculated"
// Set boolean
IsHidden = true
IsAvailableInMDX = false
// Set enum
DataType = DataType.Decimal
SummarizeBy = AggregateFunction.None
CrossFilteringBehavior = CrossFilteringBehavior.OneDirectionMethod Calls
// Delete object (use with caution!)
Delete()Enum Values
DataType: Unknown, String, Int64, Double, DateTime, Decimal, Boolean, Binary, Variant
AggregateFunction: Default, None, Sum, Min, Max, Count, Average, DistinctCount
CrossFilteringBehavior: OneDirection, BothDirections, Automatic
DAX Token Analysis (Tokenize)
The Tokenize() method parses DAX expressions into tokens for precise analysis.
Basic Usage
// Check if expression uses division operator (not dividing by literal)
Tokenize().Any(Type = DIV and Next.Type <> INTEGER_LITERAL and Next.Type <> REAL_LITERAL)
// Check for specific function usage
Tokenize().Any(Type = FUNCTION and Text = "CALCULATE")
// Find FILTER(ALL(...)) pattern
Tokenize().Any(Type = FUNCTION and Text = "FILTER" and Next.Next.Type = FUNCTION and Next.Next.Text = "ALL")Token Types
Common token types available in Tokenize():
| Token Type | Description |
|---|---|
FUNCTION | DAX function name |
COLUMN_OR_MEASURE | Column or measure reference |
TABLE | Table reference |
INTEGER_LITERAL | Integer constant |
REAL_LITERAL | Decimal constant |
STRING_LITERAL | String constant |
DIV | Division operator / |
MULT | Multiplication operator * |
PLUS | Addition operator + |
MINUS | Subtraction operator - |
EQ | Equals operator = |
OPEN_PARENS | Opening parenthesis ( |
CLOSE_PARENS | Closing parenthesis ) |
COMMA | Comma separator , |
Token Properties
Type // Token type (see table above)
Text // Actual text of the token
Next // Next token in sequence
Previous // Previous token in sequenceExamples
// Detect division by variable (not constant)
Tokenize().Any(Type = DIV and Next.Type <> INTEGER_LITERAL and Next.Type <> REAL_LITERAL)
// Find SUMX usage
Tokenize().Any(Type = FUNCTION and Text = "SUMX")
// Find nested CALCULATE
Tokenize().Count(Type = FUNCTION and Text = "CALCULATE") > 1
// Detect IF with three arguments (potential for SWITCH)
Tokenize().Any(Type = FUNCTION and Text = "IF")DependsOn Collection
The DependsOn property provides dependency analysis for measures and calculated columns.
Structure
DependsOn.Any() // Has any dependencies
DependsOn.Any(Key.ObjectType = "Column") // Depends on columns
DependsOn.Any(Key.ObjectType = "Measure") // Depends on measures
DependsOn.Any(Key.ObjectType = "Table") // Depends on tablesChecking Qualification
// Column references should be fully qualified
DependsOn.Any(Key.ObjectType = "Column" and Value.Any(not FullyQualified))
// Measure references should NOT be fully qualified
DependsOn.Any(Key.ObjectType = "Measure" and Value.Any(FullyQualified))Value Properties
Each dependency entry has a Value collection with properties:
FullyQualified // Is the reference fully qualified (e.g., 'Table'[Column])ReferencedBy Collection
The ReferencedBy property shows what objects reference the current object.
Common Uses
// Not referenced by anything
ReferencedBy.Count = 0
// Referenced by visible measures
ReferencedBy.AllMeasures.Any(not IsHidden)
// Referenced by roles (RLS)
ReferencedBy.Roles.Any()
// Check for orphaned hidden objects
(IsHidden or Table.IsHidden) and
not ReferencedBy.AllMeasures.Any(not IsHidden) and
not ReferencedBy.AllColumns.Any(not IsHidden)Sub-collections
ReferencedBy.AllMeasures // All measures referencing this
ReferencedBy.AllColumns // All columns referencing this
ReferencedBy.AllTables // All tables referencing this
ReferencedBy.Roles // Security roles referencing thisOuterIt Reference
When using nested LINQ expressions, outerIt refers to the outer object.
Examples
// Check if column is used in any relationship from this table
Model.Relationships.Any(FromColumn = outerIt)
// Check if table has any measure with empty description
Measures.Any(string.IsNullOrWhitespace(outerIt.Description))
// Multiple relationships between same tables
Model.Relationships.Count(FromTable = OuterIt.FromTable and ToTable = OuterIt.ToTable) > 1Tips
1. Test incrementally - Build complex expressions step by step 2. Check parentheses - Group conditions explicitly 3. Handle nulls - Use string.IsNullOrWhitespace() for strings 4. Consider inheritance - Column checks apply to all column types 5. Performance - Complex expressions slow BPA scanning 6. Use Tokenize() for DAX analysis - More precise than string matching 7. Prefer DependsOn over string matching - Handles edge cases correctly
BPA Rules Quick Reference
Rule JSON Structure
BPA rules have the following fields:
| Field | Required | Type | Description |
|---|---|---|---|
ID | Yes | string | Unique identifier for the rule (e.g., META_MEASURE_NO_DESC) |
Name | Yes | string | Human-readable name displayed in UI |
Category | No | string | Rule grouping (e.g., Performance, DAX Expressions, Metadata) |
Description | No | string | Explanation of why the rule matters. Supports placeholders: %object%, %objectname%, %objecttype% |
Severity | Yes | int | Priority level: 1 (Low), 2 (Medium), 3 (High) |
Scope | Yes | string | Comma-separated list of object types the rule applies to |
Expression | Yes | string | Dynamic LINQ expression evaluated against scoped objects; returns true for violations |
FixExpression | No | string | Dynamic LINQ expression to auto-fix violations (e.g., IsHidden = true) |
CompatibilityLevel | No | int | Minimum model compatibility level required for the rule to apply |
Remarks | No | string | Additional notes or context about the rule |
{
"ID": "RULE_PREFIX_NAME",
"Name": "Human-readable rule name",
"Category": "Performance|Formatting|Metadata|DAX Expressions|Naming Conventions|Governance",
"Description": "Explanation of why this rule matters for %objecttype% '%objectname%'",
"Severity": 2,
"Scope": "Measure, CalculatedColumn, Table",
"Expression": "DynamicLINQ expression returning true for violations",
"FixExpression": "PropertyName = Value",
"CompatibilityLevel": 1200
}Valid Scope Values
All valid scope values from the RuleScope enum (can be combined with commas):
| Scope | TOM Type | Description |
|---|---|---|
Model | Model | The entire semantic model |
Table | Table | Regular tables (excludes calculated tables) |
CalculatedTable | CalculatedTable | Tables defined by DAX expressions |
Measure | Measure | DAX measures |
DataColumn | DataColumn | Columns from data source |
CalculatedColumn | CalculatedColumn | Columns defined by DAX |
CalculatedTableColumn | CalculatedTableColumn | Columns in calculated tables |
Hierarchy | Hierarchy | User-defined hierarchies |
Level | Level | Hierarchy levels |
Relationship | SingleColumnRelationship | Table relationships |
Partition | Partition | Table partitions |
Perspective | Perspective | Model perspectives |
Culture | Culture | Translations/cultures |
KPI | KPI | Key Performance Indicators |
CalculationGroup | CalculationGroupTable | Calculation group tables |
CalculationItem | CalculationItem | Items within calculation groups |
ProviderDataSource | ProviderDataSource | Legacy/provider data sources |
StructuredDataSource | StructuredDataSource | M/Power Query data sources |
NamedExpression | NamedExpression | Shared M expressions |
ModelRole | ModelRole | Security roles |
ModelRoleMember | ModelRoleMember | Members of security roles |
TablePermission | TablePermission | RLS table permissions |
Variation | Variation | Column variations |
Calendar | Calendar | Calendar/date tables |
UserDefinedFunction | UserDefinedFunction | DAX user-defined functions |
Backwards compatibility: Column expands to DataColumn, CalculatedColumn, CalculatedTableColumn; DataSource expands to ProviderDataSource
Severity Levels
| Level | Name | Meaning |
|---|---|---|
| 1 | Low | Informational suggestion, minor improvement |
| 2 | Medium | Warning, should fix for quality |
| 3 | High | Error, must fix for correctness |
Compatibility Levels
The CompatibilityLevel field specifies the minimum model version required. Rules only apply if the model's compatibility level >= the rule's level.
| Level | Platform | Features Introduced |
|---|---|---|
| 1200 | AAS/SSAS 2016 | JSON metadata format, base TOM |
| 1400 | AAS/SSAS 2017 | Detail rows, object-level security, ragged hierarchies |
| 1500 | AAS/SSAS 2019 | Calculation groups |
| 1560+ | Power BI | Power BI-specific features begin |
| 1600 | SQL Server 2022 | Enhanced AS features |
| 1702 | Power BI / Fabric | Current Power BI compatibility level (dynamic format strings, field parameters, DAX UDFs, etc.) |
Note: Power BI models use 1560+ with current level at 1702. Use Model.Database.CompatibilityLevel in expressions to check the model's level.
Category Prefixes
Common ID prefix conventions:
| Prefix | Category |
|---|---|
DAX_ | DAX Expressions |
META_ | Metadata |
PERF_ | Performance |
NAME_ | Naming Conventions |
LAYOUT_ | Model Layout |
FORMAT_ | Formatting |
ERR_ | Error Prevention |
GOV_ | Governance |
MAINT_ | Maintenance |
Expression Syntax Overview
BPA expressions use Dynamic LINQ with access to TOM (Tabular Object Model) properties.
Basic Patterns
// String checks
string.IsNullOrWhitespace(Description)
Name.StartsWith("_")
Expression.Contains("CALCULATE")
// Boolean checks
IsHidden
not IsHidden
IsVisible and not HasAnnotations
// Numeric checks
ReferencedBy.Count = 0
Columns.Count > 100
// Collection checks
DependsOn.Any()
Columns.All(IsHidden)Common Properties by Scope
Measure:
Expression,FormatString,DisplayFolder,DescriptionIsHidden,IsVisible,ReferencedBy,DependsOn
Column:
DataType,SourceColumn,FormatString,SummarizeByIsHidden,IsKey,IsNullable,SortByColumn
Table:
Columns,Measures,Partitions,IsHiddenCalculationGroup(for calc group tables)
For complete expression syntax, see expression-syntax.md.
TMDL Annotations
BPA rules can be embedded in TMDL files via annotations:
annotation BestPracticeAnalyzer = [{ "ID": "...", ... }]
annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["RULE1","RULE2"]}
annotation BestPracticeAnalyzer_ExternalRuleFiles = ["https://..."]For complete annotation patterns, see tmdl-annotations.md.