# Power BI Documentation (/docs) # Power BI Documentation [#power-bi-documentation] Clear, practical documentation for building better Power BI reports and models — from your first install through advanced DAX, data modeling, and AI-assisted workflows. ## Browse by Topic [#browse-by-topic] # DAX with AI (/docs/ai-power-bi/dax-with-ai) # DAX with AI [#dax-with-ai] DAX is one of the areas where AI assistance pays off fastest — measures follow recognizable patterns, and a tool that's seen thousands of them can draft, explain, or debug a formula in seconds. It still takes a developer who understands filter context to check the result. ## What AI Can Help With [#what-ai-can-help-with] ### Explaining Existing Measures [#explaining-existing-measures] Pasting an unfamiliar measure and asking for a plain-language walkthrough is one of the fastest ways to understand a model you didn't build. ```text Prompt: "Explain what this DAX measure does, step by step: Sales YoY % = DIVIDE( [Total Sales] - CALCULATE([Total Sales], SAMEPERIODLASTYEAR(DimDate[Date])), CALCULATE([Total Sales], SAMEPERIODLASTYEAR(DimDate[Date])) )" ``` A good explanation should call out the `CALCULATE` context transition, what `SAMEPERIODLASTYEAR` shifts, and why `DIVIDE` is used instead of `/`. ### Drafting New Measures [#drafting-new-measures] Describing the business logic in plain language, rather than starting from a blank formula bar, tends to produce a solid first draft. ```text Prompt: "Write a DAX measure for a running total of [Total Sales] by DimDate[Date], that resets at the start of each fiscal year (fiscal year starts in July)." ``` Treat the result as a first draft — verify function choices, especially around time intelligence, against the model's actual date table setup. ### Debugging Calculation Errors [#debugging-calculation-errors] Pasting the measure, the error message (or the wrong result), and a short description of what's expected usually gets a faster diagnosis than searching function documentation from scratch. ```text Prompt: "This measure returns BLANK for every row instead of a percentage. What's wrong? Margin % = [Gross Margin] / [Total Sales]" ``` In this example, the fix is almost always to use `DIVIDE()` instead of `/`, so blank or zero denominators don't propagate as errors — a good AI response should catch that immediately. ### Improving Performance [#improving-performance] AI can suggest alternative formulations of a slow measure — trading `FILTER` for a native filter argument, replacing iterators with a set-based function — though the actual impact should be confirmed with Performance Analyzer or DAX Studio, not assumed from the suggestion alone. *** ## Cautions [#cautions] * AI-generated DAX can look syntactically correct while still misunderstanding row context vs. filter context — test against known values, not just "it ran without an error." * AI doesn't know the actual model behind a pasted measure. Relationships, cardinality, and other measures it references all affect correctness, and none of that is visible from a snippet alone. * Time intelligence functions in particular depend on having a proper marked date table — AI suggestions assume one exists even if the current model doesn't have one. *** ## A Reasonable Workflow [#a-reasonable-workflow] ```text Describe the requirement | v Get an AI-drafted measure | v Read it: does the logic actually match the requirement? | v Test against a known value in the report | v Check performance if the measure is used in a heavily-filtered visual ``` *** ## Next Steps [#next-steps] * [Power Query with AI](/docs/ai-power-bi/power-query-with-ai) * [Model Advisor](/docs/ai-power-bi/model-advisor) * [Prompt Library](/docs/ai-power-bi/prompt-library) # AI-Assisted Power BI (/docs/ai-power-bi) # AI-Assisted Power BI [#ai-assisted-power-bi] AI tools can meaningfully speed up building DAX, Power Query, and reports — but only when paired with an understanding of what the generated code is actually doing. This section covers where AI genuinely helps, and where it needs a second look. ## Start Here [#start-here] # Introduction (/docs/ai-power-bi/introduction) # AI-Assisted Power BI [#ai-assisted-power-bi] Artificial intelligence is changing how Power BI solutions are designed, developed, and optimized. AI can help developers and analysts move faster while still following strong data modeling and analytics practices. ## The Modern Power BI Workflow [#the-modern-power-bi-workflow] * **Data connectivity** — Connect to databases, files, APIs, and cloud services. * **Data transformation** — Clean and reshape data using Power Query. * **Data modeling** — Create relationships, dimensions, and analytical structures. * **DAX development** — Build calculations and business logic. * **Report design** — Create meaningful visualizations and dashboards. * **AI assistance** — Improve development speed, troubleshoot issues, and explore solutions. ## Where AI Helps [#where-ai-helps] ### DAX Development [#dax-development] AI can help: * Explain existing measures * Create DAX calculations * Identify performance issues * Convert business requirements into formulas ### Power Query [#power-query] AI can assist with: * M code generation * Transformation logic * Error troubleshooting * Query optimization ### Data Modeling [#data-modeling] AI can help review: * Star schema design * Relationships * Fact and dimension tables * Model performance ## AI Is an Accelerator [#ai-is-an-accelerator] AI should support good Power BI practices, not replace them. Understanding data modeling, DAX, and analytics fundamentals remains essential. # Model Advisor (/docs/ai-power-bi/model-advisor) # Model Advisor [#model-advisor] Reviewing a data model is harder to delegate to AI than a single DAX measure or Power Query step, since it depends on the whole model — but describing the model's structure and asking for a review still catches issues a developer might miss from being too close to their own work. ## What AI Can Help Review [#what-ai-can-help-review] ### Relationship Design [#relationship-design] Describing a model's tables and how they connect can surface issues like missing relationships, relationships going the wrong direction, or an unintended many-to-many. ```text Prompt: "Here are my tables and relationships: - DimDate (1) -> FactSales (many) on DateKey - DimProduct (1) -> FactSales (many) on ProductKey - DimCustomer (1) -> FactSales (many) on CustomerKey - FactSales (many) -> FactInventory (many) on ProductKey Does this relationship design look correct for a Power BI star schema?" ``` A good response should flag the direct fact-to-fact relationship as a problem, and suggest connecting both fact tables through a shared `DimProduct` relationship instead. See [Star Schema](/docs/modeling/star-schema) for why fact-to-fact relationships are avoided. ### Star Schema Adherence [#star-schema-adherence] Describing the model's tables — which are facts, which are dimensions, and how wide each one is — can help confirm the model actually follows a star schema shape rather than a flatter, harder-to-maintain design. ```text Prompt: "I have one table called SalesData with columns: Date, CustomerName, CustomerRegion, ProductName, Category, Quantity, SalesAmount. Should I split this into a star schema, and if so, how?" ``` ### DAX Performance Patterns [#dax-performance-patterns] Pasting a handful of measures alongside a description of the model can help identify patterns likely to cause slow visuals — heavy use of `FILTER` over large tables, iterators nested inside iterators, or measures that don't use variables and recompute the same subexpression repeatedly. ### Model Organization [#model-organization] AI can suggest naming conventions, folder structures for measures (display folders), and whether a model has grown enough tables that some restructuring or documentation would help maintainability. *** ## Cautions [#cautions] * AI can only review what it's told about the model; it has no visibility into actual data volumes, cardinality, or real-world query patterns, all of which matter for genuine performance analysis. * Treat model-level suggestions as a starting point for discussion, not a definitive verdict — validate real performance issues with Performance Analyzer or DAX Studio. * A model that looks fine described in a paragraph can still have relationship or filter-direction issues that only show up in the model view itself. Use AI review to complement a visual inspection, not replace it. *** ## A Reasonable Workflow [#a-reasonable-workflow] ```text Describe the model's tables and relationships | v Ask for a review against star schema principles | v Cross-check flagged issues against the actual Model view | v Validate any performance concern with Performance Analyzer / DAX Studio ``` *** ## Next Steps [#next-steps] * [DAX with AI](/docs/ai-power-bi/dax-with-ai) * [Report Design with AI](/docs/ai-power-bi/report-design) * [Prompt Library](/docs/ai-power-bi/prompt-library) # Power Query with AI (/docs/ai-power-bi/power-query-with-ai) # Power Query with AI [#power-query-with-ai] Power Query transformations are step-by-step and mechanical enough that AI tools handle them well: generating M code for a described transformation, explaining what an unfamiliar step does, or spotting why a query broke after a source change. ## What AI Can Help With [#what-ai-can-help-with] ### Generating M Code from a Description [#generating-m-code-from-a-description] Describing the desired shape of the output, rather than the exact M syntax, is usually enough for a solid starting point. ```text Prompt: "Write Power Query M code that takes a table with columns [OrderID, ProductID, Quantity] and returns total Quantity per ProductID, sorted descending." ``` The result is usually close to `Table.Group` with a sum aggregation — a good response should use it directly rather than a manual loop. ### Explaining Existing M Code [#explaining-existing-m-code] Pasting a query from the Advanced Editor and asking what each step does is a fast way to understand a query someone else built. ```text Prompt: "Explain what each step in this M query does: let Source = Sql.Database("server", "db"), Filtered = Table.SelectRows(Source, each [Region] = "West"), Grouped = Table.Group(Filtered, {"ProductID"}, {{"TotalQty", each List.Sum([Quantity]), type number}}) in Grouped" ``` ### Troubleshooting Errors [#troubleshooting-errors] Power Query error messages are often terse, like `Expression.Error: The column 'X' of the table wasn't found`. Pasting the error alongside the step that produced it usually gets a faster diagnosis than guessing. ```text Prompt: "I'm getting this Power Query error after a Merge step: 'Expression.Error: The column 'CustomerID' of the table wasn't found.' Here's the step: Table.ExpandTableColumn(Merged, "NewColumn", {"CustomerID"}) What's causing it, and how do I fix it?" ``` This particular error usually means the merged column was renamed or the join produced no matches — a good answer should walk through both possibilities. ### Optimizing for Query Folding [#optimizing-for-query-folding] AI can suggest reordering steps, or replacing a custom column with a foldable built-in transformation, to keep more of a query folding back to the source. See [Query Folding](/docs/power-query/query-folding) for why that matters for refresh performance. *** ## Cautions [#cautions] * AI doesn't know whether a suggested transformation will actually fold back to the source — check **View Native Query** after applying it, don't assume. * Generated M code should be reviewed against the actual column names and types in the source; AI will happily use plausible-looking names that don't exist in the real data. * For anything touching a production refresh schedule, test AI-suggested changes against a copy of the query first. *** ## A Reasonable Workflow [#a-reasonable-workflow] ```text Describe the transformation, or paste the failing step | v Get an AI-drafted M expression or explanation | v Paste into the Advanced Editor and check the preview | v Confirm folding didn't break, if working against a database source ``` *** ## Next Steps [#next-steps] * [DAX with AI](/docs/ai-power-bi/dax-with-ai) * [Model Advisor](/docs/ai-power-bi/model-advisor) * [Prompt Library](/docs/ai-power-bi/prompt-library) # Prompt Library (/docs/ai-power-bi/prompt-library) # Power BI AI Prompt Library [#power-bi-ai-prompt-library] A starting set of prompts for common Power BI tasks. Replace the bracketed placeholders with your own measure, code, or context before using them. ## DAX [#dax] ```text Explain what this DAX measure does, step by step, including what filter context it changes: [paste measure] ``` ```text Write a DAX measure that calculates [business logic], using the table [table name] and the column [column name]. ``` ```text This DAX measure returns [wrong result / an error]. What's wrong with it, and how should it be fixed? [paste measure] [paste error or describe the wrong result] ``` ```text Suggest a more efficient version of this DAX measure, and explain why the original might be slow: [paste measure] ``` ## Power Query / M [#power-query--m] ```text Write Power Query M code that takes a table with columns [list columns] and produces [describe desired output]. ``` ```text Explain what each step in this M query does: [paste M code] ``` ```text I'm getting this Power Query error: "[paste error message]" Here's the step that's failing: [paste step] What's causing it, and how do I fix it? ``` ```text Will this M code fold back to a SQL source, or will it run locally? If not, what's breaking the fold, and how can I restructure it to preserve folding? [paste M code] ``` ## Data Modeling [#data-modeling] ```text Here are my tables and relationships: [describe tables and relationship cardinality/direction]. Does this follow star schema principles? What would you change? ``` ```text I have one flat table with columns [list columns]. Should this be split into a star schema, and if so, what fact and dimension tables would you suggest? ``` ```text I need to track [attribute] changing over time for [dimension]. Should this be a Type 1 or Type 2 slowly changing dimension, and why? ``` ## Report Design [#report-design] ```text I'm building a [type of report] for [audience]. The key questions it needs to answer are: [list questions]. Suggest a page layout and which visuals to use for each. ``` ```text I want to compare [what's being compared] over [time period / categories]. What visual type fits best, and why? ``` ```text This report page has [list visuals]. What would you simplify, remove, or restructure, and why? ``` ## Using These Prompts Well [#using-these-prompts-well] * Always include real context — table names, column names, actual measure text — rather than describing the situation abstractly. AI can't see the model; the prompt is the only information it has. * Treat the response as a draft. Verify DAX and M code against real data, and cross-check report design suggestions against your organization's report theme and the actual audience. * Iterate. If the first response doesn't fit, describe specifically what's wrong rather than starting a new prompt from scratch — most tools do better with a follow-up correction than a fresh, less-detailed request. *** ## Next Steps [#next-steps] * [DAX with AI](/docs/ai-power-bi/dax-with-ai) * [Power Query with AI](/docs/ai-power-bi/power-query-with-ai) * [Model Advisor](/docs/ai-power-bi/model-advisor) * [Report Design with AI](/docs/ai-power-bi/report-design) # Report Design with AI (/docs/ai-power-bi/report-design) # Report Design with AI [#report-design-with-ai] Report design is more subjective than DAX or Power Query, so AI works best here as a brainstorming partner — proposing layouts, visual choices, and structure to react to — rather than a tool that produces a finished report on its own. ## What AI Can Help With [#what-ai-can-help-with] ### Dashboard Layout Ideas [#dashboard-layout-ideas] Describing the audience and the questions the report needs to answer, rather than asking for "a good layout," tends to produce more useful suggestions. ```text Prompt: "I'm building a sales executive dashboard. The audience is regional VPs who check it weekly. Key questions: are we hitting quarterly targets, which regions are behind, and what's driving any variance. Suggest a page layout and which visuals to use." ``` ### Choosing the Right Visual [#choosing-the-right-visual] Describing the data and the comparison being made is enough to get a recommendation, and a rationale worth checking against the site's own guidance in [Charts](/docs/visuals/charts). ```text Prompt: "I want to show sales trend over the last 12 months, broken out by 4 product categories. Line chart, area chart, or something else?" ``` ### Improving an Existing Report [#improving-an-existing-report] Describing an existing page's visuals and layout can surface issues — too many visuals competing for attention, inconsistent color use, unclear labeling. ```text Prompt: "This report page has 9 visuals: 3 KPI cards, 2 bar charts, a matrix, a line chart, a map, and a slicer, all on one page. What would you simplify or restructure?" ``` ### Storytelling and Narrative Flow [#storytelling-and-narrative-flow] AI can suggest an order for a multi-page report — the sequence a reader should move through, what belongs on a summary page versus a detail page — based on the intended audience and decision the report supports. *** ## Cautions [#cautions] * AI can't see the actual report, only a description of it. A screenshot-based review by a person, or user testing with real viewers, will catch things a text description misses. * Visual recommendations are general best practice, not a hard rule — some AI suggestions won't fit the organization's existing report theme or accessibility requirements. See [Report Themes](/docs/governance/report-themes) for house style constraints. * Good report design depends heavily on who's actually going to read it. A layout suggestion for "executives" and one for "analysts doing deep exploration" should look different — make sure the audience described in the prompt matches the real one. *** ## A Reasonable Workflow [#a-reasonable-workflow] ```text Describe the audience and the key questions the report answers | v Get layout and visual-choice suggestions | v Cross-check against house style / report theme guidelines | v Validate with a real viewer from the intended audience, not just visually ``` *** ## Next Steps [#next-steps] * [Model Advisor](/docs/ai-power-bi/model-advisor) * [Prompt Library](/docs/ai-power-bi/prompt-library) * [Charts](/docs/visuals/charts) # Coverage & Verification (Requirements Traceability) (/docs/dax-patterns/coverage-verification) # Coverage & Verification (Requirements Traceability) [#coverage--verification-requirements-traceability] A traceability matrix answers a sharper question than "is this done" — it separates *covered* (a test exists) from *verified* (a test actually passed), and it has to do both through a many-to-many bridge table without double-counting. ```text DimRequirement <--- BridgeRequirementTest ---> DimTestCase <--- FactTestResults | | | "does this requirement | "did the linked | have a test mapped at all?" test actually pass?" | | Requirements Covered Requirements Verified ``` Covered and verified are always different numbers, and the gap between them is the point of building the dashboard in the first place. *** ## Total, Covered, and the Gap [#total-covered-and-the-gap] ```dax lineNumbers Total Requirements = COUNTROWS(DimRequirement) Requirements Covered = DISTINCTCOUNT(BridgeRequirementTest[RequirementID]) Requirements With No Test Mapped = [Total Requirements] - [Requirements Covered] Coverage % = DIVIDE([Requirements Covered], [Total Requirements]) ``` `Requirements Covered` counts distinct requirements that appear *anywhere* in the bridge table — regardless of whether their linked test has passed, failed, or hasn't run yet. It only means "at least one test case exists for this requirement." A requirement with zero bridge rows never gets counted here, which is exactly how the gap surfaces. *** ## Verified: A Harder Question Than Covered [#verified-a-harder-question-than-covered] Coverage only asks whether a test exists. Verification asks whether a test **passed** — a genuinely different filter path through the bridge table, worth writing out explicitly rather than relying on relationship propagation alone: ```dax lineNumbers Requirements Verified = VAR PassingTestCaseIDs = CALCULATETABLE( VALUES(FactTestResults[TestCaseID]), FactTestResults[Status] = "Pass" ) VAR VerifiedRequirementIDs = CALCULATETABLE( VALUES(BridgeRequirementTest[RequirementID]), FILTER( BridgeRequirementTest, BridgeRequirementTest[TestCaseID] IN PassingTestCaseIDs ) ) RETURN COUNTROWS(VerifiedRequirementIDs) Verified % = DIVIDE([Requirements Verified], [Total Requirements]) ``` `PassingTestCaseIDs` is the set of test cases with at least one passing result. `VerifiedRequirementIDs` then filters the bridge table down to only the rows whose test case is in that passing set, and pulls the distinct requirement IDs out of what's left — a filter path that reads almost exactly like the English description of the question, which matters when someone needs to verify the logic by hand. ```text Coverage % -> a test exists for this requirement Verified % -> that test actually passed Verified % is always <= Coverage %, and the gap between them is requirements that have a test, but not a passing one yet. ``` *** ## Test Execution and Pass Rate [#test-execution-and-pass-rate] ```dax lineNumbers Tests Executed = COUNTROWS(FactTestResults) Tests Passed = CALCULATE([Tests Executed], FactTestResults[Status] = "Pass") Pass Rate = DIVIDE([Tests Passed], [Tests Executed]) ``` `Pass Rate` is a test-level metric (how many *executions* passed), separate from `Verified %` (how many *requirements* have at least one passing execution) — the two numbers are related but answer different questions, and a report showing only one can be read as answering the other by mistake. *** ## The Gap List: Checked Directly, Not Inferred [#the-gap-list-checked-directly-not-inferred] A percentage alone doesn't say *which* requirements are the problem. Filter `DimRequirement` down to rows with zero matching bridge rows: ```text Table visual: Columns: DimRequirement[RequirementID], DimRequirement[RequirementText], DimRequirement[Category] Filter: [Requirements Covered] for this requirement = BLANK() (equivalently: this requirement has no row in BridgeRequirementTest at all) ``` Checking the gap list directly — not just trusting that `Coverage %` looks high enough — is what catches a specific requirement that was never mapped to any test case, something an aggregate percentage alone can hide. *** ## Common Mistakes [#common-mistakes] ### Treating Covered and Verified as the Same Thing [#treating-covered-and-verified-as-the-same-thing] A requirement with a test case mapped to it isn't verified until that test has actually passed. Reporting `Coverage %` alone as if it answers "is this done" overstates progress — a requirement can be 100% covered and 0% verified if every mapped test has failed or hasn't run. ### Double-Counting Through the Bridge Table [#double-counting-through-the-bridge-table] Summing a value across the bridge table directly — instead of `DISTINCTCOUNT` on the ID column — counts a requirement once per test case it's linked to. A requirement linked to two test cases gets counted twice, silently inflating any total built this way. See [Bridge Tables and Double-Counting](/docs/modeling/bridge-tables#bridge-tables-and-double-counting). ### Assuming No Result Means Failed [#assuming-no-result-means-failed] A test case with zero rows in the results fact table hasn't failed — it simply hasn't run yet, a different state that needs its own handling. Collapsing "not yet run" into "failed" (or into "passed," in the other direction) misreports a requirement's real status either way. ### Reporting Pass Rate as if It Were Verified % [#reporting-pass-rate-as-if-it-were-verified-] `Pass Rate` counts test executions; `Verified %` counts requirements with at least one passing execution. A single flaky requirement re-tested many times can pull `Pass Rate` down while `Verified %` stays high (one eventual pass is enough) — or the reverse, if most requirements only have one attempt each. *** ## Best Practices [#best-practices] * Keep `Requirements Covered` and `Requirements Verified` as two separate measures — never conflate "a test exists" with "a test passed." * Use `DISTINCTCOUNT()` (or an explicit `VALUES()`/`FILTER()` path) on bridge-table ID columns, never a plain `SUM` or `COUNTROWS` across the bridge table itself. * Give "not yet run" its own explicit state, distinct from both "passed" and "failed." * Surface the actual gap list as a filtered table, not just a coverage percentage — a percentage alone can't say which specific item is the problem. *** ## Coverage & Verification Checklist [#coverage--verification-checklist] * `Requirements Covered` and `Requirements Verified` are computed as distinct measures. * Any total crossing the bridge table uses `DISTINCTCOUNT()` on an ID column, not a raw sum across bridge rows. * "Not yet run" is a real, separate state from "Failed" everywhere it matters. * A gap-list table (or equivalent) is checked directly, not inferred from `Coverage %` alone. *** ## Next Steps [#next-steps] * [Build a Requirements Traceability Matrix (RTM) Dashboard in Power BI](/tutorials/build-a-requirements-traceability-matrix) — the full walkthrough this pattern is drawn from, with a real dataset and report layout * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) # Earned Value Management (EVM) Metrics (/docs/dax-patterns/evm-metrics) # Earned Value Management (EVM) Metrics [#earned-value-management-evm-metrics] Earned Value Management compares three numbers, all expressed in the same cost unit, to answer one question honestly: is this program on budget and on schedule, and if not, by how much? ```text Planned Value (PV) — what should have been spent by now Earned Value (EV) — what the completed work is actually worth, at budget Actual Cost (AC) — what was actually spent ``` Every other EVM metric — CPI, SPI, EAC, VAC, TCPI — is derived from these three. *** ## The Three Base Measures [#the-three-base-measures] ```dax lineNumbers PV (Planned Value) = SUMX(Tasks, Tasks[BAC] * Tasks[PlannedPercentComplete]) EV (Earned Value) = SUMX(Tasks, Tasks[BAC] * Tasks[PercentComplete]) AC (Actual Cost) = SUM(Tasks[ActualCost]) ``` `BAC` (Budget at Completion) is each task's total planned cost. This version assumes `PlannedPercentComplete` is already known per task as of the current reporting date — the simplest, most common setup for a periodic status report. When PV needs to be computed live from each task's own baseline schedule (so a chart can show the planned curve continuously over time, not just as of today), a disconnected date table is the standard technique — see [Build an EVM Dashboard](/tutorials/build-an-evm-dashboard) for that full walkthrough. *** ## CPI and SPI: The Two Headline Ratios [#cpi-and-spi-the-two-headline-ratios] ```dax lineNumbers CPI (Cost Performance Index) = DIVIDE([EV (Earned Value)], [AC (Actual Cost)]) SPI (Schedule Performance Index) = DIVIDE([EV (Earned Value)], [PV (Planned Value)]) ``` ```text CPI < 1 -> over budget for the work performed CPI = 1 -> exactly on budget CPI > 1 -> under budget for the work performed SPI < 1 -> behind schedule SPI = 1 -> exactly on schedule SPI > 1 -> ahead of schedule ``` Both are ratios, so 1.0 is always the neutral point — above is good, below is a problem. *** ## CV and SV: The Variance Form [#cv-and-sv-the-variance-form] ```dax lineNumbers CV (Cost Variance) = [EV (Earned Value)] - [AC (Actual Cost)] SV (Schedule Variance) = [EV (Earned Value)] - [PV (Planned Value)] ``` The same comparison, expressed as a dollar amount instead of a ratio — useful for a KPI card showing "$42K over budget" rather than a bare index number. Here 0 is neutral: positive is good, negative is a problem — the **opposite-feeling** direction from a ratio, where the neutral point is 1, not 0. Moving between the two forms is the easiest place to misread a program's status, covered below. *** ## EAC, ETC, VAC, and TCPI: Forecasting to Completion [#eac-etc-vac-and-tcpi-forecasting-to-completion] ```dax lineNumbers BAC (Budget at Completion) = SUM(Tasks[BAC]) EAC (Estimate at Completion) = DIVIDE([BAC (Budget at Completion)], [CPI (Cost Performance Index)]) ETC (Estimate to Complete) = [EAC (Estimate at Completion)] - [AC (Actual Cost)] VAC (Variance at Completion) = [BAC (Budget at Completion)] - [EAC (Estimate at Completion)] TCPI (To-Complete Performance Index) = DIVIDE( [BAC (Budget at Completion)] - [EV (Earned Value)], [BAC (Budget at Completion)] - [AC (Actual Cost)] ) ``` `TCPI` answers a different question from the others: given what's actually been spent, how efficiently does the *remaining* work need to be performed to still hit the original `BAC`? A `TCPI` well above 1 signals the original budget is no longer realistic, even before `EAC` makes that explicit. ### EAC Has More Than One Standard Formula [#eac-has-more-than-one-standard-formula] `EAC = BAC / CPI` (shown above) is the simplest of several standard EAC formulas, and it makes a specific assumption: that the cost efficiency observed so far continues unchanged for the remaining work. ```text EAC = AC + (BAC - EV) -- assumes the variance so far was atypical, won't recur EAC = BAC / CPI -- assumes current cost performance continues as-is EAC = AC + (BAC - EV) / (CPI * SPI) -- assumes both cost AND schedule performance affect the remaining work ``` Which formula fits depends on *why* the program is over or under — a one-time cost overrun on a single task points toward the first; a systemic efficiency problem likely to persist points toward the second or third. *** ## Common Mistakes [#common-mistakes] ### Mixing Up the Ratio and Variance Sign Conventions [#mixing-up-the-ratio-and-variance-sign-conventions] `CPI`/`SPI` are ratios where **1.0** is neutral and higher is better; `CV`/`SV` are dollar variances where **0** is neutral and higher is better. A dashboard mixing both forms without clear labeling makes it easy to misread "0.90" as a small, tolerable number when it actually means the program is running 10% over budget for the work performed. ### Computing EV as a Single Program-Wide Percentage [#computing-ev-as-a-single-program-wide-percentage] `EV` needs to be earned per task (or per work package) and then summed — `Tasks[BAC] * Tasks[PercentComplete]`, aggregated with `SUMX`. Multiplying the *total* `BAC` by one blended, program-wide percent-complete produces a materially different (and usually more optimistic) number than summing each task's own earned value. ### Treating a Single EAC Formula as Definitive [#treating-a-single-eac-formula-as-definitive] `EAC = BAC / CPI` is a starting point for a conversation, not a certainty — see the formula variants above. Reporting it without stating which assumption it makes can overstate how precise the forecast actually is. ### Confusing SPI With Percent of the Calendar Elapsed [#confusing-spi-with-percent-of-the-calendar-elapsed] `SPI` measures work completed against work planned, not time elapsed against total duration. A program can be exactly on schedule by the calendar and still show a poor `SPI` if the wrong tasks were prioritized first. *** ## Best Practices [#best-practices] * Compute `PV`, `EV`, and `AC` as their own base measures first, then build every ratio and variance from those three — never recompute the underlying aggregation inside each derived measure. * Use `DIVIDE()`, never the raw `/` operator — `AC` and `PV` can both legitimately be `0` early in a program, before any cost has been incurred or any task has reached its planned start. * Label ratio-form and variance-form metrics clearly and separately; don't rely on a viewer to remember which form a given card is showing. * Pick one `EAC` formula deliberately, based on whether the observed variance looks likely to persist, and document that choice next to the measure. *** ## EVM Metrics Checklist [#evm-metrics-checklist] Before publishing an EVM dashboard: * `PV`, `EV`, and `AC` are each computed per task and summed with `SUMX`, not derived from one blended program-wide percentage. * Every ratio and variance measure uses `DIVIDE()`, not `/`. * `CPI`/`SPI` (ratio) and `CV`/`SV` (variance) are labeled clearly enough that their opposite neutral points (1.0 vs. 0) can't be confused. * The `EAC` formula in use is a deliberate choice, not just whichever one was easiest to write first. *** ## Next Steps [#next-steps] * [Build an Earned Value Management (EVM) Dashboard in Power BI](/tutorials/build-an-evm-dashboard) — a full walkthrough with a real task-list dataset and a disconnected date table for time-phased PV * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [Totals](/docs/dax-patterns/totals) * [Percent of Total](/docs/dax-patterns/percent-of-total) * [DIVIDE()](/docs/dax/divide) * [CALCULATE](/docs/dax/calculate) # DAX Patterns (/docs/dax-patterns) # DAX Patterns [#dax-patterns] A handful of calculation shapes show up in nearly every Power BI report — totals that ignore filters, rankings, percent-of-total breakdowns, and running totals. This section covers the standard pattern for each one, ready to adapt. For systems and program engineering reports specifically, it also covers the standard Earned Value Management (CPI, SPI, EAC) metric set, the latest-value-per-item plus disconnected-axis pattern behind a risk matrix heat map, the covered-vs-verified bridge-table pattern behind a requirements traceability matrix, and the per-asset uptime pattern behind MTBF/MTTR reliability metrics. ## Start Here [#start-here] ## Where to Go Next [#where-to-go-next] * [CALCULATE](/docs/dax/calculate) — the function every one of these patterns is built on. * [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet) — a fast reference for `ALL`, `ALLEXCEPT`, and the other filter modifiers these patterns use. # Percent Of Total (/docs/dax-patterns/percent-of-total) # Percent Of Total [#percent-of-total] Percent of total shows how much a single row contributes to a larger whole. ```text Category | Sales | % of Total ----------|---------|------------ Bikes | 45,000 | 60% Gear | 20,000 | 27% Apparel | 10,000 | 13% ``` The pattern always has the same shape: a filtered value, divided by an unfiltered (or less-filtered) value. *** ## The Basic Pattern [#the-basic-pattern] ```dax lineNumbers Sales % of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` ```text Sales % of Total | | = | [Total Sales] (filtered) / [Total Sales] (ALL — unfiltered) ``` The numerator respects whatever filters are active. The denominator deliberately removes them, producing the grand total to divide by. *** ## Percent of Grand Total [#percent-of-grand-total] Using `ALL()` on the entire table produces a percentage against the true grand total, regardless of any other filters active in the report. ```dax lineNumbers Sales % of Grand Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(FactSales) ) ) ``` *** ## Percent of Parent Category [#percent-of-parent-category] Instead of comparing against the grand total, a row can be compared against just its own parent group. ```dax lineNumbers Sales % of Category = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALLEXCEPT(DimProduct, DimProduct[Category]) ) ) ``` Example output: ```text Category | Product | Sales | % of Category ---------|-----------|---------|---------------- Bikes | Tire A | 30,000 | 67% Bikes | Tire B | 15,000 | 33% Gear | Helmet A | 12,000 | 60% Gear | Helmet B | 8,000 | 40% ``` Each product's percentage is relative to its own category total, not the overall grand total. *** ## Percent of a Fixed Selection [#percent-of-a-fixed-selection] Sometimes the denominator should stay fixed to a specific value, regardless of what the user filters elsewhere in the report. ```dax lineNumbers Sales % of West Region = DIVIDE( [Total Sales], CALCULATE( [Total Sales], DimStore[Region] = "West" ) ) ``` This always divides by West region sales specifically, even if the report is currently filtered to a different region. *** ## Why DIVIDE() Instead of / [#why-divide-instead-of-] `DIVIDE()` handles division by zero gracefully, returning blank instead of an error. ```dax lineNumbers Safe: DIVIDE([Total Sales], [Grand Total]) Risky: [Total Sales] / [Grand Total] ``` If the denominator is ever zero — an empty category, a filter with no matching rows — the plain `/` operator returns an error that can break visuals. `DIVIDE()` avoids that entirely. *** ## Formatting as a Percentage [#formatting-as-a-percentage] The measure itself returns a decimal (0.60, not "60%"). Formatting is applied separately. ```text Raw value: 0.60 | | percentage formatting | Displayed: 60% ``` Set from the measure's **Format** property in the Modeling ribbon, choosing **Percentage**. *** ## Best Practices [#best-practices] * Always use `DIVIDE()`, never the raw `/` operator, for percentage measures. * Be explicit in the measure name about what the percentage is relative to (total, category, region, and so on). * Reuse the base measure (`[Total Sales]`) instead of repeating the aggregation logic in both numerator and denominator. * Set number formatting to Percentage so the measure doesn't display as a raw decimal. *** ## Common Mistakes [#common-mistakes] ### Dividing by a Filtered Denominator [#dividing-by-a-filtered-denominator] Forgetting to remove filters from the denominator (with `ALL()` or `ALLEXCEPT()`) makes the denominator match the numerator, and every row shows 100%. ### Using / Instead of DIVIDE() [#using--instead-of-divide] The raw division operator errors on divide-by-zero, which can silently break an otherwise-working visual whenever a filter combination produces an empty group. ### Forgetting Percentage Formatting [#forgetting-percentage-formatting] A correct measure that returns `0.6` but isn't formatted as a percentage displays as a confusing raw decimal instead of "60%". *** ## Percent of Total Checklist [#percent-of-total-checklist] Before publishing a percent-of-total measure: * `DIVIDE()` is used, not the `/` operator. * The denominator's filter removal matches what "total" should mean for this measure (grand total, category, or a fixed selection). * The measure name makes clear what the percentage is relative to. * The measure is formatted as a percentage, not a raw decimal. *** ## Next Steps [#next-steps] Continue exploring DAX patterns: * [Totals](/docs/dax-patterns/totals) * [Ranking](/docs/dax-patterns/ranking) * [Running Total](/docs/dax-patterns/running-total) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [ALL, ALLEXCEPT, ALLSELECTED & REMOVEFILTERS](/docs/dax/filter-functions) Fast reference for these and the other `CALCULATE` modifiers: [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet). * [DIVIDE](/docs/dax/divide) — the safe-division function every percent-of-total measure should be built on. # Ranking (/docs/dax-patterns/ranking) # Ranking [#ranking] Ranking answers "where does this row stand compared to the others?" — 1st, 2nd, 3rd, and so on. ```text Product | Sales | Rank ----------|---------|------ Tire A | 50,000 | 1 Tire B | 42,000 | 2 Helmet A | 18,000 | 3 ``` DAX handles ranking with `RANKX()`. *** ## Basic Ranking [#basic-ranking] ```dax lineNumbers Product Rank = RANKX( ALL(DimProduct), [Total Sales] ) ``` `RANKX()` needs a table to rank across (`ALL(DimProduct)`, so every product is compared, not just the ones visible after filtering) and an expression to rank by (`[Total Sales]`). *** ## Why ALL() Matters Here [#why-all-matters-here] Without `ALL()`, the ranking table would only include whatever rows survive the current filter context — which, inside a table visual grouped by product, is usually just one row per calculation. ```text Without ALL(): each row only sees itself -> every rank is 1 With ALL(): every row is compared against every product -> real ranks ``` `ALL(DimProduct)` re-expands the comparison set back to every product, regardless of what the visual is currently filtering. *** ## Descending vs. Ascending Rank [#descending-vs-ascending-rank] By default, `RANKX()` ranks highest value as 1st (descending). ```dax lineNumbers Product Rank (Highest First) = RANKX( ALL(DimProduct), [Total Sales], , DESC ) Product Rank (Lowest First) = RANKX( ALL(DimProduct), [Total Sales], , ASC ) ``` Use ascending order for rankings like "lowest performing products" or "smallest to largest." *** ## Ranking Within a Group [#ranking-within-a-group] Ranking within each category — instead of across the whole table — uses `ALLEXCEPT()` to keep the category filter active. ```dax lineNumbers Rank Within Category = RANKX( ALLEXCEPT(DimProduct, DimProduct[Category]), [Total Sales] ) ``` Example output: ```text Category | Product | Sales | Rank ---------|-----------|---------|------ Bikes | Tire A | 50,000 | 1 Bikes | Tire B | 42,000 | 2 Gear | Helmet A | 18,000 | 1 Gear | Helmet B | 9,000 | 2 ``` Each category restarts its own ranking from 1. *** ## Handling Ties [#handling-ties] By default, tied values receive the same rank, and the next rank skips accordingly. ```text Product | Sales | Rank ----------|---------|------ Tire A | 50,000 | 1 Tire B | 50,000 | 1 Helmet A | 18,000 | 3 ``` This matches how rankings are usually expected to behave — two products tied for 1st mean there is no 2nd place. *** ## Top N Filtering with Rank [#top-n-filtering-with-rank] A rank measure can be used to filter a visual down to a Top N, using the **Filters** pane with "is less than or equal to." `Filter: [Product Rank] <= 5` This is a common alternative to the built-in Top N filter, useful when the ranking logic needs to be reused elsewhere too (like a "Rank" column shown directly in a table). *** ## Best Practices [#best-practices] * Always specify the full table for `RANKX()`'s first argument (usually with `ALL()`) to avoid every row ranking as 1. * Use `ALLEXCEPT()` when ranking needs to reset within a group, like category or region. * Reuse an existing base measure inside `RANKX()` rather than repeating the aggregation expression. * Be explicit about ascending vs. descending order in the measure name if a report has both. *** ## Common Mistakes [#common-mistakes] ### Forgetting ALL() [#forgetting-all] Omitting `ALL()` from the ranking table is the most common `RANKX()` mistake, and it silently produces a rank of 1 for every row, since each row is only ever compared to itself. ### Ranking the Wrong Table [#ranking-the-wrong-table] Ranking `DimProduct` when the real business question is about `DimCustomer` will produce a rank that looks plausible but answers the wrong question. Double-check which table actually represents what's being ranked. ### Ignoring Ties [#ignoring-ties] Assuming every rank is unique can break Top N filters when several rows are tied — a `<= 5` filter might return more than 5 rows if multiple items share 5th place. *** ## Ranking Checklist [#ranking-checklist] Before publishing a ranking measure: * The table argument uses `ALL()` or `ALLEXCEPT()` as appropriate. * Ascending or descending order matches the business question being asked. * Ranking within groups uses `ALLEXCEPT()`, not a plain `ALL()`. * Tie behavior has been checked against real data. *** ## Next Steps [#next-steps] Continue exploring DAX patterns: * [Totals](/docs/dax-patterns/totals) * [Percent of Total](/docs/dax-patterns/percent-of-total) * [Running Total](/docs/dax-patterns/running-total) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [RANKX](/docs/dax/rankx) # Reliability Metrics (MTBF, MTTR & Availability) (/docs/dax-patterns/reliability-mtbf-mttr) # Reliability Metrics (MTBF, MTTR & Availability) [#reliability-metrics-mtbf-mttr--availability] Reliability engineering runs on two numbers: how often something breaks (`MTBF`), and how long it takes to fix once it does (`MTTR`). Both come from the same raw log of failure and repair timestamps — but getting there needs one calculated column first: the uptime *between* one repair and the next failure, per asset. ```text FactFailureEvents — one row per failure, with FailureStart and RepairComplete | | for each row, find the previous repair for the SAME asset | Uptime Hours (this failure's gap since the prior repair, or since commissioning) Repair Hours (this failure's own repair duration) | | SUM across all failures, divided by failure count | MTBF (Hours) MTTR (Hours) Availability % ``` *** ## Finding Each Event's Uptime (Calculated Column) [#finding-each-events-uptime-calculated-column] This step needs a calculated column, not a plain measure: for each failure, how long was the asset running *before* it broke? That's the gap between this failure's start and whichever came before it for the *same asset* — either the previous repair completion, or the asset's commissioning date if this is its first recorded failure. ```dax lineNumbers Previous Repair Complete = VAR CurrentAsset = FactFailureEvents[AssetID] VAR CurrentFailureStart = FactFailureEvents[FailureStart] VAR PriorRepairs = FILTER( FactFailureEvents, FactFailureEvents[AssetID] = CurrentAsset && FactFailureEvents[RepairComplete] < CurrentFailureStart ) RETURN MAXX(PriorRepairs, FactFailureEvents[RepairComplete]) ``` `VAR` captures the current row's `AssetID` and `FailureStart` before `FILTER()` introduces its own inner row context over the whole table — the same reason [Variables (VAR)](/docs/dax/variables) have mostly replaced `EARLIER()` for exactly this shape of calculated column. `PriorRepairs` ends up holding every earlier repair for *this* asset only; `MAXX()` picks the most recent one. An asset's first-ever failure has no prior repair, so this returns blank — handled next. ```dax lineNumbers Uptime Hours = VAR PriorComplete = FactFailureEvents[Previous Repair Complete] VAR StartPoint = IF( ISBLANK(PriorComplete), RELATED(DimAsset[CommissionedDate]), PriorComplete ) RETURN DATEDIFF(StartPoint, FactFailureEvents[FailureStart], HOUR) Repair Hours = DATEDIFF(FactFailureEvents[FailureStart], FactFailureEvents[RepairComplete], HOUR) ``` `RELATED()` pulls the asset's `CommissionedDate` across the relationship for that first-failure case — see [RELATED & RELATEDTABLE](/docs/dax/related) — and `DATEDIFF()` with `HOUR` turns the gap into a plain number. Notice there's no dedicated date table anywhere in this pattern: both columns work directly off the two timestamp columns already on the fact table. *** ## MTBF, MTTR, and Availability [#mtbf-mttr-and-availability] ```dax lineNumbers Total Failures = COUNTROWS(FactFailureEvents) Total Uptime Hours = SUM(FactFailureEvents[Uptime Hours]) Total Repair Hours = SUM(FactFailureEvents[Repair Hours]) MTBF (Hours) = DIVIDE([Total Uptime Hours], [Total Failures]) MTTR (Hours) = DIVIDE([Total Repair Hours], [Total Failures]) Availability % = DIVIDE([MTBF (Hours)], [MTBF (Hours)] + [MTTR (Hours)]) ``` ```text MTBF < higher is better -> the asset runs longer between failures MTTR < lower is better -> the asset gets fixed faster once it fails Availability % = MTBF / (MTBF + MTTR) -> the fraction of total time actually up ``` Every division uses [DIVIDE()](/docs/dax/divide), never `/` — with only a handful of failures recorded per asset, a filtered view with zero failures is a real, common case here, not an edge case to dismiss. *** ## Common Mistakes [#common-mistakes] ### Averaging Repair Time Instead of Summing First [#averaging-repair-time-instead-of-summing-first] ```dax lineNumbers MTTR (Wrong) = AVERAGE(FactFailureEvents[Repair Hours]) ``` This happens to match `DIVIDE([Total Repair Hours], [Total Failures])` at the grand-total level, but breaks the moment `MTTR` needs to combine with other filtered totals — a weighted view blending multiple assets, for instance. Building it from explicit sums keeps it consistent with `MTBF`, which can't be expressed as a plain `AVERAGE()` at all (there's no single "uptime" column to average without first computing it per event). ### Forgetting the First Failure Needs a Fallback [#forgetting-the-first-failure-needs-a-fallback] Without the `CommissionedDate` fallback in `Uptime Hours`, an asset's very first failure shows a blank uptime instead of the real time since commissioning — quietly excluding that event from `Total Uptime Hours` and understating `MTBF` for every asset with at least one failure in the dataset. ### Trusting MTBF From a Single Failure [#trusting-mtbf-from-a-single-failure] An asset with one recorded failure can produce a large, real-looking `MTBF` number that statistically says almost nothing about how reliable it actually is going forward. A reliability figure needs enough failures behind it before it's worth acting on — flag low-failure-count assets rather than ranking them at face value against ones with a real failure history. *** ## Best Practices [#best-practices] * Compute uptime as a calculated column per event, scoped to the *same asset's* previous repair — never a fixed calendar period shared across assets. * Always give the first failure per asset an explicit fallback (commissioning date, or install date) instead of letting it go blank. * Build `MTBF` and `MTTR` from summed totals divided by failure count, not `AVERAGE()`, so they stay consistent with each other and with any further filtering. * Surface failure count alongside `MTBF`/`MTTR` on the report, so a figure built from too little data doesn't get read the same as one built from a real history. *** ## Reliability Metrics Checklist [#reliability-metrics-checklist] * `Uptime Hours` is calculated from the previous repair for that specific asset, not a shared calendar period. * The first failure per asset falls back to a commissioning/install date rather than going blank. * `MTBF` and `MTTR` are built from summed totals divided by failure count, not `AVERAGE()`. * Every division uses `DIVIDE()`, and assets with very few recorded failures are flagged as statistically thin rather than treated as equally reliable data. *** ## Next Steps [#next-steps] * [Build a Reliability (MTBF/MTTR) Dashboard in Power BI](/tutorials/build-a-reliability-mtbf-mttr-dashboard) — the full walkthrough this pattern is drawn from, with a real dataset and report layout * [Variables (VAR)](/docs/dax/variables) * [RELATED & RELATEDTABLE](/docs/dax/related) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) # Risk Score & Heat Map (/docs/dax-patterns/risk-score-heat-map) # Risk Score & Heat Map [#risk-score--heat-map] A risk register's core question — *what's currently the worst thing on this list?* — depends on three patterns working together: pulling each risk's **latest** assessment (not a blend of every historical one), scoring it, and laying scores out as a matrix that reads like a heat map at a glance. ```text FactRiskAssessment — one row per risk, per assessment date | | latest row per risk | Current Likelihood, Current Impact | | Likelihood x Impact | Risk Score -> Risk Level (Critical / High / Medium / Low) | | plotted on a disconnected 5x5 axis | Risk Matrix (heat map) ``` *** ## Finding Each Item's Latest Value [#finding-each-items-latest-value] The core problem: a naive `SUM` or `AVERAGE` over a repeatedly-reassessed value blends every historical row together, which isn't what "current" means. The fix is finding the single most recent row per item. ```dax lineNumbers Current Likelihood = VAR LatestDate = CALCULATE(MAX(FactRiskAssessment[AssessmentDate])) RETURN CALCULATE( SELECTEDVALUE(FactRiskAssessment[Likelihood]), FactRiskAssessment[AssessmentDate] = LatestDate ) Current Impact = VAR LatestDate = CALCULATE(MAX(FactRiskAssessment[AssessmentDate])) RETURN CALCULATE( SELECTEDVALUE(FactRiskAssessment[Impact]), FactRiskAssessment[AssessmentDate] = LatestDate ) ``` With `DimRisk[RiskID]` in the current filter context (a table visual's rows, or a `FILTER` iterating `DimRisk`), `LatestDate` finds the most recent assessment date *within that one risk's rows*, and the outer `CALCULATE` pulls the value from that specific row. This "latest value per entity" shape isn't specific to risk registers — the same pattern applies to any repeatedly-reassessed value: a sensor's latest reading, a project's latest status, an inspection's latest result. *** ## Risk Score and Threshold Bands [#risk-score-and-threshold-bands] ```dax lineNumbers Risk Score = [Current Likelihood] * [Current Impact] Risk Level = SWITCH( TRUE(), [Risk Score] >= 15, "Critical", [Risk Score] >= 9, "High", [Risk Score] >= 4, "Medium", [Risk Score] > 0, "Low", BLANK() ) ``` `SWITCH(TRUE(), ...)` checks each threshold in order and returns the first match — see [SWITCH](/docs/dax/switch#switchtrue--for-range-conditions) for why this beats nested `IF()` once there are more than two or three bands, and for a real shadowing bug that shows up if the thresholds aren't ordered highest-to-lowest. *** ## Counting Items by Band [#counting-items-by-band] ```dax lineNumbers Critical Risks = CALCULATE( DISTINCTCOUNT(DimRisk[RiskID]), FILTER(DimRisk, [Risk Level] = "Critical") ) High Risks = CALCULATE( DISTINCTCOUNT(DimRisk[RiskID]), FILTER(DimRisk, [Risk Level] = "High") ) ``` `FILTER(DimRisk, ...)` iterates every risk, evaluating `[Risk Level]` once per risk (each risk's own row context resolves `Current Likelihood`/`Current Impact` correctly), then keeps only the ones matching — the standard way to count rows by a measure-derived category rather than a stored column. *** ## The Disconnected-Axis Heat Map [#the-disconnected-axis-heat-map] A matrix needs a fixed axis for both likelihood and impact — but that 1-5 scale doesn't exist as a real table in the source data. Build two small calculated tables instead: ```dax lineNumbers DimLikelihood = SELECTCOLUMNS(GENERATESERIES(1, 5, 1), "Likelihood", [Value]) DimImpact = SELECTCOLUMNS(GENERATESERIES(1, 5, 1), "Impact", [Value]) ``` Leave both **disconnected** — no relationship to `DimRisk` or `FactRiskAssessment`. The matrix's measure reads whatever likelihood/impact value is currently in context (one matrix cell) directly, instead of through a relationship: ```dax lineNumbers Risks in Cell = VAR SelectedLikelihood = SELECTEDVALUE(DimLikelihood[Likelihood]) VAR SelectedImpact = SELECTEDVALUE(DimImpact[Impact]) RETURN CALCULATE( DISTINCTCOUNT(DimRisk[RiskID]), FILTER( DimRisk, [Current Likelihood] = SelectedLikelihood && [Current Impact] = SelectedImpact ) ) ``` Put `DimLikelihood[Likelihood]` on a matrix visual's rows, `DimImpact[Impact]` on its columns, and `[Risks in Cell]` as the value — then add conditional formatting (background color, red for high combinations, green for low) to turn the grid of numbers into an actual heat map. *** ## Common Mistakes [#common-mistakes] ### Averaging or Summing Every Historical Assessment [#averaging-or-summing-every-historical-assessment] Blending two assessments (likelihood 3, then 4) into an average of 3.5 hides the fact that the item has gotten worse — the current status is 4, not an average of everything ever recorded. ### Relating the Axis Tables to the Fact Table [#relating-the-axis-tables-to-the-fact-table] `DimLikelihood` and `DimImpact` need to stay disconnected. Relating them would filter the matrix down to only combinations that already exist in the data, breaking its ability to show empty cells — a 1×1 combination with zero risks is still meaningful; it means nothing is currently that low-severity, and a heat map that can't show that is missing the point. ### Treating the Score as Precise Instead of a Sorting Tool [#treating-the-score-as-precise-instead-of-a-sorting-tool] A score of 15 isn't meaningfully more "exact" than 14 — the score exists to bucket and rank items for review priority, not to imply false precision. The threshold bands matter more than the raw number for actually deciding what gets attention first. *** ## Best Practices [#best-practices] * Always resolve a repeatedly-reassessed value to its single latest row before scoring it — never average or sum across history. * Keep the matrix's axis tables disconnected from the fact table so empty cells still render. * Order `SWITCH(TRUE(), ...)` threshold conditions from highest to lowest — checking the lowest threshold first would match everything above it too, before the intended band is ever reached. * Treat the numeric score as a ranking aid, and communicate severity primarily through the named bands (Critical/High/Medium/Low), not the raw number. *** ## Risk Score & Heat Map Checklist [#risk-score--heat-map-checklist] * Each item's current likelihood/impact comes from its single most recent assessment, not a blend of history. * `Risk Level` uses `SWITCH(TRUE(), ...)` with thresholds ordered highest-to-lowest. * The likelihood/impact axis tables are disconnected from the rest of the model. * The heat map's conditional formatting is checked against a few known cells to confirm severity colors line up with the actual threshold bands. *** ## Next Steps [#next-steps] * [Build a Risk Register and Risk Matrix Dashboard in Power BI](/tutorials/build-a-risk-register-dashboard) — the full walkthrough this pattern is drawn from, with a real dataset and report layout * [SWITCH](/docs/dax/switch) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [Star Schema](/docs/modeling/star-schema) # Running Total (/docs/dax-patterns/running-total) # Running Total [#running-total] A running total adds each row to the sum of everything before it. ```text Month | Sales | Running Total ----------|---------|--------------- January | 50,000 | 50,000 February | 60,000 | 110,000 March | 45,000 | 155,000 ``` Time-based running totals (like Year-to-Date) have dedicated DAX functions — see [Time Intelligence](/docs/dax/time-intelligence). This page covers the general pattern, which works over any sortable column, not just dates. *** ## The General Pattern [#the-general-pattern] ```dax lineNumbers Running Total = CALCULATE( [Total Sales], FILTER( ALL(DimDate[Date]), DimDate[Date] <= MAX(DimDate[Date]) ) ) ``` ```text FILTER( ALL(DimDate[Date]), all dates, filters removed DimDate[Date] <= MAX(DimDate[Date]) keep only dates up to the current row ) ``` `MAX(DimDate[Date])` picks up the current row's date from context. `FILTER()` keeps every date less than or equal to it. `ALL()` makes sure the comparison set isn't limited to whatever's already been filtered. *** ## Running Total Over Any Sorted Value [#running-total-over-any-sorted-value] The same pattern works over a rank or sequence number, not just a date. ```dax lineNumbers Running Total by Rank = CALCULATE( [Total Sales], FILTER( ALL(DimProduct[SalesRank]), DimProduct[SalesRank] <= MAX(DimProduct[SalesRank]) ) ) ``` Useful for cumulative charts like "what percentage of total sales come from the top N products" (a Pareto / 80-20 analysis). *** ## Running Total Within a Group [#running-total-within-a-group] Restarting the running total for each group (like each year) combines the pattern with a group-preserving filter. ```dax lineNumbers Running Total Within Year = CALCULATE( [Total Sales], FILTER( ALL(DimDate[Date]), DimDate[Date] <= MAX(DimDate[Date]) && DimDate[Year] = MAX(DimDate[Year]) ) ) ``` ```text Year | Month | Sales | Running Total -----|-----------|---------|--------------- 2025 | November | 40,000 | 40,000 2025 | December | 35,000 | 75,000 2026 | January | 50,000 | 50,000 2026 | February | 60,000 | 110,000 ``` The running total resets at the start of each year, instead of accumulating across the entire date range. *** ## Running Total Using Time Intelligence Functions [#running-total-using-time-intelligence-functions] For calendar-based running totals specifically, `TOTALYTD()`, `TOTALQTD()`, and `TOTALMTD()` are simpler and more efficient than the generic `FILTER()` pattern. ```dax lineNumbers Sales YTD = TOTALYTD( [Total Sales], DimDate[Date] ) ``` Use the generic `FILTER()`-based pattern when the running total isn't calendar-based — ranks, sequence numbers, or any other sortable column where the built-in time intelligence functions don't apply. *** ## Performance Considerations [#performance-considerations] The `FILTER(ALL(...), ...)` pattern re-evaluates across the full unfiltered column for every row, which can be slow on very large date or rank columns. `Rows evaluated per cell = size of the unfiltered column` For calendar dates specifically, `TOTALYTD()` and related functions are generally faster, since they're optimized internally rather than relying on a generic row-by-row filter scan. *** ## Best Practices [#best-practices] * Use dedicated time intelligence functions (`TOTALYTD()`, etc.) for calendar-based running totals. * Reserve the `FILTER(ALL(...), ...)` pattern for running totals over non-date columns. * Test running total measures against a large, realistic dataset — this pattern's performance degrades with column size. * Add a group condition (like Year) when the running total should reset periodically instead of accumulating indefinitely. *** ## Common Mistakes [#common-mistakes] ### Using FILTER(ALL()) for Simple Date Running Totals [#using-filterall-for-simple-date-running-totals] When the running total is just Year-to-Date or similar, `TOTALYTD()` does the same job more efficiently and more clearly than the generic pattern. ### Forgetting to Reset Within Groups [#forgetting-to-reset-within-groups] A running total that should restart each year, but doesn't include the year condition in the `FILTER()`, will keep accumulating across year boundaries instead of resetting. ### Sorting Assumptions [#sorting-assumptions] The pattern depends on comparing values with `<=`. If the column being compared isn't naturally ordered the way the business expects (like a text field instead of a number or date), the "running total" won't make sense. *** ## Running Total Checklist [#running-total-checklist] Before publishing a running total measure: * Calendar-based running totals use `TOTALYTD()`/`TOTALQTD()`/`TOTALMTD()` where possible. * Non-date running totals correctly use `FILTER(ALL(...), ...)` over a genuinely sortable column. * Group resets (like per-year) are included if the business logic requires them. * Performance has been tested against production-scale data. *** ## Next Steps [#next-steps] Continue exploring DAX patterns: * [Totals](/docs/dax-patterns/totals) * [Ranking](/docs/dax-patterns/ranking) * [Percent of Total](/docs/dax-patterns/percent-of-total) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [Time Intelligence](/docs/dax/time-intelligence) * [FILTER](/docs/dax/filter) # Totals (/docs/dax-patterns/totals) # Totals [#totals] "Total" sounds simple, but DAX offers several different kinds depending on which filters should — and shouldn't — apply. ```text Total | +-- Filtered total (respects slicers/filters) | +-- Grand total (ignores row-level filters) | +-- Total ignoring one specific filter ``` *** ## Basic Total [#basic-total] A simple `SUM()` respects whatever filter context is currently active. ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` In a table visual grouped by Category, this measure automatically returns the total for each category, because the visual applies a filter per row. *** ## Grand Total (Ignoring All Filters) [#grand-total-ignoring-all-filters] `ALL()` removes filters from a table or column, producing a true grand total regardless of the current selection. ```dax lineNumbers Grand Total Sales = CALCULATE( [Total Sales], ALL(FactSales) ) ``` Every row in a table visual using this measure shows the same overall total, ignoring the row's own category. *** ## Percent of Grand Total [#percent-of-grand-total] Combining a filtered total with a grand total produces a percentage of the whole. ```dax lineNumbers Sales % of Total = DIVIDE( [Total Sales], [Grand Total Sales] ) ``` Each row shows its own share of the overall total — see [Percent of Total](/docs/dax-patterns/percent-of-total) for a closer look at this pattern. *** ## Total Ignoring One Specific Filter [#total-ignoring-one-specific-filter] `ALL()` can target a single column instead of an entire table, removing just that filter while keeping others active. ```dax lineNumbers Sales Ignoring Region = CALCULATE( [Total Sales], ALL(DimStore[Region]) ) ``` This keeps filters like Category or Date active, while ignoring whatever Region is currently selected. *** ## Total Within a Group, Ignoring Sub-Filters [#total-within-a-group-ignoring-sub-filters] `ALLEXCEPT()` removes all filters except the ones explicitly listed, useful for a subtotal that should stay fixed within a larger group. ```dax lineNumbers Sales Within Category = CALCULATE( [Total Sales], ALLEXCEPT(DimProduct, DimProduct[Category]) ) ``` This ignores filters on every `DimProduct` column except `Category`, producing a total for the whole category regardless of which specific product row is being evaluated. *** ## Comparing the Patterns [#comparing-the-patterns] | Pattern | Function | Result | | ------------------------- | ------------- | ------------------------------------------ | | Basic total | `SUM()` | Respects all active filters | | Grand total | `ALL(table)` | Ignores every filter | | Total ignoring one column | `ALL(column)` | Ignores just that filter | | Total within a group | `ALLEXCEPT()` | Ignores all filters except the ones listed | *** ## Example Output [#example-output] ```text Category | Sales | % of Total ----------|---------|------------ Bikes | 45,000 | 60% Gear | 20,000 | 27% Apparel | 10,000 | 13% Total | 75,000 | 100% ``` The `% of Total` column comes from dividing each row's filtered total by the unfiltered grand total. *** ## Best Practices [#best-practices] * Reuse a base measure (like `[Total Sales]`) inside `CALCULATE()` rather than repeating the same `SUM()` expression everywhere. * Prefer `ALLEXCEPT()` over listing multiple `ALL()` calls when a subtotal needs to ignore several columns except one or two. * Be explicit about which filters a "total" should ignore — an unlabeled measure named `Total` can mean different things to different people. * Test totals measures inside a table visual with grouping, not just as a single card, to confirm the filter behavior is correct. *** ## Common Mistakes [#common-mistakes] ### Confusing SUM() with a Grand Total [#confusing-sum-with-a-grand-total] A plain `SUM()` still respects the current filter context. Without `ALL()`, it is not a grand total — it's just the total for whatever is currently selected. ### Removing Too Many Filters [#removing-too-many-filters] Using `ALL(FactSales)` when only one column's filter needed to be ignored removes every filter on that table, which can silently produce a much bigger number than intended. ### Dividing by Zero [#dividing-by-zero] `Sales % of Total` using plain division can error when the grand total is zero. Use `DIVIDE()`, which returns blank instead of an error by default. *** ## Totals Checklist [#totals-checklist] Before publishing totals-related measures: * Grand total measures explicitly use `ALL()` or `ALLEXCEPT()`, not just `SUM()`. * Percentage measures use `DIVIDE()`, not the `/` operator. * Measure names make clear what kind of total they represent. * Totals have been tested inside a grouped table, not just a single card visual. *** ## Next Steps [#next-steps] Continue exploring DAX patterns: * [Ranking](/docs/dax-patterns/ranking) * [Percent of Total](/docs/dax-patterns/percent-of-total) * [Running Total](/docs/dax-patterns/running-total) * [Earned Value Management (EVM) Metrics](/docs/dax-patterns/evm-metrics) * [Risk Score & Heat Map](/docs/dax-patterns/risk-score-heat-map) * [Coverage & Verification](/docs/dax-patterns/coverage-verification) * [Reliability Metrics (MTBF, MTTR & Availability)](/docs/dax-patterns/reliability-mtbf-mttr) * [ALL, ALLEXCEPT, ALLSELECTED & REMOVEFILTERS](/docs/dax/filter-functions) # ADDCOLUMNS() vs SELECTCOLUMNS() (/docs/dax/addcolumns-selectcolumns) # ADDCOLUMNS() vs SELECTCOLUMNS() [#addcolumns-vs-selectcolumns] Both functions compute new columns from expressions against a table — the difference is entirely in what happens to the columns that were already there. ```dax lineNumbers ADDCOLUMNS(, , , ...) SELECTCOLUMNS(
, , , ...) ``` *** ## ADDCOLUMNS(): Keeps Everything, Adds More [#addcolumns-keeps-everything-adds-more] ```dax lineNumbers ADDCOLUMNS(Products, "Profit", Products[Price] * 0.3) ``` The result has every original column from `Products` — `Product`, `Category`, `Price` — plus the new `Profit` column tacked on. Nothing from the source table disappears. *** ## SELECTCOLUMNS(): Only What's Listed [#selectcolumns-only-whats-listed] ```dax lineNumbers SELECTCOLUMNS(Products, "Product", Products[Product], "Profit", Products[Price] * 0.3) ``` The result has **only** `Product` and `Profit` — `Category` and `Price` are gone entirely, not hidden or still-accessible-if-needed. `SELECTCOLUMNS()` is a reshaping/projection function: it builds a brand new table shape from scratch, and anything not explicitly listed simply isn't part of it. ```text Source table (Products): Product, Category, Price ADDCOLUMNS(Products, "Profit", ...) -> Product, Category, Price, Profit (everything + new) SELECTCOLUMNS(Products, "Product", ..., "Profit", ...) -> Product, Profit (only what's listed) ``` *** ## Common Mistakes [#common-mistakes] ### Expecting SELECTCOLUMNS to Add Like ADDCOLUMNS Does [#expecting-selectcolumns-to-add-like-addcolumns-does] Reaching for `SELECTCOLUMNS()` out of habit when the actual goal is "keep everything, plus this one new column" silently drops every column not explicitly re-listed — a nested expression or a later step referencing one of those dropped columns then fails, sometimes confusingly far from where the column actually disappeared. ### Forgetting to Re-List a Column SELECTCOLUMNS Still Needs [#forgetting-to-re-list-a-column-selectcolumns-still-needs] Every column the result needs to carry forward — including ones just passing through unchanged — has to be explicitly named in `SELECTCOLUMNS()`'s argument list. There's no shorthand for "this one, unchanged, plus these new ones." ### Using ADDCOLUMNS When the Goal Is Actually a Narrow, Reshaped Table [#using-addcolumns-when-the-goal-is-actually-a-narrow-reshaped-table] The reverse mistake: `ADDCOLUMNS()` always keeps the full original column set, which can leave a calculated table or nested table expression carrying far more columns than it actually needs, when `SELECTCOLUMNS()` would have produced the intended narrower shape from the start. *** ## Best Practices [#best-practices] * Use `ADDCOLUMNS()` when the goal is "everything that was already there, plus new computed columns." * Use `SELECTCOLUMNS()` when the goal is a specific, narrower table shape — and explicitly list every column that needs to survive, including unchanged ones. * Double-check a `SELECTCOLUMNS()` call's full argument list whenever a downstream step reports a missing column — it's very often simply not in the list. *** ## Next Steps [#next-steps] * [SUMMARIZE()](/docs/dax/summarize) * [Calculated Tables](/docs/dax/calculated-tables) * [CALCULATE](/docs/dax/calculate) * [DAX Function Reference](/docs/dax/functions) # Basics (/docs/dax/basics) # DAX Basics [#dax-basics] Before learning advanced DAX functions, it's important to understand how DAX formulas are written. DAX formulas consist of expressions that reference tables, columns, measures, operators, and functions. Understanding these building blocks makes writing calculations much easier. *** ## DAX Formula Structure [#dax-formula-structure] Most DAX formulas follow this pattern: ```text Measure Name = Expression ``` Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Everything to the left of the equals sign is the measure name. Everything to the right is the expression that Power BI evaluates. *** ## Tables and Columns [#tables-and-columns] DAX references data using table and column names. The syntax is: `TableName[ColumnName]` Example: `SUM(FactSales[SalesAmount])` Here: * `FactSales` is the table. * `SalesAmount` is the column. *** ## Measures [#measures] Measures are referenced using square brackets only. Example: ```dax lineNumbers Profit Margin = DIVIDE( [Total Profit], [Total Sales] ) ``` Notice that measures do **not** include the table name. *** ## Constants [#constants] DAX supports numeric, text, date, and Boolean constants. Examples: ```text 100 3.14 "North" TRUE() FALSE() DATE(2026,1,1) ``` Constants are often used inside calculations. *** ## Arithmetic Operators [#arithmetic-operators] | Operator | Meaning | | -------- | -------------- | | `+` | Addition | | `-` | Subtraction | | `*` | Multiplication | | `/` | Division | | `^` | Exponent | Example: ```dax lineNumbers Total Cost = SUM(Sales[Quantity]) * 25 ``` *** ## Comparison Operators [#comparison-operators] Comparison operators return TRUE or FALSE. | Operator | Meaning | | -------- | --------------------- | | `=` | Equal | | `<>` | Not Equal | | `>` | Greater Than | | `<` | Less Than | | `>=` | Greater Than or Equal | | `<=` | Less Than or Equal | Example: ```dax lineNumbers IF( [Total Sales] > 100000, "High", "Low" ) ``` *** ## Logical Operators [#logical-operators] DAX also supports logical operations. | Operator | Purpose | | | | -------- | ------- | -- | -- | | `&&` | AND | | | | \` | | \` | OR | | `NOT()` | NOT | | | Example: ```dax lineNumbers IF( Sales[Quantity] > 10 && Sales[SalesAmount] > 500, "Large Order", "Standard Order" ) ``` *** ## Comments [#comments] Single-line comments begin with two forward slashes. ```dax lineNumbers // Calculate total sales Total Sales = SUM(FactSales[SalesAmount]) ``` Block comments can also be used. ```dax lineNumbers /* Revenue calculations for monthly reporting */ ``` Comments improve readability and maintainability. *** ## Functions [#functions] Functions perform calculations. General syntax: `FUNCTION(argument1, argument2, ...)` Example: `SUM(FactSales[SalesAmount])` Here: * `SUM` is the function. * `FactSales[SalesAmount]` is the argument. *** ## Nesting Functions [#nesting-functions] Functions can be combined together. Example: ```dax lineNumbers Profit Margin = DIVIDE( SUM(FactSales[Profit]), SUM(FactSales[SalesAmount]) ) ``` The `SUM()` functions execute before `DIVIDE()` returns the final result. *** ## DAX Is Case Insensitive [#dax-is-case-insensitive] These formulas are equivalent: `SUM(FactSales[SalesAmount])` `sum(FactSales[SalesAmount])` However, using proper capitalization improves readability. *** ## Best Practices [#best-practices] When writing DAX: * Use descriptive measure names. * Keep formulas readable. * Format complex calculations across multiple lines. * Reuse existing measures whenever possible. * Add comments for complex business logic. Readable DAX is easier to debug and maintain. *** ## Next Steps [#next-steps] Continue learning: * [Measures](/docs/dax/measures) * [Calculated Columns](/docs/dax/calculated-columns) * [Filter Context](/docs/dax/filter-context) * [LEFT(), RIGHT() & MID()](/docs/dax/mid-left-right) * [CALCULATE Function](/docs/dax/calculate) # BLANK() vs 0: ISBLANK() and the Comparison Trap (/docs/dax/blank-vs-zero) # BLANK() vs 0: ISBLANK() and the Comparison Trap [#blank-vs-0-isblank-and-the-comparison-trap] `BLANK()` and `0` look the same in a lot of visuals, but they're genuinely different values in DAX — and the two obvious ways to test for "no value," a direct comparison and `ISBLANK()`, don't always agree with each other. ```dax lineNumbers ISBLANK(value) ``` *** ## Aggregations Return BLANK(), Not 0 [#aggregations-return-blank-not-0] ```dax lineNumbers Total Sales = SUM(Sales[Amount]) ``` ```text Filter context has zero matching rows -> Total Sales = BLANK() <- not 0 ``` `SUM()` and most other aggregation functions return `BLANK()`, not `0`, when there's nothing to aggregate — a category with no sales in the current filter context shows an empty cell in a matrix, not a `0`, unless something explicitly forces it. *** ## The Comparison Trap: BLANK() = 0 Is TRUE [#the-comparison-trap-blank--0-is-true] ```dax lineNumbers ISBLANK([Total Sales]) // TRUE only when Total Sales is genuinely blank [Total Sales] = 0 // TRUE when Total Sales is blank OR an actual zero ``` DAX's comparison operators (`=`, `<`, `>`, and the rest) automatically convert `BLANK()` to `0` for a numeric comparison before comparing — so `BLANK() = 0` evaluates to `TRUE`. `ISBLANK()` doesn't do this conversion; it specifically tests whether the value is `BLANK()`, and returns `FALSE` for a real, actual `0`. These two checks answer different questions, and it's easy to reach for the wrong one: ```text ISBLANK(value) -> "is this genuinely missing?" value = 0 -> "is this missing, OR an actual zero?" (can't tell which) ``` *** ## Where This Actually Bites [#where-this-actually-bites] ```dax lineNumbers Status = IF([Total Sales] = 0, "No Activity", "Active") ``` If a customer genuinely had `0` in sales recorded (a real, legitimate zero-dollar transaction, say) and a different customer simply has no rows at all in the current filter context, both show `"No Activity"` here — the `= 0` comparison can't distinguish "recorded a real zero" from "no data exists." Switching to `ISBLANK([Total Sales])` for the "no data" branch specifically, with a separate check for an actual zero, is the fix when that distinction matters. ```dax lineNumbers Status = IF(ISBLANK([Total Sales]), "No Data", IF([Total Sales] = 0, "Zero Sales", "Active")) ``` *** ## Common Mistakes [#common-mistakes] ### Using = 0 to Test for Missing Data [#using--0-to-test-for-missing-data] As covered above — a plain `= 0` comparison silently also matches a real zero, which is fine when the two cases genuinely don't need distinguishing, but wrong when they do. ### Assuming ISBLANK(0) Is TRUE [#assuming-isblank0-is-true] `ISBLANK()` never treats a real `0` as blank — only an actual `BLANK()` value passes. A calculated column or measure that legitimately produces `0` isn't "blank" just because it looks empty-ish in some report contexts. ### Forgetting Comparisons Convert BLANK() Automatically [#forgetting-comparisons-convert-blank-automatically] Any numeric comparison against a measure that can return `BLANK()` — not just `= 0`, but `< 0`, `> 100`, and so on — treats that blank as `0` first. A filter like `[Margin] < 0` also catches every row where `[Margin]` is blank, which may or may not be the intended set of rows. *** ## Best Practices [#best-practices] * Use `ISBLANK()` specifically when the question is "did this genuinely have no data," not "is this zero or less." * Remember every comparison operator converts `BLANK()` to `0` (or `""` for text) automatically — factor that in before trusting a `< `/`>`/`=` comparison against a measure that can be blank. * See [DIVIDE()](/docs/dax/divide#common-mistakes) for the related, redundant pattern of wrapping `DIVIDE()` in an unnecessary `ISBLANK()` check — `DIVIDE()` already returns `BLANK()` on its own. *** ## Next Steps [#next-steps] * [DIVIDE()](/docs/dax/divide) * [IFERROR()](/docs/dax/iferror) * [Filter Context](/docs/dax/filter-context) * [Measures](/docs/dax/measures) * [DAX Function Reference](/docs/dax/functions) # Calculate (/docs/dax/calculate) # CALCULATE [#calculate] `CALCULATE()` is the most powerful and widely used function in DAX. It evaluates an expression while modifying the current filter context. Most advanced DAX calculations—including percentages, running totals, Year-to-Date calculations, and business KPIs—depend on `CALCULATE()`. If you understand `CALCULATE()`, you understand much of DAX. *** ## What Does CALCULATE Do? [#what-does-calculate-do] `CALCULATE()` evaluates an expression using one or more modified filters. General syntax: ```dax lineNumbers CALCULATE( , , , ... ) ``` The first argument is the calculation. The remaining arguments change the filter context before the calculation is evaluated. *** ## Simple Example [#simple-example] Suppose we already have this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Now create a new measure: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` Regardless of the report filters, this measure evaluates sales only for products in the **Bikes** category. *** ## How CALCULATE Works [#how-calculate-works] Power BI follows this process: ```text Current Filter Context | v CALCULATE modifies filters | v New Filter Context | v Expression is evaluated | v Result returned ``` Unlike most DAX functions, `CALCULATE()` changes the environment in which the calculation runs. *** ## Adding Filters [#adding-filters] `CALCULATE()` can add new filters. Example: ```dax lineNumbers West Region Sales = CALCULATE( [Total Sales], DimCustomer[Region] = "West" ) ``` Only customers in the **West** region are included. Additional filters can be added. ```dax lineNumbers Bike Sales West = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes", DimCustomer[Region] = "West" ) ``` Both filters are applied before the calculation is evaluated. *** ## Replacing Existing Filters [#replacing-existing-filters] If a filter already exists on the same column, `CALCULATE()` replaces it. Suppose a report is filtered to: `Category = Accessories` This measure: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` ignores the report's category filter and evaluates only Bike sales. This behavior makes `CALCULATE()` incredibly powerful for creating custom business calculations. *** ## Removing Filters with ALL() [#removing-filters-with-all] Sometimes you want a calculation to ignore the current filters. The `ALL()` function removes filters from a table or column before the expression is evaluated. Example: ```dax lineNumbers Total Sales (All Products) = CALCULATE( [Total Sales], ALL(DimProduct) ) ``` Even if the report is filtered to: `Category = Bikes` this measure returns sales for **all products**. `ALL()` is commonly used to calculate percentages of totals. *** ## Example: Percent of Total Sales [#example-percent-of-total-sales] Suppose you have these measures: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` ```dax lineNumbers Percent of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` Results: | Category | Sales | Percent of Total | | ----------- | -------: | ---------------: | | Bikes | $150,000 | 30% | | Accessories | $80,000 | 16% | | Clothing | $270,000 | 54% | The numerator respects the current filter context. The denominator removes the product filter to calculate the overall total. *** ## REMOVEFILTERS() [#removefilters] `REMOVEFILTERS()` is another way to clear filters. Example: ```dax lineNumbers Total Sales = CALCULATE( [Total Sales], REMOVEFILTERS(DimProduct) ) ``` Like `ALL()`, it removes filters before evaluating the expression. Many developers prefer `REMOVEFILTERS()` because its purpose is more explicit and easier to understand. *** ## KEEPFILTERS() [#keepfilters] Normally, `CALCULATE()` replaces filters on the same column. `KEEPFILTERS()` changes this behavior by adding filters instead of replacing them. Example: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], KEEPFILTERS( DimProduct[Category] = "Bikes" ) ) ``` If another filter already limits the category, `KEEPFILTERS()` combines both filters instead of overwriting them. This is especially useful in more advanced business calculations. *** ## Multiple Filters [#multiple-filters] `CALCULATE()` can apply several filters at the same time. Example: ```dax lineNumbers West Bike Sales = CALCULATE( [Total Sales], DimCustomer[Region] = "West", DimProduct[Category] = "Bikes", DimDate[Year] = 2026 ) ``` Power BI evaluates only rows that satisfy **all** filter conditions. ```text Region = West + Category = Bikes + Year = 2026 ↓ Filtered FactSales Rows ↓ Total Sales ``` *** ## Filter Precedence [#filter-precedence] When `CALCULATE()` applies a filter to a column that already has an existing filter, the new filter usually replaces the old one. Example: Current report filter: `Category = Accessories` Measure: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` Result: `Category = Bikes` The filter inside `CALCULATE()` takes precedence over the existing filter. Understanding this behavior is essential when building advanced DAX calculations. *** ## Context Transition [#context-transition] One of the most important features of `CALCULATE()` is **context transition**. Context transition occurs when `CALCULATE()` converts an existing **row context** into a **filter context**. This most commonly happens inside calculated columns and iterator functions. Example: ```dax lineNumbers Customer Sales = CALCULATE( [Total Sales] ) ``` If this expression is evaluated within a row context, `CALCULATE()` automatically converts the current row into filters before evaluating the measure. Without context transition, many advanced DAX calculations would not be possible. *** ## USERELATIONSHIP() [#userelationship] Sometimes a data model contains multiple relationships between two tables. Only one relationship can be active at a time. `USERELATIONSHIP()` temporarily activates an inactive relationship during a calculation. Example: ```dax lineNumbers Sales by Ship Date = CALCULATE( [Total Sales], USERELATIONSHIP( FactSales[ShipDate], DimDate[Date] ) ) ``` Instead of using the active **Order Date** relationship, this measure evaluates sales using **Ship Date**. This is commonly used when a fact table contains multiple date columns. *** ## CROSSFILTER() [#crossfilter] `CROSSFILTER()` changes the direction of filtering between two related tables while the measure is evaluated. Example: ```dax lineNumbers Sales Both Directions = CALCULATE( [Total Sales], CROSSFILTER( FactSales[CustomerKey], DimCustomer[CustomerKey], BOTH ) ) ``` This is useful in specialized scenarios but should be used carefully, as changing filter directions can affect performance and produce unexpected results. *** ## Real-World Business Examples [#real-world-business-examples] `CALCULATE()` is used in many common business calculations. Examples include: | Business Requirement | Example | | ---------------------- | --------------------------- | | Sales for one category | Filter by Category | | Sales for one region | Filter by Region | | Year-to-date sales | Apply date filters | | Previous year's sales | Modify the date context | | Percent of total | Remove filters with `ALL()` | | Sales using Ship Date | `USERELATIONSHIP()` | Many of the KPIs found in executive dashboards rely on `CALCULATE()`. *** ## Performance Best Practices [#performance-best-practices] `CALCULATE()` is extremely efficient when used correctly. Follow these recommendations: * Build reusable base measures. * Keep filter expressions simple. * Use dimension tables for filtering. * Avoid unnecessary nested `CALCULATE()` calls. * Test calculations using different slicers and report filters. A clean star schema greatly improves the effectiveness of `CALCULATE()`. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common issues: * Forgetting that `CALCULATE()` changes filter context. * Confusing row context with filter context. * Using `CALCULATE()` when a simple measure is sufficient. * Creating unnecessary nested calculations. * Ignoring inactive relationships when multiple date fields exist. Understanding how filter context changes is often the key to debugging DAX. *** ## Summary [#summary] `CALCULATE()` is the most important function in DAX because it changes the filter context in which an expression is evaluated. With `CALCULATE()`, you can: * Add filters * Replace filters * Remove filters * Activate inactive relationships * Control how calculations respond to report interactions Mastering `CALCULATE()` is one of the biggest milestones in becoming a proficient Power BI developer. *** ## Next Steps [#next-steps] Continue building your DAX skills: * [Variables (VAR)](/docs/dax/variables) * [Iterators](/docs/dax/iterator) * [Time Intelligence](/docs/dax/time-intelligence) * [KEEPFILTERS()](/docs/dax/keepfilters) * [CROSSFILTER()](/docs/dax/crossfilter) * [ALL(), ALLEXCEPT(), ALLSELECTED() & REMOVEFILTERS()](/docs/dax/filter-functions) Want all the filter-modifying functions side by side? See the [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet). See a disconnected date table used for real, non-time-intelligence math: [Build an Earned Value Management Dashboard](/tutorials/build-an-evm-dashboard). Packaging a CALCULATE-based pattern into a reusable, named function? See [DAX User-Defined Functions (UDFs)](/docs/dax/user-defined-functions#parameters-type-subtype-and-val-vs-expr) for how `val`/`expr` parameter modes control filter context inside a UDF. # Calculated Columns (/docs/dax/calculated-columns) # Calculated Columns [#calculated-columns] Calculated columns create new columns in a table using DAX. Unlike measures, calculated columns are evaluated during data refresh and the results are stored in the Power BI model. Calculated columns are commonly used to: * Categorize data * Combine values * Create lookup values * Build sorting columns * Generate business attributes *** ## What Is a Calculated Column? [#what-is-a-calculated-column] A calculated column evaluates one row at a time. General syntax: ```text Column Name = Expression ``` Example: ```dax lineNumbers Sales Amount = FactSales[Quantity] * FactSales[Unit Price] ``` Power BI calculates the value for every row during model refresh. The result is stored in the table. *** ## Measures vs Calculated Columns [#measures-vs-calculated-columns] Although both use DAX, they serve different purposes. | Measures | Calculated Columns | | -------------------------- | ----------------------------------- | | Calculated when viewed | Calculated during refresh | | Dynamic | Stored in the model | | Respond to filters | Same value until refresh | | Do not increase model size | Increase model size | | Used in visuals | Used for grouping and relationships | As a general rule: * Use **measures** for calculations. * Use **calculated columns** to create new data attributes. *** ## Row-by-Row Evaluation [#row-by-row-evaluation] Calculated columns automatically evaluate each row independently. Example source data: | Quantity | Unit Price | | -------: | ---------: | | 5 | 50 | | 3 | 75 | | 8 | 40 | Calculated column: ```dax lineNumbers Sales Amount = FactSales[Quantity] * FactSales[Unit Price] ``` Result: | Quantity | Unit Price | Sales Amount | | -------: | ---------: | -----------: | | 5 | 50 | 250 | | 3 | 75 | 225 | | 8 | 40 | 320 | Every row receives its own calculated value. *** ## Common Uses [#common-uses] Calculated columns are useful when creating: * Product categories * Customer segments * Full names * Year-Month labels * Custom sort columns * Status indicators Example: ```dax lineNumbers Full Name = Customer[First Name] & " " & Customer[Last Name] ``` The new column becomes part of the data model and can be used throughout reports. *** ## Creating Categories [#creating-categories] One of the most common uses of calculated columns is creating business categories. Example: ```dax lineNumbers Sales Category = IF( FactSales[SalesAmount] >= 1000, "Large Sale", "Standard Sale" ) ``` Result: | Sales Amount | Sales Category | | -----------: | -------------- | | $2,450 | Large Sale | | $320 | Standard Sale | | $1,180 | Large Sale | The category is stored with each row in the table. *** ## Using SWITCH() [#using-switch] When multiple categories are required, `SWITCH()` is often easier to read than several nested `IF()` statements. Example: ```dax lineNumbers Performance Rating = SWITCH( TRUE(), FactSales[SalesAmount] >= 5000, "Excellent", FactSales[SalesAmount] >= 2500, "Good", FactSales[SalesAmount] >= 1000, "Average", "Needs Improvement" ) ``` `SWITCH()` creates cleaner, more maintainable DAX for multiple conditions. *** ## Combining Columns [#combining-columns] Calculated columns can combine multiple fields into a single value. Example: ```dax lineNumbers Customer Name = Customer[First Name] & " " & Customer[Last Name] ``` Result: | First Name | Last Name | Customer Name | | ---------- | --------- | ------------- | | John | Smith | John Smith | | Sarah | Jones | Sarah Jones | *** ## Using RELATED() [#using-related] `RELATED()` retrieves a value from a related table. Example model: ```text DimProduct | | FactSales ``` Calculated column: ```dax lineNumbers Category = RELATED(DimProduct[Category]) ``` The new column copies the product category from the related dimension into each sales row. This function requires an existing relationship between the tables. *** ## Date Calculations [#date-calculations] Calculated columns are often used to simplify reporting dates. Example: ```dax lineNumbers Year = YEAR(FactSales[OrderDate]) ``` Example: ```dax lineNumbers Month Name = FORMAT( FactSales[OrderDate], "MMMM" ) ``` Example: ```dax lineNumbers Year Month = FORMAT( FactSales[OrderDate], "YYYY-MM" ) ``` These columns are useful for grouping and sorting reports. *** ## Sorting Text Values [#sorting-text-values] Power BI sorts text alphabetically by default. Calculated columns can create a numeric sort order. Example: ```dax lineNumbers Month Number = MONTH(DimDate[Date]) ``` Then: * Sort **Month Name** * By **Month Number** Instead of: ```text April August December February ``` Power BI displays: ```text January February March April ... December ``` *** ## Practical Business Examples [#practical-business-examples] Calculated columns are commonly used for: | Business Need | Example | | -------------------- | ----------------- | | Customer Full Name | First + Last Name | | Product Category | RELATED() | | Sales Classification | IF() | | Order Year | YEAR() | | Month Name | FORMAT() | | Custom Sort Columns | Month Number | These columns become permanent parts of the semantic model and can be reused throughout reports. *** ## Performance Considerations [#performance-considerations] Because calculated columns are stored in the data model, they increase the model's memory usage. Every value is calculated during refresh and saved for every row. Example: ```text 1 Million Rows + Existing Columns + Calculated Column ↓ 1 Million Additional Stored Values ``` As models grow larger, unnecessary calculated columns can significantly increase refresh times and memory consumption. Whenever possible, use measures for report calculations. *** ## When Not to Use Calculated Columns [#when-not-to-use-calculated-columns] A common mistake is creating calculated columns for values that should be measures. For example, avoid creating: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` as a calculated column. Why? * It produces the same value on every row. * It wastes storage space. * It does not respond to report filters. * A measure performs the calculation more efficiently. Instead, create it as a measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Although the DAX looks identical, the behavior is completely different. *** ## Choosing Between Measures and Calculated Columns [#choosing-between-measures-and-calculated-columns] Use this guide when deciding which approach to use. | If you need to... | Use | | ---------------------- | ----------------- | | Calculate totals | Measure | | Create KPIs | Measure | | Build percentages | Measure | | Respond to slicers | Measure | | Create categories | Calculated Column | | Combine text fields | Calculated Column | | Create sort columns | Calculated Column | | Generate lookup values | Calculated Column | A simple rule: > If the value should change when a user filters a report, create a **measure**. > If the value should remain fixed for every row until the data refreshes, create a **calculated column**. *** ## Best Practices [#best-practices] When creating calculated columns: * Only create columns that are truly needed. * Keep formulas simple and readable. * Use descriptive column names. * Avoid storing values that can be calculated dynamically. * Use dimensions instead of duplicating descriptive data. * Prefer measures whenever possible. Keeping the model lean improves performance and simplifies maintenance. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common issues: * Creating calculated columns for totals. * Using calculated columns instead of measures. * Duplicating existing dimension attributes. * Creating unnecessary text columns. * Forgetting that calculated columns increase model size. Many Power BI performance problems begin with overusing calculated columns. *** ## Summary [#summary] Calculated columns are best used to create permanent attributes that become part of the data model. They are evaluated during data refresh and stored for every row. Measures, on the other hand, are evaluated dynamically and should be used for business calculations that respond to report filters. Understanding the difference between these two calculation types is one of the most important Power BI skills. *** ## Next Steps [#next-steps] Continue learning DAX: * [Filter Context](/docs/dax/filter-context) * [Row Context](/docs/dax/row-context) * [CALCULATE](/docs/dax/calculate) * [TODAY() & NOW(): Calculated Column vs Measure Timing](/docs/dax/today-now) * [Variables (VAR)](/docs/dax/variables) * [Time Intelligence](/docs/dax/time-intelligence) Getting "a single value for column cannot be determined" when the same formula moves from a calculated column to a measure? See [A Single Value for Column Cannot Be Determined](/blog/single-value-column-cannot-be-determined) for why. # Calculated Tables (/docs/dax/calculated-tables) # Calculated Tables [#calculated-tables] A calculated table is a table whose rows come from a DAX expression, instead of an imported data source. ```text Data Source Tables | | loaded via Power Query | Regular Tables DAX Expression | | evaluated once, at refresh | Calculated Table ``` The result becomes a real table in the model — it can have relationships, be used in visuals, and be referenced by other DAX just like any other table. *** ## Creating a Calculated Table [#creating-a-calculated-table] From the **Modeling** ribbon in Power BI Desktop, select **New Table**, then enter a DAX expression that returns a table. Example: ```dax lineNumbers ActiveProducts = FILTER( DimProduct, DimProduct[Status] = "Active" ) ``` This creates a new table containing only the rows from `DimProduct` where `Status` equals `"Active"`. *** ## When Calculated Tables Are Evaluated [#when-calculated-tables-are-evaluated] Calculated tables are recalculated on every refresh, not on every interaction. ```text Refresh | | recalculates | Calculated Table ``` Unlike a measure, which recalculates constantly as filters change, a calculated table's rows stay fixed between refreshes. *** ## Common Uses [#common-uses] ### Building a Date Table [#building-a-date-table] ```dax lineNumbers DimDate = CALENDAR( DATE(2020, 1, 1), DATE(2026, 12, 31) ) ``` This generates one row per day across the specified range, commonly extended with calculated columns for Year, Month, and Quarter. ### Combining Tables [#combining-tables] ```dax lineNumbers AllProducts = UNION( OnlineProducts, RetailProducts ) ``` Useful when the same kind of data arrives from two different sources that need to be modeled as one table. ### Creating a Distinct Values Table [#creating-a-distinct-values-table] ```dax lineNumbers UniqueRegions = DISTINCT(DimStore[Region]) ``` Useful as a clean lookup or filter table when the source data doesn't already provide one. ### Summarizing Data into a New Table [#summarizing-data-into-a-new-table] ```dax lineNumbers SalesByCategory = SUMMARIZE( FactSales, DimProduct[Category], "Total Sales", SUM(FactSales[SalesAmount]) ) ``` Produces one row per category with an aggregated total, useful as a simplified table for specific visuals. *** ## Calculated Tables vs. Power Query Tables [#calculated-tables-vs-power-query-tables] | Aspect | Power Query Table | Calculated Table | | ------------- | -------------------------------- | -------------------------------------------- | | Built with | M language | DAX | | Runs | During refresh, before load | During refresh, after load | | Can reference | External data sources | Only tables already in the model | | Best for | Shaping and cleaning source data | Deriving new tables from existing model data | A calculated table cannot reach out to an external data source — it can only work with tables and columns already loaded into the model. *** ## Calculated Tables vs. Measures [#calculated-tables-vs-measures] A calculated table produces rows. A measure produces a single aggregated value. ```text Calculated Table | | returns | A table of rows Measure | | returns | A single value ``` If the goal is a number to display in a card or chart, use a measure. If the goal is a new table to relate, filter by, or use as a slicer source, use a calculated table. *** ## Best Practices [#best-practices] * Prefer Power Query for shaping raw source data; reserve calculated tables for logic that depends on the model itself. * Keep calculated tables small — they add to model size and refresh time just like any other table. * Use calculated tables for date tables, distinct value lists, and combining same-shape tables. * Avoid using a calculated table where a measure would do the job with less model overhead. *** ## Common Mistakes [#common-mistakes] ### Using a Calculated Table Instead of a Measure [#using-a-calculated-table-instead-of-a-measure] Building a calculated table just to hold a single aggregated number adds unnecessary model complexity. A measure is lighter and recalculates dynamically with filters. ### Expecting Real-Time Updates [#expecting-real-time-updates] Calculated tables only update on refresh. Expecting them to reflect filter or slicer changes in real time — the way a measure does — leads to confusing, seemingly "stuck" results. ### Referencing External Sources Directly [#referencing-external-sources-directly] DAX calculated tables cannot query an external database directly. Any external data needs to already be loaded into the model as a regular table first. *** ## Calculated Table Checklist [#calculated-table-checklist] Before adding a calculated table to a model: * The result genuinely needs to be a table, not a single value. * The logic only depends on data already loaded into the model. * The table size is reasonable and won't meaningfully slow refresh. * A Power Query solution wasn't a better fit for shaping the same data. *** ## Next Steps [#next-steps] Continue exploring DAX: * [Calculated Columns](/docs/dax/calculated-columns) * [SUMMARIZE](/docs/dax/summarize) * [FILTER](/docs/dax/filter) * [ADDCOLUMNS() vs SELECTCOLUMNS()](/docs/dax/addcolumns-selectcolumns) # CALENDAR() vs CALENDARAUTO() (/docs/dax/calendar-calendarauto) # CALENDAR() vs CALENDARAUTO() [#calendar-vs-calendarauto] Both functions build a continuous table of dates for a date table's `Date` column — the difference is entirely in how the start and end dates get decided. ```dax lineNumbers CALENDAR(StartDate, EndDate) CALENDARAUTO([FiscalYearEndMonth]) ``` *** ## CALENDAR(): You Choose the Range [#calendar-you-choose-the-range] ```dax lineNumbers DimDate = CALENDAR(DATE(2024, 1, 1), DATE(2026, 12, 31)) ``` Explicit start and end dates — the range is exactly what's written, nothing more. *** ## CALENDARAUTO(): Scans Every Date Column in the Model [#calendarauto-scans-every-date-column-in-the-model] `CALENDARAUTO()` takes no date arguments at all — instead, it scans **every column of type date or datetime in every table in the entire model**, and returns a range spanning the earliest date and the latest date found *anywhere*, not just in the fact table the date table is meant to support. A table that has nothing to do with sales reporting — an audit log with a `CreatedDate` defaulted to `1900-01-01` for legacy rows, or a system table with a placeholder date far in the future — silently widens the entire date table the moment it's added to the model, with no error or warning. ```text Sales[OrderDate]: 2024-01-01 to 2026-06-30 <- what the report actually needs AuditLog[CreatedDate]: 1900-01-01 to 2026-08-01 <- unrelated table, has a legacy default date CALENDARAUTO() -> 1900-01-01 to 2026-08-01 <- the whole model's range, not just Sales ``` *** ## Why This Matters in Practice [#why-this-matters-in-practice] A date table stretching back to 1900 isn't just cosmetic — every year/quarter/month in that unused range still gets iterated over by time-intelligence calculations, filter dropdowns show over a century of mostly-empty years, and `FIRSTDATE()`/`LASTDATE()`-based measures can pick up an unintended boundary from a column that was never meant to define the reporting window. *** ## Common Mistakes [#common-mistakes] ### Assuming CALENDARAUTO() Only Looks at the Fact Table [#assuming-calendarauto-only-looks-at-the-fact-table] It doesn't scope to any particular table — it scans the entire model, including tables added later, staging tables, audit columns, or anything else with a date/datetime type. A date table built with `CALENDARAUTO()` can silently change range every time an unrelated table is added to the model. ### Not Noticing a Default/Placeholder Date in a Source Column [#not-noticing-a-defaultplaceholder-date-in-a-source-column] A source system's `NULL`-turned-into-`1900-01-01` (or `9999-12-31`) convention for "not yet set" is a common cause — that placeholder value is a completely legitimate date value as far as `CALENDARAUTO()` is concerned, and it happily includes it in the range. ### Reaching for CALENDARAUTO() by Default [#reaching-for-calendarauto-by-default] `CALENDARAUTO()` exists mainly for a quick prototype where scanning the model is genuinely convenient. For anything production-facing, `CALENDAR()` with an explicit range tied to the actual reporting need is the safer default — it can't be silently widened by a table added six months later. *** ## Best Practices [#best-practices] * Default to `CALENDAR()` with an explicit range for any date table meant to ship, not `CALENDARAUTO()`. * If `CALENDARAUTO()` is used, audit every date/datetime column in the model afterward — not just the fact table — for outlier values. * Base the explicit range on `MIN()`/`MAX()` of the specific column(s) the date table actually needs to support, e.g. `CALENDAR(MIN(Sales[OrderDate]), MAX(Sales[OrderDate]))`. *** ## Next Steps [#next-steps] * [Date Tables](/docs/modeling/date-tables) * [DATEDIFF()](/docs/dax/datediff) * [DAX Function Reference](/docs/dax/functions) # COUNTROWS() (/docs/dax/countrows) # COUNTROWS() [#countrows] `COUNTROWS()` counts the number of rows in a table — every row, regardless of whether any particular column contains a value. ```dax lineNumbers COUNTROWS(Table) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Total Orders = COUNTROWS(FactSales) ``` ```text FactSales OrderID | Discount 1001 | 10 1002 | (blank) 1003 | 5 COUNTROWS(FactSales) -> 3 ``` Every row counts, including the one with a blank `Discount` — `COUNTROWS()` doesn't look at any specific column at all. *** ## COUNTROWS vs. COUNT vs. COUNTA [#countrows-vs-count-vs-counta] | Function | Counts | | ------------------ | --------------------------------------------------- | | `COUNTROWS(Table)` | Every row in the table | | `COUNT(Column)` | Rows where that column contains a number | | `COUNTA(Column)` | Rows where that column contains any non-blank value | ```text Same FactSales table as above: COUNTROWS(FactSales) -> 3 (every row) COUNT(FactSales[Discount]) -> 2 (skips the blank) COUNTA(FactSales[Discount]) -> 2 (skips the blank) ``` `COUNT()` and `COUNTA()` are tied to one specific column and skip blanks in it; `COUNTROWS()` doesn't reference a column at all, so it can't be affected by blanks in any particular field. *** ## Counting Distinct Combinations [#counting-distinct-combinations] `COUNTROWS()` is the standard way to count distinct *combinations* of values, by pairing it with `SUMMARIZE()` or `VALUES()` on more than one column. ```dax lineNumbers Distinct Customer-Region Pairs = COUNTROWS( SUMMARIZE(FactSales, DimCustomer[CustomerKey], DimCustomer[Region]) ) ``` `DISTINCTCOUNT()` only works on a single column — for a distinct count across a *combination* of columns, `COUNTROWS()` over a summarized table is the pattern. See [SUMMARIZE](/docs/dax/summarize) and [DISTINCTCOUNT](/docs/dax/distinctcount). *** ## Counting Rows in a Filtered Table [#counting-rows-in-a-filtered-table] Since the argument is any table expression, `COUNTROWS()` combines naturally with `FILTER()`. ```dax lineNumbers Large Orders = COUNTROWS( FILTER(FactSales, FactSales[SalesAmount] > 1000) ) ``` *** ## Common Mistakes [#common-mistakes] ### Using COUNT Instead of COUNTROWS to Count "All Rows" [#using-count-instead-of-countrows-to-count-all-rows] ```dax lineNumbers Total Orders (Fragile) = COUNT(FactSales[OrderID]) ``` This happens to work only if `OrderID` is never blank. `COUNTROWS(FactSales)` counts every row unconditionally, regardless of what any column contains — it's the more robust choice whenever the intent is genuinely "how many rows." ### Expecting COUNTROWS to Deduplicate [#expecting-countrows-to-deduplicate] `COUNTROWS()` counts rows exactly as they exist in the table given to it — it doesn't remove duplicates on its own. Pair it with `DISTINCT()` or `SUMMARIZE()` first if the actual goal is a distinct count. *** ## Best Practices [#best-practices] * Use `COUNTROWS()` whenever the question is "how many rows," not "how many non-blank values in this specific column." * Combine with `SUMMARIZE()` (or `VALUES()` for a single column) to count distinct combinations, rather than reaching for `DISTINCTCOUNT()` on the wrong grain. * Filter the table argument with `FILTER()` to count only rows meeting a condition. *** ## Next Steps [#next-steps] * [DISTINCTCOUNT](/docs/dax/distinctcount) * [SUMMARIZE](/docs/dax/summarize) * [Filter Context](/docs/dax/filter-context) # CROSSFILTER() (/docs/dax/crossfilter) # CROSSFILTER() [#crossfilter] A standard one-to-many relationship filters in one direction only: from the "one" side to the "many" side. `CROSSFILTER()` temporarily overrides that direction — or removes it entirely — for a single calculation. ```dax lineNumbers CROSSFILTER(, , ) ``` `` is one of `NONE`, `ONEDIRECTION`, or `BOTH`. *** ## The Default: Filters Flow One Way [#the-default-filters-flow-one-way] ```dax lineNumbers CALCULATE(COUNTROWS(DimProduct), FactSales[Promotion] = "Yes") ``` With `DimProduct` on the "one" side and `FactSales` on the "many" side, filtering `FactSales[Promotion]` has no path back to `DimProduct` — a standard relationship simply doesn't propagate that direction. This measure still counts **every** row in `DimProduct`, completely unaffected by the `Promotion` filter. *** ## CROSSFILTER(..., BOTH): Letting It Flow Backward [#crossfilter-both-letting-it-flow-backward] ```dax lineNumbers CALCULATE( COUNTROWS(DimProduct), CROSSFILTER(FactSales[ProductID], DimProduct[ProductID], BOTH), FactSales[Promotion] = "Yes" ) ``` Adding `CROSSFILTER(FactSales[ProductID], DimProduct[ProductID], BOTH)` temporarily makes the relationship bidirectional for this one calculation — now the `Promotion = "Yes"` filter on `FactSales` *does* propagate back, narrowing `DimProduct` down to only the products that actually have a matching promotional sale. ```text DimProduct: A, B, C FactSales: A/Yes, A/No, B/Yes, C/No <- only A and B have a "Yes" promotion sale CALCULATE(COUNTROWS(DimProduct), FactSales[Promotion]="Yes") -> 3 (unaffected) CALCULATE(COUNTROWS(DimProduct), CROSSFILTER(...,BOTH), FactSales[Promotion]="Yes") -> 2 (A and B only) ``` *** ## The Other Directions: NONE and ONEDIRECTION [#the-other-directions-none-and-onedirection] ```text CROSSFILTER(..., NONE) -- disables the relationship's filtering entirely, in either direction CROSSFILTER(..., ONEDIRECTION) -- restores the relationship to its normal single-direction behavior ``` `NONE` is useful when a calculation specifically needs to ignore a relationship altogether for one measure, without changing the relationship itself in the model. `ONEDIRECTION` is mostly useful for explicitly restoring the default inside a calculation that's already inside a context where the direction was changed some other way. *** ## Common Mistakes [#common-mistakes] ### Assuming a Fact-Table Filter Always Reaches the Dimension Table [#assuming-a-fact-table-filter-always-reaches-the-dimension-table] The single most common surprise here: a filter on the "many" side has no effect on the "one" side by default, no matter how intuitive it feels that it should. `CROSSFILTER()` (or a model-level bidirectional relationship) is required to make that direction work. ### Reaching for a Bidirectional Relationship in the Model Instead of CROSSFILTER() [#reaching-for-a-bidirectional-relationship-in-the-model-instead-of-crossfilter] Setting a relationship to bidirectional at the model level affects every calculation that touches it, everywhere in the report — often causing ambiguous filter propagation in models with multiple relationships. `CROSSFILTER()` scopes the same behavior to exactly one calculation, which is usually the safer choice. ### Forgetting CROSSFILTER() Only Lasts for the One CALCULATE It's In [#forgetting-crossfilter-only-lasts-for-the-one-calculate-its-in] The direction change applies only within that specific `CALCULATE()` call — it doesn't persist to any other measure or visual, even ones evaluated immediately afterward in the same report. *** ## Best Practices [#best-practices] * Default to `CROSSFILTER()` scoped to a single calculation rather than changing a relationship's direction in the model, unless every calculation touching that relationship genuinely needs the bidirectional behavior. * Be explicit about which direction is intended (`BOTH`, `NONE`) — don't rely on assuming the current model-level setting. * Document why a specific measure needs `CROSSFILTER()` — it's not obvious from the measure's name that it's overriding the model's normal relationship behavior. *** ## Next Steps [#next-steps] * [KEEPFILTERS()](/docs/dax/keepfilters) * [CALCULATE](/docs/dax/calculate) * [Filter Context](/docs/dax/filter-context) * [Star Schema](/docs/modeling/star-schema) * [DAX Function Reference](/docs/dax/functions) # DATEADD() vs PARALLELPERIOD() (/docs/dax/dateadd-parallelperiod) # DATEADD() vs PARALLELPERIOD() [#dateadd-vs-parallelperiod] Both functions shift the current date range back or forward by a calendar interval — the difference is entirely in what shape the *result* takes when the original range isn't already a complete period. ```dax lineNumbers DATEADD(Dates[Date], NumberOfIntervals, Interval) PARALLELPERIOD(Dates[Date], NumberOfIntervals, Interval) ``` *** ## DATEADD(): Preserves the Exact Shape, Just Shifted [#dateadd-preserves-the-exact-shape-just-shifted] ```dax lineNumbers DATEADD(Dates[Date], -1, MONTH) ``` If the current filter context is January 1–15 (a month-to-date selection, say), `DATEADD(..., -1, MONTH)` returns December 1–15 — the same 15-day shape, shifted back exactly one month. *** ## PARALLELPERIOD(): Always the Full Period [#parallelperiod-always-the-full-period] ```dax lineNumbers PARALLELPERIOD(Dates[Date], -1, MONTH) ``` The same January 1–15 context, shifted with `PARALLELPERIOD()` instead, returns **all of December** — December 1–31, not just the 1st through the 15th. `PARALLELPERIOD()` shifts by the interval and then rounds the result out to the entire calendar period(s) it touches, regardless of how much of the *current* period was actually selected. *** ## Why This Only Shows Up With a Partial Period [#why-this-only-shows-up-with-a-partial-period] If the current filter context already happens to be a complete month (January 1–31, the whole month), both functions return the same thing — all of December, either way. The divergence only appears when the current selection is a **partial** period: a month-to-date range, a single day, a custom date-range slicer selection that doesn't align to calendar boundaries. ```text Current context: January 1-31 (a full month) DATEADD(..., -1, MONTH) -> December 1-31 (same as PARALLELPERIOD here) PARALLELPERIOD(..., -1, MONTH) -> December 1-31 Current context: January 1-15 (a partial month, e.g. month-to-date) DATEADD(..., -1, MONTH) -> December 1-15 (same shape, shifted) PARALLELPERIOD(..., -1, MONTH) -> December 1-31 (the whole month, regardless) ``` This is exactly why a "previous month" comparison measure can look correct in a full-month report and then look wrong the moment someone applies a mid-month, month-to-date filter — the two functions were never doing the same thing, it just wasn't visible until the current period stopped being a complete one. *** ## Common Mistakes [#common-mistakes] ### Assuming DATEADD and PARALLELPERIOD Are Interchangeable [#assuming-dateadd-and-parallelperiod-are-interchangeable] They agree whenever the current context is already a complete period, which is common enough in testing to hide the difference until a partial-period filter (a live month-to-date report, most obviously) exposes it in production. ### Using PARALLELPERIOD for a True Month-to-Date Comparison [#using-parallelperiod-for-a-true-month-to-date-comparison] If the goal is "the same day-range last month" (a genuine month-to-date-vs-month-to-date comparison), `PARALLELPERIOD()`'s full-month result isn't what's needed — `DATEADD()` is the one that preserves the current partial range's shape. ### Using DATEADD When the Goal Is a Full Prior-Period Total [#using-dateadd-when-the-goal-is-a-full-prior-period-total] Conversely, a "total sales for all of last month" measure evaluated during a mid-month, month-to-date filtered view needs `PARALLELPERIOD()` (or an explicit `STARTOFMONTH`/`ENDOFMONTH` pair) — `DATEADD()` would only return the equivalent partial slice of last month, not the full period. ### Running DATEADD Against a Non-Contiguous or Unmarked Date Column [#running-dateadd-against-a-non-contiguous-or-unmarked-date-column] `DATEADD()` (and most other time-intelligence functions) expect a contiguous date column, ideally from a table marked as a date table — running it against a transaction table's date column directly, which typically has gaps, can produce incomplete or unexpected results. *** ## Best Practices [#best-practices] * Use `DATEADD()` when the comparison should mirror whatever partial or full range is currently selected. * Use `PARALLELPERIOD()` (or explicit `STARTOFMONTH`/`ENDOFMONTH`, `STARTOFQUARTER`/`ENDOFQUARTER`) when the comparison specifically needs the entire prior period, regardless of what's currently selected. * Test time-intelligence measures against both a full-period selection and a partial one (month-to-date, a single day) before shipping — the two functions' difference only shows up in the partial case. *** ## Next Steps [#next-steps] * [Time Intelligence](/docs/dax/time-intelligence) * [DATEDIFF()](/docs/dax/datediff) * [CALENDAR() vs CALENDARAUTO()](/docs/dax/calendar-calendarauto) * [DAX Function Reference](/docs/dax/functions) # DATEDIFF() (/docs/dax/datediff) # DATEDIFF() [#datediff] `DATEDIFF()` returns the number of calendar-unit boundaries between two dates — and "boundaries crossed" is a genuinely different thing from "full periods elapsed," which is where the surprises start. ```dax lineNumbers DATEDIFF(Date1, Date2, Interval) ``` `Interval` can be `SECOND`, `MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `QUARTER`, or `YEAR`. *** ## It Counts Boundaries Crossed, Not Full Periods [#it-counts-boundaries-crossed-not-full-periods] ```dax lineNumbers DATEDIFF(DATE(2026, 1, 31), DATE(2026, 2, 1), MONTH) ``` ```text DATE(2026,1,31) to DATE(2026,2,1) -> 1 calendar day apart DATEDIFF(..., MONTH) -> 1 ``` Only a single day separates these two dates, but `DATEDIFF(..., MONTH)` returns `1` — because the calendar month changed from January to February between them. `DATEDIFF()` for `MONTH` is really just `(Year2 * 12 + Month2) - (Year1 * 12 + Month1)`; the day-of-month on either end doesn't factor in at all. ```text DATE(2026, 1, 1) to DATE(2026, 1, 31) -> 30 days apart, DATEDIFF(..., MONTH) -> 0 (same month) DATE(2026, 1, 31) to DATE(2026, 2, 1) -> 1 day apart, DATEDIFF(..., MONTH) -> 1 (crossed a boundary) ``` A 30-day span that stays inside one calendar month returns `0`; a 1-day span that happens to cross midnight into the next month returns `1`. The same asymmetry applies to `QUARTER` and `YEAR` — each just counts how many times that specific boundary was crossed, independent of how many actual days that took. *** ## This Is Different From a Simple Day-Count Division [#this-is-different-from-a-simple-day-count-division] A rough mental model of "months apart" as "days apart ÷ 30" doesn't match `DATEDIFF()` at all — there's no division by an average period length happening here, just a count of calendar labels changing. ```text DATE(2025, 12, 31) to DATE(2026, 1, 1) -> 1 day apart DATEDIFF(..., MONTH) -> 1 (Dec -> Jan) DATEDIFF(..., QUARTER) -> 1 (Q4 -> Q1) DATEDIFF(..., YEAR) -> 1 (2025 -> 2026) ``` A single day, right at year-end, registers as crossing a month boundary, a quarter boundary, *and* a year boundary simultaneously — all three return `1` for a 1-day gap, while a 90-day gap that stays entirely within one quarter returns `0` for all three. *** ## Common Mistakes [#common-mistakes] ### Expecting DATEDIFF(..., MONTH) to Reflect Elapsed 30-Day Periods [#expecting-datediff-month-to-reflect-elapsed-30-day-periods] As covered above — it's a calendar-label count, not a days-divided-by-30 calculation. A tenure or age calculation genuinely needing "how many full months has this been" (accounting for day-of-month) needs custom logic layered on top, not `DATEDIFF()` alone. ### Assuming a Small DATEDIFF Result Means a Small Elapsed Time [#assuming-a-small-datediff-result-means-a-small-elapsed-time] A `DATEDIFF(..., YEAR)` of `0` can still span up to 364 days (any two dates within the same calendar year), while a result of `1` can span as little as a single day (December 31st to January 1st). The unit result alone doesn't say how much time actually elapsed. ### Using DATEDIFF for a Precise Age or Duration in Days [#using-datediff-for-a-precise-age-or-duration-in-days] For an exact elapsed span in days, `DATEDIFF(..., DAY)` (or subtracting the dates directly) is the right tool — reaching for `MONTH` or `YEAR` when the real need is a precise duration produces a boundary-count, not a duration. *** ## Best Practices [#best-practices] * Use `DATEDIFF(..., DAY)` (or direct date subtraction) when the actual elapsed time matters, not just how many calendar boundaries were crossed. * Remember that `MONTH`, `QUARTER`, and `YEAR` results can be `1` for a gap as small as a single day, right at a boundary — don't treat the unit result as a proxy for elapsed duration. * For a genuine "full months completed" calculation (accounting for day-of-month, like an age-in-years calculation), combine `DATEDIFF()` with an explicit day-of-month comparison rather than trusting the boundary count alone. *** ## Next Steps [#next-steps] * [Time Intelligence](/docs/dax/time-intelligence) * [Date.AddMonths() and the Month-End Edge Case](/docs/power-query/date-functions#the-month-end-edge-case) * [CALENDAR() vs CALENDARAUTO()](/docs/dax/calendar-calendarauto) * [DATEADD() vs PARALLELPERIOD()](/docs/dax/dateadd-parallelperiod) * [DAX Function Reference](/docs/dax/functions) # DISTINCTCOUNT() (/docs/dax/distinctcount) # DISTINCTCOUNT() [#distinctcount] `DISTINCTCOUNT()` counts the number of distinct values in a column, evaluated within the current filter context. ```dax lineNumbers DISTINCTCOUNT(Column) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Unique Customers = DISTINCTCOUNT(FactSales[CustomerKey]) ``` ```text FactSales[CustomerKey] 101 102 101 103 102 DISTINCTCOUNT -> 3 (101, 102, 103 — each counted once) ``` This is the standard way to answer "how many unique X" — unique customers, unique products sold, unique visits — from a fact table where the same key can legitimately repeat across many rows. *** ## How DISTINCTCOUNT Handles Blanks [#how-distinctcount-handles-blanks] `DISTINCTCOUNT()` counts a blank value as one distinct value, if the column contains any blanks at all. ```text FactSales[PromoCode] "SAVE10" "SAVE10" (blank) "WELCOME" DISTINCTCOUNT -> 3 ("SAVE10", blank, "WELCOME") ``` If the intent is "how many distinct promo codes were actually used," including blank as a countable value overstates it by one. `DISTINCTCOUNTNOBLANK()` excludes the blank: ```dax lineNumbers Promo Codes Used = DISTINCTCOUNTNOBLANK(FactSales[PromoCode]) ``` ```text DISTINCTCOUNTNOBLANK -> 2 ("SAVE10", "WELCOME") ``` *** ## DISTINCTCOUNT Is Filter-Context Aware [#distinctcount-is-filter-context-aware] Like any DAX aggregation, `DISTINCTCOUNT()` only counts distinct values among the rows visible in the current filter context — not the whole table unconditionally. ```dax lineNumbers Unique Customers = DISTINCTCOUNT(FactSales[CustomerKey]) ``` ```text No filters applied -> counts distinct customers across all sales Category = "Bikes" filter -> counts distinct customers who bought Bikes specifically ``` To get an unfiltered total for comparison (a "percent of all customers" style measure), wrap it in `CALCULATE()` with `ALL()`: ```dax lineNumbers Unique Customers (All Categories) = CALCULATE( DISTINCTCOUNT(FactSales[CustomerKey]), ALL(DimProduct) ) ``` See the [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet) for this pattern applied more generally. *** ## Common Mistakes [#common-mistakes] ### Not Accounting for Blank Inflating the Count by One [#not-accounting-for-blank-inflating-the-count-by-one] A `DISTINCTCOUNT()` that's consistently one higher than expected is often the blank-counts-as-a-value behavior, not a data quality bug — check whether `DISTINCTCOUNTNOBLANK()` is actually the intended function. ### Using DISTINCTCOUNT for a Multi-Column Distinct Count [#using-distinctcount-for-a-multi-column-distinct-count] `DISTINCTCOUNT()` only accepts a single column. Counting distinct *combinations* of two or more columns needs `COUNTROWS()` over a `SUMMARIZE()`'d table instead — see [COUNTROWS](/docs/dax/countrows#counting-distinct-combinations). ### Running It Against a High-Cardinality Column on a Huge Table [#running-it-against-a-high-cardinality-column-on-a-huge-table] `DISTINCTCOUNT()` on a column with millions of unique values (a raw transaction ID, for example) is one of the more expensive DAX aggregations — worth checking whether a lower-cardinality key would answer the same business question. *** ## Best Practices [#best-practices] * Use `DISTINCTCOUNTNOBLANK()` when a blank value shouldn't count as a real, distinct answer. * Reach for `COUNTROWS()` + `SUMMARIZE()` for a distinct count across more than one column. * Be mindful of running `DISTINCTCOUNT()` against very high-cardinality columns on large fact tables — it's not free. *** ## Next Steps [#next-steps] * [COUNTROWS](/docs/dax/countrows) * [SUMMARIZE](/docs/dax/summarize) * [VALUES() vs DISTINCT()](/docs/dax/values-distinct) * [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet) # DIVIDE() (/docs/dax/divide) # DIVIDE() [#divide] `DIVIDE()` performs division and safely handles the case where the denominator is zero or blank — returning `BLANK()` (or a specified fallback) instead of an error. ```dax lineNumbers DIVIDE(Numerator, Denominator, [AlternateResult]) ``` Try setting the denominator to `0` or clearing it entirely — that's the one behavior every example below is really about. *** ## Basic Example [#basic-example] ```dax lineNumbers Profit Margin = DIVIDE([Total Profit], [Total Sales]) ``` ```text Total Sales = 500,000 -> Profit Margin = 0.24 Total Sales = 0 -> Profit Margin = BLANK (not an error) ``` The equivalent written with the `/` operator errors out the entire visual the moment any row or subtotal has a zero or blank denominator: ```dax lineNumbers Profit Margin (Unsafe) = [Total Profit] / [Total Sales] ``` ```text Total Sales = 0 -> "Can't divide by zero" — the whole visual breaks, not just that one value ``` *** ## The Optional Fallback [#the-optional-fallback] The third argument controls what's returned instead of blank when the denominator is zero or blank. ```dax lineNumbers Profit Margin = DIVIDE([Total Profit], [Total Sales], 0) ``` ```text No AlternateResult supplied -> BLANK (shows as an empty cell) AlternateResult = 0 -> shows as an actual 0 ``` A chart that needs a real zero bar (rather than a gap in the axis) is the most common reason to supply this — otherwise, leaving it as blank is usually the more honest representation of "no data," not "zero." *** ## Why Not Just Use `/`? [#why-not-just-use-] `/` is fine for a calculation where the denominator is mathematically guaranteed never to be zero or blank — inside a fixed conversion constant, for example. The moment a denominator comes from data (a measure, a column, an aggregation that could legitimately be empty for some filter combination), `/` is one edge case away from breaking a report. ```text / (division operator): fast to type, breaks the visual on a zero/blank denominator DIVIDE(): one extra argument, handles zero/blank cleanly every time ``` `DIVIDE()` also has a minor performance edge over wrapping `/` in a manual `IF(denominator = 0, ...)` check, since it's a single optimized operation rather than an extra conditional evaluated on every row. *** ## Common Mistakes [#common-mistakes] ### Still Reaching for `/` in Report-Facing Measures [#still-reaching-for--in-report-facing-measures] The habit of typing `/` is hard to break, but any measure a report visual will actually display should default to `DIVIDE()` — the cost of being wrong (a broken visual) is much higher than the cost of typing a few extra characters. ### Assuming DIVIDE Returns 0 by Default [#assuming-divide-returns-0-by-default] Without a third argument, `DIVIDE()` returns `BLANK()`, not `0` — a measure expected to show `0%` instead shows an empty cell unless the fallback is supplied explicitly. ### Wrapping DIVIDE in a Redundant Blank Check [#wrapping-divide-in-a-redundant-blank-check] ```dax lineNumbers Profit Margin = IF( ISBLANK([Total Sales]), BLANK(), DIVIDE([Total Profit], [Total Sales]) ) ``` `DIVIDE()` already handles this — the surrounding `IF`/`ISBLANK` adds nothing but extra evaluation cost. *** ## Best Practices [#best-practices] * Default to `DIVIDE()` for every report-facing ratio, percentage, or average calculation. * Reserve `/` for divisions where the denominator is a fixed, known-safe constant. * Only supply an explicit `AlternateResult` when the visual genuinely needs a real zero instead of a blank — don't default to `0` out of habit. *** ## Next Steps [#next-steps] * [Percent of Total](/docs/dax-patterns/percent-of-total) * [Performance Optimization](/docs/dax/performance) * [Measures](/docs/dax/measures) * [BLANK() vs 0: ISBLANK() and the Comparison Trap](/docs/dax/blank-vs-zero) * [IFERROR()](/docs/dax/iferror) See it applied end to end: [Build a Reliability (MTBF/MTTR) Dashboard](/tutorials/build-a-reliability-mtbf-mttr-dashboard) — every MTBF/MTTR measure is built on DIVIDE, since a filtered view can have zero recorded failures. # EARLIER() (/docs/dax/earlier) # EARLIER() [#earlier] `EARLIER()` refers to a value from an *outer* row context, from inside a calculation that has created a *nested* (inner) row context — most often inside a calculated column that uses `FILTER()` internally. ```dax lineNumbers EARLIER(Column, [Number]) ``` This is one of the more confusing corners of DAX, largely because it's rarely needed in modern DAX — variables usually solve the same problem more clearly. It's still worth understanding, since it shows up constantly in older tutorials, forum answers, and existing models. *** ## Why It's Needed: Two Row Contexts at Once [#why-its-needed-two-row-contexts-at-once] A calculated column already has one row context — "the current row." Using `FILTER()` inside that column's expression creates a *second*, inner row context for evaluating the filter condition. Inside that inner context, a bare column reference means the *inner* row, not the original one — `EARLIER()` is how the expression reaches back to the outer row's value instead. ```text Calculated column's own row context: Row context #1 (outer) | | FILTER() inside the expression creates: | Row context #2 (inner) — a bare column reference now means THIS row, not the outer one | | EARLIER(Column) reaches back to | Row context #1's value ``` *** ## Classic Example: Ranking Within a Calculated Column [#classic-example-ranking-within-a-calculated-column] ```dax lineNumbers Sales Rank = COUNTROWS( FILTER( FactSales, FactSales[SalesAmount] > EARLIER(FactSales[SalesAmount]) ) ) + 1 ``` ```text For each row, count how many OTHER rows have a higher SalesAmount, then add 1. SalesAmount = 500 -> 2 rows have more -> Rank 3 SalesAmount = 900 -> 0 rows have more -> Rank 1 ``` `FactSales[SalesAmount]` inside `FILTER()` refers to each row `FILTER()` is currently examining (the inner context). `EARLIER(FactSales[SalesAmount])` refers back to the row the calculated column itself is being computed for (the outer context) — without it, the comparison would just be `FactSales[SalesAmount] > FactSales[SalesAmount]`, which is never true. *** ## The Modern Alternative: Just Use a Measure [#the-modern-alternative-just-use-a-measure] The example above is a calculated column doing what [RANKX](/docs/dax/rankx) already does, as a measure, without any nested row context to manage: ```dax lineNumbers Sales Rank = RANKX(ALL(FactSales), [Total Sales]) ``` For genuinely one-off cases where a calculated column really is the right tool (not every ranking-shaped problem is), `VAR` typically replaces `EARLIER()` more clearly when the outer value is captured before entering the nested expression: ```dax lineNumbers Sales Rank (Calculated Column) = VAR CurrentSales = FactSales[SalesAmount] RETURN COUNTROWS( FILTER( FactSales, FactSales[SalesAmount] > CurrentSales ) ) + 1 ``` `VAR` captures `CurrentSales` in the outer row context *before* `FILTER()` creates the inner one, so there's no ambiguity about which context a bare column reference belongs to — see [Variables (VAR)](/docs/dax/variables) for why this is generally the clearer pattern now. *** ## When EARLIER(Column, 2) Shows Up [#when-earliercolumn-2-shows-up] With three or more nested row contexts, a single `EARLIER()` only reaches back one level — the optional second argument specifies how many levels to skip. ```text Row context #1 (outermost) Row context #2 Row context #3 (innermost) EARLIER(Column) -> reaches row context #2 (one level back) EARLIER(Column, 2) -> reaches row context #1 (two levels back) ``` This is exactly the kind of nested-context bookkeeping that makes `EARLIER()` hard to read — it's a strong signal to restructure with variables instead, once the nesting goes this deep. *** ## Common Mistakes [#common-mistakes] ### Reaching for EARLIER Inside a Measure [#reaching-for-earlier-inside-a-measure] `EARLIER()` requires a genuine outer row context to reach back to — a measure has no row context of its own by default, so `EARLIER()` inside a measure almost always means the calculation was designed for a calculated column, or should be rewritten as an iterator/`CALCULATE()`-based measure instead. ### Not Realizing VAR Solves the Same Problem More Clearly [#not-realizing-var-solves-the-same-problem-more-clearly] Since `VAR` captures a value before a nested context is introduced, most real-world `EARLIER()` uses can be rewritten with a variable instead — and most style guides now recommend doing so, since `VAR` doesn't require counting context levels. ### Assuming EARLIER "Looks Ahead" [#assuming-earlier-looks-ahead] The name is easy to misread — `EARLIER()` refers to a context that already existed *before* the current (inner) one was created, not a future or upcoming row in some sequence. *** ## Best Practices [#best-practices] * Prefer `VAR` over `EARLIER()` in new DAX — it's clearer and doesn't depend on counting nesting levels. * Reserve calculated columns (and therefore `EARLIER()`) for logic that genuinely needs to be stored per row; a measure is usually the better fit for ranking, running totals, and similar patterns. * If `EARLIER(Column, 2)` or deeper ever seems necessary, treat it as a signal to restructure the calculation rather than push the nesting further. *** ## Next Steps [#next-steps] * [Variables (VAR)](/docs/dax/variables) * [Row Context](/docs/dax/row-context) * [RANKX](/docs/dax/rankx) # Filter Context (/docs/dax/filter-context) # Filter Context [#filter-context] Filter context is one of the most important concepts in DAX. Every measure in Power BI is evaluated within a filter context. Understanding how filters affect calculations is essential for writing accurate and efficient DAX. Many DAX functions—including `CALCULATE()`, `ALL()`, and Time Intelligence functions—depend on filter context. *** ## What Is Filter Context? [#what-is-filter-context] Filter context is the collection of filters applied before a DAX expression is evaluated. Filters can come from: * Report slicers * Page filters * Visual filters * Relationships * Rows and columns in a visual * DAX functions Power BI first determines which rows are visible. Only then is the DAX measure evaluated. *** ## How Filter Context Works [#how-filter-context-works] A typical report follows this process: ```text User selects filters | v Power BI filters the model | v Visible rows are determined | v DAX measure is evaluated | v Result is displayed ``` The measure never changes. Only the rows being evaluated change. *** ## Simple Example [#simple-example] Suppose we have this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` If no filters exist: | Category | Sales | | ----------- | -------: | | Bikes | $150,000 | | Accessories | $80,000 | | Clothing | $40,000 | The measure returns: `$270,000` Now suppose the report is filtered to: `Category = Bikes` Only Bike sales remain visible. The same measure now returns: `$150,000` The DAX formula did not change. Only the filter context changed. *** ## Sources of Filter Context [#sources-of-filter-context] Several parts of Power BI can apply filters simultaneously. Example: ```text Report Filter + Page Filter + Visual Filter + Slicer + Relationships ↓ Filter Context ``` Every active filter contributes to the final result. *** ## Visuals Create Filter Context [#visuals-create-filter-context] Every visual automatically creates its own filter context. Example: ``` Product Category Total Sales Bikes $150,000 Accessories $80,000 Clothing $40,000 ``` Although only one measure is used: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Power BI evaluates it separately for every category shown in the visual. Each row has its own filter context. *** ## Relationships Create Filter Context [#relationships-create-filter-context] Relationships allow filters to flow between tables. Consider this simple star schema: ```text DimDate | | DimCustomer -- FactSales -- DimProduct | | DimStore ``` Suppose a report contains this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` If a user selects: `Category = Bikes` Power BI filters **DimProduct**. That filter then flows through the relationship into **FactSales**, leaving only Bike sales visible before the measure is calculated. *** ## Multiple Filters [#multiple-filters] A report rarely has only one filter. For example: ```text Category = Bikes Year = 2026 Region = West ``` Power BI combines all active filters before evaluating the measure. ```text Category + Year + Region ↓ Visible FactSales Rows ↓ Measure Evaluation ``` The result includes only rows matching **all** active filters. *** ## Slicers Create Filter Context [#slicers-create-filter-context] Slicers are one of the most common sources of filter context. Example: ``` Year ☐ 2024 ☐ 2025 ☑ 2026 ``` When the user selects **2026**, every connected visual is filtered automatically. Measures recalculate immediately using only data from 2026. No changes to the DAX formula are required. *** ## Visuals Apply Their Own Filters [#visuals-apply-their-own-filters] Every visual evaluates measures independently. Example: | Category | Total Sales | | ----------- | ----------: | | Bikes | $150,000 | | Accessories | $80,000 | | Clothing | $40,000 | Power BI executes the same measure three separate times. For the **Bikes** row: ```text Filter Context Category = Bikes ``` For the **Accessories** row: ```text Filter Context Category = Accessories ``` For the **Clothing** row: ```text Filter Context Category = Clothing ``` The measure is identical. Only the filter context changes. *** ## Cross-Filtering Between Visuals [#cross-filtering-between-visuals] Visuals can also filter one another. Suppose a report contains: * A bar chart by Category * A table of Sales * A KPI card When the user clicks **Bikes** in the bar chart: ```text Bar Chart ↓ Category = Bikes ↓ Table updates ↓ KPI updates ↓ Measures recalculate ``` Power BI automatically creates a new filter context for every affected visual. *** ## Why One Measure Returns Different Results [#why-one-measure-returns-different-results] Consider this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Depending on where it appears, it may return completely different values. | Location | Result | | --------------- | -------: | | Card (All Data) | $500,000 | | Bikes Row | $150,000 | | 2026 Column | $220,000 | | West Region | $98,000 | The formula never changes. The surrounding filter context determines the result. *** ## Removing Filters [#removing-filters] Sometimes you want a calculation to ignore one or more filters. The `ALL()` function removes filters from a table or column. Example: ```dax lineNumbers Total Sales (All Products) = CALCULATE( [Total Sales], ALL(DimProduct) ) ``` Even if the report is filtered to **Bikes**, this measure returns sales for **all products**. This is commonly used when calculating percentages of totals. *** ## Modifying Filter Context [#modifying-filter-context] One of the most powerful features of DAX is the ability to change the current filter context. The `CALCULATE()` function evaluates an expression using a modified set of filters. Example: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` Regardless of the report filters, this measure always evaluates sales for the **Bikes** category. Understanding how `CALCULATE()` changes filter context is one of the most important DAX skills. *** ## Example: Percent of Total Sales [#example-percent-of-total-sales] Filter context is often modified to compare a filtered value against the overall total. Example: ```dax lineNumbers Percent of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` If Bikes account for $150,000 of $500,000 total sales, the measure returns: `30%` The numerator respects the current filter context. The denominator removes the product filter and returns the overall total. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Many new DAX developers misunderstand filter context. Common mistakes include: * Assuming measures always return one fixed value. * Forgetting that visuals automatically create filter context. * Ignoring relationships between tables. * Using calculated columns when a measure is required. * Expecting `CALCULATE()` to work without understanding filter context. Learning filter context first makes advanced DAX much easier. *** ## Best Practices [#best-practices] Keep these guidelines in mind: * Build a clean star schema. * Use dimension tables for filtering. * Create reusable measures. * Test calculations with different slicers. * Understand the current filter context before writing complex DAX. * Modify filters only when necessary. Most complex DAX formulas become much easier to understand once you identify the active filter context. *** ## Summary [#summary] Filter context determines **which rows are visible** before a DAX measure is evaluated. It can be created by: * Report filters * Page filters * Visual filters * Slicers * Relationships * Other DAX functions The same measure can return different results because the filter context changes—not because the formula changes. Understanding filter context is the foundation for mastering DAX. *** ## Next Steps [#next-steps] Now that you understand filter context, you're ready to learn how to control it using `CALCULATE()`. Continue with: * [Row Context](/docs/dax/row-context) * [CALCULATE](/docs/dax/calculate) * [Variables (VAR)](/docs/dax/variables) * [KEEPFILTERS()](/docs/dax/keepfilters) * [Iterators](/docs/dax/iterator) * [Time Intelligence](/docs/dax/time-intelligence) # ALL(), ALLEXCEPT(), ALLSELECTED() & REMOVEFILTERS() (/docs/dax/filter-functions) # ALL(), ALLEXCEPT(), ALLSELECTED() & REMOVEFILTERS() [#all-allexcept-allselected--removefilters] One of the greatest strengths of DAX is its ability to control filter context. Functions such as `ALL()`, `ALLEXCEPT()`, `ALLSELECTED()`, and `REMOVEFILTERS()` allow you to ignore, preserve, or modify filters while evaluating a calculation. These functions are commonly used for: * Percent of Total calculations * Running Totals * Ranking * Year-over-Year comparisons * Dynamic KPIs * Dashboard summaries Although these functions appear similar, each serves a different purpose. Understanding the differences is essential for writing advanced DAX. *** ## Why Remove Filters? [#why-remove-filters] Suppose a report is filtered to: `Category = Bikes` Your measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` returns only Bike sales. Sometimes, however, you need to compare Bike sales to **all products**. That's where filter removal functions become useful. *** ## The ALL() Function [#the-all-function] `ALL()` removes filters from a table or column before evaluating an expression. General syntax: ```dax lineNumbers ALL(Table) ALL(Column) ``` When used inside `CALCULATE()`, the specified filters are removed. Example: ```dax lineNumbers Total Sales All Products = CALCULATE( [Total Sales], ALL(DimProduct) ) ``` Even if the report is filtered to: `Category = Bikes` the measure returns sales for **every product**. *** ## Removing a Single Column Filter [#removing-a-single-column-filter] `ALL()` can also remove filters from a specific column. Example: ```dax lineNumbers Sales All Categories = CALCULATE( [Total Sales], ALL(DimProduct[Category]) ) ``` Only the **Category** filter is removed. Other filters, such as: * Brand * Color * Size continue to affect the calculation. This makes column-level filter removal much more targeted than removing filters from an entire table. *** ## How ALL() Changes Filter Context [#how-all-changes-filter-context] Normal report: ```text Report Filter ↓ Category = Bikes ↓ FactSales ↓ $150,000 ``` Using `ALL()`: ```text Report Filter ↓ ALL(DimProduct) ↓ All Products ↓ FactSales ↓ $500,000 ``` The original report filter is ignored, allowing the measure to calculate against the complete dataset. *** ## A Common Use Case [#a-common-use-case] One of the most common applications of `ALL()` is calculating percentages of the grand total. Example: ```dax lineNumbers Percent of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` The numerator respects the current report filters. The denominator removes the product filter and calculates the overall total. This pattern appears in countless Power BI reports and dashboards. *** ## ALLEXCEPT() [#allexcept] `ALLEXCEPT()` removes all filters from a table **except** the columns you specify. General syntax: ```dax lineNumbers ALLEXCEPT( Table, Column1, Column2 ) ``` Example: ```dax lineNumbers Sales by Category = CALCULATE( [Total Sales], ALLEXCEPT( DimProduct, DimProduct[Category] ) ) ``` In this example, every filter on **DimProduct** is removed except **Category**. If the report contains filters for: * Brand * Color * Size those filters are ignored, while the Category filter remains active. *** ## ALLSELECTED() [#allselected] `ALLSELECTED()` removes filters applied inside the visual while preserving filters selected by the user. This makes it especially useful for interactive reports. Example: ```dax lineNumbers Visual Total Sales = CALCULATE( [Total Sales], ALLSELECTED(DimProduct) ) ``` Suppose a report has a slicer selecting: `Category = Bikes` Inside a table visual, each row displays an individual product. `ALLSELECTED()` removes the row-level product filter but keeps the slicer selection. The result is the total sales for **all Bikes**, not just the current product. *** ## REMOVEFILTERS() [#removefilters] `REMOVEFILTERS()` explicitly removes filters from a table or column. General syntax: ```dax lineNumbers REMOVEFILTERS(Table) REMOVEFILTERS(Column) ``` Example: ```dax lineNumbers Overall Sales = CALCULATE( [Total Sales], REMOVEFILTERS(DimProduct) ) ``` This produces the same result as: ```dax lineNumbers CALCULATE( [Total Sales], ALL(DimProduct) ) ``` Many developers prefer `REMOVEFILTERS()` because its intent is immediately clear. *** ## Comparing the Filter Functions [#comparing-the-filter-functions] Although these functions appear similar, they behave differently. | Function | Purpose | | ----------------- | -------------------------------------------------- | | `ALL()` | Removes all filters from a table or column. | | `ALLEXCEPT()` | Removes all filters except selected columns. | | `ALLSELECTED()` | Removes visual filters but keeps user selections. | | `REMOVEFILTERS()` | Explicitly removes filters from a table or column. | Choosing the correct function depends on the type of report you are building and how you want filters to behave. *** ## Choosing the Right Function [#choosing-the-right-function] Use **ALL()** when you need to ignore all filters. Example: ```dax lineNumbers Percent of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` Use **ALLEXCEPT()** when one or more filters should remain active. Use **ALLSELECTED()** when calculations should respect slicers but ignore the current visual's row context. Use **REMOVEFILTERS()** when your goal is simply to remove filters and make the code easier to read. *** ## Real Business Examples [#real-business-examples] These functions appear in many Power BI reports. | Business Requirement | Function | | ---------------------- | ----------------- | | Percent of Grand Total | `ALL()` | | Sales Within Category | `ALLEXCEPT()` | | Visual Totals | `ALLSELECTED()` | | KPI Cards | `REMOVEFILTERS()` | Understanding the differences between these functions is essential for building accurate and interactive reports. *** ## Percent of Total [#percent-of-total] One of the most common uses of filter functions is calculating a percentage of the overall total. Example: ```dax lineNumbers Percent of Total = DIVIDE( [Total Sales], CALCULATE( [Total Sales], ALL(DimProduct) ) ) ``` How it works: 1. The numerator calculates sales for the current filter context. 2. The denominator removes the Product filter. 3. The result is the percentage of overall sales. Example: | Category | Sales | Percent of Total | | ----------- | -------: | ---------------: | | Bikes | $150,000 | 30% | | Clothing | $100,000 | 20% | | Accessories | $250,000 | 50% | *** ## Ranking Products [#ranking-products] Filter functions are commonly used with `RANKX()`. Example: ```dax lineNumbers Product Rank = RANKX( ALL(DimProduct), [Total Sales] ) ``` Using `ALL()` removes the current product filter so every product is ranked against the complete list. Without `ALL()`, every product would rank as **1** because only the current row would be visible. *** ## Common Mistakes [#common-mistakes] The most common mistakes include: * Using `ALL()` when `ALLSELECTED()` is required. * Removing more filters than intended. * Forgetting that `ALL(Table)` removes every filter on the table. * Applying `ALL()` outside of `CALCULATE()`. * Expecting `ALLSELECTED()` to ignore slicers. Understanding which filters remain active is the key to writing correct DAX. *** ## Best Practices [#best-practices] When working with filter functions: * Use `REMOVEFILTERS()` when your intention is simply to remove filters. * Use `ALL()` for grand totals and rankings. * Use `ALLEXCEPT()` when one or more filters should remain. * Use `ALLSELECTED()` for interactive reports that use slicers. * Keep measures simple by combining filter functions with reusable base measures. Filter functions become much easier to understand when each measure has a single purpose. *** ## Performance Considerations [#performance-considerations] Filter functions are generally efficient because they modify filter context rather than looping through rows. However, performance can decrease when they are combined with: * Large iterator functions (`SUMX()`, `FILTER()`) * Complex virtual tables * Multiple nested `CALCULATE()` statements When working with large models, test measures using **Performance Analyzer** to identify expensive calculations. *** ## Summary [#summary] Filter functions control how DAX evaluates filter context. The four most common functions are: | Function | Purpose | | ----------------- | ------------------------------------------------------- | | `ALL()` | Removes all filters from a table or column. | | `ALLEXCEPT()` | Keeps specified filters while removing the rest. | | `ALLSELECTED()` | Keeps user selections but ignores visual-level filters. | | `REMOVEFILTERS()` | Explicitly removes filters for clearer code. | Mastering these functions is essential for building percentages, rankings, KPIs, and advanced analytical measures. *** ## Next Steps [#next-steps] Continue learning advanced DAX functions: * [FILTER](/docs/dax/filter) * [RELATED & RELATEDTABLE](/docs/dax/related) * [LOOKUPVALUE](/docs/dax/lookupvalue) * [SELECTEDVALUE](/docs/dax/selectedvalue) * [KEEPFILTERS()](/docs/dax/keepfilters) * [CROSSFILTER()](/docs/dax/crossfilter) These functions are frequently combined with filter functions to create powerful business calculations. Want a fast side-by-side reference instead? See the [DAX CALCULATE Modifiers Cheat Sheet](/blog/dax-calculate-modifiers-cheat-sheet). # FILTER() (/docs/dax/filter) # FILTER() [#filter] The `FILTER()` function returns a table containing only rows that meet a specified condition. It is one of the most powerful functions in DAX because it allows you to create custom filter logic that goes beyond standard report filters. `FILTER()` is commonly used with: * `CALCULATE()` * Iterator functions (`SUMX()`, `AVERAGEX()`) * Virtual tables * Ranking calculations * Running totals * Advanced business rules Understanding `FILTER()` is a major step toward writing professional DAX. *** ## What Does FILTER() Do? [#what-does-filter-do] The function evaluates each row in a table and keeps only the rows that satisfy a condition. General syntax: ```dax lineNumbers FILTER( Table, Condition ) ``` Example: ```dax lineNumbers FILTER( FactSales, FactSales[SalesAmount] > 1000 ) ``` This returns only sales records where: `SalesAmount > 1000` The result is a filtered table. *** ## How FILTER Works [#how-filter-works] Imagine the following table: | Order | Sales Amount | | ----- | -----------: | | 1001 | 500 | | 1002 | 1500 | | 1003 | 2000 | | 1004 | 750 | Applying: ```dax lineNumbers FILTER( FactSales, FactSales[SalesAmount] > 1000 ) ``` returns: | Order | Sales Amount | | ----- | -----------: | | 1002 | 1500 | | 1003 | 2000 | Rows that fail the condition are removed. *** ## FILTER Returns a Table [#filter-returns-a-table] One of the most important concepts in DAX is that: > FILTER does not return a number. It returns a table. Example: ```dax lineNumbers FILTER( FactSales, FactSales[Quantity] > 10 ) ``` Result: `Filtered FactSales Table` Because FILTER returns a table, it is commonly used inside functions that expect a table argument. *** ## FILTER with CALCULATE() [#filter-with-calculate] The most common use of FILTER is inside `CALCULATE()`. Example: ```dax lineNumbers Large Sales = CALCULATE( [Total Sales], FILTER( FactSales, FactSales[SalesAmount] > 1000 ) ) ``` How it works: 1. FILTER creates a table containing sales greater than $1,000. 2. CALCULATE applies that table as a filter. 3. The measure returns sales for only those records. This pattern appears constantly in business reporting. *** ## Simple Business Example [#simple-business-example] Suppose management wants to analyze high-value orders. Measure: ```dax lineNumbers High Value Sales = CALCULATE( [Total Sales], FILTER( FactSales, FactSales[SalesAmount] >= 5000 ) ) ``` The measure ignores smaller orders and evaluates only transactions above $5,000. This type of calculation would be difficult to achieve using report filters alone. *** ## Why FILTER Is Important [#why-filter-is-important] Many DAX functions apply simple filters automatically. For example: ```dax lineNumbers CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` However, more complex conditions require FILTER. Examples include: * Sales greater than a threshold * Multiple conditions * Dynamic comparisons * Date ranges * Ranking calculations FILTER provides the flexibility needed for these advanced scenarios. *** ## Multiple Conditions [#multiple-conditions] `FILTER()` can evaluate more than one condition at the same time. Example: ```dax lineNumbers Large Bike Sales = CALCULATE( [Total Sales], FILTER( FactSales, FactSales[SalesAmount] > 1000 && FactSales[Category] = "Bikes" ) ) ``` Both conditions must be true. The measure returns only Bike sales where the sales amount exceeds $1,000. *** ## Using AND and OR [#using-and-and-or] Multiple conditions can be combined using logical operators. ### AND (`&&`) [#and-] Every condition must be true. ```dax lineNumbers FILTER( FactSales, FactSales[Quantity] > 10 && FactSales[SalesAmount] > 1000 ) ``` Result: ```text Quantity > 10 AND SalesAmount > 1000 ``` *** ### OR (`||`) [#or-] Only one condition needs to be true. ```dax lineNumbers FILTER( FactSales, FactSales[Category] = "Bikes" || FactSales[Category] = "Accessories" ) ``` Result: ```text Bikes OR Accessories ``` *** ## Filtering Date Ranges [#filtering-date-ranges] `FILTER()` is frequently used with dates. Example: ```dax lineNumbers Sales This Year = CALCULATE( [Total Sales], FILTER( DimDate, DimDate[Year] = 2026 ) ) ``` Only dates from 2026 are included in the calculation. Although Time Intelligence functions are usually preferred for standard calendar calculations, `FILTER()` is useful for custom date logic. *** ## FILTER with ALL() [#filter-with-all] One of the most common patterns combines `FILTER()` with `ALL()`. Example: ```dax lineNumbers Running Sales = CALCULATE( [Total Sales], FILTER( ALL(DimDate), DimDate[Date] <= MAX(DimDate[Date]) ) ) ``` How it works: 1. `ALL()` removes the current date filter. 2. `FILTER()` returns all dates up to the current date. 3. `CALCULATE()` evaluates sales over that date range. This pattern creates a running total. *** ## Dynamic Business Rules [#dynamic-business-rules] Unlike simple filters, `FILTER()` can compare one value to another. Example: ```dax lineNumbers Above Average Sales = CALCULATE( [Total Sales], FILTER( FactSales, FactSales[SalesAmount] > AVERAGE(FactSales[SalesAmount]) ) ) ``` Instead of using a fixed value such as: `SalesAmount > 1000` the threshold changes automatically based on the average sales amount. This allows business rules to adapt as data changes. *** ## Common FILTER Examples [#common-filter-examples] | Requirement | Example | | | | ------------------- | -------------------- | - | -- | | Orders over $1,000 | `SalesAmount > 1000` | | | | Current Year Sales | `Year = 2026` | | | | Bike Sales | `Category = "Bikes"` | | | | Multiple Conditions | `&&` | | | | Either Condition | \` | | \` | | Running Totals | `FILTER(ALL(...))` | | | These patterns appear frequently in real-world Power BI reports and dashboards. *** ## FILTER() vs Simple CALCULATE Filters [#filter-vs-simple-calculate-filters] Not every calculation requires `FILTER()`. For simple filter conditions, use `CALCULATE()` directly. Example: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` This is cleaner and typically performs better than: ```dax lineNumbers Bike Sales = CALCULATE( [Total Sales], FILTER( DimProduct, DimProduct[Category] = "Bikes" ) ) ``` Whenever a simple filter expression is sufficient, prefer the first approach. Reserve `FILTER()` for situations where more advanced logic is required. *** ## When Should You Use FILTER()? [#when-should-you-use-filter] Use `FILTER()` when you need to: * Compare values between rows. * Apply multiple complex conditions. * Build dynamic filters. * Create running totals. * Filter using calculations or measures. * Build virtual tables. If a simple column filter works, `FILTER()` is usually unnecessary. *** ## Performance Considerations [#performance-considerations] `FILTER()` evaluates every row in the specified table. Example: ```text FactSales 250,000 Rows ↓ Evaluate Condition ↓ Return Matching Rows ``` For large tables, this can increase query execution time. To improve performance: * Filter the smallest table possible. * Avoid unnecessary nested `FILTER()` functions. * Use simple `CALCULATE()` filters whenever possible. * Reuse existing measures instead of repeating calculations. Efficient filtering can significantly improve report responsiveness. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common issues: * Using `FILTER()` when a simple `CALCULATE()` filter would work. * Filtering an entire fact table unnecessarily. * Forgetting that `FILTER()` returns a **table**, not a value. * Creating overly complex nested conditions. * Ignoring performance when filtering very large datasets. Choosing the simplest solution usually produces the best results. *** ## Best Practices [#best-practices] When writing DAX with `FILTER()`: * Keep filter conditions easy to read. * Use variables (`VAR`) to simplify complex expressions. * Prefer dimension tables for filtering when possible. * Combine `FILTER()` with `CALCULATE()` for advanced business logic. * Test measures with slicers and different filter combinations. Readable DAX is easier to debug and maintain. *** ## Summary [#summary] The `FILTER()` function creates a table containing only rows that satisfy a specified condition. It is commonly used with: * `CALCULATE()` * Iterator functions * Running totals * Ranking calculations * Dynamic business rules Although `FILTER()` is one of the most powerful DAX functions, it should be used only when simple filter expressions are not sufficient. Learning when **not** to use `FILTER()` is just as important as learning when to use it. *** ## Next Steps [#next-steps] Continue learning additional DAX functions: * [RELATED & RELATEDTABLE](/docs/dax/related) * [LOOKUPVALUE](/docs/dax/lookupvalue) * [SELECTEDVALUE](/docs/dax/selectedvalue) * [SWITCH](/docs/dax/switch) These functions are frequently combined with `FILTER()` to build powerful business calculations. # DAX Function Reference (/docs/dax/functions) # DAX Function Reference [#dax-function-reference] A quick-scan index of the DAX functions most commonly used in Power BI, grouped by category. Where a function has a full explanation elsewhere on this site — syntax, examples, common mistakes — the name links to it. Functions without a dedicated page get a one-line description here, since they're simple enough not to need one. *** ## Aggregation Functions [#aggregation-functions] | Function | Description | | ---------------------------------------- | ---------------------------------------------------------- | | `SUM` | Adds up all values in a column. | | `AVERAGE` | Returns the arithmetic mean of a column. | | `COUNT` | Counts rows where the column contains a number. | | `COUNTA` | Counts rows where the column contains any non-blank value. | | [COUNTROWS](/docs/dax/countrows) | Counts the rows in a table, regardless of column content. | | [DISTINCTCOUNT](/docs/dax/distinctcount) | Counts the distinct values in a column. | | `MIN` / `MAX` | Returns the smallest or largest value in a column. | These accept a single column and evaluate it within the current filter context — no row-by-row iteration involved. See [Measures](/docs/dax/measures) for how they're typically wrapped into named calculations. *** ## Filter & Context Functions [#filter--context-functions] | Function | Description | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | [CALCULATE](/docs/dax/calculate) | Evaluates an expression in a modified filter context — the most-used function in DAX. | | [FILTER](/docs/dax/filter) | Returns a table containing only rows that meet a condition. | | [ALL, ALLEXCEPT, ALLSELECTED & REMOVEFILTERS](/docs/dax/filter-functions) | Remove or partially restore filters — the basis of percent-of-total and grand-total patterns. | | [KEEPFILTERS](/docs/dax/keepfilters) | ANDs a filter with an existing one on the same column instead of CALCULATE's default of replacing it. | | [CROSSFILTER](/docs/dax/crossfilter) | Changes a relationship's cross-filter direction or disables it, for a single calculation. | | [EARLIER](/docs/dax/earlier) | Reaches back to an outer row context from inside a nested one — mostly replaced by `VAR` in modern DAX. | See [Filter Context](/docs/dax/filter-context) for how these functions interact with what's currently filtering a calculation. *** ## Iterator (X) Functions [#iterator-x-functions] | Function | Description | | ------------------------ | --------------------------------------------------------------------------------- | | [SUMX](/docs/dax/sumx) | Evaluates an expression per row, then sums the results. | | `AVERAGEX` | Evaluates an expression per row, then averages the results. | | `COUNTX` / `COUNTAX` | Evaluates an expression per row, then counts non-blank results. | | `MAXX` / `MINX` | Evaluates an expression per row, then returns the largest or smallest result. | | [RANKX](/docs/dax/rankx) | Ranks a value against every other value produced by an expression across a table. | All of these evaluate their expression once per row of the given table — see [Iterators](/docs/dax/iterator) for how row-by-row evaluation actually works, and [Performance Optimization](/docs/dax/performance) for when iterators get expensive. *** ## Time Intelligence Functions [#time-intelligence-functions] | Function | Description | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `TOTALYTD` / `TOTALQTD` / `TOTALMTD` | Running total from the start of the year, quarter, or month. | | `SAMEPERIODLASTYEAR` | The same date range, shifted back exactly one year. | | [DATEADD](/docs/dax/dateadd-parallelperiod) | Shifts a date range by a given number of years, quarters, months, or days — preserving the exact shape of the current selection. | | [PARALLELPERIOD](/docs/dax/dateadd-parallelperiod) | Like `DATEADD`, but always snaps the result out to the entire shifted period, regardless of the current selection's shape. | | `FIRSTDATE` / `LASTDATE` | The earliest or latest date in the current filter context. | | `STARTOFMONTH` / `ENDOFMONTH` | The first or last date of the month containing the current context. | | `STARTOFYEAR` / `ENDOFYEAR` | The first or last date of the year containing the current context. | See [Time Intelligence](/docs/dax/time-intelligence) for the full explanation, or the [DAX Time Intelligence Cheat Sheet](/blog/dax-time-intelligence-cheat-sheet) for a fast, scannable version of this same table with examples. *** ## Relationship Functions [#relationship-functions] | Function | Description | | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [RELATED](/docs/dax/related) | Pulls a single value across a relationship, from the "one" side. | | [RELATEDTABLE](/docs/dax/related) | Pulls every related row across a relationship, from the "one" side, as a table. | | [LOOKUPVALUE](/docs/dax/lookupvalue) | Retrieves a value from another table by matching columns — works without an existing relationship. | | [USERELATIONSHIP](/docs/dax/userelationship) | Activates a specific inactive relationship for one calculation. | *** ## Table Functions [#table-functions] | Function | Description | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | [SUMMARIZE](/docs/dax/summarize) | Groups a table by columns, optionally computing an aggregation per group. | | [ADDCOLUMNS](/docs/dax/addcolumns-selectcolumns) | Adds computed columns to a table, evaluated per row — keeps every original column too. | | [SELECTCOLUMNS](/docs/dax/addcolumns-selectcolumns#selectcolumns-only-whats-listed) | Returns a table with only specific columns, optionally renamed — drops everything else entirely. | | [VALUES](/docs/dax/values-distinct) | Returns the distinct values of a column — adds an extra blank row for unmatched fact rows if a relationship's referential integrity is violated. | | [DISTINCT](/docs/dax/values-distinct#the-referential-integrity-blank-row) | Returns the distinct values of a column, without VALUES' referential-integrity blank row. | | [TOPN](/docs/dax/topn) | Returns the top (or bottom) N rows of a table by a given expression. | | [UNION](/docs/dax/union) | Stacks two or more tables into one — matches columns by position, not by name. | | [EXCEPT / INTERSECT](/docs/dax/union#except-and-intersect-share-the-same-rule) | Returns rows in one table but not another, or rows common to both — same positional column matching as UNION. | *** ## Logical Functions [#logical-functions] | Function | Description | | ---------------------------------------- | ------------------------------------------------------------------------------------------------ | | [IF](/docs/dax/if) | Returns one of two results based on whether a condition is true. | | [SWITCH](/docs/dax/switch) | Evaluates an expression against multiple possible values — a flatter alternative to nested `IF`. | | `AND` / `OR` / `NOT` | Combine or invert logical conditions. (`&&` and `\|\|` work the same as `AND`/`OR` inline.) | | [IFERROR](/docs/dax/iferror) | Returns a fallback value if an expression errors — doesn't catch or replace a BLANK() result. | | [SELECTEDVALUE](/docs/dax/selectedvalue) | Returns a column's value when exactly one value is selected, and a fallback otherwise. | *** ## Text Functions [#text-functions] | Function | Description | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `CONCATENATE` | Joins two text values. (The `&` operator does the same thing, and is more common in practice.) | | [UPPER / LOWER](/docs/dax/trim-upper-lower) | Converts text to all uppercase or lowercase. | | [LEFT / RIGHT / MID](/docs/dax/mid-left-right) | Extracts a substring from the start, end, or middle of a text value — MID counts positions from 1, unlike Power Query's Text.Middle. | | [TRIM](/docs/dax/trim-upper-lower#trim-also-collapses-internal-spaces) | Removes leading and trailing spaces — and collapses internal double spaces too, unlike Power Query's Text.Trim. | | `LEN` | Returns the number of characters in a text value. | | `FORMAT` | Converts a number or date to text, using a specified format. | *** ## Date & Time Functions [#date--time-functions] | Function | Description | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `DATE` | Constructs a date from year, month, and day values. | | [TODAY / NOW](/docs/dax/today-now) | Returns the current date, or current date and time — frozen at refresh time in a calculated column, live in a measure. | | `YEAR` / `MONTH` / `DAY` | Extracts the year, month, or day from a date. | | [CALENDAR](/docs/dax/calendar-calendarauto) | Generates a continuous table of dates between a start and end date. | | [CALENDARAUTO](/docs/dax/calendar-calendarauto) | Generates a continuous date table automatically, spanning every date column in the *entire model* — not just one table. | | [DATEDIFF](/docs/dax/datediff) | Returns the difference between two dates, in a specified unit — counts calendar boundaries crossed, not full elapsed periods. | See [Date Tables](/docs/modeling/date-tables) for how `CALENDAR`/`CALENDARAUTO` fit into building a proper date table. *** ## Information Functions [#information-functions] | Function | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | [ISBLANK](/docs/dax/blank-vs-zero) | Returns true if a value is blank — not the same as testing `= 0`, since BLANK() = 0 is also true. | | `ISERROR` | Returns true if an expression would produce an error. | | `HASONEVALUE` | Returns true if exactly one value is visible in the current filter context for a column. | | [DIVIDE](/docs/dax/divide) | Divides two numbers, returning blank (or a specified fallback) instead of an error on division by zero. | `DIVIDE` in particular should be the default over the `/` operator in report-facing measures — see [Best Practices](/docs/dax/performance) for where this comes up. *** ## Beyond Built-In Functions [#beyond-built-in-functions] Every function above is built into DAX. As of the June 2026 release, DAX also supports **user-defined functions** — package a calculation once with the `FUNCTION` keyword, and reuse it across measures, calculated columns, and visual calculations like any built-in function. See [DAX User-Defined Functions (UDFs)](/docs/dax/user-defined-functions) for the syntax and the `val`/`expr` distinction that controls how a UDF's parameters get evaluated. *** ## How to Use This Reference [#how-to-use-this-reference] * Linked functions have a full page: syntax, worked examples, common mistakes, and best practices. * Unlinked functions are simple enough that a one-line description is genuinely sufficient — Microsoft's own [DAX function reference](https://learn.microsoft.com/en-us/dax/dax-function-reference) covers full parameter lists for anything not detailed here. * Start with [Introduction](/docs/dax/introduction) and [Basics](/docs/dax/basics) if any of this terminology (filter context, row context, iterator) isn't yet familiar — the function list assumes those concepts. *** ## Next Steps [#next-steps] Continue learning DAX: * [CALCULATE](/docs/dax/calculate) * [Filter Context](/docs/dax/filter-context) * [Iterators](/docs/dax/iterator) * [Time Intelligence](/docs/dax/time-intelligence) # IF() (/docs/dax/if) # IF() [#if] `IF()` evaluates a condition and returns one of two results depending on whether it's true or false — the most basic branching function in DAX. ```dax lineNumbers IF(LogicalTest, ResultIfTrue, [ResultIfFalse]) ``` Try clearing "Result if false" — that's the optional-third-argument behavior covered further down. *** ## Basic Example [#basic-example] ```dax lineNumbers Sales Status = IF([Total Sales] > 100000, "On Target", "Below Target") ``` ```text Total Sales = 150,000 -> "On Target" Total Sales = 80,000 -> "Below Target" ``` *** ## The Third Argument Is Optional [#the-third-argument-is-optional] Omitting `ResultIfFalse` returns `BLANK()` when the condition isn't met, rather than an explicit value. ```dax lineNumbers High Value Flag = IF([Total Sales] > 100000, "High Value") ``` ```text Total Sales = 150,000 -> "High Value" Total Sales = 80,000 -> BLANK ``` This is a reasonable choice when "not flagged" should genuinely look empty in a table, rather than showing a literal "No" or "False" in every other row. *** ## Nesting IF, and When to Stop [#nesting-if-and-when-to-stop] `IF()` can nest inside itself for more than two outcomes, but it gets hard to read fast. ```dax lineNumbers Sales Tier = IF( [Total Sales] > 500000, "Platinum", IF( [Total Sales] > 100000, "Gold", "Silver" ) ) ``` ```text Total Sales = 600,000 -> "Platinum" Total Sales = 200,000 -> "Gold" Total Sales = 50,000 -> "Silver" ``` This works, but each additional tier adds another level of nesting. See [SWITCH](/docs/dax/switch#switch-vs-nested-if) for the point where `SWITCH(TRUE(), ...)` becomes the more readable choice — as a rough rule, more than two or three conditions is usually that point. *** ## IF vs. SWITCH [#if-vs-switch] | | Best for | | ---------- | ------------------------------------------------------------ | | `IF()` | A single, genuinely binary condition | | `SWITCH()` | Three or more discrete outcomes, or several range conditions | Reaching for `SWITCH()` on a true yes/no condition is the opposite mistake — a single `IF()` is more direct than a `SWITCH()` with one real case and a default. *** ## A Common Trap: IF Inside an Aggregation [#a-common-trap-if-inside-an-aggregation] ```dax lineNumbers Total High Value Sales = SUMX( FactSales, IF(FactSales[SalesAmount] > 1000, FactSales[SalesAmount], 0) ) ``` This is a correct and common pattern — `IF()` used *inside* an iterator to conditionally include each row's value. It's a different thing from using `IF()` to branch an entire measure's logic, and it's worth recognizing the two shapes are solving different problems. *** ## Common Mistakes [#common-mistakes] ### Nesting Past the Point of Readability [#nesting-past-the-point-of-readability] More than two or three nested `IF()` calls is a strong signal to switch to `SWITCH(TRUE(), ...)` instead — see [SWITCH](/docs/dax/switch) for the equivalent pattern. ### Comparing to BLANK() Incorrectly [#comparing-to-blank-incorrectly] ```dax lineNumbers Status = IF([Total Sales] = BLANK(), "No Sales", "Has Sales") ``` This works but `ISBLANK([Total Sales])` is clearer and is the idiomatic way to test for blank specifically, rather than an equality comparison against `BLANK()`. ### Returning Inconsistent Types [#returning-inconsistent-types] ```dax lineNumbers Result = IF([Total Sales] > 0, [Total Sales], "N/A") ``` Returning a number in one branch and text in the other forces an implicit type conversion that can produce unexpected formatting — keep both branches the same data type. *** ## Best Practices [#best-practices] * Keep `IF()` to genuinely binary conditions; move to `SWITCH()` once there are more than two or three outcomes. * Use `ISBLANK()` to test for blank specifically, rather than comparing to `BLANK()` with `=`. * Return the same data type from both branches to avoid implicit conversion surprises. *** ## Next Steps [#next-steps] * [SWITCH](/docs/dax/switch) * [Iterators](/docs/dax/iterator) * [Filter Context](/docs/dax/filter-context) Getting a "comparison operations do not support" error from an IF with mismatched branch types? See [DAX Comparison Operations Do Not Support Comparing Values of Type Text With Values of Type Boolean](/blog/dax-comparison-operations-do-not-support-error). # IFERROR() (/docs/dax/iferror) # IFERROR() [#iferror] `IFERROR()` catches a genuine DAX **error** and substitutes a fallback value. It does not catch, replace, or otherwise notice a `BLANK()` result — blank is a valid value, not an error. ```dax lineNumbers IFERROR(value, value_if_error) ``` *** ## Catching a Real Error [#catching-a-real-error] ```dax lineNumbers IFERROR([Total Sales] / 0, "N/A") ``` Dividing by the literal `0` with the raw `/` operator produces a genuine DAX error — without `IFERROR()`, that error can break the entire visual. Wrapped in `IFERROR()`, the error is caught and `"N/A"` is returned instead. *** ## Not Catching a Blank [#not-catching-a-blank] ```dax lineNumbers IFERROR(SUM(Sales[Amount]), "N/A") ``` If the current filter context has zero matching rows, `SUM()` returns `BLANK()` — not an error. `IFERROR()` only intercepts genuine errors, so this passes the `BLANK()` straight through unchanged; the `"N/A"` fallback is never used, even though the result looks just as "empty" to a viewer as an error would. ```text [Total Sales] / 0 -> Error: division by zero IFERROR([Total Sales] / 0, "N/A") -> "N/A" (caught) SUM(Sales[Amount]) with 0 matching rows -> BLANK() IFERROR(SUM(Sales[Amount]), "N/A") -> BLANK() (not caught -- blank isn't an error) ``` *** ## Common Mistakes [#common-mistakes] ### Expecting IFERROR to Also Convert Blanks to a Fallback [#expecting-iferror-to-also-convert-blanks-to-a-fallback] `IFERROR(measure, 0)` does **not** turn a blank result into `0` — only an actual error becomes `0` this way; a blank result stays blank. Converting blank to a specific fallback value needs an explicit check instead, such as `IF(ISBLANK(measure), 0, measure)`, or DAX's `COALESCE()` function (which returns the first non-blank value in a list, built specifically for this). ### Wrapping DIVIDE() in IFERROR [#wrapping-divide-in-iferror] ```dax lineNumbers IFERROR(DIVIDE([Total Profit], [Total Sales]), 0) ``` `DIVIDE()` already returns `BLANK()` (or an explicit alternate result, if supplied as its third argument) instead of erroring on division by zero — it never produces the kind of error `IFERROR()` exists to catch. Wrapping it in `IFERROR()` adds nothing but extra evaluation cost; see [DIVIDE()](/docs/dax/divide#wrapping-divide-in-a-redundant-blank-check) for the same redundant pattern already covered there. ### Using IFERROR to Mask a Genuine Formula Bug [#using-iferror-to-mask-a-genuine-formula-bug] Catching every error with a generic fallback can hide a real modeling or formula mistake that's worth actually seeing and fixing, rather than silently papering over it with `"N/A"` everywhere. *** ## Best Practices [#best-practices] * Reach for `IFERROR()` specifically for expressions that can genuinely error (a raw `/` operator, a type-mismatch-prone calculation) — not as a catch-all for anything that might look empty. * Use `DIVIDE()` instead of `/` to prevent a division error in the first place, rather than catching it after the fact. * Use `ISBLANK()` or `COALESCE()` when the actual goal is converting a blank result to a specific fallback — `IFERROR()` doesn't do this. *** ## Next Steps [#next-steps] * [DIVIDE()](/docs/dax/divide) * [BLANK() vs 0: ISBLANK() and the Comparison Trap](/docs/dax/blank-vs-zero) * [DAX Function Reference](/docs/dax/functions) # DAX (/docs/dax) # DAX [#dax] DAX (Data Analysis Expressions) is the formula language behind every measure and calculated column in Power BI — this section covers it from first principles through the specific functions you'll reach for most. ## Start Here [#start-here] ## Where to Go Next [#where-to-go-next] * [DAX Patterns](/docs/dax-patterns/totals) — ready-made patterns for totals, ranking, and percent of total. * [Data Modeling](/docs/modeling/introduction) — the relationships and schema DAX measures depend on. * [DAX User-Defined Functions (UDFs)](/docs/dax/user-defined-functions) — new as of the June 2026 release, for packaging a calculation into a reusable, named function. Want a starting point instead of writing one from scratch? Try the [DAX Formula Builder](/tools/dax-formula-builder) — describe the calculation in plain English and get back a measure to check against what you learn here. # Introduction (/docs/dax/introduction) # DAX Introduction [#dax-introduction] Data Analysis Expressions (DAX) is the formula language used in Power BI to create calculations, measures, and analytical logic. DAX allows developers to transform business requirements into dynamic calculations that respond to report filters and user interactions. Common uses of DAX include: * Creating measures * Performing calculations * Analyzing trends over time * Comparing results * Building business metrics ## What Is DAX? [#what-is-dax] DAX is similar to Excel formulas but designed for relational data models. While Excel formulas typically work with individual cells, DAX works with: * Tables * Columns * Relationships * Filter context Example: ```dax lineNumbers Total Sales = SUM(Sales[SalesAmount]) ``` A measure calculates a result dynamically based on the current filter context. For example: ```text No Filter | v All Sales Records | v Total Sales = $500,000 Filter: Category = Bikes | v Filtered Sales Records | v Total Sales = $150,000 ``` The same measure returns different results depending on the filters applied in a report. *** ## Measures vs Calculated Columns [#measures-vs-calculated-columns] Power BI calculations are mainly created using: * Measures * Calculated Columns * Calculated Tables ## Measures [#measures] Measures are dynamic calculations evaluated when a report is viewed. Example: ```dax lineNumbers Total Quantity = SUM(Sales[Quantity]) ``` Measures: * Respond to filters and slicers * Do not store results in the model * Calculate when needed * Are commonly used in visuals Example: | Filter | Result | | ------------ | -------: | | All Products | $500,000 | | Bikes | $150,000 | | 2026 | $220,000 | The formula remains the same. The filter context changes. *** ## Calculated Columns [#calculated-columns] Calculated columns create values stored inside a table. Example: ```dax lineNumbers Sales Amount = Sales[Quantity] * Sales[Unit Price] ``` Calculated columns: * Evaluate row by row * Store results during refresh * Increase model size * Are useful for grouping and categories Example: | Quantity | Unit Price | Sales Amount | | -------- | ---------: | -----------: | | 5 | $50 | $250 | | 3 | $75 | $225 | *** ## DAX and the Data Model [#dax-and-the-data-model] DAX is most effective when built on a well-designed Power BI data model. A typical model looks like this: ```text DimProduct | | DimCustomer--FactSales--DimDate | | DimStore ``` Relationships allow filters to flow from dimension tables to the fact table. When a user selects: * A product * A customer * A year * A region Power BI automatically filters the fact table before evaluating the DAX measure. *** ## Understanding Filter Context [#understanding-filter-context] Filter context is one of the most important concepts in DAX. A measure does not calculate a single fixed value. Instead, it evaluates only the rows currently visible after filters have been applied. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The measure can produce different results depending on the report context. | Report Filter | Result | | ---------------- | -------: | | All Products | $500,000 | | Category = Bikes | $150,000 | | Year = 2026 | $220,000 | | Region = North | $98,000 | The formula never changes. Only the filter context changes. *** ## Common DAX Functions [#common-dax-functions] DAX contains hundreds of functions, but a small group is used in most reports. | Function | Purpose | | ----------------- | ------------------------------------ | | `SUM()` | Adds values | | `AVERAGE()` | Calculates averages | | `COUNTROWS()` | Counts table rows | | `DISTINCTCOUNT()` | Counts unique values | | `IF()` | Performs logical tests | | `CALCULATE()` | Changes filter context | | `FILTER()` | Returns filtered tables | | `RELATED()` | Retrieves values from related tables | As you progress through this documentation, you'll learn each of these functions in detail. *** ## Best Practices [#best-practices] When writing DAX: * Build reusable base measures. * Use meaningful measure names. * Keep calculations simple whenever possible. * Prefer measures over calculated columns for reporting. * Organize measures into display folders. * Test calculations using different report filters. Good DAX is usually simple, readable, and reusable. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common problems: * Writing one very large measure instead of several reusable measures. * Creating calculated columns when a measure is sufficient. * Ignoring filter context. * Using duplicate calculations throughout the model. * Giving measures unclear names. Small, focused measures are easier to maintain and troubleshoot. *** ## Summary [#summary] DAX is the analytical engine behind Power BI. It allows you to: * Create business metrics * Build KPIs * Analyze trends * Compare time periods * Respond dynamically to report filters Combined with a strong data model, DAX enables interactive reports that answer complex business questions with simple, reusable calculations. *** ## Next Steps [#next-steps] Continue your DAX journey: * [DAX Function Reference](/docs/dax/functions) * [DAX Basics](/docs/dax/basics) * [Measures](/docs/dax/measures) * [Calculated Columns](/docs/dax/calculated-columns) * [Filter Context](/docs/dax/filter-context) * [CALCULATE Function](/docs/dax/calculate) * [Time Intelligence](/docs/dax/time-intelligence) # Iterator Functions (X Functions) (/docs/dax/iterator) # Iterator Functions (X Functions) [#iterator-functions-x-functions] Iterator functions evaluate an expression **one row at a time** before returning a final result. Unlike simple aggregation functions such as `SUM()` or `AVERAGE()`, iterator functions perform a calculation for every row in a table. Because they evaluate rows individually, iterator functions automatically create **row context**. Common iterator functions include: * `SUMX()` * `AVERAGEX()` * `COUNTX()` * `MINX()` * `MAXX()` * `RANKX()` Iterator functions are among the most powerful tools available in DAX. *** ## What Is an Iterator? [#what-is-an-iterator] An iterator processes one row at a time. General syntax: ```dax lineNumbers SUMX( Table, Expression ) ``` Unlike `SUM()`, which simply adds an existing column, `SUMX()` first evaluates an expression for every row. After every row has been calculated, the results are added together. *** ## SUM vs SUMX [#sum-vs-sumx] Consider this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` This adds the values already stored in the **SalesAmount** column. Now compare it to: ```dax lineNumbers Total Sales = SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` Instead of summing an existing column, `SUMX()` calculates: `Quantity × Unit Price` for every row before calculating the final total. *** ## How SUMX Works [#how-sumx-works] Power BI evaluates each row individually. ```text Row 1 Quantity × Unit Price ↓ Row 2 Quantity × Unit Price ↓ Row 3 Quantity × Unit Price ↓ ... ↓ Add Every Result ↓ Final Total ``` This row-by-row evaluation makes iterator functions extremely flexible. *** ## Example Data [#example-data] Suppose **FactSales** contains: | Product | Quantity | Unit Price | | ------- | -------: | ---------: | | Tire A | 5 | 50 | | Tire B | 3 | 75 | | Tire C | 8 | 40 | Using: ```dax lineNumbers SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` Power BI calculates: | Product | Calculation | | ------- | ----------: | | Tire A | 250 | | Tire B | 225 | | Tire C | 320 | Final result: `250 + 225 + 320 = 795` No **SalesAmount** column is required. The calculation is performed dynamically. *** ## Why Use Iterator Functions? [#why-use-iterator-functions] Iterator functions allow calculations that simple aggregation functions cannot perform. Typical uses include: * Revenue calculations * Weighted averages * Profit calculations * Ranking * Dynamic scoring * Complex business rules Whenever every row requires its own calculation, an iterator is usually the correct choice. *** ## AVERAGEX() [#averagex] `AVERAGEX()` evaluates an expression for every row and then returns the average. General syntax: ```dax lineNumbers AVERAGEX( Table, Expression ) ``` Example: ```dax lineNumbers Average Revenue Per Sale = AVERAGEX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` Instead of averaging an existing column, `AVERAGEX()` first calculates revenue for each row and then averages the results. *** ## COUNTX() [#countx] `COUNTX()` evaluates an expression for every row and counts the non-blank results. Example: ```dax lineNumbers Orders With Sales = COUNTX( FactSales, FactSales[SalesAmount] ) ``` This returns the number of rows that contain a sales value. *** ## MINX() and MAXX() [#minx-and-maxx] `MINX()` and `MAXX()` evaluate an expression for each row before returning the smallest or largest value. Example: ```dax lineNumbers Highest Order Value = MAXX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` Example: ```dax lineNumbers Lowest Order Value = MINX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` These functions are useful when the value being compared is calculated rather than stored. *** ## RANKX() [#rankx] `RANKX()` ranks rows based on an expression. Example: ```dax lineNumbers Product Rank = RANKX( ALL(DimProduct), [Total Sales] ) ``` This measure ranks every product based on total sales. Example output: | Product | Sales | Rank | | ------- | -------: | ---: | | Tire A | $450,000 | 1 | | Tire B | $325,000 | 2 | | Tire C | $210,000 | 3 | `RANKX()` is commonly used for Top N reports and leaderboards. *** ## Combining FILTER() with SUMX() [#combining-filter-with-sumx] Iterator functions are often paired with `FILTER()` to evaluate only selected rows. Example: ```dax lineNumbers Large Order Sales = SUMX( FILTER( FactSales, FactSales[SalesAmount] > 1000 ), FactSales[SalesAmount] ) ``` Evaluation process: ```text FactSales ↓ FILTER() ↓ Only Orders > $1,000 ↓ SUMX() ↓ Total Sales ``` This pattern is extremely common in advanced DAX. *** ## Real Business Examples [#real-business-examples] Iterator functions solve many practical business problems. | Business Question | Function | | ---------------------- | ------------ | | Total Revenue | `SUMX()` | | Average Order Value | `AVERAGEX()` | | Number of Valid Orders | `COUNTX()` | | Largest Sale | `MAXX()` | | Smallest Sale | `MINX()` | | Top-Selling Product | `RANKX()` | Many financial and operational reports rely on iterator functions because they calculate values dynamically rather than relying on stored columns. *** ## Why Iterator Functions Are Powerful [#why-iterator-functions-are-powerful] Simple aggregation functions operate on existing values. Iterator functions evaluate expressions. Compare these examples: ```dax lineNumbers SUM( FactSales[SalesAmount] ) ``` versus ```dax lineNumbers SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` The second measure performs a calculation for every row before producing the final result. This flexibility is what makes iterator functions some of the most powerful tools in DAX. *** ## Performance Considerations [#performance-considerations] Iterator functions are extremely powerful, but they perform more work than simple aggregation functions. Unlike `SUM()`, which simply totals an existing column, functions such as `SUMX()` evaluate an expression for every row before returning a result. Example: ```text FactSales 100,000 Rows ↓ Evaluate Expression ↓ 100,000 Calculations ↓ Return Total ``` For large tables, this additional processing can increase query execution time. Whenever possible, choose the simplest function that satisfies the business requirement. *** ## SUM() vs SUMX() [#sum-vs-sumx-1] A common question is when to use `SUM()` instead of `SUMX()`. Use `SUM()` when the values already exist in a column. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Use `SUMX()` when each row requires a calculation. Example: ```dax lineNumbers Total Sales = SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` ### Decision Guide [#decision-guide] | If you need to... | Use | | ------------------------ | ------------ | | Add an existing column | `SUM()` | | Calculate each row first | `SUMX()` | | Average an expression | `AVERAGEX()` | | Rank rows | `RANKX()` | | Count calculated values | `COUNTX()` | As a rule of thumb: > **If the calculation already exists in a column, use a standard aggregation function.** > **If every row must be evaluated first, use an iterator function.** *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common issues: * Using `SUMX()` when `SUM()` would produce the same result. * Creating calculated columns instead of using iterator functions. * Forgetting that iterator functions create row context. * Performing expensive calculations on very large tables unnecessarily. * Nesting multiple iterator functions without understanding the performance impact. Iterator functions are powerful, but they should be used intentionally. *** ## Best Practices [#best-practices] When working with iterator functions: * Use iterator functions only when row-by-row calculations are required. * Create reusable base measures whenever possible. * Use variables (`VAR`) to simplify complex expressions. * Filter data before iterating whenever practical. * Test performance when working with large fact tables. Keeping calculations simple improves both readability and report performance. *** ## Summary [#summary] Iterator functions evaluate expressions one row at a time before returning a final result. Common iterator functions include: * `SUMX()` * `AVERAGEX()` * `COUNTX()` * `MINX()` * `MAXX()` * `RANKX()` Because they create **row context**, iterator functions can perform calculations that standard aggregation functions cannot. Understanding when to use iterator functions is an essential skill for writing advanced DAX. *** ## Next Steps [#next-steps] Continue learning DAX with one of the most widely used feature sets in Power BI: * [Time Intelligence](/docs/dax/time-intelligence) * [SUMX](/docs/dax/sumx) — a deeper look at the most commonly used iterator, including when SUM is actually the better choice. After mastering Time Intelligence, you'll be ready to build running totals, year-over-year comparisons, rolling averages, and many other advanced business calculations. # KEEPFILTERS() (/docs/dax/keepfilters) # KEEPFILTERS() [#keepfilters] By default, `CALCULATE()`'s own filter arguments **replace** any existing filter on the same column — `KEEPFILTERS()` changes that to **AND** with it instead. ```dax lineNumbers CALCULATE(, KEEPFILTERS()) ``` *** ## The Default: CALCULATE Replaces, It Doesn't Add [#the-default-calculate-replaces-it-doesnt-add] ```dax lineNumbers CALCULATE(SUM(Sales[Amount]), Sales[Category] = "Furniture") ``` If the surrounding visual is already filtered to `Category = "Electronics"`, this measure still returns the **Furniture** total — `CALCULATE()`'s own condition on `Category` overwrites the existing `Electronics` filter on that same column entirely, rather than combining with it. This is the documented, correct default behavior of `CALCULATE()` — not a bug — but it surprises people who expect filter arguments to narrow down further from whatever's already selected, the way an additional slicer would. *** ## KEEPFILTERS(): AND Instead of Replace [#keepfilters-and-instead-of-replace] ```dax lineNumbers CALCULATE(SUM(Sales[Amount]), KEEPFILTERS(Sales[Category] = "Furniture")) ``` Wrapping the condition in `KEEPFILTERS()` makes it combine with the existing filter using `AND`, instead of overwriting it. With the visual still filtered to `Electronics`, this now asks for rows that are **both** `Electronics` and `Furniture` at once — impossible for a single-valued `Category` column — so the result is `0` (technically `BLANK()`, since no rows satisfy both conditions), not the plain Furniture total. ```text Visual filtered to: Category = Electronics CALCULATE(..., Category = "Furniture") -> 300 (Furniture's own total; replaced Electronics) CALCULATE(..., KEEPFILTERS(Category = "Furniture")) -> 0 (Electronics AND Furniture -> no matching rows) ``` *** ## They Only Diverge When the Filters Actually Disagree [#they-only-diverge-when-the-filters-actually-disagree] If the outer filter and `CALCULATE()`'s own condition already happen to match the same value, both versions return the identical result — `KEEPFILTERS()` only changes anything once the two conditions conflict. ```text Visual filtered to: Category = Furniture CALCULATE(..., Category = "Furniture") -> 300 (same value either way) CALCULATE(..., KEEPFILTERS(Category = "Furniture")) -> 300 (Furniture AND Furniture -> still Furniture) ``` This is exactly why the behavior can go unnoticed for a long time — a measure written and tested against a report where the filters happen to line up won't show any difference at all, until a viewer picks a different slicer value. *** ## Common Mistakes [#common-mistakes] ### Assuming CALCULATE()'s Filter Always Narrows Down Further [#assuming-calculates-filter-always-narrows-down-further] The intuitive mental model — "this just adds another restriction on top of whatever's already selected" — is exactly backwards for the same-column case. `CALCULATE()`'s own condition on a column always wins outright unless `KEEPFILTERS()` says otherwise. ### Adding KEEPFILTERS() as a Reflexive Habit [#adding-keepfilters-as-a-reflexive-habit] `KEEPFILTERS()` is specifically for the case where the outer and inner filters genuinely need to combine (AND) rather than one overriding the other. Adding it everywhere "just in case" can silently change measures that were correctly relying on the replace behavior — for instance, a measure meant to show one specific category's total *regardless* of what's currently filtered would break under `KEEPFILTERS()`, since it would then return blank for every other filter selection instead of the intended fixed total. ### Not Testing Against a Filter That Actually Conflicts [#not-testing-against-a-filter-that-actually-conflicts] Since the two versions agree whenever the outer and inner filters already match, a measure only gets tested meaningfully by trying a *different* outer filter value than the one hardcoded inside `CALCULATE()` — testing against the same value proves nothing about which behavior is actually in effect. *** ## Best Practices [#best-practices] * Reach for `KEEPFILTERS()` specifically when a filter argument needs to narrow down within whatever's already selected, not replace it. * Test a `KEEPFILTERS()` measure against a report state where the outer filter genuinely disagrees with the inner condition — that's the only state where the two versions produce different, checkable results. * Comment on any `CALCULATE()` filter argument on a column the report also lets users slice by, noting explicitly whether it's meant to replace or combine with that selection. *** ## Next Steps [#next-steps] * [CROSSFILTER()](/docs/dax/crossfilter) * [CALCULATE](/docs/dax/calculate) * [Filter Context](/docs/dax/filter-context) * [ALL, ALLEXCEPT, ALLSELECTED & REMOVEFILTERS](/docs/dax/filter-functions) * [DAX Function Reference](/docs/dax/functions) # LOOKUPVALUE() (/docs/dax/lookupvalue) # LOOKUPVALUE() [#lookupvalue] `LOOKUPVALUE()` retrieves a value from another table by matching one or more columns — similar to `RELATED()`, but it works even when no relationship connects the two tables. ```dax lineNumbers LOOKUPVALUE( Result Column, Search Column, Search Value, [Search Column 2, Search Value 2], ... ) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Product Category = LOOKUPVALUE( DimProduct[Category], DimProduct[ProductKey], FactSales[ProductKey] ) ``` ```text DimProduct FactSales ProductKey | Category ProductKey 1001 | Bikes <-- 1001 -> looks up Category = "Bikes" ``` This returns the same result as `RELATED(DimProduct[Category])` would, if a relationship existed — the difference is `LOOKUPVALUE()` doesn't need one. *** ## When a Relationship Doesn't Exist [#when-a-relationship-doesnt-exist] `LOOKUPVALUE()`'s main use case is exactly this: pulling a value from a table that isn't (and sometimes shouldn't be) related in the model. ```text DimExchangeRates FactSales Currency | Rate Currency | Amount USD | 1.00 <-- EUR -> looks up Rate for "EUR" EUR | 1.08 ``` Building an actual relationship for a small reference table like exchange rates is often unnecessary — `LOOKUPVALUE()` reads it directly instead. *** ## Matching on Multiple Columns [#matching-on-multiple-columns] More than one search column/value pair can be supplied, all of which must match for a row to qualify. ```dax lineNumbers Price = LOOKUPVALUE( PriceList[Price], PriceList[ProductKey], FactSales[ProductKey], PriceList[Region], FactSales[Region] ) ``` Only a row where both `ProductKey` and `Region` match returns a result — this is how `LOOKUPVALUE()` handles a composite key without a corresponding composite relationship in the model. *** ## What Happens With No Match, or Multiple Matches [#what-happens-with-no-match-or-multiple-matches] ```text No matching row -> returns BLANK (or a specified default, see below) Exactly one match -> returns that value More than one match -> returns an error, unless every matching row has the same value ``` An optional final argument sets what to return instead of blank when no match is found: ```dax lineNumbers Price = LOOKUPVALUE( PriceList[Price], PriceList[ProductKey], FactSales[ProductKey], 0 ) ``` This returns `0` instead of blank for any product missing from `PriceList`. *** ## LOOKUPVALUE vs. RELATED [#lookupvalue-vs-related] | | Requires a Relationship | Typical Use | | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | | `RELATED()` | Yes | Pulling a value across an existing, modeled relationship | | `LOOKUPVALUE()` | No | Pulling a value from a table intentionally left unrelated, or matched on columns a relationship can't represent | If a relationship already exists and could be used, `RELATED()` is simpler and typically performs better — reach for `LOOKUPVALUE()` specifically when there's no relationship to rely on. *** ## Performance Considerations [#performance-considerations] `LOOKUPVALUE()` scans the target table looking for a matching row, rather than following a pre-built relationship index. ```text Small reference table (rates, price lists) -> fine Large fact table as the lookup target -> can be slow, scanned per row ``` It's well suited to small reference or lookup tables, but using it against a large fact table as the search target is a common source of slow calculated columns. *** ## Common Mistakes [#common-mistakes] ### Using LOOKUPVALUE Where a Relationship Would Be Simpler [#using-lookupvalue-where-a-relationship-would-be-simpler] If the two tables genuinely have a one-to-many relationship that could just be modeled directly, using `LOOKUPVALUE()` instead adds unnecessary complexity — model the relationship and use `RELATED()`. ### Not Handling Multiple Matches [#not-handling-multiple-matches] If the search columns don't uniquely identify a row, `LOOKUPVALUE()` errors instead of picking one arbitrarily — the search columns need to be a real, unique key in the target table. ### Using It Against Large Fact Tables [#using-it-against-large-fact-tables] `LOOKUPVALUE()` against a multi-million-row fact table, especially inside a calculated column evaluated per row, can be significantly slower than an equivalent relationship-based approach. *** ## Best Practices [#best-practices] * Reserve `LOOKUPVALUE()` for genuinely unrelated tables, or matches a standard relationship can't express (composite keys, non-key matching). * Confirm the search columns uniquely identify a row in the target table before relying on it in production. * Prefer `RELATED()` whenever an actual relationship exists or reasonably could. * Keep the lookup target small — reference tables and lookup lists, not large fact tables. *** ## Next Steps [#next-steps] Continue learning DAX functions: * [RELATED & RELATEDTABLE](/docs/dax/related) * [SELECTEDVALUE](/docs/dax/selectedvalue) * [Relationships](/docs/modeling/relationships) Getting a "key didn't match any rows" error? See [The Key Didn't Match Any Rows in the Table](/blog/key-didnt-match-any-rows-error) for the four usual causes. # Measures (/docs/dax/measures) # Measures [#measures] Measures are the most important type of calculation in Power BI. A measure performs a calculation dynamically based on the current filter context. Unlike calculated columns, measures are **not stored** in the data model. Instead, they are evaluated each time a visual or report requests a result. Measures are commonly used to calculate: * Sales * Revenue * Profit * Percentages * KPIs * Running totals * Year-to-date values *** ## What Is a Measure? [#what-is-a-measure] A measure is a reusable DAX expression. General syntax: ```text Measure Name = Expression ``` Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The measure calculates the total sales for the current report context. *** ## Creating a Measure [#creating-a-measure] Measures are created from a table in the Fields pane. After a measure is created, it can be reused in: * Tables * Matrix visuals * Cards * Charts * KPIs * Other DAX measures One measure can support dozens of report visuals. *** ## How Measures Work [#how-measures-work] Measures are evaluated after Power BI applies report filters. Example: ```text User selects: Year = 2026 Category = Bikes | v Power BI filters FactSales | v Measure evaluates only visible rows | v Returns Total Sales ``` The DAX formula never changes. Only the rows being evaluated change. *** ## Simple Measure Example [#simple-measure-example] ```dax lineNumbers Total Quantity = SUM(FactSales[Quantity]) ``` Power BI adds together every visible value in the **Quantity** column. If a slicer filters the report to a single year, only that year's values are included. *** ## Measures Can Build on Other Measures [#measures-can-build-on-other-measures] One of the biggest advantages of DAX is reusability. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` ```dax lineNumbers Total Cost = SUM(FactSales[Cost]) ``` ```dax lineNumbers Profit = [Total Sales] - [Total Cost] ``` Rather than repeating calculations, measures can reference existing measures. This makes models easier to maintain. *** ## Common Measure Functions [#common-measure-functions] Most Power BI reports use a relatively small group of DAX aggregation functions. | Function | Purpose | | ----------------- | -------------------------- | | `SUM()` | Adds values | | `AVERAGE()` | Calculates the average | | `MIN()` | Returns the smallest value | | `MAX()` | Returns the largest value | | `COUNT()` | Counts non-blank values | | `COUNTROWS()` | Counts table rows | | `DISTINCTCOUNT()` | Counts unique values | Example: ```dax lineNumbers Average Sales = AVERAGE(FactSales[SalesAmount]) ``` Example: ```dax lineNumbers Customer Count = DISTINCTCOUNT(FactSales[CustomerKey]) ``` These functions form the foundation for many business calculations. *** ## Measures Respond to Filter Context [#measures-respond-to-filter-context] Measures automatically respond to report filters, slicers, and relationships. Suppose the report contains this measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The result changes depending on the report context. | Report Filter | Total Sales | | ------------- | ----------: | | All Products | $500,000 | | Bikes | $150,000 | | Accessories | $85,000 | | Year = 2026 | $220,000 | The DAX expression never changes. Only the rows being evaluated change. *** ## Reusing Measures [#reusing-measures] One measure can be used inside another measure. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` ```dax lineNumbers Total Cost = SUM(FactSales[Cost]) ``` ```dax lineNumbers Gross Profit = [Total Sales] - [Total Cost] ``` Building calculations this way creates reusable business logic and makes maintenance much easier. *** ## Formatting Measures [#formatting-measures] Measures should always be formatted appropriately. Examples: | Measure | Recommended Format | | ------------ | ------------------ | | Sales | Currency | | Quantity | Whole Number | | Margin % | Percentage | | Average Cost | Currency | | Growth Rate | Percentage | Formatting improves report readability without changing the calculation itself. *** ## Naming Conventions [#naming-conventions] Good measure names make reports easier to understand. Recommended: ```text Total Sales Average Sales Gross Profit Profit Margin Order Count ``` Avoid: ```text Measure1 Sales2 Calc NewMeasure ``` Choose names that clearly describe the business calculation. *** ## Organizing Measures [#organizing-measures] As a model grows, measures should be organized into display folders. Example: ```text Measures ├── Sales │ ├── Total Sales │ ├── Average Sales │ └── Sales YTD │ ├── Profit │ ├── Gross Profit │ ├── Profit Margin │ └── Gross Margin % │ └── Customers ├── Customer Count └── Average Customer Spend ``` A well-organized model is easier for report developers to navigate. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common issues: * Creating calculated columns instead of measures. * Repeating the same calculation in multiple measures. * Giving measures unclear names. * Creating one extremely large measure instead of several reusable measures. * Forgetting to format measures correctly. Simple, reusable measures are easier to maintain and debug. *** ## Performance Best Practices [#performance-best-practices] Well-designed measures improve both report performance and maintainability. Follow these recommendations: * Build reusable base measures. * Keep calculations focused on one business concept. * Avoid repeating the same DAX logic. * Use variables (`VAR`) to simplify complex calculations. * Format measures consistently. * Organize measures into display folders. A small collection of reusable measures is usually better than one large, complex formula. *** ## Using Variables [#using-variables] Variables make DAX easier to read and often improve performance. Instead of repeating calculations, store intermediate results in variables. Example: ```dax lineNumbers Profit Margin = VAR Revenue = [Total Sales] VAR Profit = [Gross Profit] RETURN DIVIDE( Profit, Revenue ) ``` Benefits of variables include: * Cleaner code * Easier debugging * Better readability * Reduced repeated calculations *** ## Real-World Business Measures [#real-world-business-measures] Measures are used to answer common business questions. Examples include: | Business Question | Example Measure | | --------------------------------- | ------------------- | | How much did we sell? | Total Sales | | How much profit did we make? | Gross Profit | | What is our profit margin? | Profit Margin | | How many customers purchased? | Customer Count | | What was the average order value? | Average Order Value | | How much have sales grown? | Sales Growth % | Most Power BI dashboards are built using dozens—or even hundreds—of reusable measures. *** ## Measure Dependencies [#measure-dependencies] Measures can reference other measures to build more advanced calculations. Example: ```text Total Sales │ ▼ Gross Profit │ ▼ Profit Margin │ ▼ Gross Margin % ``` Building calculations in layers keeps your model organized and reduces duplicated logic. *** ## Summary [#summary] Measures are the foundation of analytical reporting in Power BI. Unlike calculated columns, measures: * Are evaluated dynamically. * Respond to filter context. * Can be reused across reports. * Do not increase model size. * Support interactive dashboards. A well-designed semantic model usually contains many small, reusable measures rather than a few large, complicated formulas. *** ## Next Steps [#next-steps] Continue learning DAX: * [Calculated Columns](/docs/dax/calculated-columns) * [Filter Context](/docs/dax/filter-context) * [Row Context](/docs/dax/row-context) * [BLANK() vs 0: ISBLANK() and the Comparison Trap](/docs/dax/blank-vs-zero) * [TODAY() & NOW(): Calculated Column vs Measure Timing](/docs/dax/today-now) Want a first draft of a measure instead of starting from a blank canvas? Try the [DAX Formula Builder](/tools/dax-formula-builder). * [CALCULATE](/docs/dax/calculate) * [Variables (VAR)](/docs/dax/variables) * [Time Intelligence](/docs/dax/time-intelligence) See it applied end to end: [Build a Complete Sales Analysis Report](/tutorials/build-a-sales-analysis-report) writes a real set of measures on top of a real model, start to finish, [Build an Earned Value Management Dashboard](/tutorials/build-an-evm-dashboard) for a program-management measure set (CPI, SPI, EAC) built the same way, or [Build a Requirements Traceability Matrix Dashboard](/tutorials/build-a-requirements-traceability-matrix) for measures that navigate a many-to-many bridge table. Getting "a single value for column cannot be determined"? See [A Single Value for Column Cannot Be Determined](/blog/single-value-column-cannot-be-determined) for why a bare column reference breaks in a measure but not a calculated column. # LEFT(), RIGHT() & MID() (/docs/dax/mid-left-right) # LEFT(), RIGHT() & MID() [#left-right--mid] These three functions extract a portion of a text value based on position and length — the DAX equivalents of Power Query's [Text.Start(), Text.End() & Text.Middle()](/docs/power-query/text-substring-functions), with one important difference in how positions are counted. ```powerquery lineNumbers LEFT(text, [num_chars]) RIGHT(text, [num_chars]) MID(text, start_num, num_chars) ``` *** ## LEFT() and RIGHT() [#left-and-right] ```powerquery lineNumbers LEFT("INV-2026-0042", 3) RIGHT("INV-2026-0042", 4) ``` ```text LEFT(..., 3) -> "INV" RIGHT(..., 4) -> "0042" ``` Both count from the respective end of the string — `LEFT` from the beginning, `RIGHT` from the end — and, like their Power Query counterparts, simply return fewer characters than requested if the text is shorter than `num_chars`, rather than erroring. *** ## MID(): One-Indexed Starting Position [#mid-one-indexed-starting-position] ```powerquery lineNumbers MID("INV-2026-0042", 5, 4) ``` ```text Position: 123456789... Text: INV-2026-0042 ^^^^ MID(..., 5, 4) -> "2026" ``` The `start_num` argument is **1-indexed** — position `5` is the 5th character, matching how Excel's `MID()` works and how most people naturally count. This is the opposite convention from Power Query's `Text.Middle()`, which is zero-indexed. Try the same extraction in both functions: `MID(text, 5, 4)` in DAX and `Text.Middle(text, 4, 4)` in Power Query return the identical substring — the start number just needs to shift by one to account for the different counting convention. *** ## The Real Trap: Moving Between DAX and Power Query [#the-real-trap-moving-between-dax-and-power-query] A Power BI report almost always uses both languages — Power Query for the load/transform layer, DAX for measures and calculated columns. Someone comfortable with one language's substring function can carry the wrong indexing assumption straight into the other: ```text Power Query: Text.Middle([Code], 4, 4) <- start counts from 0 DAX: MID([Code], 4, 4) <- would start one character too early ``` The same `start` value of `4` extracts a different substring in each language — `Text.Middle` treats it as the 5th character, `MID` treats it as the 4th. Neither function errors when this happens; it just silently returns a substring shifted by one character from what was intended. *** ## Common Mistakes [#common-mistakes] ### Assuming MID() Is Zero-Indexed Like Text.Middle() [#assuming-mid-is-zero-indexed-like-textmiddle] Porting a `Text.Middle([Code], 4, 4)` expression into a DAX calculated column as `MID([Code], 4, 4)` produces a result shifted one character early — `MID([Code], 5, 4)` is the actual equivalent. ### Assuming Text.Middle() Is One-Indexed Like MID() [#assuming-textmiddle-is-one-indexed-like-mid] The same mistake in the opposite direction: writing `Text.Middle([Code], 5, 4)` while thinking in DAX/Excel terms starts one character too late in Power Query. ### Assuming a Fixed Length That Doesn't Hold for Every Row [#assuming-a-fixed-length-that-doesnt-hold-for-every-row] `LEFT([Code], 3)` assumes every value in `[Code]` has at least a 3-character meaningful prefix — a shorter value doesn't error, it just returns less than expected, which can silently produce wrong-looking results rather than an obvious failure. *** ## Best Practices [#best-practices] * When porting a substring expression between Power Query and DAX, explicitly adjust the start number by one rather than copying it directly — don't assume either language's convention. * Use `LEN([Code])` (DAX) or `Text.Length([Code])` (Power Query) to compute a variable start or length instead of hardcoding a position that only holds for some rows. * Prefer doing substring extraction in whichever layer the value is first available in, rather than duplicating the same extraction logic in both languages. *** ## Next Steps [#next-steps] * [Text.Start(), Text.End(), Text.Middle() & Text.Length()](/docs/power-query/text-substring-functions) * [TRIM(), UPPER() & LOWER()](/docs/dax/trim-upper-lower) * [DAX Function Reference](/docs/dax/functions) * [Basics](/docs/dax/basics) # Performance Optimization (/docs/dax/performance) # DAX Performance Optimization [#dax-performance-optimization] A slow report is almost always a slow measure, and a slow measure almost always comes down to one of a handful of recurring causes — how much data gets scanned, how many times an expression gets re-evaluated, and how well the model itself is shaped. ```text Slow Visual | +-- Slow measure (DAX) +-- Poor model design (relationships, storage mode) +-- Too much data being scanned unnecessarily ``` *** ## Storage Engine vs. Formula Engine [#storage-engine-vs-formula-engine] Every DAX query splits its work between two engines, and understanding which one is spending the time changes what "optimize this" actually means. ```text Storage Engine (SE) Formula Engine (FE) | | Scans and aggregates Handles anything the storage data - fast, parallel, engine can't do natively - set-based row-by-row logic, complex expressions, iterators ``` Most performance problems come from pushing work into the Formula Engine that the Storage Engine could have handled — a well-written measure lets the Storage Engine do as much of the heavy lifting as possible. *** ## Use Performance Analyzer First [#use-performance-analyzer-first] Before optimizing anything, **Performance Analyzer** (Power BI Desktop's **View > Performance Analyzer**) shows exactly which visuals are slow and how their time splits between DAX query time and visual rendering time. ```text Performance Analyzer | +-- Visual A: 1,200ms DAX query, 50ms rendering <- DAX is the problem +-- Visual B: 80ms DAX query, 900ms rendering <- rendering is the problem ``` Optimizing DAX on a visual whose slowness is actually rendering-related (too many data points, an unoptimized custom visual) wastes effort on the wrong layer. *** ## Common Cause: Iterators Over Large Tables [#common-cause-iterators-over-large-tables] Iterator functions (`SUMX()`, `AVERAGEX()`, `FILTER()`) evaluate an expression once per row, which scales with table size in a way simple aggregations don't. ```dax lineNumbers Slow: Total Margin = SUMX( FactSales, FactSales[Quantity] * (FactSales[UnitPrice] - FactSales[UnitCost]) ) ``` ```dax lineNumbers Faster, if pre-computed at load time: Total Margin = SUM(FactSales[MarginAmount]) ``` Where possible, compute a value once in Power Query or as a column at load time, rather than recomputing it per row on every query. See [Iterators](/docs/dax/iterator) for more on how these functions work. *** ## Common Cause: Filtering More Than Necessary [#common-cause-filtering-more-than-necessary] Filtering the largest table in the model, when a smaller related table could be filtered instead, forces the engine to scan more rows than the calculation actually needs. ```text Filter the fact table directly (250,000 rows scanned) vs. Filter the dimension table, let the relationship propagate (500 rows scanned) ``` Whenever a filter condition is really about a dimension attribute (category, region, year), filtering the dimension table and letting the relationship propagate to the fact table is almost always faster than filtering the fact table directly. *** ## Common Cause: Overusing FILTER Inside CALCULATE [#common-cause-overusing-filter-inside-calculate] ```dax lineNumbers Slower: CALCULATE( [Total Sales], FILTER(DimProduct, DimProduct[Category] = "Bikes") ) ``` ```dax lineNumbers Faster: CALCULATE( [Total Sales], DimProduct[Category] = "Bikes" ) ``` For simple equality or comparison conditions, `CALCULATE()`'s native filter argument is translated more efficiently than the equivalent wrapped in `FILTER()`. See [FILTER](/docs/dax/filter) for when `FILTER()` is genuinely needed versus when it isn't. *** ## Use Variables to Avoid Recomputation [#use-variables-to-avoid-recomputation] Referencing the same measure or expression multiple times inside one calculation causes it to be evaluated multiple times, unless it's captured in a variable first. ```dax lineNumbers Slower: Profit Margin % = DIVIDE( [Total Sales] - [Total Cost], [Total Sales] ) ``` ```dax lineNumbers Faster: Profit Margin % = VAR TotalSales = [Total Sales] VAR TotalCost = [Total Cost] RETURN DIVIDE(TotalSales - TotalCost, TotalSales) ``` `[Total Sales]` is only evaluated once in the variable-based version, instead of potentially twice. See [Variables](/docs/dax/variables) for more on this pattern. *** ## Model Design Affects DAX Performance Too [#model-design-affects-dax-performance-too] DAX can only be as fast as the model underneath it allows. ```text Star schema, proper relationships -> DAX engine optimizes well Wide flat table, no relationships -> DAX engine has far less to work with ``` A poorly-shaped model — a wide flat table instead of a star schema, unnecessary bidirectional relationships, high-cardinality columns that don't need to be — limits how much even well-written DAX can improve things. See [Star Schema](/docs/modeling/star-schema) for the modeling side of this. *** ## Common Mistakes [#common-mistakes] ### Optimizing DAX Before Confirming DAX Is the Problem [#optimizing-dax-before-confirming-dax-is-the-problem] Rewriting a measure without first checking Performance Analyzer risks optimizing something that wasn't actually slow, while the real bottleneck (rendering, a poor relationship) goes unaddressed. ### Repeating the Same Expression Instead of Using Variables [#repeating-the-same-expression-instead-of-using-variables] Referencing `[Total Sales]` three times in one measure re-evaluates it three times — a variable computes it once and reuses the result. ### Filtering the Largest Table When a Smaller One Would Do [#filtering-the-largest-table-when-a-smaller-one-would-do] Filtering directly on a multi-million-row fact table, when the same filter could be expressed on a small related dimension table instead, does far more work than necessary. *** ## Best Practices [#best-practices] * Start every performance investigation with Performance Analyzer, not a guess about which measure "feels" slow. * Push filtering onto dimension tables rather than fact tables wherever the filter condition is really about a dimension attribute. * Use variables to avoid re-evaluating the same expression multiple times within one measure. * Prefer `CALCULATE()`'s native filter arguments over `FILTER()` for simple conditions. * Fix model-level issues (star schema, relationship cardinality) before assuming a measure rewrite alone will solve a performance problem. *** ## Performance Checklist [#performance-checklist] * Performance Analyzer has confirmed the DAX query, not rendering, is the actual bottleneck. * Iterators and `FILTER()` are used only where a simpler `CALCULATE()` filter or a pre-computed column wouldn't do. * Repeated subexpressions are captured in variables. * The model follows a star schema, with filters flowing from small dimension tables to the fact table. *** ## Next Steps [#next-steps] Continue learning DAX: * [Iterators](/docs/dax/iterator) * [Variables](/docs/dax/variables) * [Star Schema](/docs/modeling/star-schema) * [DIVIDE](/docs/dax/divide) # RANKX() (/docs/dax/rankx) # RANKX() [#rankx] `RANKX()` ranks a value against every other value produced by evaluating an expression across a table. ```dax lineNumbers RANKX( Table, Expression, [Value], [Order], [Ties] ) ``` For a deeper look at ranking patterns — handling ties, ranking within a group, filtered ranks — see [Ranking](/docs/dax-patterns/ranking). *** ## Basic Example [#basic-example] ```dax lineNumbers Product Rank = RANKX( ALL(DimProduct), [Total Sales] ) ``` ```text Product | Sales | Rank ----------|---------|------ Tire A | 50,000 | 1 Tire B | 42,000 | 2 Helmet A | 18,000 | 3 ``` `RANKX()` needs a table to rank across (`ALL(DimProduct)`) and an expression to rank by (`[Total Sales]`), evaluated once per row of that table. *** ## Why ALL() Is Almost Always Required [#why-all-is-almost-always-required] Without `ALL()`, the table `RANKX()` ranks across is limited to whatever the current filter context already allows — which, inside a table visual grouped by product, is usually just the current row. ```text Without ALL(): each product only sees itself in the comparison set -> every rank is 1 With ALL(): every product is compared against every other product -> real ranks ``` `ALL(DimProduct)` removes the existing filter on `DimProduct`, giving `RANKX()` the full set of products to compare against, regardless of what the visual itself is filtering down to. *** ## Order: Descending vs. Ascending [#order-descending-vs-ascending] The fourth argument controls rank direction. ```dax lineNumbers Product Rank (Lowest First) = RANKX( ALL(DimProduct), [Total Sales], , ASC ) ``` ```text DESC (default): highest value = Rank 1 ASC: lowest value = Rank 1 ``` Leaving the third argument blank (as in the example above, with two commas in a row) tells `RANKX()` to use the same expression for both ranking and comparison, which is the typical case. *** ## Ties [#ties] By default, tied values receive the same rank, and the next rank skips accordingly. ```text Product | Sales | Rank ----------|---------|------ Tire A | 50,000 | 1 Tire B | 50,000 | 1 <- tied with Tire A Helmet A | 18,000 | 3 <- skips rank 2 ``` The optional fifth argument (`Skip` or `Dense`) controls this — `Dense` ranking keeps consecutive ranks without skipping after a tie. See [Ranking](/docs/dax-patterns/ranking) for worked examples of both. *** ## Common Mistakes [#common-mistakes] ### Forgetting ALL() [#forgetting-all] The single most common `RANKX()` mistake — without `ALL()`, every row ends up ranked 1st, because the comparison table has been filtered down to just that row already. ### Ranking Within the Wrong Table [#ranking-within-the-wrong-table] Passing a table that's too broad or too narrow for the intended comparison — ranking products globally when the intent was to rank within each category — needs `ALL()` combined with the right grouping columns kept in context, not stripped entirely. ### Ignoring Ties [#ignoring-ties] Not considering how ties should behave (skip vs. dense) can produce a ranking that looks wrong to report viewers when several rows share the same value. *** ## Best Practices [#best-practices] * Default to `ALL()` (or a scoped version of it) as the table argument, unless there's a specific reason not to re-expand the comparison set. * Be explicit about tie behavior (`Skip` vs. `Dense`) rather than relying on the default without checking it matches the intended result. * Use variables to store the ranking expression's inputs when the expression itself is complex, for readability. *** ## Next Steps [#next-steps] Continue learning DAX functions and patterns: * [Ranking Patterns](/docs/dax-patterns/ranking) * [TOPN](/docs/dax/topn) * [FILTER](/docs/dax/filter) Seen ranking done with `EARLIER()` in an older calculated column instead? See [EARLIER](/docs/dax/earlier) for why `RANKX()` as a measure is almost always the simpler choice. # RELATED() & RELATEDTABLE() (/docs/dax/related) # RELATED() & RELATEDTABLE() [#related--relatedtable] `RELATED()` and `RELATEDTABLE()` pull data across an existing relationship, without needing a manual lookup or merge. They only work where a relationship already connects the two tables — neither function creates a relationship, they just read across one that's already there. *** ## RELATED(): Pulling a Single Value [#related-pulling-a-single-value] `RELATED()` returns a single related value from the "one" side of a relationship, evaluated from the "many" side. ```dax lineNumbers RELATED(Column) ``` Example — pulling a product's category onto each sales row: ```dax lineNumbers Category = RELATED(DimProduct[Category]) ``` ```text DimProduct (one) FactSales (many) ProductKey | Category ProductKey | ... 1001 | Bikes 1001 | ... 1001 | ... <- Category = "Bikes" via RELATED() ``` `RELATED()` only works in the direction from many to one — it can't be used on a fact table's column to pull a value across to a dimension table, since a single dimension row can relate to many fact rows, not one. *** ## RELATEDTABLE(): Pulling Multiple Rows [#relatedtable-pulling-multiple-rows] `RELATEDTABLE()` is the reverse: from the "one" side, it returns every related row from the "many" side, as a table. ```dax lineNumbers RELATEDTABLE(Table) ``` Example — counting how many orders a customer has placed: ```dax lineNumbers Order Count = COUNTROWS(RELATEDTABLE(FactSales)) ``` ```text DimCustomer (one) FactSales (many) CustomerKey | ... CustomerKey | OrderID 1 1 | 5001 1 | 5002 1 | 5003 RELATEDTABLE returns all 3 rows for CustomerKey 1 ``` Because it returns a table, `RELATEDTABLE()` is almost always wrapped in an aggregation function — `COUNTROWS()`, `SUMX()`, `AVERAGEX()` — rather than used on its own. *** ## RELATED vs. RELATEDTABLE [#related-vs-relatedtable] | | Direction | Returns | Typical Use | | ---------------- | ----------- | -------------- | -------------------------------------------------------------- | | `RELATED()` | Many-to-one | A single value | Pulling a dimension attribute onto a fact row | | `RELATEDTABLE()` | One-to-many | A table | Counting or aggregating related fact rows from a dimension row | *** ## RELATED Inside a Calculated Column [#related-inside-a-calculated-column] `RELATED()` is most commonly used in a calculated column, to flatten a dimension attribute directly onto the fact table. ```dax lineNumbers Product Category = RELATED(DimProduct[Category]) ``` This is useful when a visual or another calculation needs the category available directly on `FactSales`, rather than requiring a relationship lookup at query time. *** ## RELATEDTABLE Inside a Measure [#relatedtable-inside-a-measure] `RELATEDTABLE()` is more commonly used inside a measure, from the "one" side of a relationship, to aggregate the related "many" rows. ```dax lineNumbers Products in Category = CALCULATE( DISTINCTCOUNT(FactSales[ProductKey]), RELATEDTABLE(FactSales) ) ``` Evaluated per category row, this counts the distinct products sold within that category. *** ## Requires an Existing Relationship [#requires-an-existing-relationship] Neither function works without an existing, active relationship connecting the two tables. ```text DimProduct FactSales | | +----- relationship --+ | RELATED() / RELATEDTABLE() can now cross it ``` If the relationship is missing, inactive, or filtered out by `USERELATIONSHIP()` pointing elsewhere, both functions return blank instead of the expected value. See [USERELATIONSHIP](/docs/dax/userelationship) for working with inactive relationships. *** ## Common Mistakes [#common-mistakes] ### Using RELATED Across Multiple Relationship Hops [#using-related-across-multiple-relationship-hops] `RELATED()` follows exactly one relationship. Pulling a value across two hops — fact table to dimension to another related dimension — needs a `RELATED()` on an intermediate calculated column, or a direct relationship, not a single call spanning both. ### Forgetting RELATEDTABLE Returns a Table [#forgetting-relatedtable-returns-a-table] Using `RELATEDTABLE()` directly where a single value is expected produces an error — it needs to be wrapped in `COUNTROWS()`, `SUMX()`, or another aggregation. ### Expecting RELATED to Work From the One Side [#expecting-related-to-work-from-the-one-side] `RELATED()` only pulls from one to many. Trying to use it on a dimension table's column to reach a fact table returns an error, since a dimension row doesn't correspond to a single fact row — that's what `RELATEDTABLE()` is for. *** ## Best Practices [#best-practices] * Use `RELATED()` in calculated columns when a dimension attribute genuinely needs to live directly on the fact table, not as a substitute for a working relationship. * Wrap `RELATEDTABLE()` in an aggregation function; it's a table function, not a scalar one. * Confirm the relationship is active before relying on either function — an inactive relationship silently returns blank rather than erroring. * Prefer a direct relationship and native filter propagation over `RELATED()`/`RELATEDTABLE()` when the same result can be achieved without a calculated column. *** ## Next Steps [#next-steps] Continue learning DAX functions: * [LOOKUPVALUE](/docs/dax/lookupvalue) * [USERELATIONSHIP](/docs/dax/userelationship) * [Relationships](/docs/modeling/relationships) See it applied end to end: [Build a Reliability (MTBF/MTTR) Dashboard](/tutorials/build-a-reliability-mtbf-mttr-dashboard) uses RELATED to pull an asset's commissioning date into a calculated column. # Row Context (/docs/dax/row-context) # Row Context [#row-context] Row context is the environment in which DAX evaluates one row at a time. Unlike filter context, which determines **which rows are visible**, row context determines **which row is currently being evaluated**. Row context is automatically created in calculated columns and iterator functions such as `SUMX()` and `AVERAGEX()`. Understanding row context is essential before learning `CALCULATE()` and context transition. *** ## What Is Row Context? [#what-is-row-context] Row context means DAX has access to the values from the current row. Imagine the following table: | Product | Quantity | Unit Price | | ------- | -------: | ---------: | | Tire A | 5 | 50 | | Tire B | 3 | 75 | | Tire C | 8 | 40 | If we create this calculated column: ```dax lineNumbers Sales Amount = FactSales[Quantity] * FactSales[Unit Price] ``` Power BI evaluates every row individually. Result: | Product | Quantity | Unit Price | Sales Amount | | ------- | -------: | ---------: | -----------: | | Tire A | 5 | 50 | 250 | | Tire B | 3 | 75 | 225 | | Tire C | 8 | 40 | 320 | Each calculation only uses values from the current row. *** ## Calculated Columns Automatically Have Row Context [#calculated-columns-automatically-have-row-context] Every calculated column receives row context automatically. Example: ```dax lineNumbers Full Name = Customer[First Name] & " " & Customer[Last Name] ``` For every customer, DAX reads the values from that specific row. No filtering is required. *** ## Measures Do NOT Have Row Context [#measures-do-not-have-row-context] Measures are evaluated using **filter context**, not row context. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The measure never knows which individual row it is on. Instead, it evaluates all rows that remain after filtering. This is one of the biggest differences between calculated columns and measures. *** ## Row Context vs Filter Context [#row-context-vs-filter-context] Although they sound similar, they serve different purposes. | Row Context | Filter Context | | ---------------------------------- | ------------------------------------------------------- | | Evaluates one row at a time | Filters visible rows | | Used by calculated columns | Used by measures | | Created automatically for each row | Created by visuals, slicers, relationships, and filters | | Reads values from the current row | Determines which rows are included in calculations | Understanding the difference between these two concepts is fundamental to writing effective DAX. *** ## Iterator Functions Create Row Context [#iterator-functions-create-row-context] Some DAX functions create their own row context. These are known as **iterator functions** because they evaluate one row at a time. Common iterators include: * `SUMX()` * `AVERAGEX()` * `COUNTX()` * `MINX()` * `MAXX()` Example: ```dax lineNumbers Total Sales = SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` `SUMX()` works as follows: ```text Read Row 1 ↓ Calculate Quantity × Unit Price ↓ Read Row 2 ↓ Calculate ↓ Read Row 3 ↓ ... ↓ Add all results together ``` Unlike `SUM()`, `SUMX()` evaluates an expression for every row before returning the final result. *** ## Row Context and RELATED() [#row-context-and-related] Row context allows DAX to retrieve related values from another table. Example model: ```text DimProduct | | FactSales ``` Calculated column: ```dax lineNumbers Category = RELATED(DimProduct[Category]) ``` Power BI reads the current **FactSales** row. It then follows the relationship to **DimProduct** and returns the matching category. Without row context, DAX would not know which product to retrieve. *** ## Multiple Row Contexts [#multiple-row-contexts] Iterator functions can create nested row contexts. Example: ```dax lineNumbers SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` For every row in **FactSales**: * Read `Quantity` * Read `Unit Price` * Multiply them together After every row has been evaluated, the results are added together. This is why iterator functions are generally more flexible than simple aggregation functions. *** ## Business Example [#business-example] Suppose a company stores: | Product | Quantity | Unit Price | | ------- | -------: | ---------: | | Tire A | 5 | 50 | | Tire B | 3 | 75 | | Tire C | 8 | 40 | Using: `SUM(FactSales[SalesAmount])` requires a stored **SalesAmount** column. Using: ```dax lineNumbers SUMX( FactSales, FactSales[Quantity] * FactSales[Unit Price] ) ``` calculates the sales amount for every row dynamically. This avoids storing an additional column while producing the same result. *** ## When Row Context Exists [#when-row-context-exists] Row context is automatically created in: * Calculated columns * Iterator functions (`SUMX`, `AVERAGEX`, etc.) * Some nested DAX expressions Measures **do not** automatically create row context. This distinction is one of the most important concepts in DAX. *** ## Context Transition [#context-transition] One of the most powerful features of DAX is **context transition**. Context transition occurs when **`CALCULATE()` converts a row context into a filter context.** This allows DAX to evaluate measures using values from the current row. Without context transition, many advanced calculations would not be possible. *** ## Why Context Transition Matters [#why-context-transition-matters] Consider a calculated column: ```dax lineNumbers Sales Percentage = DIVIDE( FactSales[SalesAmount], [Total Sales] ) ``` The measure **\[Total Sales]** is evaluated using filter context. However, a calculated column only has **row context**. By using `CALCULATE()`, DAX converts the current row into a filter, allowing measures to evaluate correctly. This behavior is known as **context transition**. *** ## Row Context vs Filter Context [#row-context-vs-filter-context-1] Understanding when each context exists makes DAX much easier to understand. | Feature | Row Context | Filter Context | | ----------------------------- | ----------- | -------------- | | Created by Calculated Columns | ✅ | ❌ | | Created by Measures | ❌ | ✅ | | Created by Iterator Functions | ✅ | ❌ | | Created by Report Filters | ❌ | ✅ | | Created by Slicers | ❌ | ✅ | | Modified by `CALCULATE()` | ➜ Converted | ✅ | Although both contexts affect calculations, they solve different problems. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Many new Power BI developers confuse row context and filter context. Common mistakes include: * Expecting measures to know the current row. * Using calculated columns for dynamic calculations. * Forgetting that iterator functions create row context. * Assuming `SUM()` behaves like `SUMX()`. * Using `RELATED()` without a valid relationship. Learning to recognize the active context makes debugging DAX much easier. *** ## Best Practices [#best-practices] When working with row context: * Use calculated columns only for permanent attributes. * Use iterator functions when calculations require row-by-row evaluation. * Prefer measures for report calculations. * Understand whether your formula is executing in row context or filter context. * Keep calculations simple and reusable. As your models grow, understanding context becomes far more important than memorizing DAX functions. *** ## Summary [#summary] Row context evaluates **one row at a time**. It is automatically created by: * Calculated columns * Iterator functions such as `SUMX()` and `AVERAGEX()` Unlike filter context, row context does **not** determine which rows are visible. Instead, it provides access to the values in the current row. Understanding both row context and filter context is the key to mastering DAX. *** ## Next Steps [#next-steps] Now that you understand both contexts, you're ready to learn the most important DAX function: * [CALCULATE](/docs/dax/calculate) * [Variables (VAR)](/docs/dax/variables) * [Iterators](/docs/dax/iterator) * [Time Intelligence](/docs/dax/time-intelligence) * [EARLIER](/docs/dax/earlier) — reaching back to an outer row context when a calculated column nests a second one. # SELECTEDVALUE() (/docs/dax/selectedvalue) # SELECTEDVALUE() [#selectedvalue] `SELECTEDVALUE()` returns a column's value when exactly one value is in context — from a slicer selection, a filter, or a single row in a visual — and a fallback value otherwise. ```dax lineNumbers SELECTEDVALUE(Column, [Alternate Result]) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Selected Category = SELECTEDVALUE(DimProduct[Category]) ``` ```text Slicer: Category = "Bikes" (one value selected) | SELECTEDVALUE returns "Bikes" Slicer: Category = "Bikes", "Accessories" (two values selected) | SELECTEDVALUE returns BLANK (or the fallback, if supplied) ``` This is most often used in a card visual or a measure's title, to display which single value is currently in context — a title that should say "Bikes" when exactly one category is selected, and something else when multiple are. *** ## Providing a Fallback [#providing-a-fallback] The second argument controls what's returned when zero or more than one value is present, instead of blank. ```dax lineNumbers Selected Category = SELECTEDVALUE(DimProduct[Category], "Multiple Categories") ``` ```text One category selected -> "Bikes" Multiple categories selected -> "Multiple Categories" No selection (all categories) -> "Multiple Categories" ``` This pattern is common for dynamic report titles that need to read sensibly regardless of how a user has filtered the page. *** ## SELECTEDVALUE vs. VALUES + IF [#selectedvalue-vs-values--if] Before `SELECTEDVALUE()` existed, the same result required combining `VALUES()`, `HASONEVALUE()`, and `IF()`: ```dax lineNumbers Selected Category (old pattern) = IF( HASONEVALUE(DimProduct[Category]), VALUES(DimProduct[Category]), "Multiple Categories" ) ``` ```dax lineNumbers Selected Category (SELECTEDVALUE) = SELECTEDVALUE(DimProduct[Category], "Multiple Categories") ``` `SELECTEDVALUE()` is the equivalent shorthand — same result, one function instead of three combined. *** ## A Dynamic Measure Title [#a-dynamic-measure-title] A common use is building a title that names the current selection: ```dax lineNumbers Report Title = "Sales for " & SELECTEDVALUE(DimDate[Year], "All Years") ``` ```text Year slicer = 2026 -> "Sales for 2026" No year selected (all) -> "Sales for All Years" ``` This keeps a report page's title accurate without a separate measure per possible selection. *** ## Common Mistakes [#common-mistakes] ### Expecting It to Aggregate [#expecting-it-to-aggregate] `SELECTEDVALUE()` doesn't sum, average, or combine multiple selected values — if more than one value is in context, it returns the fallback (or blank), not a combined result. ### Omitting the Fallback [#omitting-the-fallback] Without a second argument, a multi-selection or no-selection state silently returns blank, which can look like a bug in a card visual rather than an intentional "nothing specific is selected" state. ### Using It Where HASONEVALUE Logic Differs [#using-it-where-hasonevalue-logic-differs] `SELECTEDVALUE()` is equivalent to the `VALUES` + `HASONEVALUE` + `IF` pattern specifically — if custom logic beyond "one value vs. not one value" is needed, the manual pattern still has a place. *** ## Best Practices [#best-practices] * Always supply a meaningful fallback value; a blank card or title reads as broken to a report viewer. * Use `SELECTEDVALUE()` for dynamic titles and single-value cards, rather than manually combining `VALUES()` and `HASONEVALUE()`. * Keep the fallback text specific enough to be useful ("Multiple Categories" rather than a generic blank or dash). *** ## Next Steps [#next-steps] Continue learning DAX functions: * [SWITCH](/docs/dax/switch) * [LOOKUPVALUE](/docs/dax/lookupvalue) * [Measures](/docs/modeling/measures) # SUMMARIZE() (/docs/dax/summarize) # SUMMARIZE() [#summarize] `SUMMARIZE()` groups a table by one or more columns and, optionally, computes aggregated values for each group — the DAX equivalent of a SQL `GROUP BY`. ```dax lineNumbers SUMMARIZE( Table, GroupBy Column1, GroupBy Column2, ..., ["Name", Expression], ... ) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Sales by Category = SUMMARIZE( FactSales, DimProduct[Category], "Total Sales", SUM(FactSales[SalesAmount]) ) ``` ```text FactSales joined to DimProduct, grouped by Category: Category | Total Sales ------------|------------- Bikes | 120,000 Accessories | 45,000 ``` The result is a table — one row per distinct `Category`, with a computed `Total Sales` column alongside it. *** ## Grouping Without Aggregating [#grouping-without-aggregating] `SUMMARIZE()` can also be used with no aggregation arguments at all, purely to get the distinct combinations of a set of columns. ```dax lineNumbers Distinct Categories = SUMMARIZE( DimProduct, DimProduct[Category] ) ``` This returns one row per unique category, similar to what `VALUES(DimProduct[Category])` would return for a single column. *** ## SUMMARIZE vs. ADDCOLUMNS + VALUES [#summarize-vs-addcolumns--values] `SUMMARIZE()`'s aggregation arguments have a well-known limitation: expressions referencing measures inside them can behave inconsistently, particularly in older versions of the DAX engine. The more predictable alternative combines `ADDCOLUMNS()` with `VALUES()` (or `SUMMARIZE()` used only for grouping, with no aggregation arguments). ```dax lineNumbers Sales by Category (SUMMARIZE) = SUMMARIZE( FactSales, DimProduct[Category], "Total Sales", SUM(FactSales[SalesAmount]) ) ``` ```dax lineNumbers Sales by Category (ADDCOLUMNS) = ADDCOLUMNS( SUMMARIZE(FactSales, DimProduct[Category]), "Total Sales", CALCULATE(SUM(FactSales[SalesAmount])) ) ``` The `ADDCOLUMNS()` version is more explicit about filter context — each row's `Total Sales` is computed with `CALCULATE()` inside the row context that `ADDCOLUMNS()` sets up, which behaves more predictably than relying on `SUMMARIZE()`'s built-in aggregation arguments. *** ## SUMMARIZE in a Calculated Table [#summarize-in-a-calculated-table] `SUMMARIZE()` is a common building block for a calculated table that pre-aggregates data at a coarser grain than the base fact table. ```dax lineNumbers Category Summary = SUMMARIZE( FactSales, DimProduct[Category], DimDate[Year], "Total Sales", SUM(FactSales[SalesAmount]) ) ``` This produces a small, category-by-year summary table — useful as the basis for an aggregation table, or a simplified export. *** ## Common Mistakes [#common-mistakes] ### Relying on SUMMARIZE's Aggregation Arguments for Complex Logic [#relying-on-summarizes-aggregation-arguments-for-complex-logic] Simple `SUM()`/`COUNT()`-style aggregations are usually fine, but more complex expressions (especially ones referencing existing measures) are more reliable wrapped in `ADDCOLUMNS()` instead. ### Using SUMMARIZE When VALUES Would Do [#using-summarize-when-values-would-do] For a single column's distinct values with no aggregation, `VALUES()` (or `DISTINCT()`) is simpler and more direct than a `SUMMARIZE()` with one grouping column and nothing else. ### Forgetting It Returns a Table [#forgetting-it-returns-a-table] Like `FILTER()` and `TOPN()`, `SUMMARIZE()`'s result is a table — it needs to be consumed by something that expects one, not treated as a single value. *** ## Best Practices [#best-practices] * Use `SUMMARIZE()` for grouping only (no aggregation arguments), and add computed columns with `ADDCOLUMNS()` for anything beyond a simple `SUM`/`COUNT`. * Prefer `VALUES()` or `DISTINCT()` over `SUMMARIZE()` when only a single column's distinct values are needed. * Use `SUMMARIZE()`-based calculated tables sparingly — they're a static snapshot, not a substitute for well-structured dimension and fact tables. *** ## Next Steps [#next-steps] Continue learning DAX functions: * [Calculated Tables](/docs/dax/calculated-tables) * [FILTER](/docs/dax/filter) * [Star Schema](/docs/modeling/star-schema) * [UNION(), EXCEPT() & INTERSECT()](/docs/dax/union) * [ADDCOLUMNS() vs SELECTCOLUMNS()](/docs/dax/addcolumns-selectcolumns) # SUMX() (/docs/dax/sumx) # SUMX() [#sumx] `SUMX()` evaluates an expression once per row of a table, then adds up the results — the most commonly used iterator function in DAX. ```dax lineNumbers SUMX(Table, Expression) ``` *** ## SUM vs. SUMX [#sum-vs-sumx] ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` ```dax lineNumbers Total Sales (SUMX) = SUMX(FactSales, FactSales[SalesAmount]) ``` These two return the same result — but only because the expression is just a single column. `SUM()` only ever adds up one column, as-is. `SUMX()` earns its keep the moment the thing being summed has to be *computed per row first*, not just read directly from a column. ```text Table has Quantity and UnitPrice, but no SalesAmount column: SUM(FactSales[SalesAmount]) -> error, no such column SUMX(FactSales, FactSales[Quantity] * FactSales[UnitPrice]) -> computes it per row, then sums ``` *** ## How It Actually Works [#how-it-actually-works] ```text FactSales Quantity | UnitPrice 2 | 45 -> 2 * 45 = 90 1 | 68 -> 1 * 68 = 68 3 | 45 -> 3 * 45 = 135 SUM: 293 ``` `SUMX()` walks the table row by row, evaluating `Quantity * UnitPrice` in each row's own row context, then sums the per-row results — conceptually identical to a spreadsheet helper column that gets summed afterward, except nothing is actually materialized as a stored column. ```dax lineNumbers Total Revenue = SUMX(FactSales, FactSales[Quantity] * FactSales[UnitPrice]) ``` *** ## Using a Measure Inside SUMX [#using-a-measure-inside-sumx] The expression can reference other measures, not just columns — useful when the per-row logic itself already exists as a reusable measure. ```dax lineNumbers Total Profit = SUMX( FactSales, FactSales[Quantity] * (FactSales[UnitPrice] - FactSales[UnitCost]) ) ``` See [Iterators](/docs/dax/iterator#how-sumx-works) for how row context and iteration interact more generally, and [Row Context](/docs/dax/row-context) for what "each row" actually means underneath an iterator. *** ## Filtering the Table SUMX Iterates Over [#filtering-the-table-sumx-iterates-over] `SUMX()`'s first argument doesn't have to be a bare table reference — it can be any table expression, including one already filtered. ```dax lineNumbers Revenue from Bikes = SUMX( FILTER(FactSales, RELATED(DimProduct[Category]) = "Bikes"), FactSales[Quantity] * FactSales[UnitPrice] ) ``` This restricts the iteration to only rows where the related product category is "Bikes," before the per-row multiplication happens. *** ## Common Mistakes [#common-mistakes] ### Using SUMX Where SUM Would Do [#using-sumx-where-sum-would-do] ```dax lineNumbers Total Sales (Unnecessary SUMX) = SUMX(FactSales, FactSales[SalesAmount]) ``` If the expression is just a single existing column with no per-row computation, `SUM()` is simpler and typically performs at least as well — reach for `SUMX()` specifically when something needs to be calculated per row first. ### Iterating Over a Huge Table Unnecessarily [#iterating-over-a-huge-table-unnecessarily] `SUMX()` evaluates its expression once per row of whatever table it's given — on a multi-million-row fact table, an expensive per-row expression (nested `CALCULATE`, multiple `RELATED` calls) can get slow fast. See [Performance Optimization](/docs/dax/performance) for iterator-specific performance guidance. ### Forgetting the Expression Runs in Row Context, Not Filter Context [#forgetting-the-expression-runs-in-row-context-not-filter-context] Inside `SUMX()`, a bare column reference like `FactSales[UnitPrice]` means "this row's value," the same as inside a calculated column — not an aggregation. Wrapping it in `SUM()` again inside the expression is a common, redundant instinct carried over from measure-writing habits. *** ## Best Practices [#best-practices] * Use `SUM()` for a single existing column; reserve `SUMX()` for expressions that need per-row computation first. * Filter the table argument (with `FILTER()`, or implicitly through relationships and context) rather than computing over more rows than necessary. * Keep the per-row expression simple — push complex logic into a variable or a separate measure where it stays readable and testable on its own. *** ## Next Steps [#next-steps] * [Iterators](/docs/dax/iterator) * [Row Context](/docs/dax/row-context) * [Performance Optimization](/docs/dax/performance) # SWITCH() (/docs/dax/switch) # SWITCH() [#switch] `SWITCH()` evaluates an expression once, then compares it against a list of possible values, returning the result tied to the first match — a cleaner alternative to nesting several `IF()` calls. ```dax lineNumbers SWITCH( Expression, Value1, Result1, Value2, Result2, ..., [Else Result] ) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Size Group = SWITCH( DimProduct[Size], "S", "Small", "M", "Medium", "L", "Large", "Unknown Size" ) ``` ```text Size = "S" -> "Small" Size = "M" -> "Medium" Size = "L" -> "Large" Size = "XL" -> "Unknown Size" (falls through to the else result) ``` The final argument, with no matching value before it, acts as the "else" — returned when nothing else matched. *** ## SWITCH vs. Nested IF [#switch-vs-nested-if] The same logic written with nested `IF()` gets harder to read fast: ```dax lineNumbers Size Group (nested IF) = IF( DimProduct[Size] = "S", "Small", IF( DimProduct[Size] = "M", "Medium", IF( DimProduct[Size] = "L", "Large", "Unknown Size" ) ) ) ``` ```dax lineNumbers Size Group (SWITCH) = SWITCH( DimProduct[Size], "S", "Small", "M", "Medium", "L", "Large", "Unknown Size" ) ``` Both return the same result. `SWITCH()` reads as a flat list of cases instead of a growing pyramid of nested parentheses, which matters once there are more than two or three conditions. *** ## SWITCH(TRUE(), ...) for Range Conditions [#switchtrue--for-range-conditions] `SWITCH()` compares an expression against exact values, but combined with `TRUE()` as the expression, each "value" becomes a condition that's evaluated for truth instead of equality — useful for ranges rather than exact matches. ```dax lineNumbers Sales Tier = SWITCH( TRUE(), [Total Sales] > 100000, "Gold", [Total Sales] > 50000, "Silver", [Total Sales] > 0, "Bronze", "No Sales" ) ``` ```text Total Sales = 120,000 -> "Gold" Total Sales = 60,000 -> "Silver" Total Sales = 10,000 -> "Bronze" Total Sales = 0 -> "No Sales" ``` Conditions are checked top to bottom, and the first one that evaluates to `TRUE()` wins — order matters here, since a broader condition placed first would shadow the more specific ones below it. Try setting the Gold row's threshold to `0` — that's the shadowing bug from the next section, and you'll see it happen instead of just reading about it. *** ## Order Matters in SWITCH(TRUE(), ...) [#order-matters-in-switchtrue-] ```text Wrong order: Correct order: [Total Sales] > 0, "Bronze" [Total Sales] > 100000, "Gold" [Total Sales] > 50000, "Silver" [Total Sales] > 50000, "Silver" [Total Sales] > 100000, "Gold" [Total Sales] > 0, "Bronze" Every positive sales value matches Each tier only matches once the "Bronze" first, "Gold" and broader ones above it have been "Silver" never get reached ruled out ``` Conditions should be ordered from most specific to least specific when using the `SWITCH(TRUE(), ...)` pattern. *** ## Common Mistakes [#common-mistakes] ### Wrong Condition Order in SWITCH(TRUE(), ...) [#wrong-condition-order-in-switchtrue-] Placing a broad condition (`> 0`) before a narrower one (`> 100000`) means the broad condition always matches first, and the narrower cases never get evaluated. ### Forgetting the Else Case [#forgetting-the-else-case] Without a final fallback value, `SWITCH()` returns blank for anything that doesn't match one of the listed values — usually not the intended behavior for a report-facing measure. ### Using SWITCH Where a Simple IF Would Do [#using-switch-where-a-simple-if-would-do] For a genuinely binary condition, a single `IF()` is more direct than a `SWITCH()` with only one real case and an else. *** ## Best Practices [#best-practices] * Always include a final else-result, even if it's just a clear "Unknown" or "Other" label. * Order `SWITCH(TRUE(), ...)` conditions from most specific to least specific. * Prefer `SWITCH()` over nested `IF()` once there are more than two or three conditions, for readability. * Keep each branch's result the same data type; mixing text and numeric results across branches can produce unexpected formatting. *** ## Next Steps [#next-steps] Continue learning DAX functions: * [IF](/docs/dax/if) * [SELECTEDVALUE](/docs/dax/selectedvalue) * [FILTER](/docs/dax/filter) * [CALCULATE](/docs/dax/calculate) Mixing return types across SWITCH(TRUE(), ...) branches? See [DAX Comparison Operations Do Not Support Comparing Values of Type Text With Values of Type Boolean](/blog/dax-comparison-operations-do-not-support-error). See `SWITCH(TRUE(), ...)` used for real severity bands: [Build a Risk Register and Risk Matrix Dashboard](/tutorials/build-a-risk-register-dashboard). # Time Intelligence (/docs/dax/time-intelligence) # Time Intelligence [#time-intelligence] Time Intelligence is a collection of DAX functions that simplify calculations involving dates. Instead of writing complex formulas to compare months, quarters, or years, DAX provides built-in functions that automatically understand time periods. Time Intelligence is commonly used to calculate: * Year-to-Date (YTD) Sales * Month-to-Date (MTD) Sales * Quarter-to-Date (QTD) Sales * Previous Year Sales * Year-over-Year Growth * Running Totals * Rolling 12-Month Averages These calculations are fundamental to business reporting and executive dashboards. *** ## Why Time Intelligence Matters [#why-time-intelligence-matters] Business users rarely want to see only today's numbers. Instead, they ask questions such as: * How much have we sold this year? * How does this month compare to last month? * Are sales increasing over time? * What was revenue last year? * What is our Year-over-Year growth? Time Intelligence functions answer these questions with relatively simple DAX. *** ## Date Table Requirements [#date-table-requirements] Time Intelligence requires a proper Date table. A typical model looks like: ```text DimDate | | FactSales -------+ ``` The Date table should contain one row for every date. Typical columns include: | Column | Purpose | | ------------ | ---------------- | | Date | Calendar date | | Year | Reporting year | | Quarter | Calendar quarter | | Month | Month name | | Month Number | Sort order | | Week | Week number | The Date table should span the entire reporting period—even if there are no sales on some dates. *** ## Mark as Date Table [#mark-as-date-table] Power BI requires a dedicated Date table for many Time Intelligence functions. To mark a table as a Date table: 1. Select the Date table. 2. Open the **Table Tools** ribbon. 3. Choose **Mark as Date Table**. 4. Select the **Date** column. This allows DAX to correctly interpret calendar periods. *** ## Basic Date Model [#basic-date-model] A common star schema looks like this: ```text DimDate | | DimCustomer --- FactSales --- DimProduct | | DimStore ``` The **FactSales** table stores transactions. The **DimDate** table provides the calendar used for reporting. Almost every Time Intelligence calculation depends on this relationship. *** ## First Time Intelligence Measure [#first-time-intelligence-measure] Suppose you already have a basic measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` This returns sales for the current filter context. To calculate sales for the current year, DAX provides specialized Time Intelligence functions that build on this measure. You'll learn those functions next. *** ## Year-to-Date (YTD) [#year-to-date-ytd] Year-to-Date (YTD) calculates the cumulative value from the beginning of the year through the current date. The `TOTALYTD()` function makes this calculation simple. Example: ```dax lineNumbers Sales YTD = TOTALYTD( [Total Sales], DimDate[Date] ) ``` As new dates are selected, the measure automatically accumulates sales from January 1 through the current date. Example: | Month | Monthly Sales | Sales YTD | | -------- | ------------: | --------: | | January | $50,000 | $50,000 | | February | $60,000 | $110,000 | | March | $45,000 | $155,000 | *** ## Month-to-Date (MTD) [#month-to-date-mtd] Month-to-Date (MTD) calculates the cumulative value from the beginning of the current month. Example: ```dax lineNumbers Sales MTD = TOTALMTD( [Total Sales], DimDate[Date] ) ``` This measure resets automatically at the beginning of each new month. *** ## Quarter-to-Date (QTD) [#quarter-to-date-qtd] Quarter-to-Date (QTD) accumulates values from the start of the current quarter. Example: ```dax lineNumbers Sales QTD = TOTALQTD( [Total Sales], DimDate[Date] ) ``` This is commonly used in financial reporting and quarterly performance dashboards. *** ## Previous Year Sales [#previous-year-sales] Comparing current performance to the previous year is one of the most common business requirements. The `SAMEPERIODLASTYEAR()` function returns the equivalent period from the previous year. Example: ```dax lineNumbers Sales Last Year = CALCULATE( [Total Sales], SAMEPERIODLASTYEAR(DimDate[Date]) ) ``` If the current report shows: `March 2026` the measure automatically evaluates: `March 2025` *** ## Comparing Different Time Periods [#comparing-different-time-periods] The `DATEADD()` function shifts the current filter by a specified interval. Example: ```dax lineNumbers Sales Previous Month = CALCULATE( [Total Sales], DATEADD( DimDate[Date], -1, MONTH ) ) ``` Other examples include: ```text -1 MONTH Previous Month -1 QUARTER Previous Quarter -1 YEAR Previous Year ``` This makes it easy to compare current performance with earlier periods. *** ## DATESYTD() [#datesytd] Unlike `TOTALYTD()`, which returns a cumulative value, `DATESYTD()` returns a **table of dates** from the beginning of the year to the current date. It is often used inside `CALCULATE()`. Example: ```dax lineNumbers Sales YTD = CALCULATE( [Total Sales], DATESYTD(DimDate[Date]) ) ``` Both approaches produce a Year-to-Date calculation, but `DATESYTD()` provides greater flexibility when building advanced measures. *** ## Choosing the Right Function [#choosing-the-right-function] | Requirement | Function | | ------------------ | ---------------------- | | Year-to-Date | `TOTALYTD()` | | Month-to-Date | `TOTALMTD()` | | Quarter-to-Date | `TOTALQTD()` | | Previous Year | `SAMEPERIODLASTYEAR()` | | Shift Dates | `DATEADD()` | | Custom YTD Filters | `DATESYTD()` | These functions cover most of the Time Intelligence calculations used in business reporting. *** ## Running Totals [#running-totals] A running total accumulates values over time. Instead of displaying sales for each individual period, a running total continuously adds each period to the previous one. Example: ```dax lineNumbers Running Sales = CALCULATE( [Total Sales], FILTER( ALL(DimDate[Date]), DimDate[Date] <= MAX(DimDate[Date]) ) ) ``` Example output: | Month | Monthly Sales | Running Total | | -------- | ------------: | ------------: | | January | $50,000 | $50,000 | | February | $60,000 | $110,000 | | March | $45,000 | $155,000 | | April | $70,000 | $225,000 | Running totals are commonly used for revenue, production, inventory, and cumulative KPI dashboards. *** ## Rolling 12-Month Sales [#rolling-12-month-sales] Rolling calculations smooth short-term fluctuations by evaluating the previous 12 months instead of a single period. Example: ```dax lineNumbers Rolling 12 Months = CALCULATE( [Total Sales], DATESINPERIOD( DimDate[Date], MAX(DimDate[Date]), -12, MONTH ) ) ``` Rolling periods are frequently used for: * Sales trends * Forecasting * Financial reporting * Manufacturing performance *** ## Year-over-Year (YoY) Growth [#year-over-year-yoy-growth] Year-over-Year compares the current period with the same period from the previous year. Example: ```dax lineNumbers Sales YoY % = DIVIDE( [Total Sales] - [Sales Last Year], [Sales Last Year] ) ``` Example results: | Year | Sales | YoY Growth | | ---- | -------: | ---------: | | 2025 | $500,000 | — | | 2026 | $575,000 | 15% | YoY comparisons help identify long-term business growth. *** ## Month-over-Month (MoM) Growth [#month-over-month-mom-growth] Month-over-Month compares the current month to the previous month. Example: ```dax lineNumbers Sales Previous Month = CALCULATE( [Total Sales], DATEADD( DimDate[Date], -1, MONTH ) ) Sales MoM % = DIVIDE( [Total Sales] - [Sales Previous Month], [Sales Previous Month] ) ``` MoM analysis is commonly used to monitor short-term trends and seasonal changes. *** ## Common Time Intelligence Mistakes [#common-time-intelligence-mistakes] Many Time Intelligence issues are caused by problems with the Date table rather than the DAX itself. Common mistakes include: * Using transaction dates without a dedicated Date table. * Forgetting to **Mark as Date Table**. * Missing dates in the calendar. * Using multiple unrelated Date tables. * Applying Time Intelligence functions to non-date columns. A complete, continuous Date table is essential for reliable calculations. *** ## Best Practices [#best-practices] When working with Time Intelligence: * Create a dedicated Date dimension. * Mark it as the official Date table. * Use one Date table across the entire model. * Build reusable base measures such as `[Total Sales]`. * Base Time Intelligence measures on existing measures rather than repeating calculations. * Test calculations using slicers for Year, Quarter, and Month. Following these practices makes Time Intelligence measures easier to maintain and reuse. *** ## Summary [#summary] Time Intelligence allows Power BI to perform sophisticated date-based calculations with relatively simple DAX. Common functions include: * `TOTALYTD()` * `TOTALMTD()` * `TOTALQTD()` * `DATESYTD()` * `DATEADD()` * `SAMEPERIODLASTYEAR()` Combined with `CALCULATE()`, these functions make it possible to build running totals, period comparisons, growth metrics, and executive dashboards. Mastering Time Intelligence is one of the final milestones toward becoming proficient in DAX. *** ## Next Steps [#next-steps] Continue exploring advanced DAX concepts: * [ALL, ALLEXCEPT, ALLSELECTED & REMOVEFILTERS](/docs/dax/filter-functions) * [FILTER](/docs/dax/filter) * [RELATED & RELATEDTABLE](/docs/dax/related) * [DATEDIFF()](/docs/dax/datediff) * [DATEADD() vs PARALLELPERIOD()](/docs/dax/dateadd-parallelperiod) * [LOOKUPVALUE](/docs/dax/lookupvalue) * [USERELATIONSHIP](/docs/dax/userelationship) * [Performance Optimization](/docs/dax/performance) # TODAY() & NOW(): Calculated Column vs Measure Timing (/docs/dax/today-now) # TODAY() & NOW(): Calculated Column vs Measure Timing [#today--now-calculated-column-vs-measure-timing] `TODAY()` and `NOW()` return the current date (or date and time) — but *when* "current" actually gets evaluated depends entirely on whether the function is used in a calculated column or a measure. ```dax lineNumbers TODAY() NOW() ``` *** ## Calculated Columns: Frozen at Refresh Time [#calculated-columns-frozen-at-refresh-time] ```dax lineNumbers DaysSinceOrder = TODAY() - [OrderDate] ``` A calculated column is computed once, during data refresh, and the result is physically stored in the model — same as any other column. `TODAY()` inside it gets evaluated exactly once, at that refresh, and the stored result doesn't change again until the next refresh actually runs. Five days after a refresh with no new refresh run, a `DaysSinceOrder` calculated column still reports the day count as of the refresh — not the actual current day count. The column isn't wrong, exactly; it's just showing a value that was correct *when it was computed*, not a live one. *** ## Measures: Re-Evaluated Every Time the Report Is Viewed [#measures-re-evaluated-every-time-the-report-is-viewed] ```dax lineNumbers Days Since Order := TODAY() - MIN(Sales[OrderDate]) ``` A measure isn't stored — it's calculated at query time, every time a visual renders or a user interacts with the report. `TODAY()` inside a measure reflects the actual current date at that exact moment, regardless of when the underlying data was last refreshed. ```text Data last refreshed: September 1 5 days pass, no new refresh runs Calculated column "DaysSinceOrder": still based on September 1 (frozen) Measure "[Days Since Order]": based on today's actual date (live) ``` *** ## Common Mistakes [#common-mistakes] ### Expecting a Calculated Column With TODAY() to Update Daily [#expecting-a-calculated-column-with-today-to-update-daily] It only updates on the next data refresh — a scheduled refresh running once a week means a `TODAY()`-based calculated column is only ever as fresh as that week's most recent refresh, not the literal current day. ### Using a Calculated Column When a Measure Was Needed [#using-a-calculated-column-when-a-measure-was-needed] If the actual goal is "always reflect today's real date, live, whenever the report is opened," that's a measure's job — a calculated column using `TODAY()` can't do this regardless of how often it's refreshed, since it only recalculates on refresh, not on view. ### Assuming NOW()'s Time Component Behaves the Same Way [#assuming-nows-time-component-behaves-the-same-way] The same refresh-time-vs-query-time split applies to `NOW()` — a calculated column using `NOW()` freezes not just the date but the exact time of the last refresh, which can look like a strange, arbitrary timestamp far removed from whenever someone actually looks at the report. ### Not Considering Which Machine's Clock TODAY()/NOW() Reflects [#not-considering-which-machines-clock-todaynow-reflects] Beyond *when* it's evaluated, `TODAY()`/`NOW()` also reflect whichever machine actually runs the refresh or the query — see [DateTime.LocalNow() and the Desktop-vs-Service Trap](/docs/power-query/datetime-localnow) for the same underlying issue on the Power Query side. *** ## Best Practices [#best-practices] * Use a measure, not a calculated column, whenever a value genuinely needs to reflect the literal current date every time the report is viewed. * If a `TODAY()`-based calculated column is required (for query-folding or performance reasons in a very large model, for instance), treat its value as "as of the last refresh," and refresh often enough that staleness doesn't matter for the use case. * Document which refresh schedule a `TODAY()`-based calculated column depends on, since its apparent "bugginess" is usually just an unrefreshed model, not a formula error. *** ## Next Steps [#next-steps] * [Measures](/docs/dax/measures) * [Calculated Columns](/docs/dax/calculated-columns) * [DateTime.LocalNow() and the Desktop-vs-Service Trap](/docs/power-query/datetime-localnow) * [DAX Function Reference](/docs/dax/functions) # TOPN() (/docs/dax/topn) # TOPN() [#topn] `TOPN()` returns a table containing the top (or bottom) N rows, ranked by a given expression — the DAX equivalent of "show me the top 10." ```dax lineNumbers TOPN( N Value, Table, OrderBy Expression, [Order] ) ``` *** ## Basic Example [#basic-example] ```dax lineNumbers Top 5 Products = TOPN( 5, ALL(DimProduct), [Total Sales] ) ``` ```text All Products (ranked by Total Sales, descending) | +-- Tire A 50,000 +-- Tire B 42,000 +-- Helmet A 18,000 +-- Lock A 12,000 +-- Pump A 9,500 | Top 5 Products = these 5 rows ``` Because `TOPN()` returns a table, it's typically wrapped in `CALCULATE()` to restrict a measure to just those top rows, or used inside another table function. *** ## TOPN Inside CALCULATE [#topn-inside-calculate] ```dax lineNumbers Top 5 Sales = CALCULATE( [Total Sales], TOPN( 5, ALL(DimProduct), [Total Sales] ) ) ``` This restricts `[Total Sales]` to only the 5 highest-selling products, regardless of how many products actually exist in the model. *** ## Order: Descending vs. Ascending [#order-descending-vs-ascending] ```dax lineNumbers Bottom 5 Products = TOPN( 5, ALL(DimProduct), [Total Sales], ASC ) ``` ```text DESC (default): highest values first -> "top" N ASC: lowest values first -> "bottom" N ``` *** ## TOPN vs. RANKX + FILTER [#topn-vs-rankx--filter] The same "top N" result can be built by ranking every row and filtering down to the top ranks — `TOPN()` is the more direct route to the same answer. ```dax lineNumbers Top 5 (via RANKX + FILTER) = CALCULATE( [Total Sales], FILTER( ALL(DimProduct), RANKX(ALL(DimProduct), [Total Sales]) <= 5 ) ) ``` ```dax lineNumbers Top 5 (via TOPN) = CALCULATE( [Total Sales], TOPN(5, ALL(DimProduct), [Total Sales]) ) ``` Both return the same result for a clean top-N cutoff. `RANKX()` combined with `FILTER()` is worth reaching for instead when the actual rank number needs to be displayed, or when ties need explicit `Skip`/`Dense` handling — see [RANKX](/docs/dax/rankx). *** ## Handling Ties at the Cutoff [#handling-ties-at-the-cutoff] `TOPN()` can return more than N rows when there's a tie at the boundary — by default, all tied rows at the cutoff point are included rather than arbitrarily cutting one out. ```text Requesting Top 3, with a tie at 3rd place: Tire A 50,000 Tire B 42,000 Helmet A 18,000 Lock A 18,000 <- tied with Helmet A, both included ``` This is usually the desired behavior for a "top N" report, but it means the result set isn't guaranteed to be exactly N rows. *** ## Common Mistakes [#common-mistakes] ### Expecting Exactly N Rows [#expecting-exactly-n-rows] A tie at the cutoff can return more than N rows — code and visuals consuming the result shouldn't assume an exact row count. ### Using TOPN Without CALCULATE for a Scalar Result [#using-topn-without-calculate-for-a-scalar-result] `TOPN()` returns a table. Wrap it in `CALCULATE()` (or another table-consuming function) rather than expecting it to produce a single value directly. ### Choosing TOPN When the Rank Value Itself Is Needed [#choosing-topn-when-the-rank-value-itself-is-needed] If a visual needs to display "Rank 1, 2, 3..." next to each row, `RANKX()` is the right tool — `TOPN()` only filters to the top rows, it doesn't expose a rank number. *** ## Best Practices [#best-practices] * Wrap `TOPN()` in `CALCULATE()` when the goal is a single aggregated result over the top rows. * Use `RANKX()` instead when the rank number itself needs to be shown, or when specific tie-handling is required. * Confirm the table argument (typically wrapped in `ALL()`) matches the intended comparison scope, the same consideration that applies to `RANKX()`. *** ## Next Steps [#next-steps] Continue learning DAX functions and patterns: * [RANKX](/docs/dax/rankx) * [Ranking Patterns](/docs/dax-patterns/ranking) * [FILTER](/docs/dax/filter) # TRIM(), UPPER() & LOWER() (/docs/dax/trim-upper-lower) # TRIM(), UPPER() & LOWER() [#trim-upper--lower] These are the DAX equivalents of Power Query's [Text.Trim(), Text.Upper() & Text.Lower()](/docs/power-query/text-trim-upper-lower) — with one real difference in what `TRIM()` actually removes. ```dax lineNumbers TRIM(text) UPPER(text) LOWER(text) ``` *** ## UPPER() and LOWER() [#upper-and-lower] ```dax lineNumbers UPPER("active") LOWER("ACTIVE") ``` ```text UPPER("active") -> "ACTIVE" LOWER("ACTIVE") -> "active" ``` These behave exactly as expected, and exactly like their Power Query counterparts — the only real subtlety with case conversion is using it for comparison rather than display, covered below. *** ## TRIM() Also Collapses Internal Spaces [#trim-also-collapses-internal-spaces] ```dax lineNumbers TRIM(" Widget Sales ") ``` ```text " Widget Sales " -> "Widget Sales" ``` DAX's `TRIM()` mirrors Excel's `TRIM()`: it removes leading and trailing spaces, **and** collapses every internal run of multiple spaces down to a single space. This is different from Power Query's `Text.Trim()`, which only touches the leading and trailing ends — internal spaces, however many there are, pass through completely unchanged. The same messy source text produces two different-length results depending on which layer cleans it: `TRIM()` in DAX quietly fixes a double space typed between two words; `Text.Trim()` in Power Query leaves it exactly as it was. ```text " Widget Sales " TRIM(...) -> "Widget Sales" (11 chars, one space between words) Text.Trim(...) -> "Widget Sales" (14 chars, all 3 internal spaces survive) ``` *** ## UPPER() / LOWER() for Comparison, Not Just Display [#upper--lower-for-comparison-not-just-display] ```dax lineNumbers Status Match = LOWER([Status]) = "active" ``` DAX text comparisons are case-sensitive by default, same as Power Query — converting both sides to a consistent case before comparing is the standard fix. *** ## Common Mistakes [#common-mistakes] ### Assuming TRIM() Only Touches the Ends, Like Text.Trim() [#assuming-trim-only-touches-the-ends-like-texttrim] Porting a value cleaned with `Text.Trim()` in Power Query into a DAX calculated column and re-cleaning it with `TRIM()` can produce a shorter result than expected — any internal double space that survived `Text.Trim()` gets collapsed by `TRIM()`, which may or may not be desired depending on whether that internal spacing was meaningful. ### Expecting Text.Trim() to Catch an Internal Double Space [#expecting-texttrim-to-catch-an-internal-double-space] The reverse mistake: assuming Power Query already normalized internal spacing because a value "went through Text.Trim()" — it didn't touch anything except the two ends. ### Overwriting Original Case When Only Comparison Needed It [#overwriting-original-case-when-only-comparison-needed-it] Applying `UPPER()` or `LOWER()` directly to a calculated column meant for display permanently destroys the original casing — convert case only inside the comparison expression itself if the original value still needs to display normally. *** ## Best Practices [#best-practices] * Use `TRIM()` when internal double-spacing genuinely needs cleaning up, not just leading/trailing whitespace. * Don't assume a value is already fully cleaned just because it passed through the other language's trim function — the two aren't equivalent. * Convert case only inside a comparison expression (`LOWER([Status]) = "active"`), not as a standing transformation, when the original casing still needs to display. *** ## Next Steps [#next-steps] * [Text.Trim(), Text.Upper() & Text.Lower()](/docs/power-query/text-trim-upper-lower) * [LEFT(), RIGHT() & MID()](/docs/dax/mid-left-right) * [DAX Function Reference](/docs/dax/functions) # UNION(), EXCEPT() & INTERSECT() (/docs/dax/union) # UNION(), EXCEPT() & INTERSECT() [#union-except--intersect] These three table set-operations combine or compare two or more tables — and all three share the same easy-to-miss rule: columns are matched by **position**, not by name. ```dax lineNumbers UNION(Table1, Table2, ...) EXCEPT(Table1, Table2) INTERSECT(Table1, Table2) ``` *** ## UNION() Stacks Tables — Matched by Position [#union-stacks-tables--matched-by-position] ```dax lineNumbers UNION(Table1, Table2) ``` The tables need the same *number* of columns, but their names don't need to match at all — and even if the names do match, `UNION()` doesn't check them. Every table's 1st column lands in the result's 1st column, the 2nd in the 2nd, and so on, regardless of what any table calls that position. If `Table2` happens to have been built with its columns in the opposite order from `Table1` — an entirely reasonable thing to do while writing a separate query — `UNION()` still combines them positionally. The result silently places `Table2`'s `Amount` values under the `Region` header and its `Region` values under `Amount`, with no error, no warning, and column headers in the output that quietly mean the wrong thing for half the rows. ```text Table1: Region, Amount -> East, 100 / West, 200 Table2: Amount, Region -> 50, North / 75, South (built in the opposite order) UNION(Table1, Table2) column headers come from Table1: Region, Amount Result: East, 100 West, 200 50, North <- wrong: this is Amount-then-Region data under Region-then-Amount headers 75, South ``` *** ## EXCEPT() and INTERSECT() Share the Same Rule [#except-and-intersect-share-the-same-rule] ```dax lineNumbers EXCEPT(Table1, Table2) // rows in Table1 not present in Table2 INTERSECT(Table1, Table2) // rows present in both ``` Both compare rows across the two tables using the same positional column matching as `UNION()` — a row is compared value-by-value in column order, not by matching column names. Two tables with the same data but columns built in a different order will report rows as different (or matching) based on position, not on what the columns are actually called. *** ## Common Mistakes [#common-mistakes] ### Assuming UNION() Matches Columns by Name [#assuming-union-matches-columns-by-name] As demonstrated above — building a second table with the same column names in a different order produces a silent, wrong-looking result with no error to flag it. Always verify column order matches before combining, not just column names. ### Combining Tables With a Different Number of Columns [#combining-tables-with-a-different-number-of-columns] `UNION()` (and `EXCEPT()`/`INTERSECT()`) require every table to have the same number of columns — this does raise an error, unlike the positional-name mismatch above, so it's the safer of the two failure modes to accidentally trigger. ### Not Reordering Columns Explicitly Before a UNION [#not-reordering-columns-explicitly-before-a-union] If two source tables genuinely have their columns in a different order, `SELECTCOLUMNS()` can rebuild one of them with columns in the matching order before the `UNION()` — safer than assuming the order already lines up. *** ## Best Practices [#best-practices] * Before using `UNION()`, `EXCEPT()`, or `INTERSECT()`, explicitly verify — don't assume — that every table's columns are in the same order, not just named the same. * Use `SELECTCOLUMNS()` to rebuild a table with columns in an explicit, known order immediately before combining it with another table. * When in doubt, add a column to each table identifying its source before a `UNION()`, then spot-check a few rows in the result to confirm nothing landed under the wrong header. *** ## Next Steps [#next-steps] * [DAX Function Reference](/docs/dax/functions) * [Table.Combine() and Merge vs. Append](/docs/power-query/merge-vs-append) * [SUMMARIZE()](/docs/dax/summarize) # DAX User-Defined Functions (UDFs) (/docs/dax/user-defined-functions) # DAX User-Defined Functions (UDFs) [#dax-user-defined-functions-udfs] DAX user-defined functions let you package a calculation once and reuse it across measures, calculated columns, visual calculations, and other UDFs — instead of copying the same expression into multiple places and hoping they stay in sync. UDFs reached general availability in Power BI Desktop and the Power BI Service with the June 2026 release, and require database compatibility level 1702 or higher. ```dax lineNumbers DEFINE FUNCTION = ( [ [: [] [] []] [= ], ...] ) => ``` *** ## A First Example [#a-first-example] ```dax lineNumbers DEFINE /// AddTax takes an amount and returns the amount including tax /// @param {NUMERIC} amount - The pre-tax value /// @param {NUMERIC} [taxRate] - Optional tax rate, default 0.1 (10%) /// @returns The amount including tax FUNCTION AddTax = ( amount : NUMERIC, taxRate : NUMERIC = 0.1 ) => amount * ( 1 + taxRate ) EVALUATE { AddTax ( 10 ) } // Returns 11 ``` The `///` comments aren't decoration — they're JSDoc-style documentation comments that show up in IntelliSense when someone types the function name later. A plain `//` or `/* */` comment won't appear there. *** ## Where UDFs Are Defined [#where-udfs-are-defined] UDFs are authored in **DAX query view** or **TMDL view** — not inline inside a single measure. Once defined, click **Update model with changes** (DAX query view) or **Apply** (TMDL view) to save the function to the model, where it becomes a first-class object visible under the **Functions** node in Model Explorer. ```text DAX query view / TMDL view | | define + save to model | Function becomes a model object | | callable from | Measures, calculated columns, visual calculations, other UDFs ``` *** ## Calling a UDF [#calling-a-udf] Once saved, a UDF is called exactly like a built-in function. ```dax lineNumbers Total Sales with Tax = AddTax ( [Total Sales] ) ``` In a calculated column, cast the result explicitly if the column needs a specific type: ```dax lineNumbers Sales Amount with Tax = CONVERT ( AddTax ( 'Sales'[Sales Amount] ), CURRENCY ) ``` UDFs can also call each other: ```dax lineNumbers DEFINE FUNCTION AddTax = ( amount : NUMERIC ) => amount * 1.1 FUNCTION AddTaxAndDiscount = ( amount : NUMERIC, discount : NUMERIC ) => AddTax ( amount - discount ) EVALUATE { AddTaxAndDiscount ( 10, 2 ) } // Returns 8.8 ``` Visual calculations can call UDFs too, but only on fields already present in that visual — a UDF used in a visual calculation can't reach a model column or measure that isn't already in the visual. *** ## Parameters: Type, Subtype, and val vs. expr [#parameters-type-subtype-and-val-vs-expr] This is the part that actually trips people up. Every parameter can optionally declare three things: a **type**, a **subtype**, and a **parameter mode**. ```text [type] [subtype] [parameterMode] ``` Omitting everything is valid — `amount` alone behaves as `AnyVal val`, evaluated immediately at call time. That's fine for simple functions. ### Type [#type] | Type | Accepts | | ------------------ | ------------------------- | | `AnyVal` (default) | A scalar or a table | | `Scalar` | A scalar value | | `Table` | A table | | `AnyRef` | Any reference | | `ColumnRef` | A reference to a column | | `MeasureRef` | A reference to a measure | | `TableRef` | A reference to a table | | `CalendarRef` | A reference to a calendar | ### Subtype (for `Scalar` only) [#subtype-for-scalar-only] `Variant`, `Int64`, `Decimal`, `Double`, `String`, `DateTime`, `Boolean`, or `Numeric` (any of the three numeric subtypes). ### ParameterMode: val vs. expr [#parametermode-val-vs-expr] This is the important one. It controls **when** the argument is actually evaluated. ```text val (eager, default) -> evaluated once, before the function runs — inherits the caller's row AND filter context expr (lazy) -> passed unevaluated, function decides when/how — inherits only filter context ``` `Scalar` and `Table` parameters can use either. `AnyRef`, `ColumnRef`, `MeasureRef`, `TableRef`, and `CalendarRef` **must** be `expr`, since a reference has to be resolved inside the function's own context, not the caller's. The difference is easiest to see with a table parameter used inside `CALCULATETABLE`: ```dax lineNumbers DEFINE /// val: receives an already-materialized table — context inside can't change it FUNCTION CountRowsNow = ( t : TABLE VAL ) => COUNTROWS ( CALCULATETABLE ( t, ALL ( 'Date' ) ) ) /// expr: receives the unevaluated expression — context inside CAN change it FUNCTION CountRowsLater = ( t : TABLE EXPR ) => COUNTROWS ( CALCULATETABLE ( t, ALL ( 'Date' ) ) ) EVALUATE { CALCULATE ( CountRowsNow ( 'Sales' ), 'Date'[Fiscal Year] = "FY2020" ), CALCULATE ( CountRowsLater ( 'Sales' ), 'Date'[Fiscal Year] = "FY2020" ) } // CountRowsNow returns rows for FY2020 only — 'Sales' was already filtered before entering the function. // CountRowsLater returns rows for all years — ALL('Date') inside the function actually takes effect. ``` `CountRowsNow`'s `ALL('Date')` does nothing, because by the time it runs, `t` is already a fixed, filtered table — there's no filter context left to remove. `CountRowsLater` receives the *expression* `'Sales'`, not its already-evaluated result, so `CALCULATETABLE` inside the function genuinely gets to apply `ALL('Date')` before the table is materialized. See [CALCULATE](/docs/dax/calculate) for the filter-context mechanics this depends on. *** ## Default Expressions (Optional Parameters) [#default-expressions-optional-parameters] Adding `= ` makes a parameter optional. ```dax lineNumbers FUNCTION AddTax = ( amount : NUMERIC, taxRate : NUMERIC = 0.1 ) => amount * ( 1 + taxRate ) ``` A default expression can only reference names visible where the function is *defined*, not where it's called, and it can't reference another optional parameter. Required parameters can technically follow optional ones — a caller can leave a gap (`MyFunc(1, , 3)`) to use the default — but the function's minimum argument count is still set by the rightmost required parameter. *** ## Type Checking Inside a UDF [#type-checking-inside-a-udf] Since parameters often accept more than one shape of input, DAX provides boolean type-check functions for use inside a UDF body: `ISNUMERIC`, `ISNUMBER`, `ISDOUBLE`, `ISINT64`, `ISINTEGER`, `ISDECIMAL`, `ISCURRENCY`, `ISSTRING`, `ISTEXT`, `ISBOOLEAN`, `ISLOGICAL`, `ISDATETIME`. ```dax lineNumbers DEFINE /// Accepts a currency key (Int64) or a currency code (String) FUNCTION GetCurrencyName = ( currency ) => IF ( ISINT64 ( currency ), LOOKUPVALUE ( 'Currency'[Currency], 'Currency'[CurrencyKey], currency ), LOOKUPVALUE ( 'Currency'[Currency], 'Currency'[Code], currency ) ) EVALUATE { GetCurrencyName ( 36 ), GetCurrencyName ( "USD" ) } // returns "Euro", "US Dollar" ``` `TABLEOF()` (returns the full table behind a column, measure, or calendar reference) and `NAMEOF()` (returns an object's name as text) are the two information functions most commonly used alongside type checking when a UDF needs to work generically across different inputs. *** ## Common Mistakes [#common-mistakes] **Expecting recursion to work.** UDFs don't support recursion or mutual recursion — a function calling itself, directly or through another function, isn't allowed. **Using `val` when the function needs to control context.** If a UDF's whole purpose is to apply a filter modification (like `ALL()`) inside `CALCULATETABLE`, a `val` parameter defeats it — the table arrives already evaluated, with nothing left to filter. Reach for `expr` (or `TableRef`/`ColumnRef`) whenever the function needs to affect *how* something is evaluated, not just *what value* it receives. **Assuming Object-Level Security carries over.** OLS on a measure or column doesn't automatically apply to a UDF that references it, and it doesn't transfer the other way either. A UDF wrapping a secured measure isn't secured just because the measure is. **Expecting an explicit return type or function overloading.** Neither is supported — a UDF's return type is inferred from its body, and you can't define two versions of the same function name for different parameter signatures. *** ## Limitations [#limitations] * No recursion or mutual recursion. * No function overloading, and no explicit return type declaration. * Parameters can't have their own descriptions (only the function itself can, via `///`). * A UDF can't return an `enum` value. * Can't be hidden/unhidden, put in display folders, or combined with translations. * Live-connected reports get no IntelliSense for UDFs from the source model; composite models can't reference source-model UDFs from model-based measures at all. *** ## Best Practices [#best-practices] * Add `///` documentation comments to every UDF meant for reuse — it's the only way the function's purpose shows up in IntelliSense later. * Use `expr`/reference parameter types specifically when a function needs to control filter or row context internally — don't default to `val` out of habit. * Add type-checking (`ISINT64`, `ISSTRING`, etc.) at the top of a function that accepts more than one shape of input, rather than assuming the caller always passes the expected type. * Keep a UDF's logic focused on one reusable calculation — nest smaller UDFs together rather than building one large function that tries to do everything. *** ## Next Steps [#next-steps] * [CALCULATE](/docs/dax/calculate) * [Measures](/docs/dax/measures) * [Variables (VAR)](/docs/dax/variables) * [DAX Function Reference](/docs/dax/functions) New to UDFs? See [DAX User-Defined Functions Are Here — What You Need to Know](/blog/dax-user-defined-functions-explained) for the shape of the feature before diving into the full syntax above. # USERELATIONSHIP() (/docs/dax/userelationship) # USERELATIONSHIP() [#userelationship] `USERELATIONSHIP()` activates a specific inactive relationship for the duration of a single calculation, without changing which relationship is active by default in the rest of the model. ```dax lineNumbers CALCULATE( Expression, USERELATIONSHIP(Column1, Column2) ) ``` *** ## Why Inactive Relationships Exist [#why-inactive-relationships-exist] A table can have more than one relationship to another table, but only one can be active at a time — every other relationship between the same two tables is created inactive. ```text FactSales | +-- OrderDateKey -> DimDate[DateKey] (active) +-- ShipDateKey -> DimDate[DateKey] (inactive) +-- DueDateKey -> DimDate[DateKey] (inactive) ``` This commonly happens with multiple date roles against one date table — order date, ship date, due date — where only one relationship can drive automatic filter propagation. *** ## Basic Example [#basic-example] ```dax lineNumbers Sales by Ship Date = CALCULATE( [Total Sales], USERELATIONSHIP(FactSales[ShipDateKey], DimDate[DateKey]) ) ``` ```text Default behavior: Total Sales filters by OrderDateKey (the active relationship) With USERELATIONSHIP: This specific measure instead filters by ShipDateKey ``` The rest of the model, and every other measure, is unaffected — `USERELATIONSHIP()` only changes which relationship is active inside the `CALCULATE()` it's used in. *** ## Multiple Date Roles Example [#multiple-date-roles-example] ```text DimDate | +-- OrderDateKey (active) -> "Total Sales" uses this by default +-- ShipDateKey (inactive) -> "Sales by Ship Date" activates this explicitly +-- DueDateKey (inactive) -> "Sales by Due Date" activates this explicitly ``` ```dax lineNumbers Sales by Due Date = CALCULATE( [Total Sales], USERELATIONSHIP(FactSales[DueDateKey], DimDate[DateKey]) ) ``` Each measure that needs a different date role gets its own `USERELATIONSHIP()` call, rather than the model needing three separate date tables. *** ## USERELATIONSHIP vs. Multiple Date Tables [#userelationship-vs-multiple-date-tables] An alternative to inactive relationships is creating a separate date table per role — `DimOrderDate`, `DimShipDate`, `DimDueDate` — each with its own active relationship. | | Inactive Relationship + USERELATIONSHIP | Separate Date Table per Role | | ---------------- | -------------------------------------------------- | ----------------------------------------------- | | Model complexity | One date table, several inactive relationships | Multiple date tables | | DAX complexity | Explicit `USERELATIONSHIP()` per measure | Simpler measures, no `USERELATIONSHIP()` needed | | Slicer behavior | One shared date slicer, roles selected via measure | Separate slicer needed per date role | Neither is universally correct — `USERELATIONSHIP()` keeps the model smaller with one shared date table, while separate date tables make each measure simpler at the cost of more tables and slicers. *** ## Common Mistakes [#common-mistakes] ### Forgetting USERELATIONSHIP Only Affects Its Own CALCULATE [#forgetting-userelationship-only-affects-its-own-calculate] Each measure that needs the inactive relationship needs its own `USERELATIONSHIP()` call — it doesn't change the relationship's active/inactive status in the model globally. ### Combining Conflicting Relationships in One CALCULATE [#combining-conflicting-relationships-in-one-calculate] Trying to activate two relationships between the same two tables inside one `CALCULATE()` produces an error — only one can be active for a given pair of tables at a time, even temporarily. ### Building a Whole Model Around Inactive Relationships [#building-a-whole-model-around-inactive-relationships] If nearly every measure needs a different date role, separate date tables per role are often simpler to maintain than a web of `USERELATIONSHIP()` calls scattered across the model's measures. *** ## Best Practices [#best-practices] * Use `USERELATIONSHIP()` for occasional alternate-date (or alternate-key) calculations, not as the default way every measure resolves its relationships. * Name relationship-specific measures clearly ("Sales by Ship Date", not just "Sales 2") so it's obvious which relationship each one activates. * Consider separate date tables per role instead, once enough measures need `USERELATIONSHIP()` that the model becomes hard to follow. *** ## Next Steps [#next-steps] Continue learning DAX functions: * [RELATED & RELATEDTABLE](/docs/dax/related) * [CALCULATE](/docs/dax/calculate) * [Date Tables](/docs/modeling/date-tables) # VALUES() vs DISTINCT() (/docs/dax/values-distinct) # VALUES() vs DISTINCT() [#values-vs-distinct] `VALUES()` and `DISTINCT()` both return a one-column table of the distinct values in a column — and in a clean, fully-matched model, they're interchangeable. They diverge in exactly one well-documented case: a broken relationship. ```dax lineNumbers VALUES() as table DISTINCT() as table ``` *** ## Identical, Until a Relationship Is Involved [#identical-until-a-relationship-is-involved] ```dax lineNumbers COUNTROWS(VALUES(DimProduct[Category])) COUNTROWS(DISTINCT(DimProduct[Category])) ``` With no relationship issues, both return the same count — every distinct value physically present in the column, nothing more. *** ## The Referential Integrity Blank Row [#the-referential-integrity-blank-row] If `DimProduct[Category]` sits on the "one" side of a relationship, and the fact table on the "many" side has a row whose foreign key doesn't match any row in `DimProduct` (a referential integrity violation — a `Category` value in `FactSales` that simply doesn't exist in `DimProduct`), `VALUES()` adds an **extra blank row** to its result to account for that unmatched fact data. `DISTINCT()` never does this. ```text DimProduct[Category]: Electronics, Furniture, Apparel FactSales has a row with Category = "Gadgets" <- not in DimProduct at all COUNTROWS(VALUES(DimProduct[Category])) -> 4 (3 real categories + 1 blank row) COUNTROWS(DISTINCT(DimProduct[Category])) -> 3 (3 real categories, no blank row) ``` This is exactly why a matrix visual with a dimension column on rows can show an unexpected `(Blank)` row: it's not a blank cell hiding somewhere in the data, it's `VALUES()` (which is what most visuals use internally to populate rows) flagging fact rows that don't have anywhere real to belong. *** ## Common Mistakes [#common-mistakes] ### Assuming a (Blank) Row Means There's a Null in the Data [#assuming-a-blank-row-means-theres-a-null-in-the-data] The blank row from a referential integrity gap isn't the same thing as an actual `BLANK()`/`NULL` value stored in the dimension column — it can appear even when every value in `DimProduct[Category]` is fully populated, purely because a fact row's foreign key doesn't match anything. ### Using COUNTROWS(VALUES(...)) as a Row Count Without Checking for This [#using-countrowsvalues-as-a-row-count-without-checking-for-this] A measure like `COUNTROWS(VALUES(DimProduct[Category]))` meant to report "how many categories exist" can silently return one more than expected the moment a single orphaned fact row exists — `DISTINCT()` is the safer choice when the actual dimension count, not a relationship-aware count, is what's needed. ### Not Investigating an Unexpected Blank Row in a Visual [#not-investigating-an-unexpected-blank-row-in-a-visual] A `(Blank)` row appearing in a matrix or table is a genuine signal worth investigating — it usually means a fact table has rows referencing a dimension value that was deleted, renamed, or never loaded, not a display quirk to filter away without understanding why it's there. *** ## Best Practices [#best-practices] * Treat an unexpected `(Blank)` row in a VALUES()-driven visual as a referential integrity check, not noise to hide. * Use `DISTINCT()` when the goal is genuinely "the values in this column," independent of any relationship's data quality. * Use `VALUES()` deliberately when the blank row's signal — "some fact data doesn't match this dimension" — is actually useful information for the report. * Fix the underlying data gap (a missing dimension row, an unmapped fact key) rather than only filtering the blank row out of the visual. *** ## Next Steps [#next-steps] * [DISTINCTCOUNT](/docs/dax/distinctcount) * [Table.Distinct()](/docs/power-query/table-distinct) * [List.Distinct() & List.Contains()](/docs/power-query/list-distinct-contains) * [DAX Function Reference](/docs/dax/functions) # Variables (VAR) (/docs/dax/variables) # Variables (VAR) [#variables-var] Variables (`VAR`) allow you to store the result of a calculation and reuse it later in the same DAX expression. Using variables makes DAX formulas: * Easier to read * Easier to debug * Easier to maintain * More efficient Although variables are optional, they are considered a best practice for writing professional DAX. *** ## What Is a Variable? [#what-is-a-variable] A variable stores a value that can be referenced later in a DAX expression. General syntax: ```dax lineNumbers VAR VariableName = Expression RETURN Expression ``` Variables are declared first. The `RETURN` statement specifies the final value returned by the measure. *** ## Simple Example [#simple-example] Without variables: ```dax lineNumbers Profit Margin = DIVIDE( [Gross Profit], [Total Sales] ) ``` With variables: ```dax lineNumbers Profit Margin = VAR Profit = [Gross Profit] VAR Sales = [Total Sales] RETURN DIVIDE( Profit, Sales ) ``` Both measures return the same result. The second version is easier to read and extend. *** ## Why Use Variables? [#why-use-variables] Variables provide several important benefits. They: * Reduce repeated calculations * Improve readability * Simplify debugging * Make formulas easier to modify * Improve performance in many scenarios As DAX formulas become more complex, variables become increasingly valuable. *** ## Variable Scope [#variable-scope] A variable only exists inside the expression where it is created. Example: ```dax lineNumbers VAR Sales = [Total Sales] RETURN Sales ``` The variable **Sales** cannot be used by another measure. Each measure has its own variables. *** ## Using Multiple Variables [#using-multiple-variables] A DAX expression can contain multiple variables. Each variable is evaluated before the `RETURN` statement. Example: ```dax lineNumbers Profit Margin = VAR Sales = [Total Sales] VAR Profit = [Gross Profit] VAR Margin = DIVIDE( Profit, Sales ) RETURN Margin ``` Breaking calculations into small steps makes the formula easier to understand and maintain. *** ## Variables Are Evaluated Once [#variables-are-evaluated-once] One of the biggest advantages of variables is that they are evaluated only once. Without variables: ```dax lineNumbers Profit Ratio = DIVIDE( [Gross Profit], [Total Sales] ) + DIVIDE( [Gross Profit], [Total Sales] ) ``` Power BI must evaluate both measures multiple times. Using variables: ```dax lineNumbers Profit Ratio = VAR Profit = [Gross Profit] VAR Sales = [Total Sales] VAR Margin = DIVIDE( Profit, Sales ) RETURN Margin + Margin ``` The variables are calculated once and reused throughout the expression. This often improves readability and can improve performance. *** ## Variables with CALCULATE() [#variables-with-calculate] Variables work exceptionally well with `CALCULATE()`. Example: ```dax lineNumbers West Sales = VAR WestSales = CALCULATE( [Total Sales], DimCustomer[Region] = "West" ) RETURN WestSales ``` Although this example is simple, variables become extremely valuable when several `CALCULATE()` statements are combined. *** ## Comparing Values [#comparing-values] Variables make comparisons much easier to read. Example: ```dax lineNumbers Sales Growth = VAR CurrentYear = [Sales] VAR PreviousYear = [Sales LY] RETURN CurrentYear - PreviousYear ``` Instead of repeatedly referencing measures, the calculation clearly describes the business logic. *** ## Business Example [#business-example] Suppose management wants to calculate profit after expenses. Example: ```dax lineNumbers Net Profit = VAR Revenue = [Total Sales] VAR Cost = [Total Cost] VAR Expenses = [Operating Expenses] RETURN Revenue - Cost - Expenses ``` Each variable represents a business concept, making the formula easy to understand for both developers and analysts. *** ## Variables Can Store More Than Numbers [#variables-can-store-more-than-numbers] Variables can store many types of values. Examples include: * Numbers * Text * Dates * Tables * Results from other DAX functions Example: ```dax lineNumbers CurrentYear = VAR SelectedYear = MAX(DimDate[Year]) RETURN SelectedYear ``` This flexibility makes variables useful in both simple and advanced DAX calculations. *** ## Debugging with Variables [#debugging-with-variables] Variables make debugging DAX much easier. Instead of returning the final calculation, you can temporarily return an intermediate variable. Example: ```dax lineNumbers Profit Margin = VAR Sales = [Total Sales] VAR Profit = [Gross Profit] VAR Margin = DIVIDE( Profit, Sales ) RETURN Margin ``` While troubleshooting, you might instead return: ```dax lineNumbers RETURN Profit ``` or ```dax lineNumbers RETURN Sales ``` This technique lets you verify each step of the calculation before returning the final result. *** ## Naming Variables [#naming-variables] Use descriptive variable names that explain what the value represents. Good examples: ```dax lineNumbers VAR TotalSales = [Total Sales] VAR PreviousYearSales = [Sales LY] VAR SalesGrowth = TotalSales - PreviousYearSales ``` Avoid generic names such as: ```dax lineNumbers VAR X VAR Temp VAR Test ``` Clear names make formulas easier to understand months later. *** ## Performance Best Practices [#performance-best-practices] Variables can improve both readability and efficiency. Recommended practices include: * Store repeated calculations in variables. * Break complex formulas into logical steps. * Use meaningful variable names. * Keep each variable focused on a single task. * Return only the final result. Variables help Power BI avoid evaluating the same expression multiple times within a measure. *** ## Common Beginner Mistakes [#common-beginner-mistakes] Avoid these common errors: * Creating variables that are never used. * Giving variables vague names. * Trying to reference a variable outside its measure. * Writing one long expression instead of breaking it into smaller variables. * Forgetting the `RETURN` statement. Remember that variables only exist within the measure in which they are declared. *** ## Variables vs Measures [#variables-vs-measures] Variables and measures are often confused, but they serve different purposes. | Variables | Measures | | -------------------------------------- | ------------------------------------------ | | Exist only within one expression | Can be reused throughout the model | | Evaluated once during the calculation | Evaluated whenever the measure is called | | Cannot be referenced by other measures | Can be referenced by any report or measure | | Improve readability | Represent reusable business logic | A common pattern is to build reusable **measures** and then use **variables** to organize complex calculations. *** ## Summary [#summary] Variables (`VAR`) are one of the most valuable features of DAX. They help you: * Write cleaner code. * Reduce repeated calculations. * Simplify debugging. * Improve readability. * Organize complex business logic. Although variables are optional, they are considered a best practice for nearly every professional DAX measure. *** ## Next Steps [#next-steps] Now that you understand variables, continue with more advanced DAX topics: * [Iterators](/docs/dax/iterator) * [Time Intelligence](/docs/dax/time-intelligence) You'll use variables extensively when building iterator expressions, running totals, and advanced business calculations. Want to reuse a calculation across multiple measures, not just within one? See [DAX User-Defined Functions (UDFs)](/docs/dax/user-defined-functions) — a variable's scope is one formula, a UDF's is the whole model. Seen `EARLIER()` used to reach back to an outer row context? See [EARLIER](/docs/dax/earlier) for why a variable now solves that same problem more clearly. See variables used for a real "previous row per entity" calculated column: [Build a Reliability (MTBF/MTTR) Dashboard](/tutorials/build-a-reliability-mtbf-mttr-dashboard). # Fabric Capacity & Cost Management (/docs/fabric/capacity) # Fabric Capacity & Cost Management [#fabric-capacity--cost-management] Every Fabric workload — Lakehouse Spark jobs, Warehouse queries, Direct Lake framing, Power BI report queries — draws from a shared pool of compute called a capacity, sized by SKU and measured in Capacity Units (CUs). ```text Fabric Capacity (e.g. F64) | +-- Workspace A | +-- Lakehouse Spark job -> consumes CUs | +-- Power BI report query -> consumes CUs | +-- Workspace B +-- Warehouse query -> consumes CUs +-- Direct Lake framing -> consumes CUs ``` Unlike Power BI Pro, which licenses individual users, capacity is a shared resource that every workload assigned to it competes for. *** ## Capacity SKUs [#capacity-skus] Fabric capacity comes in SKUs from F2 up through F2048, each roughly double the Capacity Units of the one before it. ```text F2 -> F4 -> F8 -> F16 -> F32 -> F64 -> F128 -> ... -> F2048 ``` F64 is commonly treated as roughly equivalent to the old Power BI Premium P1 SKU in terms of capacity, which is a useful anchor point when estimating what size a workload that previously ran on Premium would need on Fabric. *** ## Smoothing and Bursting [#smoothing-and-bursting] Fabric doesn't charge every operation at face value the instant it runs. Background operations (like scheduled refreshes or Spark jobs) are smoothed over a 24-hour window, and interactive operations get short-term burst capacity beyond the SKU's baseline. ```text Background operation (e.g. a large refresh) | | cost smoothed across 24 hours, not charged all at once | Interactive operation (e.g. a user opening a report) | | can briefly burst above baseline capacity for responsiveness ``` This is why a single heavy job doesn't necessarily throttle the capacity outright — but sustained, consistent overuse across many operations will. *** ## Throttling [#throttling] When a capacity is consistently over its available Capacity Units, Fabric throttles it — starting with delaying background operations, and escalating to rejecting interactive requests if the overage continues. ```text Capacity usage over time | | briefly over -> smoothed, minimal impact | | consistently over -> background operations delayed | | still over -> interactive operations rejected ``` Throttling is the practical signal that a capacity is undersized for its assigned workloads, not just a temporary spike worth ignoring. *** ## Autoscale and Pausing [#autoscale-and-pausing] Capacity doesn't have to run at a fixed size all the time. ```text Autoscale — automatically increases capacity size temporarily during sustained overage Pause — stops billing entirely for a capacity that isn't in active use ``` Pausing a development or test capacity outside of working hours, and relying on autoscale for a production capacity with unpredictable peak load, are both common ways to avoid paying for headroom that sits idle most of the time. *** ## Migrating from Power BI Premium [#migrating-from-power-bi-premium] Power BI Premium (P SKUs) is being phased out in favor of Fabric capacity (F SKUs). Existing Premium capacities can be migrated to an equivalent Fabric capacity from the admin portal, generally without needing to rebuild workspaces or content. ```text Power BI Premium (P1, P2, P3, ...) | | admin portal migration | Fabric Capacity (F64, F128, F256, ...) ``` Workspaces assigned to the old Premium capacity carry over to the new Fabric capacity, keeping the same reports, datasets, and permissions in place. *** ## Monitoring Usage [#monitoring-usage] The Fabric Capacity Metrics app, installed from the admin portal, shows Capacity Unit consumption broken out by workspace, item, and operation type (background vs. interactive), which is the main tool for understanding what's actually driving a capacity's usage. ```text Fabric Capacity Metrics app | +-- Usage by workspace +-- Usage by item (which Lakehouse, which report) +-- Background vs. interactive breakdown +-- Throttling history ``` Reviewing this regularly is what turns "the capacity feels slow" into a specific, actionable finding — a particular Spark job, a particular report, or a particular refresh schedule. *** ## Best Practices [#best-practices] * Size capacity based on actual measured usage from the Capacity Metrics app, not a guess extrapolated from Premium sizing alone. * Pause non-production capacities outside of active working hours. * Use autoscale for production capacities with unpredictable peaks, rather than permanently over-provisioning a fixed size to cover worst case. * Review the Capacity Metrics app regularly, not just when something is already noticeably slow. *** ## Common Mistakes [#common-mistakes] ### Sizing Capacity Once and Never Revisiting It [#sizing-capacity-once-and-never-revisiting-it] Workloads grow as more teams and reports land on a capacity. A SKU sized correctly at launch can become undersized months later without anyone noticing until throttling starts. ### Leaving Dev/Test Capacity Running Around the Clock [#leaving-devtest-capacity-running-around-the-clock] A development capacity used only during business hours but left running 24/7 pays for idle time that pausing would have avoided entirely. ### Diagnosing Slowness Without the Metrics App [#diagnosing-slowness-without-the-metrics-app] Guessing which workload is causing throttling, instead of checking the Capacity Metrics app's breakdown by workspace and item, wastes time chasing the wrong fix. *** ## Fabric Capacity Checklist [#fabric-capacity-checklist] * Capacity size is based on measured usage, reviewed periodically. * Non-production capacities are paused when not in active use. * Autoscale is enabled for production capacities with unpredictable peak load. * The Capacity Metrics app is checked regularly, not only after a slowdown is reported. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [Introduction](/docs/fabric/introduction) * [Direct Lake Mode](/docs/fabric/direct-lake) * [Workspaces](/docs/power-bi-service/workspaces) # Data Factory (/docs/fabric/data-factory) # Data Factory [#data-factory] Data Factory is Fabric's data movement and orchestration workload, built around two complementary tools: Pipelines for scheduling and moving data, and Dataflows Gen2 for Power Query-based transformation that lands directly in OneLake. ```text Data Factory | +-- Pipelines — orchestration: move data, run activities, on a schedule or trigger +-- Dataflows Gen2 — transformation: Power Query logic, output lands as a Delta table ``` The two are often used together — a pipeline triggers a dataflow as one of its steps, then continues on to whatever depends on that dataflow's output. *** ## Pipelines [#pipelines] A pipeline is a sequence of activities, similar in concept to Azure Data Factory pipelines, that move and orchestrate data across a workflow. ```text Pipeline: "Daily Sales Load" | +-- Copy Data activity — pulls from source system into a Lakehouse +-- Dataflow activity — runs a Dataflow Gen2 to clean the copied data +-- Notebook activity — runs a Spark notebook for further transformation +-- Stored Procedure — calls a Warehouse stored procedure ``` Activities run in a defined order, with support for branching, conditionals, and parameters — the same orchestration concepts as a traditional ETL tool, built into the Fabric workspace. *** ## Triggers [#triggers] Pipelines run either on a schedule or in response to an event. ```text Trigger types | +-- Scheduled — e.g. every day at 6:00 AM +-- Event-based — e.g. runs when a file lands in a storage location ``` Event-based triggers are useful when a source system delivers data irregularly, and waiting for the next scheduled run would introduce unnecessary delay. *** ## Dataflows Gen2 [#dataflows-gen2] A Dataflow Gen2 uses the same Power Query Online editor as a Power BI dataflow, but with Fabric-native output: instead of landing in a Power BI-only dataset format, its output writes directly to a Lakehouse table or Warehouse table in OneLake. ```text Source | | Power Query transformations (same engine as Power BI Desktop) | Output destination: a Lakehouse table or Warehouse table ``` This makes Dataflow Gen2 output immediately usable by anything else reading OneLake — a notebook, a Warehouse query, or a Power BI semantic model — without an extra export step. *** ## Dataflows Gen2 vs. Pipeline Copy Activity [#dataflows-gen2-vs-pipeline-copy-activity] Both can move data from a source into OneLake, but they solve different parts of the problem. | Aspect | Copy Data Activity (Pipeline) | Dataflow Gen2 | | ----------- | ------------------------------------------------------ | ---------------------------------------------------------- | | Best for | Fast, large-volume copying with minimal transformation | Transformation-heavy logic (filtering, reshaping, merging) | | Editor | Simple source/destination mapping | Full Power Query Editor | | Typical use | Landing raw data into Files or a staging table | Producing a cleaned, shaped table ready for reporting | A common pattern uses a pipeline's Copy activity to land raw data quickly, then a Dataflow Gen2 (or a notebook) to clean and shape it. *** ## How This Differs from Power BI Dataflows (Gen1) [#how-this-differs-from-power-bi-dataflows-gen1] Fabric's Dataflows Gen2 is a successor to the dataflows already available directly in the Power BI Service — see [Dataflows](/docs/power-bi-service/dataflows) for that earlier version. The core Power Query experience is the same; the main differences are where the output lands (OneLake, as a queryable Delta table, rather than a Power BI-only dataset format) and Gen2's tighter integration with Pipelines as an orchestration step. ```text Dataflow Gen1 (Power BI Service) Dataflow Gen2 (Fabric) | | Output: Power BI-only Output: Delta table in OneLake, dataset format readable by any Fabric workload ``` *** ## Best Practices [#best-practices] * Use a pipeline's Copy activity for fast bulk movement, and Dataflow Gen2 for the transformation logic itself. * Prefer event-based triggers over tight scheduled polling when a source delivers data irregularly. * Chain a Dataflow Gen2 as a pipeline activity when both orchestration and transformation are needed in one workflow, rather than running them as disconnected, manually-sequenced pieces. * Land Dataflow Gen2 output as a proper Lakehouse or Warehouse table, so downstream consumers can read it without re-running the transformation. *** ## Common Mistakes [#common-mistakes] ### Using Dataflow Gen2 for Simple Bulk Copies [#using-dataflow-gen2-for-simple-bulk-copies] Running a heavy Power Query transformation just to move data unchanged from source to destination is slower than a plain Copy Data activity built for that exact job. ### Ignoring Pipeline Dependencies [#ignoring-pipeline-dependencies] Triggering a pipeline before an upstream data source has actually finished landing its data produces a run against incomplete data, with no error to signal it — dependency and timing need to be designed deliberately. ### Confusing Dataflow Gen1 and Gen2 [#confusing-dataflow-gen1-and-gen2] Gen1 dataflows (Power BI Service) and Gen2 dataflows (Fabric) share an editor but have different output destinations and different downstream compatibility — assuming they're interchangeable can lead to building against the wrong version's output. *** ## Data Factory Checklist [#data-factory-checklist] * Bulk copying and transformation-heavy logic are split between Copy activities and Dataflow Gen2, not forced into one tool. * Triggers match how the source actually delivers data — scheduled for predictable cadences, event-based for irregular arrivals. * Pipeline dependencies and run order are explicit, not assumed. * Dataflow Gen2 output lands as a queryable table, not left stranded in an intermediate format. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [Lakehouse](/docs/fabric/lakehouse) * [Data Warehouse](/docs/fabric/data-warehouse) * [Dataflows (Power BI Service)](/docs/power-bi-service/dataflows) [Build a Product Usage Dashboard on a Fabric Lakehouse](/tutorials/build-a-fabric-lakehouse-dashboard) lands and shapes data manually — the natural next step is automating that same ingestion with a pipeline. # Data Warehouse (/docs/fabric/data-warehouse) # Data Warehouse [#data-warehouse] A Fabric Warehouse gives teams a full T-SQL experience — tables, views, stored procedures, multi-statement transactions — over data that's still stored as Delta tables in OneLake underneath. ```text Warehouse | +-- Schemas | +-- Tables | +-- Views | +-- Stored Procedures | all backed by Delta tables in OneLake ``` Where a Lakehouse is built around notebooks and Files/Tables, a Warehouse is built around the same relational concepts a SQL Server or Synapse dedicated pool user already knows. *** ## A Familiar SQL Surface [#a-familiar-sql-surface] A Warehouse supports standard DDL and DML — `CREATE TABLE`, `INSERT`, `UPDATE`, `DELETE`, multi-table transactions, stored procedures — the way a traditional relational warehouse would. ```sql CREATE TABLE dbo.FactSales ( SalesKey INT, ProductKey INT, Amount DECIMAL(18,2) ); INSERT INTO dbo.FactSales SELECT SalesKey, ProductKey, Amount FROM staging.RawSales WHERE Status = 'Completed'; ``` This is the main practical difference from a Lakehouse's SQL analytics endpoint, which is read-only — a Warehouse can be written to directly with T-SQL, not just queried. *** ## Cross-Database Queries [#cross-database-queries] A Warehouse can query across other Warehouses and Lakehouse SQL analytics endpoints in the same workspace, without needing to physically move data between them first. ```text Warehouse: Sales | | SELECT ... FROM Inventory.dbo.StockLevels | Warehouse: Inventory (separate item, same workspace) ``` This makes it possible to keep data organized across multiple warehouses or lakehouses by domain, while still writing queries that join across them when needed. *** ## Cloning Tables [#cloning-tables] Fabric Warehouse supports zero-copy table cloning — creating a new table that points at the same underlying Delta files as the source, instead of physically duplicating the data. ```text dbo.FactSales | | CREATE TABLE dbo.FactSales_dev AS CLONE OF dbo.FactSales | dbo.FactSales_dev (same files, new pointer, until either diverges) ``` This is useful for spinning up a development or testing copy of a large table instantly, without waiting on or paying for a full physical copy. *** ## Lakehouse vs. Warehouse [#lakehouse-vs-warehouse] | Aspect | Lakehouse | Warehouse | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ | | Primary interface | Spark notebooks, read-only SQL endpoint | Full read/write T-SQL | | Best for | Data engineering, semi-structured data, large-scale transforms | Traditional warehousing, structured schemas, SQL-first teams | | Write access | Via Spark/notebooks | Direct via T-SQL (`INSERT`/`UPDATE`/`DELETE`) | | Underlying format | Delta tables in OneLake | Delta tables in OneLake | Both ultimately produce the same Delta table format in OneLake — a Warehouse table and a Lakehouse table look identical to anything reading them afterward, including Power BI. *** ## When to Choose a Warehouse [#when-to-choose-a-warehouse] ```text Team is SQL-first, wants T-SQL writes and stored procedures -> Warehouse Team is Spark/Python-first, needs large-scale transforms -> Lakehouse Need both -> Both, in the same workspace ``` It's common for a single workspace to contain both — a Lakehouse for ingesting and Spark-transforming messy source data, and a Warehouse for the SQL-first team building the final, curated schema on top of it. *** ## Best Practices [#best-practices] * Choose Warehouse over Lakehouse when the team's primary skill is T-SQL and the workload benefits from direct writes and stored procedures. * Use zero-copy cloning for development and testing copies of large tables, instead of physically duplicating them. * Organize warehouses by domain (Sales, Inventory, Finance) and use cross-database queries to join across them, rather than building one enormous warehouse. * Remember that Warehouse and Lakehouse tables are both just Delta tables underneath — a report or pipeline reading one doesn't need to know or care which produced it. *** ## Common Mistakes [#common-mistakes] ### Choosing Warehouse or Lakehouse Based on Perceived Prestige, Not Fit [#choosing-warehouse-or-lakehouse-based-on-perceived-prestige-not-fit] The right choice depends on the team's skills and the workload's needs, not which one sounds more "modern." A SQL-first team forced into Spark notebooks will be slower, not better off. ### Physically Copying Instead of Cloning [#physically-copying-instead-of-cloning] Creating a full physical copy of a large table for a dev/test environment wastes storage and time when a zero-copy clone would have done the job instantly. ### Ignoring Cross-Database Query Capability [#ignoring-cross-database-query-capability] Building one sprawling warehouse to avoid cross-database joins adds unnecessary complexity — splitting by domain and querying across warehouses is usually cleaner. *** ## Warehouse Checklist [#warehouse-checklist] * The choice between Warehouse and Lakehouse matches the team's actual skills, not just habit. * Dev/test copies of large tables use zero-copy cloning. * Warehouses are organized by domain, with cross-database queries used to join across them. * Downstream consumers (Power BI, other pipelines) don't need to know whether a table came from a Warehouse or a Lakehouse. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [Lakehouse](/docs/fabric/lakehouse) * [OneLake](/docs/fabric/onelake) * [Direct Lake Mode](/docs/fabric/direct-lake) # Direct Lake Mode (/docs/fabric/direct-lake) # Direct Lake Mode [#direct-lake-mode] Direct Lake is a Power BI storage mode built specifically for OneLake. It reads Delta tables directly from OneLake into Power BI's query engine, aiming for Import-like query speed without a traditional import step. ```text Import: Source -> copied into Power BI on refresh -> fast, but stale until refresh DirectQuery: Source -> queried live every time -> fresh, but slower Direct Lake: OneLake Delta tables -> read directly, no copy step -> fast and current ``` See [DirectQuery vs. Import](/docs/modeling/storage-modes) for how the two older modes work — Direct Lake is a third option that sits alongside them, available only for OneLake-backed data. *** ## Framing and Transcoding [#framing-and-transcoding] Direct Lake doesn't import data ahead of time. Instead, when a semantic model is opened, it goes through a process called **framing**: reading the Delta table's metadata and mapping its Parquet files into structures the VertiPaq engine can query, without copying the actual column values yet. ```text Delta table (Parquet files in OneLake) | | framing: read metadata, map row groups | Semantic model "knows about" the data, hasn't loaded values yet | | a query touches a column | That column's data is transcoded into memory on demand ``` Column data is only paged into memory the first time a query actually needs it — this is why Direct Lake can open instantly even against a very large table, while still returning fast, in-memory-speed results once columns are "warmed up." *** ## When Direct Lake Falls Back to DirectQuery [#when-direct-lake-falls-back-to-directquery] Certain conditions cause a query, or an entire table, to silently fall back to DirectQuery-style behavior instead of using the fast in-memory path. ```text Direct Lake query | | can it be answered from the framed, in-memory structures? | +-- Yes -> fast, VertiPaq-speed answer | +-- No -> falls back to querying OneLake directly (DirectQuery-like) ``` Common triggers for fallback: * The Delta table has too many row groups (heavily fragmented Parquet files that haven't been optimized). * The model or query uses a feature Direct Lake doesn't support at the storage layer. * Fabric capacity is throttled or has hit a guardrail limiting Direct Lake operations. Fallback doesn't produce an error — the report still returns a result, just slower, which makes it easy to miss unless it's actively monitored. *** ## Reframing Instead of Refresh [#reframing-instead-of-refresh] Direct Lake models don't need a traditional data refresh, since there's no copy to refresh — but they do need **reframing** to pick up new data written to the underlying Delta tables. ```text New data written to Delta table (by a pipeline, notebook, or dataflow) | | model still shows old framing until... | Reframing occurs (scheduled, or triggered manually / via API) | | model now reflects the new data ``` Reframing can be scheduled like a refresh, or triggered on demand — but until it happens, the semantic model keeps showing data as of its last framing, even though the underlying table has already changed. *** ## Table Maintenance Matters [#table-maintenance-matters] Because framing works against a Delta table's actual Parquet file layout, how that table is maintained directly affects Direct Lake performance. ```text Well-maintained table: Fragmented table: Few, well-sized Parquet files vs. Many small Parquet files | | Fast framing, low fallback risk Slower framing, higher fallback risk ``` Running `OPTIMIZE` (and periodically `VACUUM`) on Delta tables from a Lakehouse notebook compacts small files into larger ones, keeping row group counts low and reducing the odds of a query falling back to DirectQuery. *** ## Limitations [#limitations] * Calculated columns and calculated tables aren't supported directly on Direct Lake tables — they typically require converting the table (or the specific column) to Import. * Some DAX functions and modeling features carry restrictions similar to DirectQuery, since a fallback effectively behaves like DirectQuery against OneLake. * Row-level security is supported, but adds overhead worth testing specifically, since it changes what has to be evaluated per query rather than answered straight from the framed structures. *** ## Best Practices [#best-practices] * Run `OPTIMIZE` regularly on Delta tables feeding a Direct Lake model, to keep row group counts low. * Schedule reframing to run shortly after the pipelines or notebooks that update the underlying tables finish, the same way a dataset refresh would be scheduled after a dataflow. * Monitor for fallback to DirectQuery using the Fabric Capacity Metrics app rather than assuming Direct Lake speed is guaranteed. * Reserve calculated columns and calculated tables for cases that genuinely need them, since they push a table (or column) out of pure Direct Lake behavior. *** ## Common Mistakes [#common-mistakes] ### Assuming Direct Lake Always Performs Like Import [#assuming-direct-lake-always-performs-like-import] Direct Lake is fast when the underlying tables are well-maintained, but a fragmented or poorly-optimized Delta table can silently fall back to DirectQuery-level performance with no obvious error. ### Forgetting to Reframe After Loading New Data [#forgetting-to-reframe-after-loading-new-data] A pipeline that successfully loads new data doesn't automatically update what a Direct Lake model shows — without a reframe, the report keeps showing the previous framing. ### Skipping Delta Table Maintenance [#skipping-delta-table-maintenance] Treating Delta tables as "write once and forget" lets small file fragmentation build up over time, gradually degrading Direct Lake performance until it's investigated. *** ## Direct Lake Checklist [#direct-lake-checklist] * Underlying Delta tables are regularly optimized (`OPTIMIZE`, periodic `VACUUM`). * Reframing is scheduled to run after the data pipeline that updates the source tables. * Fallback to DirectQuery is monitored, not assumed away. * Calculated columns/tables are used deliberately, with their effect on Direct Lake behavior understood. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [DirectQuery vs. Import](/docs/modeling/storage-modes) * [OneLake](/docs/fabric/onelake) * [Lakehouse](/docs/fabric/lakehouse) See it applied end to end: [Build a Product Usage Dashboard on a Fabric Lakehouse](/tutorials/build-a-fabric-lakehouse-dashboard) builds a real Direct Lake semantic model on top of a Lakehouse star schema, start to finish. # Microsoft Fabric (/docs/fabric) # Microsoft Fabric [#microsoft-fabric] Microsoft Fabric is the unified data platform Power BI now sits inside — one shared lake (OneLake) underneath Lakehouses, Warehouses, pipelines, and the semantic models that report on top of them. ```text OneLake (one shared lake for the whole tenant) | +-- Lakehouse / Data Warehouse (store and transform data) +-- Data Factory (Pipelines, Dataflows Gen2) | | Direct Lake | Power BI Semantic Model ``` ## Start Here [#start-here] See it applied end to end: [Build a Product Usage Dashboard on a Fabric Lakehouse](/tutorials/build-a-fabric-lakehouse-dashboard) lands data, shapes it with a notebook, and connects Power BI in Direct Lake mode, start to finish. # Introduction (/docs/fabric/introduction) # Microsoft Fabric [#microsoft-fabric] Microsoft Fabric is a unified, SaaS analytics platform that brings data engineering, data warehousing, data science, real-time analytics, and Power BI together on one shared storage layer, instead of stitching together separate products with separate copies of data. ```text Microsoft Fabric | +-- Data Engineering (Lakehouse, Spark notebooks) +-- Data Warehouse (SQL-based warehousing) +-- Data Factory (pipelines, Dataflows Gen2) +-- Real-Time Intelligence (streaming, KQL) +-- Power BI (semantic models, reports) | all reading and writing the same OneLake storage ``` *** ## OneLake: One Copy of Data [#onelake-one-copy-of-data] OneLake is Fabric's built-in data lake — every workspace gets a OneLake location automatically, and every Fabric workload reads and writes to it using the open Delta Lake format. ```text OneLake | +-- Lakehouse writes Delta tables +-- Warehouse reads/writes the same Delta tables +-- Power BI reads the same Delta tables directly | no separate export/import step between workloads ``` The practical effect: a table produced by a data engineering pipeline doesn't need to be re-exported or re-imported for Power BI to use it — it's already sitting in a format Power BI can read straight from storage. *** ## Core Workloads [#core-workloads] ### Lakehouse [#lakehouse] A Lakehouse combines file storage with a SQL query layer over Delta tables, aimed at data engineering work — ingesting raw files, transforming them with Spark notebooks, and landing clean Delta tables for downstream use. ### Data Warehouse [#data-warehouse] Fabric's Warehouse is a SQL-first alternative to the Lakehouse, better suited to teams who want a traditional warehousing experience (T-SQL, stored procedures, standard schema design) rather than a Spark-based one. ### Data Factory [#data-factory] Data Factory in Fabric covers orchestration and transformation: pipelines for scheduling and moving data, and Dataflows Gen2 for Power Query-based transformation logic that lands directly in OneLake. ### Real-Time Intelligence [#real-time-intelligence] Real-Time Intelligence handles streaming and event-based data — eventstreams for ingesting continuously arriving data, and KQL databases for querying it, aimed at scenarios where waiting for a scheduled refresh isn't acceptable. ### Power BI [#power-bi] Power BI in Fabric works the same way it does standalone, with one addition: semantic models can read Delta tables in OneLake directly, without importing a copy first. *** ## Lakehouse vs. Warehouse [#lakehouse-vs-warehouse] | Aspect | Lakehouse | Warehouse | | ----------------------- | --------------------------------------------------- | ------------------------------------------- | | Primary interface | Spark notebooks, SQL endpoint | T-SQL | | Best for | Data engineering, unstructured/semi-structured data | Traditional warehousing, structured schemas | | Transformation approach | Notebooks (PySpark, Spark SQL) | Stored procedures, T-SQL | | Underlying format | Delta tables in OneLake | Delta tables in OneLake | Both ultimately produce the same Delta table format in OneLake — the choice is about which authoring experience fits the team, not a difference in what Power BI can consume afterward. *** ## Direct Lake: How Power BI Connects to Fabric [#direct-lake-how-power-bi-connects-to-fabric] Direct Lake is a storage mode, alongside [Import and DirectQuery](/docs/modeling/storage-modes), built specifically for OneLake. It reads Delta tables directly from OneLake into Power BI's query engine, without a traditional import step and without querying a live source on every request. ```text Import: Source -> copied into Power BI on refresh -> fast, but stale until refresh DirectQuery: Source -> queried live every time -> fresh, but slower Direct Lake: OneLake Delta tables -> read directly, no copy step -> fast and current ``` Direct Lake gets Import-like query speed without a separate refresh cycle, because there's no copy being made — Power BI reads the same Delta files the Lakehouse or Warehouse already produced. *** ## Fabric Capacity [#fabric-capacity] Fabric workloads run on Fabric capacity (SKUs F2 through F2048), which extends and eventually replaces Power BI Premium capacity. Workspaces are assigned to a capacity the same way they are under Premium, and capacity sizing follows the same logic — bigger, more demanding workloads need a larger SKU. *** ## Best Practices [#best-practices] * Land data in OneLake once, and let every downstream workload (Warehouse, Power BI, notebooks) read that same copy instead of duplicating it. * Choose Lakehouse or Warehouse based on the team's skill set (Spark vs. T-SQL), not a technical limitation — both produce the same underlying format. * Use Direct Lake for semantic models built directly on Fabric data, and reserve traditional Import for sources outside Fabric. * Size Fabric capacity based on actual workload demand, and monitor capacity utilization before adding more workspaces to it. *** ## Common Mistakes [#common-mistakes] ### Treating Fabric as Just "Power BI With Extra Steps" [#treating-fabric-as-just-power-bi-with-extra-steps] Fabric's value is the shared OneLake layer across workloads. Using only the Power BI piece and ignoring Lakehouse/Warehouse/Data Factory misses the point of consolidating on one platform. ### Duplicating Data Across Workloads [#duplicating-data-across-workloads] Copying the same data into a Lakehouse and a separate Warehouse, when one would do, recreates the exact duplication problem OneLake is meant to eliminate. ### Assuming Direct Lake Behaves Exactly Like Import [#assuming-direct-lake-behaves-exactly-like-import] Direct Lake is fast because it skips the copy step, but it depends on OneLake data being well-organized (properly structured Delta tables). Messy or excessively fragmented tables can hurt Direct Lake performance in ways a normal Import model wouldn't hit. *** ## Fabric Checklist [#fabric-checklist] Before building on Fabric in production: * The right workload (Lakehouse vs. Warehouse) has been chosen based on team skills, not guesswork. * Data lands in OneLake once and is reused across workloads, not duplicated. * Power BI semantic models use Direct Lake where the data already lives in OneLake. * Fabric capacity is sized for the actual workload, with utilization monitored. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [OneLake](/docs/fabric/onelake) * [Lakehouse](/docs/fabric/lakehouse) * [Data Warehouse](/docs/fabric/data-warehouse) * [Data Factory](/docs/fabric/data-factory) * [Direct Lake Mode](/docs/fabric/direct-lake) * [Fabric Capacity & Cost Management](/docs/fabric/capacity) # Lakehouse (/docs/fabric/lakehouse) # Lakehouse [#lakehouse] A Lakehouse is a single Fabric item that combines file storage with a SQL query layer, aimed at data engineering work — ingesting raw data, transforming it, and landing clean, structured tables for everything downstream to use. ```text Lakehouse | +-- Files — raw/unstructured storage +-- Tables — structured Delta tables, SQL-queryable | both backed by OneLake ``` Unlike a traditional data lake, where "queryable" usually means standing up a separate compute engine, a Lakehouse's Tables area is queryable the moment data lands in it. *** ## Getting Data In [#getting-data-in] Data typically arrives in a Lakehouse through one of a few paths, depending on the source and how much transformation is needed on the way in. ```text Getting data into a Lakehouse | +-- Pipelines — scheduled, orchestrated copy from a source +-- Dataflows Gen2 — Power Query-based transformation, output lands as a table +-- Notebooks — Spark code reads a source and writes Delta tables directly +-- Manual upload — drag-and-drop files into the Files area ``` See [Data Factory](/docs/fabric/data-factory) for how Pipelines and Dataflows Gen2 fit into this. *** ## Files vs. Tables in Practice [#files-vs-tables-in-practice] Raw data usually lands in **Files** first, in whatever format the source provides it — CSV, JSON, Parquet, or arbitrary folders of files. A transformation step then reads from Files and writes a cleaned result into **Tables** as a Delta table. ```text Files/raw/orders_2024_01.csv | | notebook or dataflow reads, cleans, types columns | Tables/dbo/FactOrders (Delta table) ``` Only what's in Tables is queryable through the SQL analytics endpoint or readable by Direct Lake — Files-area content needs to be processed into a Delta table before Power BI or T-SQL can use it directly. *** ## Notebooks and Spark [#notebooks-and-spark] A Lakehouse's primary transformation tool is the notebook: cells of PySpark, Spark SQL, or Scala that run against a Spark compute session attached to the workspace. ```python df = spark.read.format("csv").option("header", "true").load("Files/raw/orders_2024_01.csv") cleaned = df.filter(df.Status == "Completed").withColumnRenamed("Amt", "Amount") cleaned.write.format("delta").mode("overwrite").save("Tables/FactOrders") ``` Notebooks are well suited to large-scale or complex transformations — deduplication across millions of rows, joins against multiple large sources, or logic that's easier to express in code than in a visual query editor. *** ## The SQL Analytics Endpoint [#the-sql-analytics-endpoint] Every Lakehouse automatically gets a read-only SQL analytics endpoint, letting the Tables area be queried with standard T-SQL without provisioning anything separately. ```text Lakehouse/Tables (Delta tables) | | auto-generated, read-only | SQL Analytics Endpoint | | queried by | T-SQL clients, Power BI (DirectQuery / Direct Lake) ``` This is what lets a BI tool, or a analyst who only knows SQL, work with Lakehouse data without touching Spark at all. *** ## Medallion Architecture [#medallion-architecture] A common pattern for organizing Lakehouse data is the medallion architecture: three progressively cleaner layers, often built as three separate Lakehouses (or three schemas within one). ```text Bronze Silver Gold raw, as-landed -> cleaned, deduplicated -> business-ready, typed, conformed aggregated for reporting ``` * **Bronze** holds data close to its original form — minimal transformation, kept mainly for traceability and reprocessing. * **Silver** applies cleaning, deduplication, and type correction, producing a trustworthy but still fairly granular dataset. * **Gold** shapes the data into the star schema or aggregated form a Power BI semantic model actually consumes. Each layer is a Delta table (or set of tables) in OneLake, and later layers are typically built by notebooks or pipelines reading the layer before them. *** ## Best Practices [#best-practices] * Land raw data in Files, and only promote cleaned, typed data to Tables as Delta tables. * Use the medallion pattern (bronze/silver/gold) once transformations get complex enough that a single raw-to-final step becomes hard to debug or reprocess. * Prefer notebooks for large-scale or code-heavy transformations, and Dataflows Gen2 for transformations a Power Query-literate team can maintain visually. * Query the SQL analytics endpoint for BI and ad hoc analysis instead of pulling data out through Spark, which is slower for simple queries. *** ## Common Mistakes [#common-mistakes] ### Skipping the Bronze Layer for "Simplicity" [#skipping-the-bronze-layer-for-simplicity] Transforming raw data directly into a final table with no intermediate raw copy makes it much harder to reprocess history when a transformation bug is found later. ### Using Notebooks for Everything [#using-notebooks-for-everything] Spark notebooks are powerful, but a Power Query-based Dataflow Gen2 is often easier for the wider team to read and maintain when the transformation itself is simple. ### Querying Files Directly Instead of Tables [#querying-files-directly-instead-of-tables] Files-area content isn't optimized for querying — always transform into a proper Delta table in Tables before treating the data as something reports should consume. *** ## Lakehouse Checklist [#lakehouse-checklist] * Raw data lands in Files before being transformed into Tables. * Transformation approach (notebook vs. Dataflow Gen2) matches the complexity of the logic and the skill set of who maintains it. * Final, report-ready tables exist in a clearly identifiable "gold" layer. * The SQL analytics endpoint, not raw Spark queries, is the entry point for BI tools. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [OneLake](/docs/fabric/onelake) * [Data Warehouse](/docs/fabric/data-warehouse) * [Data Factory](/docs/fabric/data-factory) See it applied end to end: [Build a Product Usage Dashboard on a Fabric Lakehouse](/tutorials/build-a-fabric-lakehouse-dashboard) lands raw data, shapes it into a star schema with a notebook, and connects Power BI in Direct Lake mode, start to finish. # OneLake (/docs/fabric/onelake) # OneLake [#onelake] OneLake is the storage layer every Fabric workload reads and writes to. Every tenant gets exactly one OneLake, automatically, with no separate provisioning step — it's there the moment a Fabric workspace exists. ```text Tenant | +-- OneLake (one per tenant, automatic) | +-- Workspace A | +-- Lakehouse -> writes Delta tables | +-- Warehouse -> writes Delta tables | +-- Workspace B +-- Lakehouse -> writes Delta tables ``` Every workspace gets its own area inside that single OneLake, and every Fabric item that stores data — Lakehouse, Warehouse, KQL Database — writes into it using the open Delta Lake format. *** ## One Copy of Data [#one-copy-of-data] OneLake's core idea, sometimes called "OneCopy," is that a table only needs to exist once. A Lakehouse produces a Delta table; a Warehouse can query that same table; a Power BI semantic model can read it directly — without any of them making their own copy first. ```text Lakehouse writes DimProduct (Delta table) | | same file, no copy | Warehouse queries DimProduct | | same file, no copy | Power BI (Direct Lake) reads DimProduct ``` This is the structural reason Fabric feels different from stitching together separate products: the data doesn't move between them, only compute does. *** ## Files vs. Tables [#files-vs-tables] Every Lakehouse (and by extension, its slice of OneLake) is split into two areas. ```text Lakehouse | +-- Files — unstructured/raw storage: CSV, JSON, images, arbitrary folders | +-- Tables — structured storage: Delta tables only, queryable with SQL ``` **Files** behaves like a general-purpose file system — anything can land there, in any format, in any folder structure. **Tables** is more constrained: everything in it is a Delta table, which is what makes it queryable from the SQL analytics endpoint, from Spark, and from Power BI. A typical flow lands raw data in Files first, then a transformation step (a notebook, a pipeline, a dataflow) writes a cleaned Delta table into Tables. ```text Raw file lands in Files (e.g. Files/raw/sales_2024.csv) | | transform (notebook, dataflow, pipeline) | Delta table written to Tables (e.g. Tables/dbo/FactSales) ``` *** ## Shortcuts: Referencing Data Without Copying It [#shortcuts-referencing-data-without-copying-it] A **shortcut** is a pointer inside OneLake to data that physically lives somewhere else — another OneLake location, Azure Data Lake Storage Gen2, or an S3-compatible source — without copying it. ```text Lakehouse/Tables/FactSales (shortcut) | | points to, doesn't copy | Azure Data Lake Storage Gen2: /raw/sales/ ``` Querying through a shortcut reads the data live from wherever it actually sits. This is what lets a Lakehouse reference a Warehouse's tables, or a Fabric workspace reference data in an existing ADLS Gen2 account, without an ingestion job just to get a copy into OneLake first. ```text Common shortcut targets | +-- Another OneLake location (cross-workspace) +-- Azure Data Lake Storage Gen2 +-- Amazon S3 / S3-compatible storage +-- Google Cloud Storage ``` *** ## OneLake Security [#onelake-security] Access to OneLake data follows the same permission model as the Fabric item it belongs to — a user with access to a Lakehouse has the corresponding access to its Files and Tables in OneLake, without a separate storage-level permission to manage. ```text Workspace role (Admin/Member/Contributor/Viewer) | | grants access to | Items in the workspace (Lakehouse, Warehouse, ...) | | which grants access to | Their data in OneLake ``` For finer-grained control than item-level access allows, OneLake data access roles can scope permissions down to specific folders or tables within a single Lakehouse, similar to row-level security but for folder/file access rather than rows. *** ## OneLake File Explorer [#onelake-file-explorer] OneLake also exposes itself as a mapped drive on Windows through the OneLake file explorer, letting Files-area content be browsed and edited with familiar desktop tools rather than only through the Fabric portal. ```text File Explorer (Windows) | | OneLake mapped as a drive | Tenant > Workspace > Lakehouse > Files ``` This is convenient for ad hoc file drops or inspection, though production data movement should still go through a pipeline, dataflow, or notebook rather than manual drag-and-drop. *** ## Best Practices [#best-practices] * Prefer a shortcut over copying data into OneLake whenever the source already lives somewhere Fabric can reference — ADLS Gen2, S3, or another workspace. * Keep raw, unprocessed data in Files, and only promote cleaned, structured output to Tables as Delta tables. * Use OneLake data access roles when different teams need different visibility into the same Lakehouse, instead of splitting one Lakehouse into several just for permission boundaries. * Treat OneLake File Explorer as a convenience for browsing and manual work, not as the primary way production data gets loaded. *** ## Common Mistakes [#common-mistakes] ### Copying Data That Could Have Been Shortcut [#copying-data-that-could-have-been-shortcut] Ingesting a full copy of data that already lives in ADLS Gen2 duplicates storage and adds a sync problem that a shortcut would have avoided entirely. ### Treating Files Like Tables [#treating-files-like-tables] Landing structured, tabular data only in the Files area — say, as CSV — means it can't be queried with SQL or read by Direct Lake the way a proper Delta table in Tables can. ### Assuming Workspace Access Alone Is Fine-Grained Enough [#assuming-workspace-access-alone-is-fine-grained-enough] For data with department- or team-specific sensitivity within a single Lakehouse, workspace-level roles alone are too coarse — that's what OneLake data access roles exist for. *** ## OneLake Checklist [#onelake-checklist] * Data available from an existing external source uses a shortcut, not a full copy. * Structured output lands as Delta tables in Tables, not as loose files. * Sensitive subsets of a Lakehouse use OneLake data access roles where workspace-level access is too broad. * Production data loading goes through a pipeline, dataflow, or notebook — not manual OneLake File Explorer edits. *** ## Next Steps [#next-steps] Continue exploring Microsoft Fabric: * [Lakehouse](/docs/fabric/lakehouse) * [Direct Lake Mode](/docs/fabric/direct-lake) * [Introduction](/docs/fabric/introduction) # Getting Started (/docs/getting-started) # Getting Started [#getting-started] New to Power BI? Start here. These three pages cover what Power BI is, how to install it, and how the desktop app is organized, before the rest of the docs go deeper into DAX, data modeling, and Power Query. ## Where to Go Next [#where-to-go-next] Once you're set up, the docs continue into: * [DAX](/docs/dax/introduction) — measures, functions, and filter context. * [Data Modeling](/docs/modeling/introduction) — star schemas, relationships, and fact/dimension tables. * [Power Query](/docs/power-query/introduction) — connecting to and transforming data. # Installation (/docs/getting-started/installation) # Installation [#installation] Power BI isn't one single install — it's a family of tools, and which one you need depends on what you're doing. ```text Power BI | +-- Desktop (build reports, Windows only) | +-- Service (view/share reports, browser-based) | +-- Mobile (view reports, iOS/Android) | +-- Report Server (on-premises alternative to the cloud Service) ``` Report *building* happens in Desktop. Report *consumption* happens through the Service, Mobile, or Report Server. *** ## Installing Power BI Desktop [#installing-power-bi-desktop] Power BI Desktop is free and available two ways. ```text Option 1: Microsoft Store Option 2: Standalone installer (.exe) from microsoft.com ``` The Microsoft Store version updates automatically. The standalone installer is preferred in locked-down corporate environments where Store access is restricted, or where IT needs to control update timing. *** ## System Requirements [#system-requirements] Power BI Desktop is Windows-only. | Requirement | Minimum | | ----------- | ---------------------------------------------------------------------- | | OS | Windows 10 or later (64-bit) | | RAM | 4 GB (8 GB+ recommended for larger models) | | .NET | .NET Framework requirements are handled automatically by the installer | Mac users run Power BI Desktop through a Windows virtual machine, or build reports entirely through the browser-based Power BI Service instead. *** ## Signing In [#signing-in] Power BI Desktop and the Service both require a Microsoft account tied to a Power BI license (often included with a Microsoft 365 subscription, or purchased separately). ```text Microsoft Account | | sign in | Power BI Desktop / Service ``` A free account can build and use Power BI Desktop locally, but publishing to a shared workspace in the Service requires at least a Power BI Pro license (or a workspace on Premium/Fabric capacity). *** ## The Power BI Service (No Install Needed) [#the-power-bi-service-no-install-needed] The Power BI Service runs entirely in a browser at `app.powerbi.com` — there's nothing to install. ```text Browser | | app.powerbi.com | Power BI Service ``` This is where published reports live, where dashboards and workspaces are managed, and where refresh schedules are configured. *** ## Power BI Mobile [#power-bi-mobile] The Power BI Mobile app (iOS and Android) provides a touch-friendly way to view reports and dashboards, and can send push notifications for data alerts. ```text Power BI Service | | sync | Mobile App ``` Mobile is for consuming reports, not building them — report authoring still happens in Desktop or the browser-based Service editor. *** ## Power BI Report Server [#power-bi-report-server] Organizations that can't use the cloud Service (due to data residency or compliance requirements) can host reports on-premises instead, using Power BI Report Server. ```text Power BI Desktop | | Publish | Report Server (on-premises) ``` Report Server requires a separate license from standard Power BI Pro, and supports a more limited feature set than the cloud Service — some newer visuals and AI features are cloud-only. *** ## Keeping Power BI Desktop Updated [#keeping-power-bi-desktop-updated] Power BI Desktop releases a new version almost every month, often including new visual features and DAX functions. ```text Monthly Release | | includes | New visuals, DAX functions, connectors ``` The Store version updates automatically. The standalone installer requires manually downloading and running the latest version, or using an organization's software deployment tooling. *** ## Best Practices [#best-practices] * Use the Microsoft Store version for automatic updates, unless organizational policy requires manual control. * Confirm the Power BI license type (Free, Pro, Premium Per User) before assuming a workspace or feature will be available. * Mac users should plan for a Windows VM or rely on the browser-based Service, since Desktop doesn't run natively on macOS. * Keep Desktop reasonably up to date — reports built with newer features may not open correctly in a much older Desktop version. *** ## Common Mistakes [#common-mistakes] ### Assuming a Free License Covers Everything [#assuming-a-free-license-covers-everything] A free Power BI account can build reports locally, but publishing to a shared workspace and collaborating with others typically requires a Pro license or Premium/Fabric capacity. ### Installing Desktop on macOS Directly [#installing-desktop-on-macos-directly] Power BI Desktop has no native macOS build. Attempting to run the Windows installer directly on a Mac won't work without a Windows virtual machine. ### Falling Too Far Behind on Updates [#falling-too-far-behind-on-updates] An outdated Desktop version can fail to open reports that use newer visuals or DAX functions, especially in organizations where updates are tightly controlled. *** ## Installation Checklist [#installation-checklist] Before starting to build reports: * Power BI Desktop is installed via Store or standalone installer, matching organizational policy. * The Microsoft account being used has an appropriate Power BI license. * Mac users have a Windows VM plan, or are using the browser-based Service instead. * Desktop is reasonably current, especially before opening reports built by others. *** ## Next Steps [#next-steps] Continue getting started with Power BI: * [Introduction](/docs/getting-started/introduction) * [Power BI Overview](/docs/getting-started/power-bi-overview) # Introduction (/docs/getting-started/introduction) # Introduction to Power BI [#introduction-to-power-bi] Power BI is Microsoft's business intelligence platform used to connect, transform, model, visualize, and analyze data. It allows organizations to turn raw data from multiple sources into interactive reports and dashboards that support better decision-making. ## What is Power BI? [#what-is-power-bi] Power BI combines several technologies: * **Data connectivity** — Connect to databases, Excel files, cloud services, APIs, and other sources. * **Data transformation** — Clean and reshape data using Power Query. * **Data modeling** — Build relationships between tables and create analytical models. * **Data analysis** — Create calculations using Data Analysis Expressions (DAX). * **Data visualization** — Present insights through interactive charts, tables, and dashboards. A typical Power BI workflow looks like:
1

Data Source

Connect to databases, files, APIs, and other sources.
2

Power Query

Clean, transform, and prepare data using M queries.
3

Data Model

Create relationships, dimensions, and fact tables.
4

DAX Calculations

Create measures, calculated columns, and business logic.
5

Reports & Dashboards

Build visualizations and share insights.
*** ## The Power BI Components [#the-power-bi-components] Power BI is made up of several applications. ## Power BI Desktop [#power-bi-desktop] Power BI Desktop is the primary development tool. Used for: * Connecting to data * Cleaning data * Creating relationships * Writing DAX measures * Building reports Most developers build their reports here before publishing. *** ## Power BI Service [#power-bi-service] The Power BI Service is the cloud platform where reports are shared and managed. Used for: * Publishing reports * Creating dashboards * Managing workspaces * Scheduling refreshes * Controlling security *** ## Power Query [#power-query] Power Query is the data preparation engine inside Power BI. It is used to: * Remove unnecessary columns * Change data types * Combine tables * Merge datasets * Create repeatable transformations Example: ```powerquery lineNumbers let Source = Excel.Workbook(File.Contents("Sales.xlsx")), SalesTable = Source{[Name="Sales"]}[Data] in SalesTable ``` # Power BI Overview (/docs/getting-started/power-bi-overview) # Power BI Overview [#power-bi-overview] [Introduction](/docs/getting-started/introduction) covers what Power BI does. This page covers how it's licensed, and where it fits alongside the rest of Microsoft's data platform. ```text Power BI | +-- Licensing tiers | +-- Microsoft Fabric integration | +-- Typical roles ``` *** ## Licensing Tiers [#licensing-tiers] | Tier | Who It's For | Key Limitation | | ------------------------- | ------------------------------------------------- | ----------------------------------------------------- | | Free | Individual, local-only use | Can't share to a workspace | | Pro | Individual contributors sharing reports | Per-user license, shared capacity limits apply | | Premium Per User (PPU) | Power users needing Premium features individually | Everyone viewing PPU content also needs a PPU license | | Premium / Fabric Capacity | Organization-wide, capacity-based | Licensed by capacity (SKU), not per user | Most individuals start on Free or Pro. Larger organizations typically move important workspaces to Premium or Fabric capacity, which removes per-user licensing friction for report *viewers* (though report *creators* still generally need Pro or PPU). *** ## Free vs. Pro [#free-vs-pro] ```text Free | | can build reports locally | | cannot share to a workspace | Pro | | can publish to shared workspaces | | can consume Pro content shared by others ``` A Free license is enough to learn Power BI Desktop and build personal reports. Collaborating with a team requires Pro (or a workspace hosted on Premium/Fabric capacity, which can let Free users view content). *** ## Premium and Fabric Capacity [#premium-and-fabric-capacity] Premium (and its successor, Microsoft Fabric capacity) is licensed by compute capacity rather than by individual user. ```text Capacity (e.g. F64) | | shared across | Multiple workspaces ``` This unlocks features unavailable on shared/Pro-only capacity: larger dataset sizes, more frequent refresh, paginated reports, and dataflows — while also letting users with only a Free license view content in a Premium-backed workspace. *** ## Power BI and Microsoft Fabric [#power-bi-and-microsoft-fabric] Microsoft Fabric is Microsoft's unified data platform, and Power BI is one of its workloads. ```text Microsoft Fabric | +-- Power BI (reporting & visualization) | +-- Data Factory (data integration) | +-- Synapse Data Engineering (big data processing) | +-- Synapse Data Science (ML/AI) | +-- OneLake (unified data storage) ``` A Power BI dataset can now sit directly on top of Fabric's OneLake, sharing storage with pipelines and data engineering workloads instead of requiring a separate copy of the data. *** ## Power BI and Microsoft 365 [#power-bi-and-microsoft-365] Power BI integrates with the broader Microsoft 365 suite. ```text Power BI Report | +-- Embed in Teams | +-- Embed in SharePoint | +-- Export to Excel / PowerPoint ``` Reports can be pinned directly inside a Teams channel, embedded in a SharePoint page, or exported for use in other Office documents. *** ## Typical Roles [#typical-roles] Power BI is used differently depending on the role. ```text Report Consumer | | views dashboards and reports (Viewer access) | Report Author | | builds reports in Desktop, publishes to the Service | Data Modeler | | designs the underlying data model and DAX measures | Administrator | | manages tenant settings, capacity, and governance ``` Smaller teams often have one person filling several of these roles. Larger organizations typically separate them, with dedicated data modelers building the semantic model that many report authors build on top of. *** ## Choosing a Starting License [#choosing-a-starting-license] | Situation | Recommended Starting Point | | ---------------------------------------------- | -------------------------- | | Learning Power BI individually | Free | | Sharing reports with a small team | Pro | | Building an org-wide reporting platform | Premium / Fabric capacity | | Working with Fabric data engineering pipelines | Fabric capacity | *** ## Best Practices [#best-practices] * Start with Free or Pro for learning and small-scale sharing; move to capacity-based licensing once an organization needs it broadly. * Understand the difference between what a *license* enables (create vs. view) before assuming a feature is unavailable. * Consider Fabric capacity when Power BI needs to share data with other data engineering or data science workloads. * Assign report-authoring roles deliberately in larger teams, rather than letting model design happen ad hoc across many individual reports. *** ## Common Mistakes [#common-mistakes] ### Assuming Pro Licenses Scale Indefinitely [#assuming-pro-licenses-scale-indefinitely] Per-user Pro licensing works well for small teams, but becomes expensive and harder to manage at scale — Premium/Fabric capacity is usually more cost-effective for larger viewer populations. ### Not Realizing Viewers Need Licensing Too [#not-realizing-viewers-need-licensing-too] Even to *view* certain content, users need at least a Free license and, depending on the workspace's capacity, sometimes Pro. Assuming reports are viewable by literally anyone in an organization without any license can lead to access surprises. ### Treating Fabric as Unrelated to Power BI [#treating-fabric-as-unrelated-to-power-bi] Since Fabric absorbed and extended much of what used to be Premium-only Power BI capacity, treating them as separate products can lead to missing capabilities — like sharing a dataset's storage directly with a data engineering pipeline. *** ## Overview Checklist [#overview-checklist] Before planning a Power BI rollout: * The right licensing tier is chosen for the team's size and sharing needs. * Report author vs. report consumer licensing requirements are both accounted for. * Fabric capacity has been considered if data engineering or data science workloads are also in scope. * Roles (author, modeler, administrator) are clear enough to avoid duplicated or conflicting model ownership. *** ## Next Steps [#next-steps] Continue getting started with Power BI: * [Introduction](/docs/getting-started/introduction) * [Installation](/docs/getting-started/installation) # Gateway & Refresh Architecture (/docs/governance/gateway-refresh) # Gateway & Refresh Architecture [#gateway--refresh-architecture] [Refresh](/docs/power-bi-service/refresh) covers full and incremental refresh at the dataset level. This page is about the layer above a single dataset: which gateway each dataset actually goes through, and what happens when several of them compete for the same gateway at once. ```text Multiple datasets | all scheduled to refresh at 6:00 AM | all routed through the same gateway machine | gateway CPU/memory maxes out, refreshes queue or time out ``` None of this shows up by looking at one dataset's refresh settings in isolation — it only becomes visible when the datasets are mapped against the gateway they share. *** ## Personal vs. Standard Gateway Mode [#personal-vs-standard-gateway-mode] | Aspect | Personal Mode | Standard Mode | | ----------------- | --------------------------------- | --------------------------------------------- | | Who can use it | The installing user only | Any authorized user in the org | | High availability | No | Yes, via clustering | | Supported sources | Import only, subset of connectors | Import, DirectQuery, all supported connectors | | Typical use | Individual, one-off refresh | Team or organization-wide | Standard mode is the right choice for anything more than a single person's individual reports — Personal mode has no failover and a narrower set of supported connectors. *** ## Gateway Clusters [#gateway-clusters] A Standard mode gateway can be installed on multiple machines as a cluster: one primary and one or more secondary members, sharing the same configuration and data source registrations. ```text Gateway Cluster "Finance-Prod" | +-- Primary member (Server A) +-- Secondary member (Server B) | if Server A goes offline, Server B keeps refreshes running ``` Clustering exists specifically to avoid one gateway machine being a single point of failure for every dataset routed through it. *** ## Mapping the Architecture [#mapping-the-architecture] The useful artifact here isn't a diagram of one gateway — it's a map of every gateway, which sources go through each one, and which datasets depend on those sources. ```text Gateway Cluster "Finance-Prod" | +-- Source: SQL Server (Finance DB) | +-- Dataset: Monthly Financials (refresh: 6:00 AM) | +-- Dataset: Budget vs. Actuals (refresh: 6:00 AM) | +-- Source: SQL Server (Sales DB) +-- Dataset: Sales Pipeline (refresh: 6:15 AM) ``` Written out this way, the 6:00 AM collision between two datasets on the same gateway is obvious. Inside each dataset's individual refresh settings, it isn't visible at all. *** ## Staggering Refresh Schedules [#staggering-refresh-schedules] Once the mapping above exists, the fix is usually simple: spread refresh times across datasets that share a gateway, instead of defaulting every dataset to the same time of day. ```text Before: 6:00 AM — Monthly Financials, Budget vs. Actuals (collide) After: 6:00 AM — Monthly Financials 6:20 AM — Budget vs. Actuals ``` The right stagger interval depends on how long each refresh actually takes — which is only knowable once refreshes aren't already contending with each other. *** ## Monitoring Gateway Performance [#monitoring-gateway-performance] The on-premises gateway app exposes performance counters (CPU, memory, network) and logs on the machine it runs on. These are worth checking before refresh failures start, not just after. ```text Gateway machine | +-- Performance counters (CPU, memory) +-- Gateway logs (connection errors, timeouts) | reviewed periodically, not only when something breaks ``` A gateway that's consistently near its resource limits during refresh windows will eventually start failing refreshes intermittently, in ways that look like source or dataset problems at first. *** ## Best Practices [#best-practices] * Use Standard mode with clustering for anything beyond one person's individual use. * Maintain a written map of gateway → sources → datasets → refresh times, and keep it current as datasets are added. * Stagger refresh schedules for datasets sharing a gateway, based on actual refresh duration. * Check gateway performance counters periodically, not only in response to failures. *** ## Common Mistakes [#common-mistakes] ### One Gateway Machine for Everything [#one-gateway-machine-for-everything] Routing every source in the organization through a single, unclustered gateway machine turns it into both a performance bottleneck and a single point of failure. ### Defaulting Every Refresh to the Same Time [#defaulting-every-refresh-to-the-same-time] Leaving every dataset on its default schedule (often early morning) creates exactly the contention this page is about — the fix costs nothing but requires knowing which datasets share a gateway. ### No Monitoring Until Refreshes Start Failing [#no-monitoring-until-refreshes-start-failing] Gateway performance problems are usually gradual — refreshes get slower and start timing out intermittently before they fail outright. Without monitoring, the first real signal is often a support ticket. *** ## Gateway & Refresh Checklist [#gateway--refresh-checklist] Before scaling up scheduled refresh across a team: * Gateways run in Standard mode with clustering for any shared or business-critical source. * A current map exists of which datasets share which gateway. * Refresh schedules are staggered based on actual refresh duration, not left on defaults. * Gateway performance is checked periodically, not only after a failure. *** ## Next Steps [#next-steps] Continue exploring Power BI governance and operations: * [Refresh](/docs/power-bi-service/refresh) * [Dataflows](/docs/power-bi-service/dataflows) * [Workspaces](/docs/power-bi-service/workspaces) * [DateTime.LocalNow() and the Desktop-vs-Service Trap](/docs/power-query/datetime-localnow) — a scheduled refresh runs on a machine with its own timezone, not the author's A refresh that works from Desktop but fails through the gateway with an "OLE DB or ODBC error"? See [OLE DB or ODBC Error](/blog/ole-db-odbc-connection-error) — a driver or credential mismatch on the gateway machine is the usual cause. For SQL Server sources specifically, see [Sql.Database()](/docs/power-query/sql-database#a-report-that-works-from-desktop-but-fails-through-the-gateway) for the same Desktop-works-gateway-fails pattern from the connection function's side. # Governance (/docs/governance) # Governance [#governance] As a Power BI tenant grows past a handful of reports, keeping security, refresh architecture, and visual standards consistent stops being something one person can just remember — this section covers reference formats for documenting each of those explicitly. ## Start Here [#start-here] ## Where to Go Next [#where-to-go-next] * [Row-Level Security](/docs/power-bi-service/security) — the mechanism the RLS Role Matrix documents. # Report Theme JSON Files (/docs/governance/report-themes) # Report Theme JSON Files [#report-theme-json-files] [Formatting](/docs/visuals/formatting) covers why a shared theme matters for consistency. This page is about the theme file itself — its structure, and how to treat it as a governed asset a team distributes and version-controls, rather than something each report author recreates by hand. ```text theme.json | applied via Format pane > Themes > Browse for themes | every visual in the report picks up its colors from one file ``` *** ## Minimal Structure [#minimal-structure] A theme file is JSON with, at minimum, a name and a data color palette. Power BI applies these to every visual that doesn't have an explicit color override. ```json { "name": "Contoso Standard", "dataColors": [ "#2E5EAA", "#5B8DEF", "#8FB8FF", "#F2A007", "#F26B3A", "#E0474C" ], "background": "#FFFFFF", "foreground": "#1A1A1A", "tableAccent": "#2E5EAA" } ``` `dataColors` is the palette charts cycle through for categories/series; `background`, `foreground`, and `tableAccent` cover the report canvas and table styling. *** ## Extending to Per-Visual Styles [#extending-to-per-visual-styles] Beyond the base palette, a theme can set defaults for specific visual types via `visualStyles` — useful for enforcing things like consistent card borders or table header styling without every author configuring them manually. ```json { "name": "Contoso Standard", "dataColors": ["#2E5EAA", "#5B8DEF", "#8FB8FF"], "visualStyles": { "card": { "*": { "background": [{ "color": { "solid": { "color": "#FFFFFF" } } }] } } } } ``` `visualStyles` is where a theme goes from "consistent colors" to "consistent formatting" — border widths, font sizing, and header styles can all be set once instead of per report. *** ## Accessible Color Palettes [#accessible-color-palettes] `dataColors` is worth checking for contrast and colorblind-safety before it becomes the standard for every report in the organization, the same way [text contrast](/docs/power-bi-service/security) matters for readability elsewhere. ```text Palette check | +-- Adjacent colors distinguishable for common color blindness types +-- Text/background pairs meet WCAG AA contrast (4.5:1) +-- Not relying on red/green alone to distinguish categories ``` A palette that looks fine to the person who picked it can still fail for a meaningful share of report viewers — checking it once at the theme level is far cheaper than fixing it report by report later. *** ## Distributing One Standard Theme [#distributing-one-standard-theme] The goal is one file, one source of truth, applied consistently — not each author's local copy slowly drifting from everyone else's. ```text theme.json (checked into the same repo as .pbip files) | +-- Report A: Browse for themes -> theme.json +-- Report B: Browse for themes -> theme.json +-- Report C: Browse for themes -> theme.json | one file, applied the same way everywhere ``` Keeping the theme file in source control alongside report files means a color or font change is a single commit, not a manual update repeated across every report. *** ## Versioning the Theme File [#versioning-the-theme-file] Treat changes to the standard theme like any other shared asset — a small changelog note (even just a comment convention in the commit message) saves guessing later about why a color changed. ```text theme.json | +-- v1: initial palette +-- v2: darkened primary blue for AA contrast +-- v3: added visualStyles for card borders ``` Report authors pulling an outdated copy of the theme is a common, avoidable source of "why does this report look different" questions. *** ## Best Practices [#best-practices] * Maintain exactly one theme file per brand/team, in source control, not per-author local copies. * Check `dataColors` for WCAG contrast and colorblind-safe adjacency before adopting it as the standard. * Use `visualStyles` to standardize recurring formatting choices, not just colors. * Note what changed and why whenever the theme file is updated. *** ## Common Mistakes [#common-mistakes] ### Divergent Local Copies [#divergent-local-copies] Once a theme file gets emailed around or copied between machines instead of pulled from one source, different reports quietly end up on different versions with no way to tell which is current. ### Hardcoding Colors Instead of Using the Theme [#hardcoding-colors-instead-of-using-the-theme] Manually setting a visual's color instead of letting it inherit from the theme defeats the purpose — that visual won't update when the theme does, and becomes an exception someone has to remember. ### Skipping the Accessibility Check [#skipping-the-accessibility-check] A palette that hasn't been checked for contrast or colorblind-safety becomes the default for every report built against it — one check at the theme level is far cheaper than catching it later across dozens of reports. *** ## Report Theme Checklist [#report-theme-checklist] Before adopting a theme file as the team standard: * `dataColors` has been checked for WCAG AA contrast and colorblind-safe adjacency. * The file lives in source control as the single source of truth. * Report authors apply it via Browse for themes rather than manually matching colors. * Changes to the theme are noted somewhere reviewers can see them. *** ## Next Steps [#next-steps] Continue exploring Power BI formatting and governance: * [Formatting](/docs/visuals/formatting) * [Charts](/docs/visuals/charts) * [RLS Role Matrix](/docs/governance/rls-matrix) # RLS Role Matrix (/docs/governance/rls-matrix) # RLS Role Matrix [#rls-role-matrix] [Row-Level Security](/docs/power-bi-service/security) covers how static and dynamic RLS work. Once a model has more than two or three roles, keeping track of which role filters which table with which DAX expression stops fitting in anyone's head — an RLS matrix makes that mapping explicit and reviewable. ```text Manage Roles dialog | shows one role at a time | no single view of every role's rules at once ``` The matrix exists to fill that gap: one document, checked in alongside the model, that shows every role side by side. *** ## Anatomy of a Matrix [#anatomy-of-a-matrix] A matrix is a table with one row per role and columns covering exactly what a reviewer needs to audit that role without opening Power BI Desktop. | Column | Purpose | | ------------- | -------------------------------------------------------------- | | Role | The role name as defined in Manage Roles | | Table | The table the DAX filter is applied to | | DAX Filter | The exact filter expression | | Propagates To | Related tables filtered indirectly via relationships | | Notes | Anything a reviewer needs — e.g. "matches AD security group X" | *** ## Example Matrix [#example-matrix] ```text Role | Table | DAX Filter | Propagates To ------------------|------------|------------------------------------------------|------------------ RegionalManager | DimRegion | [RegionManagerEmail] = USERPRINCIPALNAME() | FactSales, DimStore SalesRep | DimStore | [SalesRepEmail] = USERPRINCIPALNAME() | FactSales Executive | (none) | no filter — full access | — ``` `Executive` having no filter is worth documenting explicitly, not leaving implicit — a blank row is ambiguous about whether it was forgotten or intentional. *** ## Documenting Propagation [#documenting-propagation] RLS applied to a dimension table filters every fact table connected to it through a single-direction relationship, but not tables it isn't related to. The matrix should record that propagation, not just the table the filter is written against. ```text DimRegion (filtered by role) | | single-direction relationship | FactSales (filtered indirectly) DimProduct (unrelated to DimRegion) | not filtered — no relationship path ``` Without writing this down, it's easy to assume a role restricts more (or less) than it actually does. *** ## Validating Against the Matrix [#validating-against-the-matrix] **View as Roles** in Power BI Desktop lets you preview the report exactly as a given role would see it — the matrix is what you check the result against, row by row. ```text Matrix says: SalesRep sees only their own store's rows | | View as Roles > SalesRep | Confirm: report shows only that store's data ``` Every role in the matrix should have a corresponding "View as Roles" check before publishing, not just a written filter expression that's never been executed. *** ## Best Practices [#best-practices] * Keep the matrix in the same repository as the `.pbix`/`.pbip` file, so it changes alongside the model instead of drifting out of date. * Include roles with no filter (full access) explicitly, rather than leaving them undocumented. * Record propagation to related tables, not just the table the DAX filter is written against. * Re-validate every role with View as Roles whenever the model's relationships change. *** ## Common Mistakes [#common-mistakes] ### Undocumented Ad-Hoc Roles [#undocumented-ad-hoc-roles] A role added quickly to unblock one user, then never added to the matrix, becomes an untracked gap the next security review will miss entirely. ### Assuming Propagation Without Checking It [#assuming-propagation-without-checking-it] RLS only propagates in the direction relationships filter. A role written against the wrong table, or across a bidirectional relationship the author didn't expect, can expose more data than intended. ### Treating "No Filter" as the Same as "Not Reviewed" [#treating-no-filter-as-the-same-as-not-reviewed] A role with no filter should be a deliberate, documented decision (e.g. an executive role), not the default state of a role nobody got around to configuring. *** ## RLS Matrix Checklist [#rls-matrix-checklist] Before publishing a model with more than one role: * Every role, including any with full access, is listed in the matrix. * Propagation to related tables is documented per role. * Every role has been checked with View as Roles against its documented filter. * The matrix lives in source control next to the model file. *** ## Next Steps [#next-steps] Continue exploring Power BI governance and security: * [Row-Level Security](/docs/power-bi-service/security) * [Workspaces](/docs/power-bi-service/workspaces) * [Relationships](/docs/modeling/relationships) A different kind of matrix: [Build a Risk Register and Risk Matrix Dashboard](/tutorials/build-a-risk-register-dashboard) builds an actual 5x5 likelihood/impact matrix in Power BI. # Dashboards (/docs/power-bi-service/dashboards) # Dashboards [#dashboards] A dashboard is a single canvas of tiles, pinned from one or more reports. ```text Report A ---+ | Report B ---+--- Tiles ---> Dashboard | Report C ---+ ``` Unlike a report, a dashboard is not interactive in the same way — it's a curated, at-a-glance summary. *** ## Dashboards vs. Reports [#dashboards-vs-reports] | Aspect | Report | Dashboard | | --------- | ----------------------------- | ----------------------------------- | | Pages | Multiple | Single | | Filtering | Full slicers and interactions | Limited (click-through only) | | Source | One dataset | Can combine tiles from many reports | | Best for | Exploration | At-a-glance monitoring | A dashboard is built *from* reports. It doesn't replace them. *** ## Pinning a Tile [#pinning-a-tile] Tiles are added to a dashboard by pinning a visual from a report. ```text Report visual | | Pin to dashboard | Dashboard tile ``` Steps: 1. Open a report and hover over the visual to pin. 2. Select the pin icon. 3. Choose an existing dashboard, or create a new one. The visual becomes a static tile that links back to the underlying report and visual state at the time it was pinned. *** ## Live Tiles vs. Pinned Images [#live-tiles-vs-pinned-images] Most tiles stay live, refreshing whenever the underlying dataset refreshes. ```text Dataset refresh | | updates | Dashboard tile ``` Clicking a live tile takes the user back into the source report, filtered to match the state that was pinned. *** ## Pinning a Live Report Page [#pinning-a-live-report-page] An entire report page can also be pinned as a single tile, preserving its own interactivity when clicked into. ```text Report Page | | Pin live page | Dashboard tile (opens full page) ``` This is useful when a whole page — not just one visual — represents a meaningful summary. *** ## Combining Multiple Sources [#combining-multiple-sources] Because a dashboard is just a collection of tiles, it can combine visuals pinned from different reports and even different datasets. ```text Sales Report -----+ | Inventory Report --+--- Dashboard | Finance Report ----+ ``` This makes dashboards useful as an executive summary that spans multiple underlying models. *** ## Q\&A Tiles [#qa-tiles] Dashboards support natural-language Q\&A tiles, letting users type a question and get a matching visual back automatically. ```text "total sales by region" | | Q&A | Auto-generated chart ``` Q\&A quality depends heavily on clear field and table naming in the underlying dataset. *** ## Setting Alerts [#setting-alerts] Numeric tiles (cards, KPIs) can trigger data alerts when a value crosses a threshold. ```text Tile value > threshold | | triggers | Email / notification ``` Alerts are useful for monitoring metrics like inventory levels or daily sales without needing to check the dashboard manually. *** ## Best Practices [#best-practices] * Keep a dashboard focused on one audience or one decision, not everything at once. * Pin only the tiles that matter for an at-a-glance view; leave exploration to the underlying reports. * Use live report page tiles when a whole page tells the story better than one visual. * Set alerts on the handful of metrics that actually need proactive monitoring. * Revisit dashboards periodically — pinned tiles can become stale if the source report changes significantly. *** ## Common Mistakes [#common-mistakes] ### Treating a Dashboard Like a Report [#treating-a-dashboard-like-a-report] Dashboards intentionally have less interactivity than reports. Trying to replicate full slicer-driven exploration on a dashboard usually means the content should be a report page instead. ### Pinning Too Many Tiles [#pinning-too-many-tiles] A dashboard crowded with dozens of tiles stops being an at-a-glance summary. If it takes scrolling and searching to find the number that matters, it has grown too large. ### Forgetting Tiles Are Snapshots of a Visual State [#forgetting-tiles-are-snapshots-of-a-visual-state] A pinned tile reflects the filters applied at the moment it was pinned. Changing the source report's default filters later does not automatically update tiles pinned earlier. *** ## Dashboard Checklist [#dashboard-checklist] Before sharing a dashboard broadly: * Every tile serves the dashboard's specific audience or purpose. * Live tiles link back to the correct report and filtered state. * Alerts are configured for metrics that need proactive monitoring. * The dashboard isn't trying to replace full report exploration. * Tile count stays small enough to scan at a glance. *** ## Next Steps [#next-steps] Continue exploring the Power BI Service: * [Workspaces](/docs/power-bi-service/workspaces) * [Refresh](/docs/power-bi-service/refresh) * [Row-Level Security](/docs/power-bi-service/security) # Dataflows (/docs/power-bi-service/dataflows) # Dataflows [#dataflows] A dataflow moves Power Query transformation logic out of individual Desktop files and into the Power BI Service, where multiple datasets can share it. ```text Without a Dataflow | +-- Report A's Power Query: clean Sales data +-- Report B's Power Query: clean Sales data (duplicated) +-- Report C's Power Query: clean Sales data (duplicated) With a Dataflow | +-- Dataflow: clean Sales data (once) | +-- Report A references it +-- Report B references it +-- Report C references it ``` Each report author avoids re-solving the same cleanup problem, and a fix to the shared logic benefits every report at once. *** ## Where Dataflows Live [#where-dataflows-live] Dataflows are created and stored inside a workspace, not inside a Desktop file. ```text Workspace | +-- Datasets | +-- Reports | +-- Dataflows | +-- Dashboards ``` Created from the workspace's **New > Dataflow** option, using the same Power Query Editor interface as Desktop. *** ## Referencing a Dataflow from Desktop [#referencing-a-dataflow-from-desktop] Once published, a dataflow's tables appear as a data source in Power BI Desktop, under **Power Platform Dataflows**. ```text Power BI Desktop | | Get Data > Power Platform Dataflows | Dataflow Table | | loaded like any other source | Report's Data Model ``` The report's Power Query just references the dataflow's output — the transformation logic itself doesn't need to be rebuilt. *** ## Dataflows Have Their Own Refresh Schedule [#dataflows-have-their-own-refresh-schedule] A dataflow refreshes independently of the datasets that consume it. ```text Dataflow Refresh (e.g. 6:00 AM) | | produces fresh tables | Dataset Refresh (e.g. 6:30 AM) | | picks up the dataflow's latest output ``` Scheduling the dataflow refresh to complete before dependent dataset refreshes avoids datasets picking up stale dataflow data. *** ## Linked vs. Computed Entities [#linked-vs-computed-entities] A **linked entity** references another dataflow's table without copying or transforming it further. ```text Dataflow A: CleanedSales | | linked | Dataflow B: uses CleanedSales as-is ``` A **computed entity** takes a linked entity and applies additional transformations, without re-touching the original source. ```text Dataflow A: CleanedSales | | linked, then transformed | Dataflow B: CleanedSalesByRegion (computed) ``` Computed entities require Premium or Fabric capacity, since the transformation runs inside the Power BI Service rather than against the original source. *** ## Dataflows vs. Datasets [#dataflows-vs-datasets] | Aspect | Dataflow | Dataset | | ----------- | --------------------------------- | --------------------------------------------- | | Contains | Cleaned tables (Power Query only) | Tables, relationships, DAX measures | | Consumed by | Datasets, other dataflows | Reports | | Runs in | Power BI Service | Power BI Service (after publish from Desktop) | | Best for | Shared, reusable data prep | The full semantic model for reporting | A dataflow is only the data-cleaning layer. Relationships, DAX measures, and the reporting model still live in a dataset built from the dataflow's tables. *** ## Why Use Dataflows [#why-use-dataflows] * Avoid duplicating the same Power Query logic across many Desktop files. * Give data source credentials and connection details a single, centrally-managed location. * Let less technical report builders consume already-cleaned tables without needing to write M themselves. * Separate data engineering work (dataflows) from report authoring work (datasets and reports). *** ## Best Practices [#best-practices] * Build one dataflow per logical entity (Sales, Customers, Products), not one giant dataflow for everything. * Schedule dataflow refresh early enough that dependent dataset refreshes always pick up current data. * Use computed entities to avoid re-fetching from the original source when further transforming already-cleaned data. * Document which workspace owns each dataflow, since dataflows are easy to lose track of once several teams depend on them. *** ## Common Mistakes [#common-mistakes] ### Duplicating Logic Instead of Sharing a Dataflow [#duplicating-logic-instead-of-sharing-a-dataflow] Building the same Power Query cleanup independently in every report defeats the purpose of dataflows — a single shared dataflow means one place to fix bugs and extend logic. ### Refresh Order Race Conditions [#refresh-order-race-conditions] If a dataset refreshes before its source dataflow finishes, it picks up yesterday's data. Refresh schedules need enough buffer between dataflow and dependent dataset refresh times. ### Treating a Dataflow Like a Full Semantic Model [#treating-a-dataflow-like-a-full-semantic-model] A dataflow has no relationships or DAX measures. Trying to build a report directly against dataflow output, without an intermediate dataset, means every calculation has to happen in Power Query instead of DAX. *** ## Dataflows Checklist [#dataflows-checklist] Before relying on a dataflow in production: * The dataflow is scoped to one logical entity, not a catch-all for unrelated data. * Refresh timing leaves enough buffer for dependent datasets to pick up fresh data. * Computed entities are used instead of re-fetching from source when chaining transformations. * Ownership and location of the dataflow are documented for the team. *** ## A Newer Alternative: Dataflows Gen2 [#a-newer-alternative-dataflows-gen2] Microsoft Fabric offers a successor to this feature, Dataflows Gen2, which uses the same Power Query Editor but lands its output as a Delta table in OneLake instead of a Power BI-only dataset format — making it readable by any Fabric workload, not just Power BI datasets. See [Data Factory](/docs/fabric/data-factory) for how it fits alongside Fabric Pipelines. *** ## Next Steps [#next-steps] Continue exploring the Power BI Service: * [Refresh](/docs/power-bi-service/refresh) * [Workspaces](/docs/power-bi-service/workspaces) * [DirectQuery vs. Import](/docs/modeling/storage-modes) * [Data Factory (Fabric)](/docs/fabric/data-factory) # Deployment Pipelines (/docs/power-bi-service/deployment-pipelines) # Deployment Pipelines [#deployment-pipelines] A deployment pipeline moves content through a sequence of stages, so changes get tested before reaching the people who depend on the report. ```text Development | | Deploy | Test | | Deploy | Production ``` Each stage is backed by its own workspace, so Development changes don't affect what Production users see until they're explicitly promoted. *** ## The Three Stages [#the-three-stages] ```text Development | +-- Where active changes happen | Test | +-- Where changes are validated before release | Production | +-- What end users actually see ``` Each stage is a separate workspace, with its own workspace roles and, optionally, its own data source connections. *** ## Creating a Pipeline [#creating-a-pipeline] Deployment pipelines are created from the Power BI Service's pipeline area, then assigned an existing workspace for each stage (or new workspaces are created automatically). ```text New Pipeline | +-- Assign Development workspace +-- Assign Test workspace +-- Assign Production workspace ``` Requires Premium or Fabric capacity — deployment pipelines aren't available on shared capacity workspaces. *** ## Deploying Between Stages [#deploying-between-stages] Deploying copies content from one stage to the next, comparing what's changed before confirming. ```text Development (source) | | Deploy | Test (target) | | content compared, then overwritten ``` The pipeline shows a diff-style comparison — which reports, dashboards, and datasets differ between stages — before the deployment actually happens. *** ## Stage-Specific Data Sources [#stage-specific-data-sources] A report often needs to point at a different database per stage — a Test database for the Test stage, Production data for the Production stage. ```text Development -> Dev Database Test -> Test Database Production -> Production Database ``` Configured through **deployment rules**, which override specific settings (like a data source connection string or a parameter value) per stage, so the same dataset definition can point at different data depending on which stage it's deployed to. *** ## Deployment Rules [#deployment-rules] A deployment rule says "when this dataset lands in this stage, apply this override." ```text Rule: Dataset "Sales" in "Production" | | override | Data source = prod-server.database.windows.net ``` Without deployment rules, deploying to Production would carry over the Development connection string, pointing Production reports at development data. *** ## Comparing Pipeline Stages [#comparing-pipeline-stages] Each stage shows its last-deployed timestamp and whether it differs from the stage before it. | Stage | Status | | ----------- | --------------------------------------------- | | Development | Actively being edited | | Test | Matches Development, or shows pending changes | | Production | Matches Test, or shows pending changes | This makes it easy to see whether Production is running the latest tested version, or has drifted behind Test. *** ## Why Use Deployment Pipelines [#why-use-deployment-pipelines] * Changes are validated in Test before reaching the people relying on Production. * Each stage can safely use different data sources without manual reconfiguration on every deploy. * The diff view prevents accidentally deploying unintended changes. * Report authors get a clear, auditable path from "in progress" to "live." *** ## Best Practices [#best-practices] * Never edit Production content directly; all changes should flow through Development and Test first. * Set up deployment rules for every stage-specific connection before the first deployment, not after a broken Production report is discovered. * Review the diff comparison carefully before deploying to Production — it's the last checkpoint before end users see the change. * Keep Test data representative of Production data volume, so performance issues surface before release. *** ## Common Mistakes [#common-mistakes] ### Editing Production Directly [#editing-production-directly] Bypassing the pipeline to fix something "just this once" in Production defeats the purpose of having a tested release path, and the fix will be overwritten by the next deployment from Test. ### Forgetting Deployment Rules [#forgetting-deployment-rules] Deploying a dataset to Production without a rule overriding its data source can silently point live reports at development or test data. ### Treating Test as Optional [#treating-test-as-optional] Skipping straight from Development to Production removes the one checkpoint designed to catch problems before real users see them. *** ## Deployment Pipeline Checklist [#deployment-pipeline-checklist] Before deploying to Production: * Changes have been validated in the Test stage first. * Deployment rules are configured for every stage-specific data source. * The diff comparison has been reviewed and matches what's expected. * Report consumers have been notified of significant changes, if relevant. *** ## Next Steps [#next-steps] Continue exploring the Power BI Service: * [Workspaces](/docs/power-bi-service/workspaces) * [Dataflows](/docs/power-bi-service/dataflows) * [Refresh](/docs/power-bi-service/refresh) # Power BI Service (/docs/power-bi-service) # Power BI Service [#power-bi-service] Once a report leaves Desktop, this is where it lives — workspaces, scheduled refresh, dataflows shared across datasets, and the security model controlling who sees what. ## Start Here [#start-here] ## Where to Go Next [#where-to-go-next] * [Governance](/docs/governance/rls-matrix) — reference formats for keeping RLS and gateway architecture auditable as they grow. * [Row-Level Security Works in Desktop But Not in the Service](/blog/rls-works-in-desktop-not-in-service) — a common surprise after publishing. # Refresh (/docs/power-bi-service/refresh) # Refresh [#refresh] Import-mode datasets are a snapshot. Refresh is what brings that snapshot up to date. ```text Source Data | | Refresh | Power BI Dataset (in-memory copy) ``` DirectQuery tables don't need refresh, since they query the source live. Refresh applies to Import and Dual-mode tables. *** ## What Happens During Refresh [#what-happens-during-refresh] A refresh re-runs the full query pipeline for every Import table in the dataset. ```text Refresh | +-- Reconnect to each data source | +-- Re-run Power Query transformations | +-- Reload data into the model | +-- Recalculate calculated columns and tables ``` The larger the model and the more complex the transformations, the longer a full refresh takes. *** ## Scheduled Refresh [#scheduled-refresh] Scheduled refresh runs automatically at set times, without anyone manually clicking refresh. ```text Schedule (e.g. 6:00 AM, 12:00 PM) | | triggers | Dataset Refresh ``` Configured from the dataset's settings in the Power BI Service, under **Scheduled refresh**. On shared capacity, datasets are limited to a small number of scheduled refreshes per day (typically 8). Premium and Fabric capacities allow more frequent refresh. *** ## Refresh Requires a Data Gateway (Sometimes) [#refresh-requires-a-data-gateway-sometimes] Cloud data sources (like most SaaS databases) refresh directly. On-premises sources need a gateway to bridge the connection. ```text On-Premises Database | | Gateway | Power BI Service ``` Without a gateway installed and configured, scheduled refresh against an on-premises source will fail. *** ## Incremental Refresh [#incremental-refresh] Incremental refresh only reloads recent data, instead of reprocessing the entire table every time. ```text Full Table | +-- Historical data (unchanged) -- skipped | +-- Recent data (changed) -- refreshed ``` It's configured with a date range policy, for example: keep 5 years of history, but only refresh the last 10 days. *** ## Setting Up Incremental Refresh [#setting-up-incremental-refresh] Incremental refresh is configured in Power BI Desktop, using **RangeStart** and **RangeEnd** parameters that Power Query uses to filter each partition. ```text RangeStart / RangeEnd | | filters | Table partitions by date ``` Once published, the Service manages the partitions automatically based on the policy defined in Desktop. *** ## Why Incremental Refresh Matters [#why-incremental-refresh-matters] Without it, refreshing a large fact table means reprocessing years of unchanged historical data every single time. | Aspect | Full Refresh | Incremental Refresh | | ---------------- | --------------------- | ---------------------- | | Data reprocessed | Entire table | Only the recent window | | Refresh time | Grows with table size | Stays roughly constant | | Source load | High every refresh | Lower, ongoing | Incremental refresh is most valuable on large fact tables where historical rows rarely change. *** ## Refresh Failures [#refresh-failures] A failed refresh leaves the dataset showing whatever data was loaded during the last *successful* refresh. ```text Refresh fails | | dataset unchanged | Last successful data still shown ``` Common causes include expired credentials, schema changes at the source, or a gateway that's offline. Refresh history, available from the dataset settings, shows the error details for any failed attempt. *** ## Refresh Notifications [#refresh-notifications] Power BI can send an email when a scheduled refresh fails, so problems don't go unnoticed until someone spots stale data. ```text Refresh fails | | notify | Dataset owner ``` Configured alongside the scheduled refresh settings. *** ## Best Practices [#best-practices] * Schedule refresh to run before people start their day, not during it. * Use incremental refresh for any large, mostly-historical fact table. * Install and monitor gateways for any on-premises source; a single offline gateway can silently break every dependent dataset. * Turn on refresh failure notifications so problems surface immediately. * Keep Power Query transformations efficient — slow transformations directly extend refresh time. *** ## Common Mistakes [#common-mistakes] ### No Incremental Refresh on Large Tables [#no-incremental-refresh-on-large-tables] Reprocessing millions of historical rows on every refresh wastes time and source load for data that never changes. ### Ignoring Refresh Failures [#ignoring-refresh-failures] A refresh that silently fails leaves users looking at stale data without knowing it, unless notifications are configured. ### Missing or Outdated Gateway [#missing-or-outdated-gateway] An offline or outdated on-premises gateway breaks scheduled refresh for every dataset that depends on it, often without an obvious error message pointing at the real cause. *** ## Refresh Checklist [#refresh-checklist] Before relying on scheduled refresh in production: * A gateway is installed and online for any on-premises source. * Incremental refresh is configured for large historical fact tables. * Refresh failure notifications are enabled. * The refresh schedule fits within the workspace's capacity limits. * Someone owns monitoring refresh history, not just setting it up once. *** ## Next Steps [#next-steps] Continue exploring the Power BI Service: * [Workspaces](/docs/power-bi-service/workspaces) * [Dashboards](/docs/power-bi-service/dashboards) * [DirectQuery vs. Import](/docs/modeling/storage-modes) Seeing "Couldn't load the data for this visual"? See [Couldn't Load the Data for This Visual](/blog/couldnt-load-data-for-this-visual) for the four usual causes, including gateway and connection failures. Refresh failing with an "OLE DB or ODBC error"? See [OLE DB or ODBC Error](/blog/ole-db-odbc-connection-error) for the four usual causes and fixes. # Row-Level Security (/docs/power-bi-service/security) # Row-Level Security [#row-level-security] Row-Level Security (RLS) restricts what data a user can see, based on who they are. The report stays the same for everyone. The rows returned underneath it do not. RLS is commonly used to enforce: * Regional sales access * Department-level visibility * Customer or account ownership * Manager vs. individual contributor views *** ## How RLS Works [#how-rls-works] RLS is built from two pieces: ```text Role | | defines a filter expression | Table ``` ```text User | | is assigned to a role | Role ``` When a user opens a report, Power BI applies the role's filter expression before any visual renders. *** ## Creating a Role [#creating-a-role] Roles are created in Power BI Desktop, under **Modeling > Manage Roles**. A role has: * A name * A table * A DAX filter expression Example role named `Region - West`, filtering `DimStore`: `[Region] = "West"` Every visual in the report is now filtered to rows where `Region` equals `"West"` for any user assigned to this role. *** ## Static Row-Level Security [#static-row-level-security] Static RLS hardcodes the filter value directly in the role. Example: `[Region] = "West"` A second role for the East team: `[Region] = "East"` This works well for a small, fixed number of regions or departments, but does not scale to hundreds of individual users. *** ## Dynamic Row-Level Security [#dynamic-row-level-security] Dynamic RLS filters data based on the signed-in user, rather than a hardcoded value. It uses `USERPRINCIPALNAME()` to identify who is viewing the report: `[Email] = USERPRINCIPALNAME()` This assumes a table (commonly `DimUser` or `DimSalesRep`) that maps each user's email to the rows they should see. *** ## Dynamic RLS with a Mapping Table [#dynamic-rls-with-a-mapping-table] A typical dynamic RLS model looks like: ```text DimUser | | Email, Region | DimStore | | FactSales ``` `DimUser` example: ```text Email | Region -----------------------|------- alice@company.com | West bob@company.com | East ``` The role filter expression on `DimUser`: `[Email] = USERPRINCIPALNAME()` Because `DimUser` filters `DimStore`, which filters `FactSales`, the security rule propagates through the whole model automatically. *** ## Filtering Through Relationships [#filtering-through-relationships] RLS relies on the same filter propagation as any other DAX filter. ```text DimUser | | Region | DimStore | | filters | FactSales ``` If a relationship is one-directional and pointed the wrong way, the RLS filter will not reach the fact table, and the role will silently show more data than intended. Always verify the filter direction between the security table and the tables it needs to restrict. *** ## Testing Roles [#testing-roles] Power BI Desktop lets you preview a role before publishing. Use **Modeling > View As** and select a role to see the report exactly as a user assigned to that role would. For dynamic RLS, `USERPRINCIPALNAME()` returns an empty value in Desktop preview unless a specific user is entered, so test with: ```dax lineNumbers "alice@company.com" = LOOKUPVALUE( DimUser[Email], DimUser[Email], "alice@company.com" ) ``` or use the **Other user** option in the View As dialog to simulate a specific email. *** ## Assigning Users to Roles [#assigning-users-to-roles] Roles are defined in Desktop, but users are assigned to them after publishing, in the Power BI Service. Steps: 1. Publish the report to a workspace. 2. Open the dataset settings. 3. Select **Security**. 4. Add users or security groups to each role. Static roles require manually adding every user. Dynamic roles typically need only one broad group added, since the filter expression itself determines what each person sees. *** ## RLS and Admins [#rls-and-admins] Workspace admins, members, and contributors bypass RLS by default. This is intentional; RLS is meant to restrict report *consumers*, not the people building and maintaining the model. To test RLS as it will actually be experienced, use a Viewer-level account or the **View As** role preview. *** ## Static vs. Dynamic RLS [#static-vs-dynamic-rls] | Aspect | Static | Dynamic | | -------------------- | --------------------- | ------------------------------ | | Filter value | Hardcoded in the role | Based on signed-in user | | Scales to many users | Poorly | Well | | Setup complexity | Low | Higher (needs a mapping table) | | Maintenance | Manual role edits | Data-driven, self-maintaining | Most enterprise models use dynamic RLS once the user base grows beyond a handful of roles. *** ## RLS Best Practices [#rls-best-practices] * Prefer dynamic RLS over maintaining many static roles. * Filter from a dedicated security/dimension table, not the fact table directly. * Always verify filter direction reaches every table that needs restricting. * Test every role with **View As** before publishing. * Keep the user-to-region (or user-to-department) mapping table up to date. *** ## Common RLS Mistakes [#common-rls-mistakes] ### Filtering the Wrong Table [#filtering-the-wrong-table] Applying the role filter directly to a fact table instead of a dimension table makes the rule harder to maintain and easy to miss when new fact tables are added. ### Bidirectional Filtering Confusion [#bidirectional-filtering-confusion] Unexpected bidirectional relationships can cause RLS to restrict more (or less) than intended, since filters may propagate through paths that were not accounted for. ### Forgetting to Assign Users [#forgetting-to-assign-users] A role with a correct DAX filter still allows unrestricted access to anyone not assigned to it. Roles only apply to the users explicitly added to them. ### Testing as an Admin [#testing-as-an-admin] Workspace admins bypass RLS, so testing while logged in as an admin can hide security problems that a real Viewer would encounter. *** ## RLS Checklist [#rls-checklist] Before publishing a model with sensitive data: * Every role has a correct DAX filter expression. * Filter direction reaches every table that needs restricting. * Each role has been tested with **View As**. * Users or security groups are assigned to every role. * No sensitive table is left unfiltered. *** ## Next Steps [#next-steps] Continue learning Power BI security and modeling: * [Relationships](/docs/modeling/relationships) * [CALCULATE()](/docs/dax/calculate) * [Workspaces](/docs/power-bi-service/workspaces) RLS filtering correctly in Desktop but not after publishing? See [Row-Level Security Works in Desktop But Not in the Service](/blog/rls-works-in-desktop-not-in-service) for the four usual causes. # Workspaces (/docs/power-bi-service/workspaces) # Workspaces [#workspaces] A workspace is where Power BI content lives once it leaves Desktop and is published to the Service. ```text Power BI Desktop | | Publish | Workspace ``` Reports, dashboards, datasets, and dataflows are all organized inside workspaces. *** ## What a Workspace Contains [#what-a-workspace-contains] ```text Workspace | +-- Datasets | +-- Reports | +-- Dashboards | +-- Dataflows ``` A single dataset published to a workspace can be reused by multiple reports, avoiding duplicate copies of the same data. *** ## Workspace Roles [#workspace-roles] Access to a workspace is controlled by roles, assigned per user or security group. | Role | Can View | Can Edit | Can Publish | Can Manage Access | | ----------- | -------- | -------- | ----------- | ----------------- | | Viewer | Yes | No | No | No | | Contributor | Yes | Yes | Yes | No | | Member | Yes | Yes | Yes | Yes | | Admin | Yes | Yes | Yes | Yes | Admins can also delete the workspace and change its settings, including who else is an Admin. *** ## Viewer [#viewer] Viewers can open reports and dashboards, interact with slicers and filters, and use bookmarks — but cannot change the underlying content. ```text Viewer | | can | Interact with reports (read-only) ``` Viewer is the appropriate role for most report consumers. *** ## Contributor [#contributor] Contributors can create and edit content inside the workspace, including publishing updated reports from Desktop. ```text Contributor | | can | Edit and publish content ``` Contributors cannot manage who else has access to the workspace. *** ## Member and Admin [#member-and-admin] Members and Admins can additionally manage workspace access, adding or removing other users and changing their roles. ```text Admin | | can | Manage workspace settings and access ``` Admin is typically reserved for whoever owns the workspace or leads the team responsible for it. *** ## Workspaces vs. My Workspace [#workspaces-vs-my-workspace] Every user also has a personal **My Workspace**, separate from shared team workspaces. ```text My Workspace | | personal, not shared | Only visible to you ``` My Workspace is useful for individual exploration, but content there cannot be shared with a team the way content in a proper workspace can. *** ## Apps [#apps] A workspace can be published as an **App**, giving consumers a clean, read-only view of selected content without exposing the workspace's editing environment. ```text Workspace (editing) | | Publish app | App (consumption) ``` Apps are the recommended way to distribute finished reports to a broad audience, since they hide in-progress work and workspace management controls. *** ## Premium and Shared Capacity [#premium-and-shared-capacity] Workspaces run on either shared capacity or a Premium/Fabric capacity. | Aspect | Shared Capacity | Premium / Fabric Capacity | | ------------------ | ------------------------- | ------------------------- | | Refresh frequency | Limited (typically 8/day) | Higher, configurable | | Dataset size limit | Smaller | Larger | | Paginated reports | Not supported | Supported | | Dataflows | Limited | Full support | Larger organizations typically assign important workspaces to a dedicated capacity for predictable performance. *** ## Best Practices [#best-practices] * Use one workspace per team or project, not one giant shared workspace for everything. * Assign the least-privileged role that still lets someone do their job (most people should be Viewers). * Publish an App for broad distribution instead of giving report consumers direct workspace access. * Use security groups instead of adding individual users one at a time. * Reserve Admin for a small number of trusted owners. *** ## Common Mistakes [#common-mistakes] ### Everyone Is a Contributor [#everyone-is-a-contributor] Giving every consumer Contributor access means anyone can accidentally modify a shared report. Most users only need Viewer access. ### One Workspace for the Entire Organization [#one-workspace-for-the-entire-organization] A single workspace holding every team's reports makes permissions difficult to manage and increases the risk of someone seeing data they shouldn't. ### Sharing the Workspace Instead of an App [#sharing-the-workspace-instead-of-an-app] Giving report consumers direct workspace access exposes in-progress content and management settings. Publishing an App keeps the editing environment separate from what consumers see. *** ## Workspace Checklist [#workspace-checklist] Before rolling out a workspace to a team: * Roles are assigned based on least privilege. * Broad distribution goes through a published App, not direct workspace access. * The workspace is assigned to appropriate capacity for its refresh and size needs. * Security groups are used instead of individually added users where possible. * Only trusted owners have the Admin role. *** ## Next Steps [#next-steps] Continue exploring the Power BI Service: * [Dashboards](/docs/power-bi-service/dashboards) * [Refresh](/docs/power-bi-service/refresh) * [Row-Level Security](/docs/power-bi-service/security) # Csv.Document() (/docs/power-query/csv-document) # Csv.Document() [#csvdocument] `Csv.Document()` parses raw CSV content into a table — the function behind **Get Data > Text/CSV**. What it returns is less finished than the wizard makes it look: generic column names, no header row, and every value as plain text. ```powerquery lineNumbers Csv.Document( csvSource as any, optional columns as any, optional delimiter as any, optional extraValues as nullable number, optional encoding as nullable number ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Source = Csv.Document(File.Contents("Sales.csv")), #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars = true]) ``` `Csv.Document()` alone doesn't know the first row is meant to be headers — it's just the first row of data. `Table.PromoteHeaders()` is a separate, explicit step that takes that first row and turns it into column names. Edit the CSV text above — the raw `Csv.Document()` output never changes shape based on what's in the first row; it's `Table.PromoteHeaders()` that turns it into real column names, one step later. *** ## Every Value Comes Back as Text [#every-value-comes-back-as-text] Even a column that's obviously numbers — `500`, `700`, `1200` — comes back from `Csv.Document()` as the text values `"500"`, `"700"`, `"1200"`, not as numbers. Typing happens in a later, separate step. ```powerquery lineNumbers #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers", {{"Sales", Int64.Type}}) ``` The **Get Data** wizard runs `Csv.Document`, `Table.PromoteHeaders`, and a type-detection step automatically and shows you only the final result — which is why it's easy to not realize these are three separate steps until something in the middle needs adjusting (a locale-specific number format, for example). *** ## Common Mistakes [#common-mistakes] ### Assuming Csv.Document Detects Headers Automatically [#assuming-csvdocument-detects-headers-automatically] It doesn't — every row, including what looks like a header row, comes back as an ordinary data row named `Column1`, `Column2`, and so on. `Table.PromoteHeaders()` is what actually promotes a row to column names, and it has to be called explicitly (or generated by the wizard) to happen at all. ### Assuming Numbers Import as Numbers [#assuming-numbers-import-as-numbers] A column of numeric-looking text still needs an explicit `Table.TransformColumnTypes()` step — `Csv.Document()` itself never inspects or converts values, it only splits text into a grid. ### Not Accounting for a Locale Mismatch on the Later Type Step [#not-accounting-for-a-locale-mismatch-on-the-later-type-step] Once real typing happens via `Table.TransformColumnTypes()`, the same locale-mismatch risk applies as any other text-to-number or text-to-date conversion — see [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the decimal-separator and date-ordering versions of this. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [Excel.Workbook()](/docs/power-query/excel-workbook) * [M Function Reference](/docs/power-query/functions) # Custom Functions in Power Query M (/docs/power-query/custom-functions) # Custom Functions in Power Query M [#custom-functions-in-power-query-m] Every `each` expression in Power Query is already a function in disguise — a custom function is just the same idea, given a name and (optionally) explicit parameter types, so it can be written once and reused anywhere a function is expected. ```powerquery lineNumbers (parameters) => expression ``` This single concept is what makes [List.Transform()](/docs/power-query/list-transform), [List.Accumulate()](/docs/power-query/list-accumulate), and [Table.AddColumn()](/docs/power-query/table-addcolumn) work — each one takes a function as an argument, and a custom function is exactly what can be passed. *** ## From each to a Named Function [#from-each-to-a-named-function] ```powerquery lineNumbers each [Amount] > 100 ``` is shorthand for: ```powerquery lineNumbers (row) => row[Amount] > 100 ``` Giving that function a name in a `let` step turns it into something reusable, rather than something written inline every time it's needed: ```powerquery lineNumbers let IsLargeOrder = (row) => row[Amount] > 100, #"Filtered Rows" = Table.SelectRows(Source, IsLargeOrder) in #"Filtered Rows" ``` *** ## Typed Parameters and Return Types [#typed-parameters-and-return-types] ```powerquery lineNumbers AddTax = (amount as number) as number => amount * 1.08 ``` `as number` after a parameter name declares its expected type; `as number` after the parameter list declares the function's return type. Neither is required — an untyped function still works — but typing catches a wrong-type call immediately with a clear error, instead of letting a type mismatch surface confusingly several steps later. ```powerquery lineNumbers AddTax(100) -- 108 AddTax("100") -- error: "100" is not a number ``` *** ## Optional Parameters [#optional-parameters] ```powerquery lineNumbers FormatAmount = (amount as number, optional currency as text) as text => Number.ToText(amount) & " " & (currency ?? "USD") ``` `optional` marks a parameter that doesn't have to be supplied; an omitted optional parameter is `null` inside the function, which the `??` (null-coalescing) operator can supply a default for, as shown above. Optional parameters must come after all required ones. *** ## Multiple Parameters [#multiple-parameters] ```powerquery lineNumbers CalculateDiscount = (price as number, discountPercent as number) as number => price * (1 - discountPercent / 100) ``` ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "FinalPrice", each CalculateDiscount([Price], [DiscountPct]) ) ``` A custom function taking multiple arguments is invoked the same way as any built-in M function — the definition doesn't change based on how many parameters it needs. *** ## Passing a Function as a Value [#passing-a-function-as-a-value] Because a function is just another kind of value in M, it can be passed directly wherever a function is expected — this is exactly how `List.Transform()`, `List.Accumulate()`, and `Table.AddColumn()` accept custom logic. ```powerquery lineNumbers let Square = (x as number) as number => x * x, Squared = List.Transform({1, 2, 3, 4}, Square) in Squared ``` ```text Squared = {1, 4, 9, 16} ``` `Square` here is passed by name (no parentheses, no call) — passing `Square(x)` instead would call it immediately with an undefined `x` and error, rather than handing the function itself to `List.Transform`. *** ## Recursive Functions [#recursive-functions] A function can't normally reference its own name from inside its own definition — the `@` prefix is M's way of allowing exactly that. ```powerquery lineNumbers Factorial = (n as number) as number => if n <= 1 then 1 else n * @Factorial(n - 1) ``` ```text Factorial(5) -> 5 * 4 * 3 * 2 * 1 = 120 ``` Recursion in M is uncommon in typical Power BI work — most repeated-application patterns are better served by [List.Accumulate()](/docs/power-query/list-accumulate) or [List.Generate()](/docs/power-query/list-generate) — but it's occasionally the clearest way to express a genuinely recursive problem (walking a variable-depth hierarchy, for instance). *** ## Common Mistakes [#common-mistakes] ### Calling the Function Instead of Passing It [#calling-the-function-instead-of-passing-it] ```powerquery lineNumbers List.Transform({1, 2, 3}, Square(_)) ``` `Square(_)` calls `Square` immediately with `_` as a literal argument name (which isn't defined at this point) rather than passing the function itself. The correct form passes the bare function name, or wraps it in `each` if extra logic beyond a single call is needed: `each Square(_)`. ### Forgetting a Function Value Can't Be Displayed Directly [#forgetting-a-function-value-cant-be-displayed-directly] Returning a function itself as a query's result (rather than calling it) shows as `[Function]` in the preview grid, not an error — a common source of "why is my query blank" confusion when a step accidentally produces a function instead of invoking it. ### Not Typing Parameters, Then Debugging a Confusing Downstream Error [#not-typing-parameters-then-debugging-a-confusing-downstream-error] An untyped function that receives the wrong data type doesn't fail at the function call — it fails wherever the resulting wrong-typed value is eventually used, which can be several steps later and much harder to trace back. Typing parameters up front turns that into an immediate, clearly-worded error at the actual call site. ### Overcomplicating Something a Built-In Function Already Does [#overcomplicating-something-a-built-in-function-already-does] Writing a custom function to trim whitespace, change case, or sum a list duplicates what `Text.Trim`, `Text.Upper`, or `List.Sum` already do — worth checking the [M Function Reference](/docs/power-query/functions) before writing custom logic for something common. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [List.Transform()](/docs/power-query/list-transform) * [List.Accumulate()](/docs/power-query/list-accumulate) * [Table.AddColumn()](/docs/power-query/table-addcolumn) * [M Function Reference](/docs/power-query/functions) # Working with Dates in Power Query (Date & Duration Functions) (/docs/power-query/date-functions) # Working with Dates in Power Query (Date & Duration Functions) [#working-with-dates-in-power-query-date--duration-functions] Power Query has two related but distinct type families for time-based work: **Date/DateTime** values (a specific point in time) and **Duration** values (a span of time, the result of subtracting two dates). Most confusion around date arithmetic in M comes from mixing the two up. ```text #date(2026, 1, 15) <- a Date: a specific day #datetime(2026, 1, 15, 9, 0, 0) <- a DateTime: a specific instant #duration(2, 0, 0, 0) <- a Duration: a 2-day span, not a date ``` *** ## Extracting Components [#extracting-components] ```powerquery lineNumbers Date.Year([OrderDate]) Date.Month([OrderDate]) Date.Day([OrderDate]) Date.DayOfWeek([OrderDate]) ``` ```text OrderDate: 2026-03-14 Date.Year -> 2026 Date.Month -> 3 Date.Day -> 14 ``` These are the functions behind most date-table calculated columns built directly in Power Query rather than DAX — see [Date Tables](/docs/modeling/date-tables) for where that pattern fits into a model. *** ## Adding and Subtracting Time [#adding-and-subtracting-time] ```powerquery lineNumbers Date.AddDays([OrderDate], 30) Date.AddMonths([OrderDate], 1) Date.AddYears([OrderDate], -1) ``` ```text 2026-01-15 + Date.AddDays(_, 30) -> 2026-02-14 2026-01-31 + Date.AddMonths(_, 1) -> 2026-02-28 <- see month-end note below ``` *** ## The Month-End Edge Case [#the-month-end-edge-case] `Date.AddMonths()` doesn't produce an invalid date when the starting day doesn't exist in the target month — it clamps to the last valid day instead. ```text Date.AddMonths(#date(2026,1,31), 1) -> #date(2026,2,28) (Feb has no 31st) Date.AddMonths(#date(2026,1,31), 2) -> #date(2026,3,31) (back to a 31-day month) ``` This is usually the desired behavior, but worth knowing explicitly: a report period defined as "one month after the 31st" doesn't consistently land on the same day-of-month every time, which can look like a bug in a rolling-window calculation until this behavior is understood. Start on January 31 and add 1 month — it clamps to February 28 instead of erroring or rolling into March. Add 2 months instead and it lands back on the 31st, since March has one. *** ## Subtracting Two Dates Produces a Duration, Not a Number [#subtracting-two-dates-produces-a-duration-not-a-number] ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "DaysOpen", each [CloseDate] - [OpenDate] ) ``` ```text CloseDate - OpenDate -> 5.00:00:00 (a Duration, displayed as days.hours:minutes:seconds) ``` The result isn't a plain number of days — it's a `duration` value. Using it directly in a numeric comparison or a chart usually needs an explicit extraction: ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "DaysOpen", each Duration.Days([CloseDate] - [OpenDate]) ) ``` ```text Duration.Days — whole days component Duration.Hours — whole hours component (0-23, not total) Duration.TotalHours — total span expressed as hours (a decimal, not just whole) ``` `Duration.Hours` and `Duration.TotalHours` are easy to swap by mistake: `Duration.Hours` on a 2-day, 3-hour span returns `3` (just the hours component), while `Duration.TotalHours` returns `51` (the whole span in hours). The `Total*` variants are almost always the ones wanted for a single combined numeric measure. *** ## Common Mistakes [#common-mistakes] ### Treating a Duration as if It Were a Number of Days [#treating-a-duration-as-if-it-were-a-number-of-days] ```powerquery lineNumbers each [CloseDate] - [OpenDate] > 5 ``` Comparing a `duration` value directly against a plain number like `5` doesn't produce the comparison intended — the duration needs to be converted first (`Duration.Days(...)` or `Duration.TotalDays(...)`) before comparing against a plain number. ### Using Duration.Hours Instead of Duration.TotalHours [#using-durationhours-instead-of-durationtotalhours] As covered above — `Duration.Hours` returns only the hours *component* of a multi-day span (0-23), not the total. A dashboard reporting suspiciously small "hours" values for what should be multi-day spans is usually this exact mix-up. ### Assuming Date.AddMonths Always Lands on the Same Day-of-Month [#assuming-dateaddmonths-always-lands-on-the-same-day-of-month] The month-end clamping behavior above means a rolling "same day next month" calculation can silently shift once it crosses a 31-day-to-shorter-month boundary — worth an explicit test against a 31st-of-the-month starting value if the calculation depends on landing on a consistent day. ### Mixing Date and DateTime Types in a Comparison [#mixing-date-and-datetime-types-in-a-comparison] ```powerquery lineNumbers each [OrderDate] = #date(2026, 1, 15) ``` If `[OrderDate]` is actually typed as `datetime` (carrying a time component, even if displayed as midnight), comparing it against a plain `#date(...)` value can fail to match rows where the time component is anything other than exactly midnight. `DateTime.Date([OrderDate]) = #date(2026, 1, 15)` strips the time component explicitly before comparing. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Date Tables](/docs/modeling/date-tables) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [Number.Round(), Number.RoundUp() & Number.RoundDown()](/docs/power-query/number-functions) * [DateTime.LocalNow() and the Desktop-vs-Service Trap](/docs/power-query/datetime-localnow) * [DATEDIFF() in DAX](/docs/dax/datediff) — counts calendar boundaries crossed, not elapsed periods * [M Function Reference](/docs/power-query/functions) # DateTime.LocalNow() and the Desktop-vs-Service Trap (/docs/power-query/datetime-localnow) # DateTime.LocalNow() and the Desktop-vs-Service Trap [#datetimelocalnow-and-the-desktop-vs-service-trap] `DateTime.LocalNow()` returns the current date and time — but "current" and "local" both mean *whatever machine is actually running the query*, not the report author's own machine or timezone. ```powerquery lineNumbers DateTime.LocalNow() as datetime ``` *** ## Same Query, Different Machine, Different Answer [#same-query-different-machine-different-answer] In Power BI Desktop, that machine is whatever computer you're authoring on — your own local time and timezone. Once a report is published and refreshes on a schedule in the Power BI Service, the query runs on a completely different machine — a gateway, or a cloud-hosted process — which is very often in a different timezone, commonly UTC. ```text Authored in Desktop, timezone UTC-5: DateTime.LocalNow() -> 2026-01-15 09:00 Same query, scheduled refresh in the Service (UTC): DateTime.LocalNow() -> 2026-01-15 14:00 ``` A calculation built around "before noon" or "yesterday" using `DateTime.LocalNow()` can genuinely produce a different result depending on *where* it happens to run — not because anything about the data changed. *** ## The Fix: DateTimeZone.UtcNow(), or a Fixed Reference [#the-fix-datetimezoneutcnow-or-a-fixed-reference] ```powerquery lineNumbers DateTimeZone.UtcNow() ``` `DateTimeZone.UtcNow()` always returns the same instant regardless of which machine's local clock is running the query — the same answer in Desktop and in the Service. If a calculation genuinely needs to be relative to a specific timezone (a business's local "today," for instance), converting explicitly with a fixed offset is more reliable than depending on whatever timezone the refreshing machine happens to be in. *** ## Common Mistakes [#common-mistakes] ### Assuming Desktop's Behavior Is What Will Run in Production [#assuming-desktops-behavior-is-what-will-run-in-production] A "yesterday" or "this month" filter built and tested in Desktop reflects the author's own timezone at the moment of testing — it's easy to not notice the dependency until the scheduled refresh in the Service produces a subtly different result. ### Using DateTime.LocalNow() for a Timestamp That Needs to Be Comparable Across Refreshes [#using-datetimelocalnow-for-a-timestamp-that-needs-to-be-comparable-across-refreshes] If a column records "when was this refreshed" and gets compared across multiple refreshes (some run from Desktop, some scheduled in the Service), a local-time timestamp can be inconsistent for reasons that have nothing to do with when the refresh actually happened — `DateTimeZone.UtcNow()` avoids that entirely. ### Not Checking Which Timezone a Gateway Machine Actually Runs In [#not-checking-which-timezone-a-gateway-machine-actually-runs-in] For an on-premises gateway refresh specifically, the "local" time is the gateway machine's own timezone setting — which may or may not match either the report author's timezone or the Service's. Worth confirming explicitly rather than assuming. *** ## Best Practices [#best-practices] * Default to `DateTimeZone.UtcNow()` for anything that needs to be consistent regardless of which machine refreshes the report. * Reserve `DateTime.LocalNow()` for cases where "whatever timezone this machine happens to be in" is genuinely the intended behavior. * Test date-relative logic with an awareness that Desktop's result reflects your own machine, not necessarily what a scheduled Service refresh will produce. *** ## Next Steps [#next-steps] * [Working with Dates in Power Query (Date & Duration Functions)](/docs/power-query/date-functions) * [Gateway & Refresh Architecture](/docs/governance/gateway-refresh) * [TODAY() & NOW() in DAX: Calculated Column vs Measure Timing](/docs/dax/today-now) * [M Function Reference](/docs/power-query/functions) # Power Query Editor (/docs/power-query/editor) # Power Query Editor [#power-query-editor] The Power Query Editor is where every data source gets connected, previewed, and transformed before it loads into the Power BI model. It opens from **Home > Transform Data** in Power BI Desktop. ```text Power BI Desktop | | Home > Transform Data | Power Query Editor ``` *** ## Layout [#layout] ```text +------------------+----------------------------+------------------+ | Queries pane | Data preview grid | Query Settings | | (left) | (center) | (right) | | | | | | Sales | ID | Name | Amount | Name: Sales | | Customers | 1 | ... | ... | Applied Steps: | | Products | 2 | ... | ... | Source | | | | Filtered Rows | +------------------+----------------------------+------------------+ ``` Three panels do most of the work: the query list on the left, a live preview of the current query in the center, and the step history for the selected query on the right. *** ## Queries Pane [#queries-pane] Every data source loaded into the editor appears here as a named query. Selecting a query switches the preview and Applied Steps to that query. ```text Queries | +-- Sales +-- Customers +-- Products +-- RawStaging (right-click > Enable Load: off) ``` Queries can be organized into groups, and a query's **Enable Load** setting can be turned off for staging queries that other queries reference but that shouldn't themselves load into the model. *** ## Data Preview Grid [#data-preview-grid] Shows the result of the query up to the currently selected step, using a sample of the source data rather than the full dataset, for performance. ```text Applied Steps: Source -> [Filtered Rows] -> Renamed Columns ^ preview reflects up to here ``` Right-clicking a column header in the preview exposes most common transformations directly — remove, rename, change type, filter, split, and more — without needing the ribbon. *** ## Applied Steps Pane [#applied-steps-pane] Every transformation is recorded here, in the order it was applied, as part of Query Settings on the right. ```text Applied Steps Source Changed Type Filtered Rows > Renamed Columns (currently selected) Removed Columns ``` Clicking any step shows the preview as it looked at that point. Steps can be renamed (double-click), reordered (drag), or removed (the "x" that appears on hover) — though reordering can break later steps that depended on the original order. *** ## The Ribbon [#the-ribbon] The ribbon's tabs group transformations by category: ```text Home — common actions: choose columns, reduce rows, combine queries Transform — reshape existing columns: split, pivot, group by, data type Add Column — create new columns from existing ones View — toggle panes, formula bar, and the Advanced Editor ``` Most transformations are reachable from more than one place — the ribbon, a right-click on a column, or typed directly as M. *** ## Formula Bar [#formula-bar] Enabled from **View > Formula Bar**, this shows the M expression for the currently selected step, and allows editing it directly instead of going through the UI. ```text = Table.SelectRows(#"Changed Type", each [Status] = "Active") ``` This is the fastest way to tweak a single step without reopening the full Advanced Editor. *** ## Advanced Editor [#advanced-editor] Opened from **Home > Advanced Editor**, this shows the entire query as one M script — every step, in order, in a single `let...in` expression. See [M Language](/docs/power-query/m-language) for how to read and write it directly. ### What the Applied Steps Pane Produces [#what-the-applied-steps-pane-produces] Every step built through the UI becomes one line of the Advanced Editor's script. Given this Applied Steps list: ```text Applied Steps Source Changed Type Filtered Rows Renamed Columns Removed Columns ``` Opening Advanced Editor for that same query shows: ```powerquery lineNumbers let Source = Sql.Database("server", "SalesDB"), #"Changed Type" = Table.TransformColumnTypes(Source, {{"OrderDate", type date}}), #"Filtered Rows" = Table.SelectRows(#"Changed Type", each [Status] = "Active"), #"Renamed Columns" = Table.RenameColumns(#"Filtered Rows", {{"Amt", "Amount"}}), #"Removed Columns" = Table.RemoveColumns(#"Renamed Columns", {"Notes"}) in #"Removed Columns" ``` Reading it line by line: * `Source` connects to the database — this is whatever step created the query, here a `Sql.Database` call. * `#"Changed Type"` corresponds to clicking a column's type icon; it became this name because "Changed Type" contains a space, so M wraps it in `#"..."`. * `#"Filtered Rows"` is the row filter applied from a column header's dropdown, referencing the previous step by name. * `#"Renamed Columns"` and `#"Removed Columns"` follow the same pattern: each one wraps the step before it. * The final `in` clause names `#"Removed Columns"` as the query's result — the same step highlighted last in the Applied Steps pane. Selecting any step in the Applied Steps pane is equivalent to placing the cursor on that same line here; the preview grid always reflects up through whichever step is currently selected, in either view. Editing this script directly — renaming a step, inserting a line, changing a function's arguments — has the same effect as doing it through the UI, but all at once instead of one click at a time. This is where a query is restructured wholesale, rather than nudged step by step. ### Adding Error Handling the UI Doesn't Expose [#adding-error-handling-the-ui-doesnt-expose] Some things are only practical to add directly in Advanced Editor, because there's no ribbon button for them. Wrapping a step in `try...otherwise` is a common example — it lets a query keep running even if one value fails to convert, instead of the whole refresh erroring out. Starting from a UI-built query: ```powerquery lineNumbers let Source = Csv.Document(File.Contents("orders.csv")), #"Changed Type" = Table.TransformColumnTypes(Source, {{"OrderDate", type date}}) in #"Changed Type" ``` If a handful of rows have a malformed date, `#"Changed Type"` errors on those rows, and the query fails entirely. Editing that line directly in Advanced Editor to add `try...otherwise` changes the outcome from a hard failure to a handled one: ```powerquery lineNumbers let Source = Csv.Document(File.Contents("orders.csv")), #"Changed Type" = Table.TransformColumns( Source, {{"OrderDate", each try Date.From(_) otherwise null, type date}} ) in #"Changed Type" ``` Now a bad date becomes `null` instead of an error, and the refresh completes with the rest of the data intact. There's no equivalent checkbox for this in the ribbon — it only exists as a direct edit to the M. See [Error Handling in Power Query](/docs/power-query/error-handling) for the full pattern — including `try` without `otherwise`, checking `[HasError]`, and when catching the error is the wrong fix. ### What a UI-Built Merge Actually Generates [#what-a-ui-built-merge-actually-generates] [Merge Queries](/docs/power-query/merge-queries) walks through doing this from the ribbon. Opening Advanced Editor afterward shows what that UI step actually produced: ```powerquery lineNumbers let Source = Sales, #"Merged Queries" = Table.NestedJoin( Source, {"CustomerID"}, Customers, {"CustomerID"}, "Customers", JoinKind.LeftOuter ), #"Expanded Customers" = Table.ExpandTableColumn( #"Merged Queries", "Customers", {"Name"}, {"Customers.Name"} ) in #"Expanded Customers" ``` `Table.NestedJoin` is the merge itself — matching `Sales` and `Customers` on `CustomerID`, using a left outer join — and it produces a column of nested tables. `Table.ExpandTableColumn` is the separate "expand" step, pulling `Name` out of each nested table into a real column. Two ribbon actions (Merge, then Expand) become these two M steps, one to one. *** ## Best Practices [#best-practices] * Turn off **Enable Load** for staging queries that exist only to be referenced by other queries. * Rename steps to something descriptive instead of leaving the auto-generated names — "Filtered Rows1" tells the next person nothing. * Use the formula bar for small edits to a single step, and the Advanced Editor when reviewing or restructuring a whole query. * Check the preview after each step; catching a wrong result early is far cheaper than debugging it five steps later. *** ## Common Mistakes [#common-mistakes] ### Working Only in the Preview, Never Checking the Advanced Editor [#working-only-in-the-preview-never-checking-the-advanced-editor] The preview shows the result, not the logic. Periodically reviewing the Advanced Editor catches inefficient or redundant steps that aren't obvious from the grid alone. ### Leaving Staging Queries Loading Into the Model [#leaving-staging-queries-loading-into-the-model] A query that exists only to be referenced by others, but still has **Enable Load** on, adds an unnecessary table to the model and slows refresh. ### Reordering Steps Without Checking Dependencies [#reordering-steps-without-checking-dependencies] A later step that references a column name or type set by an earlier step can silently break if that earlier step moves. *** ## Editor Checklist [#editor-checklist] * Queries are named clearly, and staging-only queries have load disabled. * Applied Steps have descriptive names, not default auto-generated ones. * The query has been spot-checked in the Advanced Editor, not just the preview grid. * No leftover debug or test steps remain before publishing. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Transformations](/docs/power-query/transformations) * [M Language](/docs/power-query/m-language) * [Merge Queries](/docs/power-query/merge-queries) # Error Handling in Power Query (try ... otherwise) (/docs/power-query/error-handling) # Error Handling in Power Query (try ... otherwise) [#error-handling-in-power-query-try--otherwise] `try ... otherwise` catches an error from a single expression and substitutes a fallback value instead of letting it fail the entire step — and by extension, the entire refresh. ```powerquery lineNumbers try expression otherwise fallbackValue ``` There's no ribbon button for this — it's one of the things that only exists as a direct edit in the Advanced Editor or formula bar. See [Power Query Editor](/docs/power-query/editor#adding-error-handling-the-ui-doesnt-expose) for the UI context this fits into. *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Changed Type" = Table.TransformColumns( Source, {{"OrderDate", each try Date.From(_) otherwise null, type date}} ) ``` ```text "2026-01-15" -> #date(2026,1,15) "not a date" -> null <- caught, refresh continues ``` Without `try...otherwise`, a single malformed value in `OrderDate` fails the whole `Table.TransformColumns()` step, and the entire query — every row, not just the bad one — errors out. Try typing something that isn't a date — the default `2026-01-15` works, but "not a date" (or anything else that doesn't parse) falls back to the `otherwise` value instead of erroring. *** ## try Without otherwise [#try-without-otherwise] ```powerquery lineNumbers try Date.From([OrderDate]) ``` Used alone, `try` doesn't suppress the error — it converts it into a **record** describing the error, rather than letting it propagate and fail the step. This is mainly useful for inspecting what actually went wrong, not for normal use in a transformation. ```text try Date.From("not a date") -> [ HasError = true, Error = [ Reason = "Expression.Error", Message = "Couldn't convert to Date.", ... ] ] ``` *** ## Raising Your Own Error with error [#raising-your-own-error-with-error] `try` and `otherwise` only handle errors something else raises — `error` is how a query raises one deliberately, when a value technically converts fine but is still invalid by the query's own rules. ```powerquery lineNumbers if [Quantity] >= 0 then [Quantity] else error "Quantity cannot be negative" ``` A custom `error` is a real, catchable error — the exact same `[HasError]` / `[Error][Message]` shape shown above works on it too, message and all. Try a negative test value — the message you typed comes back exactly as `[Error][Message]`, the same field `try` populates for a built-in error like a failed `Date.From()`. *** ## Checking \[HasError] Explicitly [#checking-haserror-explicitly] ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "ParsedDate", each try Date.From([OrderDate]) ), #"Added Flag" = Table.AddColumn( #"Added Custom", "IsValid", each not [ParsedDate][HasError] ) ``` This pattern — keeping the full `try` record instead of collapsing it with `otherwise` — is useful when the query needs to *know* which rows failed, not just silently default them. A common follow-up is filtering to just the failed rows to review them, rather than losing that information the moment `otherwise` replaces it. ```powerquery lineNumbers #"Failed Rows" = Table.SelectRows(#"Added Custom", each [ParsedDate][HasError]) ``` *** ## Common Mistakes [#common-mistakes] ### Using try...otherwise to Hide a Problem Instead of Fixing It [#using-tryotherwise-to-hide-a-problem-instead-of-fixing-it] ```powerquery lineNumbers each try [Amount] / [Quantity] otherwise 0 ``` This silently turns every divide-by-zero into `0`, which may or may not be the right business answer — a `0` sales-per-unit figure looks like a real, low number in a chart, not like "this row had no quantity recorded." If the fallback value itself needs to communicate "something was wrong here," a text flag or a separate boolean column communicates that more honestly than a plausible-looking number. ### Wrapping an Entire Step Instead of Just the Risky Expression [#wrapping-an-entire-step-instead-of-just-the-risky-expression] ```powerquery lineNumbers try Table.TransformColumnTypes(Source, {{"OrderDate", type date}}) otherwise Source ``` This catches an error from **any** row failing, and on failure discards the *entire* type conversion for *every* row, falling back to the untouched source table. The narrower, per-value form — wrapping just the conversion inside `Table.TransformColumns` with an `each` — keeps every row that succeeds and only defaults the ones that actually fail. ### Forgetting That otherwise's Value Needs to Match the Expected Type [#forgetting-that-otherwises-value-needs-to-match-the-expected-type] ```powerquery lineNumbers each try Date.From([OrderDate]) otherwise "unknown" ``` The column ends up holding a mix of dates and the text `"unknown"` — the column's type can't be a clean `date` anymore, and anything downstream expecting a date (a date table relationship, a time-intelligence measure) breaks against those rows. `null` is almost always the more correct fallback for a typed column, keeping the column's type consistent even where a value is missing. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Power Query Editor](/docs/power-query/editor) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Value.Type(), Value.Is() & Comparing to null](/docs/power-query/value-type-null) * [Table.SelectColumns() and MissingField](/docs/power-query/table-selectcolumns) — a different way to avoid a "column not found" error * [M Function Reference](/docs/power-query/functions) Type conversion failing on a value that looks fine? See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the usual root causes worth fixing before reaching for `try...otherwise` as a blanket catch. Getting "We cannot convert the value null to type Table" further downstream? See [that error explained](/blog/cannot-convert-null-to-type-table-error) — `try...otherwise` is one way to handle it, but finding where the null actually comes from is the real fix. A renamed Excel sheet or an inconsistent JSON response are two of the most common real sources of that null — see [Excel.Workbook()](/docs/power-query/excel-workbook#the-renamed-sheet-problem) and [Json.Document()](/docs/power-query/json-document). # Excel.Workbook() (/docs/power-query/excel-workbook) # Excel.Workbook() [#excelworkbook] `Excel.Workbook()` reads the contents of an Excel file — every sheet, named range, and defined table — into a navigable structure, the function behind **Get Data > Excel Workbook**. ```powerquery lineNumbers Excel.Workbook( workbook as binary, optional useHeaders as any, optional delayTypes as nullable logical ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Source = Excel.Workbook(File.Contents("Sales.xlsx")), SalesTable = Source{[Item="SalesData", Kind="Table"]}[Data] ``` `Excel.Workbook()` returns a navigation table listing every sheet and named object in the workbook; the record-access step (`{[Item=..., Kind=...]}`) picks out one specific item, and `[Data]` extracts its contents as a table. *** ## Kind="Sheet" vs. Kind="Table" [#kindsheet-vs-kindtable] The `Kind` field distinguishes a plain worksheet from a defined Excel Table (created via **Insert > Table**, or Format as Table): ```text Kind="Sheet" -> the entire sheet's used range, headers not guaranteed Kind="Table" -> a defined Excel Table object, with real column headers and a stable range ``` A defined Table is generally the more reliable source — its range is self-contained and doesn't depend on guessing where data starts and ends on a sheet, and its column headers come from the Table's own header row rather than being inferred. ```powerquery lineNumbers Source{[Item="Sheet1", Kind="Sheet"]}[Data] -- whole sheet, less structured Source{[Item="SalesData", Kind="Table"]}[Data] -- defined Table, more reliable ``` *** ## The Renamed-Sheet Problem [#the-renamed-sheet-problem] ```powerquery lineNumbers Source{[Name="Sheet1"]}[Data] ``` If "Sheet1" is later renamed, deleted, or the workbook is replaced with a differently-structured version, this record-access step doesn't error where the mistake actually is — it returns `null`, which then fails at whatever step tries to use `[Data]` as a table. See [We Cannot Convert the Value Null to Type Table](/blog/cannot-convert-null-to-type-table-error) for exactly this failure mode and the fix. This is the single most common source of a "worked for months, then broke" Excel-based query: nothing in the M code changed, but the source workbook's sheet name did. *** ## useHeaders: Column Names vs. Column1, Column2... [#useheaders-column-names-vs-column1-column2] ```powerquery lineNumbers Excel.Workbook(File.Contents("Sales.xlsx"), true) ``` Passing `true` for the second argument promotes the first row of each sheet to column headers automatically, avoiding a separate **Use First Row as Headers** step later. This only affects `Kind="Sheet"` results — a defined Table already has real headers regardless of this argument. *** ## Common Mistakes [#common-mistakes] ### Assuming a Sheet Name or Table Name Will Never Change [#assuming-a-sheet-name-or-table-name-will-never-change] As covered above — this is the recurring root cause of Excel-source queries breaking without any visible M change. If the workbook is maintained by someone other than the person who built the query, confirming the sheet/table naming convention is stable (or building resilience via `try...otherwise`, see [Error Handling in Power Query](/docs/power-query/error-handling)) is worth doing up front. ### Referencing a Sheet by Kind="Sheet" When a Table Exists [#referencing-a-sheet-by-kindsheet-when-a-table-exists] Using the whole-sheet access when a defined Table already exists on that sheet gives up the Table's more reliable structure for no benefit — worth checking the navigator for a `Kind="Table"` option before defaulting to the raw sheet. ### Not Accounting for Merged Cells or Multi-Row Headers [#not-accounting-for-merged-cells-or-multi-row-headers] A sheet with merged header cells or a title row above the actual data doesn't produce clean column headers automatically — `Excel.Workbook()` reads exactly what's in the used range, including any messiness above the real header row, which usually needs manual cleanup steps (removing top rows, promoting headers) before the data is usable. ### Loading Every Sheet When Only One Is Needed [#loading-every-sheet-when-only-one-is-needed] ```powerquery lineNumbers Source = Excel.Workbook(File.Contents("Sales.xlsx")) ``` `Excel.Workbook()` itself is lightweight — it just reads the workbook's structure, not every sheet's full contents — but a query left at this step without navigating to a specific sheet leaves the whole navigation table as the query's output, which is rarely what's actually wanted as a final result. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Error Handling in Power Query](/docs/power-query/error-handling) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [Csv.Document()](/docs/power-query/csv-document) Getting "We cannot convert the value null to type Table" after a workbook change? See [that error explained](/blog/cannot-convert-null-to-type-table-error) — a renamed or missing sheet is the most common cause. # M Function Reference (/docs/power-query/functions) # M Function Reference [#m-function-reference] A quick-scan index of the M functions most commonly used in Power Query, grouped by category. Where a function is covered in depth elsewhere on this site — as part of a real transformation, merge, or troubleshooting example — the name links to it. Functions without a dedicated example get a one-line description here. *** ## Custom Functions [#custom-functions] Not a single function, but the concept behind every `each` expression and every function passed to `Table.AddColumn`, `List.Transform`, or `List.Accumulate`. See [Custom Functions in Power Query M](/docs/power-query/custom-functions) — this is the single highest-leverage M concept for anything beyond basic UI-driven transformations. *** ## Table Functions [#table-functions] | Function | Description | | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | [Table.SelectRows](/docs/power-query/table-selectrows) | Filters a table to rows matching a condition. | | [Table.RemoveColumns](/docs/power-query/table-selectcolumns) | Drops specified columns from a table — shares the MissingField option below. | | [Table.RenameColumns](/docs/power-query/table-selectcolumns) | Renames one or more columns — shares the MissingField option below. | | [Table.SelectColumns](/docs/power-query/table-selectcolumns) | Returns a table with only the specified columns, dropping the rest. | | [Table.TransformColumnTypes](/docs/power-query/table-transformcolumntypes) | Sets the data type of one or more columns. | | [Table.TransformColumns](/docs/power-query/table-transformcolumns) | Applies a function to every value in a column, transforming it in place. | | [Table.AddColumn](/docs/power-query/table-addcolumn) | Adds a new column, computed from an expression evaluated per row. | | [Table.AddIndexColumn](/docs/power-query/table-addindexcolumn) | Adds a sequential index column — the standard way to generate a surrogate key. | | [Table.Group](/docs/power-query/table-group) | Groups rows and computes an aggregation per group, like SQL's `GROUP BY`. | | [Table.Sort](/docs/power-query/table-sort) | Sorts a table by one or more columns. | | [Table.Distinct](/docs/power-query/table-distinct) | Removes duplicate rows, optionally based on specific columns. | | `Table.RowCount` | Returns the number of rows in a table. | | [Table.FirstN / Table.Skip](/docs/power-query/table-firstn-skip) | Returns the first N rows, or skips the first N rows — a condition instead of N behaves like "take while," not a filter. | | [Table.SplitColumn](/docs/power-query/table-splitcolumn-combinecolumns) | Splits one column into multiple columns, by delimiter or position. | | [Table.CombineColumns](/docs/power-query/table-splitcolumn-combinecolumns) | Merges multiple columns into one, with a separator. | | [Table.Pivot / Table.Unpivot](/docs/power-query/table-pivot-unpivot) | Turns row values into columns, or columns into rows. | | [Table.ReplaceValue](/docs/power-query/table-replacevalue) | Replaces specific values throughout a column. | | [Table.Buffer](/docs/power-query/table-buffer) | Loads a table fully into memory, useful for stabilizing a source before repeated reads. | See [Transformations](/docs/power-query/transformations) for most of these applied to a real dataset, and [Power Query Editor](/docs/power-query/editor#what-the-applied-steps-pane-produces) for how Applied Steps map directly to these function calls. *** ## Merge & Combine Functions [#merge--combine-functions] | Function | Description | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Table.NestedJoin](/docs/power-query/merge-queries) | Joins two tables on matching columns, producing a column of nested tables. | | [Table.ExpandTableColumn](/docs/power-query/merge-queries) | Expands a nested-table column (typically from a merge) into regular columns. | | [Table.Combine](/docs/power-query/merge-vs-append) | Stacks multiple tables with matching columns into one — the function behind **Append Queries**. | | `Table.NestedJoin` (Left Anti) | The same merge function, with a join kind that returns only unmatched rows — see [Merge Queries](/docs/power-query/merge-queries#left-anti-finding-unmatched-rows). | Not sure whether a given problem calls for a merge or an append? See [Merge vs. Append: When to Use Each](/docs/power-query/merge-vs-append). *** ## Text Functions [#text-functions] | Function | Description | | --------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | [Text.Trim](/docs/power-query/text-trim-upper-lower) | Removes leading and trailing whitespace. | | [Text.Upper / Text.Lower](/docs/power-query/text-trim-upper-lower) | Converts text to uppercase or lowercase. | | [Text.Start / Text.End / Text.Middle](/docs/power-query/text-substring-functions) | Extracts a substring from the start, end, or middle of a text value. | | [Text.Split](/docs/power-query/text-split-combine) | Splits a text value into a list, by delimiter. | | [Text.Combine](/docs/power-query/text-split-combine) | Joins a list of text values into one, with a separator. | | [Text.Contains](/docs/power-query/text-contains-replace) | Returns true if a text value contains a given substring. | | [Text.Replace](/docs/power-query/text-contains-replace) | Replaces all occurrences of a substring within a text value. | | [Text.Length](/docs/power-query/text-substring-functions) | Returns the number of characters in a text value. | *** ## List Functions [#list-functions] | Function | Description | | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [List.Select](/docs/power-query/m-language) | Filters a list to values matching a condition. | | `List.Sum` / `List.Average` / `List.Count` | Aggregates the values in a list. | | [List.Distinct](/docs/power-query/list-distinct-contains) | Removes duplicate values from a list — case-sensitive by default, like Text.Contains. | | [List.Contains](/docs/power-query/list-distinct-contains#the-fix-comparerordinalignorecase) | Returns true if a list contains a given value — also case-sensitive by default. | | [List.Transform](/docs/power-query/list-transform) | Applies a function to every value in a list. | | [List.Accumulate](/docs/power-query/list-accumulate) | Reduces a list to a single value by carrying state across every item — M's general-purpose "reduce." | | [List.Generate](/docs/power-query/list-generate) | Builds a list by repeatedly applying a function, useful for custom sequences. | *** ## Date & DateTime Functions [#date--datetime-functions] | Function | Description | | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | [Date.From](/blog/couldnt-convert-to-number-date-error#cause-2-an-ambiguous-date-format--and-the-silent-version-is-worse) | Converts a value to a date — locale-ambiguous text like "03/04/2026" can silently parse into the wrong day. | | [DateTime.LocalNow](/docs/power-query/datetime-localnow) | Returns the current local date and time — the *refreshing machine's* local time, not necessarily the author's. | | [Date.Year / Date.Month / Date.Day](/docs/power-query/date-functions) | Extracts the year, month, or day from a date. | | [Date.AddDays / Date.AddMonths / Date.AddYears](/docs/power-query/date-functions#adding-and-subtracting-time) | Shifts a date by a given number of days, months, or years. | | [Duration.Days / Duration.TotalHours](/docs/power-query/date-functions#subtracting-two-dates-produces-a-duration-not-a-number) | Extracts a component from a duration value (the result of subtracting two dates/datetimes). | *** ## Number Functions [#number-functions] | Function | Description | | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | [Number.Round / Number.RoundUp / Number.RoundDown](/docs/power-query/number-functions) | Rounds a number to a specified number of decimal places — nearest, always up, or always down. | | [Number.From](/blog/couldnt-convert-to-number-date-error#cause-1-a-locale-mismatch-on-the-decimal-separator) | Converts a value (often text) to a number — a locale mismatch on the decimal separator can misread or fail the conversion. | | [Number.ToText](/docs/power-query/number-totext) | Converts a number to text, optionally with a format string — "P" multiplies by 100. | *** ## Type & Value Functions [#type--value-functions] | Function | Description | | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | [Value.Type](/docs/power-query/value-type-null) | Returns the type of a value — useful for debugging unexpected type errors. | | [Value.Is](/docs/power-query/value-type-null#valueis-for-checking-a-type) | Tests whether a value matches a given type. | | `Value.ReplaceType` | Overrides the declared type of a value without changing the value itself. | *** ## Error Handling [#error-handling] | Function | Description | | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | [try ... otherwise](/docs/power-query/error-handling) | Catches an error from an expression, substituting a fallback value instead of failing the step. | | [error](/docs/power-query/error-handling#raising-your-own-error-with-error) | Explicitly raises a custom error from within an expression. | *** ## Source & Connector Functions [#source--connector-functions] | Function | Description | | -------------------------------------------------- | ------------------------------------------------------------------------------ | | [Csv.Document](/docs/power-query/csv-document) | Parses CSV content into a table — the function behind **Get Data > Text/CSV**. | | [Excel.Workbook](/docs/power-query/excel-workbook) | Reads an Excel file's sheets and tables. | | [Sql.Database](/docs/power-query/sql-database) | Connects to a SQL Server database. | | [Json.Document](/docs/power-query/json-document) | Parses JSON content into a record or list. | | [Web.Contents](/docs/power-query/web-contents) | Fetches raw content from a URL — the basis of most API-based connectors. | | `File.Contents` | Reads the raw binary contents of a local file. | See [M Language](/docs/power-query/m-language) for how these source functions typically appear as the first step (`Source =`) in a generated query. *** ## How to Use This Reference [#how-to-use-this-reference] * Linked functions are shown applied to a real, worked example elsewhere on this site — not just a syntax definition. * Unlinked functions are simple enough that a one-line description is sufficient — Microsoft's own [Power Query M function reference](https://learn.microsoft.com/en-us/powerquery-m/power-query-m-function-reference) covers full parameter lists for anything not detailed here. * Start with [Introduction](/docs/power-query/introduction) and [M Language](/docs/power-query/m-language) if the underlying syntax (the `let...in` structure, `each`, referencing previous steps) isn't yet familiar — the function list assumes those concepts. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Transformations](/docs/power-query/transformations) * [Merge Queries](/docs/power-query/merge-queries) * [Query Folding](/docs/power-query/query-folding) # Power Query (/docs/power-query) # Power Query [#power-query] Power Query is the data transformation engine behind every Power BI model, handling everything from connecting to a source to producing the clean, well-typed tables a report is built on. ```text Source Data | | Power Query | Clean, Modeled Tables | | loaded into | Power BI Data Model ``` ## Start Here [#start-here] Want to work with the underlying formula language directly? See [M Language](/docs/power-query/m-language). # Introduction (/docs/power-query/introduction) # Introduction [#introduction] Power Query is how data gets into Power BI, and how it gets cleaned up before anyone builds a report on top of it. ```text Source Data | | Power Query | Clean, Modeled Tables | | loaded into | Power BI Data Model ``` Every Import or Dual-mode table in a Power BI model passes through Power Query first. *** ## What Power Query Does [#what-power-query-does] Power Query handles three jobs: ```text Power Query | +-- Connect to data sources | +-- Transform and clean data | +-- Load the result into the model ``` It connects to databases, files, APIs, and dozens of other source types, then reshapes that raw data into the tables a Power BI model actually needs. *** ## The Power Query Editor [#the-power-query-editor] Transformations are built visually in the **Power Query Editor**, opened from **Home > Transform Data** in Power BI Desktop. ```text Source Data (preview) | | apply a step | Transformed Preview | | apply another step | Final Table ``` Every transformation is recorded as a step, shown in the **Applied Steps** pane, and can be reordered, edited, or removed. See [Power Query Editor](/docs/power-query/editor) for a closer look at the interface itself. *** ## Under the Hood: M Language [#under-the-hood-m-language] Every action in the Editor generates code in **M**, Power Query's underlying formula language. ```text Click "Remove Columns" in the UI | | generates | Table.RemoveColumns(Source, {"Column1"}) ``` Most work happens through the visual editor, but M can also be written or edited directly in the **Advanced Editor** for transformations the UI doesn't expose. See [M Language](/docs/power-query/m-language) for more detail. *** ## Queries [#queries] Each data source loaded into a model becomes a **query** — a named sequence of steps that produces one table. ```text Query: "Sales" | +-- Source +-- Filtered Rows +-- Renamed Columns +-- Changed Type | Result: FactSales table ``` A single Power BI model typically has one query per table, visible in the **Queries** pane on the left side of the Editor. *** ## Applied Steps Are Sequential [#applied-steps-are-sequential] Steps run in order, each one operating on the result of the step before it. ```text Source | v Step 1: Filter Rows | v Step 2: Rename Columns | v Step 3: Change Types | v Final Table ``` Reordering steps can change the result, or break later steps that depended on the earlier order — a common source of Power Query errors. *** ## Query Dependencies [#query-dependencies] Queries can reference other queries, letting transformation logic be reused instead of duplicated. ```text Query: RawSales | | referenced by | Query: CleanedSales | | referenced by | Query: FinalSales ``` This is useful for staging raw, unfiltered data in one query, then building cleaned or filtered versions from it without re-fetching the source each time. *** ## Where Power Query Fits [#where-power-query-fits] ```text Data Source | | Power Query (extract, transform) | Power BI Model | | DAX (calculate, aggregate) | Report Visuals ``` Power Query shapes the data. DAX calculates on top of the shape Power Query produced. Getting the Power Query layer right makes the DAX layer significantly simpler. *** ## Best Practices [#best-practices] * Do as much cleaning and shaping as possible in Power Query, rather than compensating for messy data with complex DAX. * Name queries and steps clearly — "Changed Type1" tells the next person nothing. * Reference queries to reuse logic instead of copy-pasting the same steps across multiple queries. * Remove unnecessary columns early, since later steps process less data as a result. *** ## Common Mistakes [#common-mistakes] ### Doing Too Much in DAX Instead of Power Query [#doing-too-much-in-dax-instead-of-power-query] Reshaping or cleaning data with DAX measures, when Power Query could have produced the correct shape at load time, adds unnecessary complexity to the model. ### Ignoring Step Order [#ignoring-step-order] Reordering steps without understanding their dependencies can silently break a query — a later step referencing a column name that an earlier step renamed, for example. ### Not Using Query References [#not-using-query-references] Duplicating the same set of cleaning steps across multiple queries, instead of building one shared query and referencing it, means every future fix has to be repeated in every copy. *** ## Introduction Checklist [#introduction-checklist] Before considering a Power Query setup finished: * Queries and steps have clear, descriptive names. * Shared logic is built once and referenced, not duplicated. * Unnecessary columns and rows are removed as early as possible. * The result loads into the model as clean, well-typed tables — not raw, unprocessed data. *** ## Next Steps [#next-steps] Continue learning Power Query: * [M Function Reference](/docs/power-query/functions) * [Power Query Editor](/docs/power-query/editor) * [Transformations](/docs/power-query/transformations) * [M Language](/docs/power-query/m-language) See it applied end to end: [Build a Complete Sales Analysis Report](/tutorials/build-a-sales-analysis-report) cleans a real messy export from scratch, then builds a full model and report on top of it. # Json.Document() (/docs/power-query/json-document) # Json.Document() [#jsondocument] `Json.Document()` parses raw JSON content into M's native record and list structures — almost always paired with [Web.Contents()](/docs/power-query/web-contents) to turn an API response into something Power Query can transform. ```powerquery lineNumbers Json.Document( jsonText as any, optional encoding as nullable number ) as any ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Source = Json.Document(Web.Contents("https://api.example.com/products")) ``` ```text JSON: [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}] | | Json.Document | M: {[id=1, name="Widget"], [id=2, name="Gadget"]} <- a list of records ``` A JSON array becomes an M `list`; a JSON object becomes an M `record`. Which one comes back from `Json.Document()` depends entirely on whether the response's top level is `[...]` or `{...}`. *** ## Turning a List of Records Into a Table [#turning-a-list-of-records-into-a-table] ```powerquery lineNumbers #"Converted to Table" = Table.FromRecords(Source) ``` ```text {[id=1, name="Widget"], [id=2, name="Gadget"]} | | Table.FromRecords | id | name 1 | Widget 2 | Gadget ``` `Table.FromRecords()` is the standard next step after `Json.Document()` returns a list of records — it's the function that actually produces the tabular shape Power Query's other transformations expect. *** ## When the Real Data Is Nested Inside a Wrapper Object [#when-the-real-data-is-nested-inside-a-wrapper-object] ```text {"status": "ok", "results": [{"id": 1, "name": "Widget"}, ...]} ``` Many APIs wrap the actual array inside a top-level object alongside metadata — the response's top level here is a `record`, not the list directly. ```powerquery lineNumbers Source = Json.Document(Web.Contents("https://api.example.com/products")), Results = Source[results], #"Converted to Table" = Table.FromRecords(Results) ``` `Source[results]` pulls the nested list out of the wrapper record by field name before converting it to a table — skipping this step and calling `Table.FromRecords()` directly on `Source` fails, since `Source` itself is a record, not the list `Table.FromRecords()` expects. *** ## Inconsistent Fields Across Records [#inconsistent-fields-across-records] JSON doesn't require every object in an array to have the same fields — a real API response often has some records missing a field that others include (an optional field, a null omitted entirely rather than sent as `null`). ```text {"id": 1, "name": "Widget", "color": "blue"} {"id": 2, "name": "Gadget"} <- no "color" field at all ``` `Table.FromRecords()` handles this without erroring — missing fields become `null` in the resulting column — but a stricter fixed-column-list call (`Table.FromRecords(Source, {"id", "name", "color"})`) is worth using explicitly when the exact expected shape matters, rather than letting the table's columns be whatever happened to appear across all records. *** ## Common Mistakes [#common-mistakes] ### Calling Table.FromRecords on the Wrong Level [#calling-tablefromrecords-on-the-wrong-level] As covered above — the most common error here is a type mismatch from calling `Table.FromRecords()` on a wrapper record instead of the nested list it actually contains. The fix is always the same: navigate to the actual array field first (`Source[fieldName]`), then convert. ### Assuming Every Record Has the Same Schema [#assuming-every-record-has-the-same-schema] An API that's evolved over time, or that omits null/empty fields entirely rather than sending them explicitly, produces inconsistent records — `Table.FromRecords()` without an explicit column list can end up with a different column set than expected if the sample used while building the query happened to have every field present. ### Not Handling a JSON Response That's Sometimes an Error Object Instead [#not-handling-a-json-response-thats-sometimes-an-error-object-instead] ```text Success: [{"id": 1, "name": "Widget"}] Failure: {"error": "Rate limit exceeded"} ``` An API that returns a genuinely different top-level shape on error (an object instead of an array) breaks `Table.FromRecords()` if the query doesn't account for it — see [Error Handling in Power Query](/docs/power-query/error-handling) for wrapping this case explicitly rather than letting the whole refresh fail on a transient rate limit or error response. ### Forgetting the Encoding Argument for Non-UTF8 Sources [#forgetting-the-encoding-argument-for-non-utf8-sources] The optional second argument to `Json.Document()` specifies text encoding — almost always unnecessary for a standard API returning UTF-8 JSON, but worth knowing about if a source returns garbled text for non-ASCII characters (accented names, non-English content). *** ## Next Steps [#next-steps] * [Web.Contents()](/docs/power-query/web-contents) * [Error Handling in Power Query](/docs/power-query/error-handling) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) Getting "We cannot convert the value null to type Table" from a JSON-based query? See [that error explained](/blog/cannot-convert-null-to-type-table-error) — an API that returned an unexpected shape is a common cause beyond the ones covered there. # List.Accumulate() (/docs/power-query/list-accumulate) # List.Accumulate() [#listaccumulate] `List.Accumulate()` walks through a list one value at a time, carrying a running "state" forward from each step to the next — M's general-purpose reduce function, for building a single result out of a list when no built-in aggregation (`List.Sum`, `List.Max`, and so on) already does it. ```powerquery lineNumbers List.Accumulate( list as list, seed as any, accumulator as function ) as any ``` * **`seed`** — the starting value, before any list items have been processed. * **`accumulator`** — a two-argument function: `(state, current) => newState`, called once per list item. *** ## Basic Example: Reimplementing List.Sum [#basic-example-reimplementing-listsum] ```powerquery lineNumbers List.Accumulate({1, 2, 3, 4}, 0, (state, current) => state + current) ``` ```text seed = 0 state=0, current=1 -> newState=1 state=1, current=2 -> newState=3 state=3, current=3 -> newState=6 state=6, current=4 -> newState=10 Result: 10 ``` `List.Sum({1,2,3,4})` already does exactly this — `List.Accumulate()` is worth reaching for once the logic needed is more than a single built-in function already covers. *** ## A Real Use Case: String Concatenation With Custom Formatting [#a-real-use-case-string-concatenation-with-custom-formatting] ```powerquery lineNumbers List.Accumulate( {"Alice", "Bob", "Carol"}, "", (state, current) => if state = "" then current else state & ", " & current ) ``` ```text Result: "Alice, Bob, Carol" ``` `Text.Combine({"Alice","Bob","Carol"}, ", ")` already does this simpler case too — `List.Accumulate()` earns its place once the joining logic needs to be conditional (skip blanks, format differently for the first item, stop early under some condition), not just a fixed separator. *** ## Building Up a Table or Record [#building-up-a-table-or-record] The state carried between steps doesn't have to be a number or text — it can be a table, a record, or any other structure, which is where `List.Accumulate()` handles cases nothing else does directly. ```powerquery lineNumbers List.Accumulate( {1, 2, 3}, #table({"Step", "Value"}, {}), (state, current) => Table.InsertRows(state, Table.RowCount(state), {[Step = current, Value = current * current]}) ) ``` ```text Step | Value 1 | 1 2 | 4 3 | 9 ``` Each pass appends one more row to the table being carried forward as `state`. *** ## List.Accumulate vs. List.Generate [#listaccumulate-vs-listgenerate] Both build something up across repeated steps, but for different purposes: | | List.Accumulate | List.Generate | | ----------- | ----------------------------------- | ------------------------------------------------- | | Input | An existing list, one pass per item | No input list — generates values from a condition | | Output | A single final value | A list of every intermediate value produced | | Typical use | Reducing a known list to one result | Producing a new sequence that doesn't exist yet | See [List.Generate()](/docs/power-query/list-generate) for the sequence-building side of this pair. *** ## Common Mistakes [#common-mistakes] ### Getting the Accumulator's Argument Order Backwards [#getting-the-accumulators-argument-order-backwards] ```powerquery lineNumbers (current, state) => state + current ``` The first argument is always the running **state**, the second is the **current list item** — swapping them silently produces wrong results rather than an error, since both are usually the same type and the expression can still evaluate without complaint. ### Reaching for List.Accumulate When a Built-In Already Exists [#reaching-for-listaccumulate-when-a-built-in-already-exists] Rebuilding `List.Sum`, `List.Max`, or `List.Count` from scratch with `List.Accumulate()` works, but is harder to read and slower to write than the built-in — worth checking the [M Function Reference](/docs/power-query/functions) first for anything that looks like a standard aggregation. ### Forgetting the Seed's Type Has to Match What the Accumulator Eventually Returns [#forgetting-the-seeds-type-has-to-match-what-the-accumulator-eventually-returns] ```powerquery lineNumbers List.Accumulate({1, 2, 3}, 0, (state, current) => state & Text.From(current)) ``` Starting with a numeric seed (`0`) while the accumulator concatenates text produces a type error on the first iteration, since `0 & "1"` mixes a number and text in a context expecting one consistent type. The seed needs to match the type the accumulator function actually produces — here, an empty string `""` rather than `0`. ### Not Handling an Empty Input List [#not-handling-an-empty-input-list] `List.Accumulate({}, seed, accumulator)` simply returns `seed` unchanged, without ever calling the accumulator — usually the right behavior, but worth confirming explicitly if the seed value isn't a sensible "nothing to accumulate" result on its own. *** ## Next Steps [#next-steps] * [Custom Functions in Power Query M](/docs/power-query/custom-functions) * [List.Transform()](/docs/power-query/list-transform) * [List.Generate()](/docs/power-query/list-generate) * [M Function Reference](/docs/power-query/functions) # List.Distinct() & List.Contains() (/docs/power-query/list-distinct-contains) # List.Distinct() & List.Contains() [#listdistinct--listcontains] `List.Distinct()` removes duplicate values from a list; `List.Contains()` checks whether a list holds a given value. Both compare values using an *exact* match by default — for text, that means case-sensitive, the same trap that shows up with [Text.Contains()](/docs/power-query/text-contains-replace#both-are-case-sensitive-by-default). ```powerquery lineNumbers List.Distinct(list as list, optional equationCriteria as any) as list List.Contains(list as list, value as any, optional equationCriteria as any) as logical ``` *** ## List.Distinct() Keeps Different-Case Values as Separate [#listdistinct-keeps-different-case-values-as-separate] ```powerquery lineNumbers List.Distinct({"Apple", "apple", "Banana", "BANANA", "Cherry"}) ``` ```text Result: {"Apple", "apple", "Banana", "BANANA", "Cherry"} <- nothing removed ``` With no second argument, `"Apple"` and `"apple"` are two different values as far as `List.Distinct()` is concerned — it only removes values that match *exactly*, character for character. A source list built from inconsistent manual entry or free-text input can carry what looks like the same value several times over, surviving a plain `List.Distinct()` untouched. *** ## The Fix: Comparer.OrdinalIgnoreCase [#the-fix-comparerordinalignorecase] ```powerquery lineNumbers List.Distinct({"Apple", "apple", "Banana", "BANANA", "Cherry"}, Comparer.OrdinalIgnoreCase) ``` ```text Result: {"Apple", "Banana", "Cherry"} <- first-seen casing wins, the rest are dropped ``` The same `Comparer.OrdinalIgnoreCase` argument that fixes `Text.Contains()` works here too, and on `List.Contains()`: ```powerquery lineNumbers List.Contains({"Apple", "apple"}, "APPLE") // false List.Contains({"Apple", "apple"}, "APPLE", Comparer.OrdinalIgnoreCase) // true ``` When a value is kept under `Comparer.OrdinalIgnoreCase`, it's whichever casing appeared **first** in the list — the same first-occurrence-wins rule that shows up in [Table.Distinct()](/docs/power-query/table-distinct#column-order-matters--the-first-match-wins) when it's scoped to specific columns. *** ## Common Mistakes [#common-mistakes] ### Assuming List.Distinct Normalizes Case [#assuming-listdistinct-normalizes-case] A source list mixing `"USA"`, `"usa"`, and `"U.S.A"` doesn't collapse to one value with a plain `List.Distinct()` — only exact character-for-character duplicates are removed. `"U.S.A"` wouldn't be caught even with `Comparer.OrdinalIgnoreCase`, since that only ignores case, not punctuation or spacing — a genuinely inconsistent source needs an explicit cleanup step (`Text.Upper`, `Text.Replace`, or a manual mapping) before `List.Distinct()` can treat the variants as one. ### Expecting List.Contains to Catch a Different-Case Match [#expecting-listcontains-to-catch-a-different-case-match] ```powerquery lineNumbers List.Contains(List.Distinct(Source[Category]), "electronics") ``` If `Source[Category]` actually contains `"Electronics"`, this returns `false` by default — silently, with no error — because the case doesn't match exactly. This is easy to miss in an `if` condition or a filter built on `List.Contains()`, since a false negative doesn't look any different from a genuine "not present" result. ### Not Realizing Which Casing Survives Under the Comparer [#not-realizing-which-casing-survives-under-the-comparer] Since the first-seen casing is the one kept, sorting or reordering the source list before a case-insensitive `List.Distinct()` changes *which* casing shows up in the result — worth being deliberate about source order (or normalizing case explicitly first) if a specific casing needs to survive. *** ## Best Practices [#best-practices] * Default to assuming both functions are case-sensitive unless `Comparer.OrdinalIgnoreCase` is explicitly passed. * Remember `Comparer.OrdinalIgnoreCase` only ignores case — punctuation, spacing, and other formatting differences still count as different values. * If a specific casing needs to survive a case-insensitive `List.Distinct()`, control the list's order first rather than relying on whichever casing happened to appear first. *** ## Next Steps [#next-steps] * [Text.Contains() & Text.Replace()](/docs/power-query/text-contains-replace) * [Table.Distinct()](/docs/power-query/table-distinct) * [List.Transform()](/docs/power-query/list-transform) * [VALUES() vs DISTINCT() in DAX](/docs/dax/values-distinct) * [M Function Reference](/docs/power-query/functions) # List.Generate() (/docs/power-query/list-generate) # List.Generate() [#listgenerate] `List.Generate()` builds a list by repeatedly applying a function, continuing until a condition is no longer met — the closest thing M has to a traditional `while` loop. ```powerquery lineNumbers List.Generate( initial as function, condition as function, next as function, optional selector as nullable function ) as list ``` *** ## Basic Example: A Custom Number Sequence [#basic-example-a-custom-number-sequence] ```powerquery lineNumbers #"Custom List" = List.Generate( () => 1, each _ < 10, each _ + 1 ) ``` ```text initial: 1 condition: _ < 10 -> keep going while true next: _ + 1 -> each step, add 1 Result: {1, 2, 3, 4, 5, 6, 7, 8, 9} ``` Each argument is itself a function (note the `() =>` and `each` syntax) — `initial` produces the starting value, `condition` decides whether to continue, and `next` computes the following value from the current one. *** ## Generating a Date Range [#generating-a-date-range] ```powerquery lineNumbers #"Date List" = List.Generate( () => #date(2026, 1, 1), each _ <= #date(2026, 12, 31), each Date.AddDays(_, 1) ) ``` A manual alternative to `{#date(2026,1,1) .. #date(2026,12,31)}` list syntax, useful when the step size isn't a simple day-by-day increment, or when the stopping condition depends on something more complex than a fixed end date. *** ## Carrying State With a Record [#carrying-state-with-a-record] The current value doesn't have to be a single number or date — using a record lets each step track more than one piece of state at once. ```powerquery lineNumbers #"Running Total" = List.Generate( () => [i = 0, total = 0], each [i] < 5, each [i = [i] + 1, total = [total] + [i] + 1] ) ``` ```text Step 0: i=0, total=0 Step 1: i=1, total=1 Step 2: i=2, total=3 Step 3: i=3, total=6 Step 4: i=4, total=10 ``` Each step's record carries forward everything the next step needs — the same pattern used for pagination (see below), where each step needs both a page number and an accumulated result. *** ## Paging Through an API [#paging-through-an-api] The most common real-world use: an API that returns results one page at a time, where the next request depends on something from the previous response (a `nextPageToken`, or simply incrementing a page number until an empty result comes back). ```powerquery lineNumbers #"All Pages" = List.Generate( () => [page = 1, result = Json.Document(Web.Contents("https://api.example.com/data?page=1"))], each List.Count([result]) > 0, each [ page = [page] + 1, result = Json.Document(Web.Contents("https://api.example.com/data?page=" & Text.From([page] + 1))) ], each [result] ) ``` The optional fourth argument (`selector`) picks what actually ends up in the final list — here, just the `result` field from each step's record, not the page number used to track progress. See [Web.Contents](/docs/power-query/web-contents) for the connector this pattern is built on. *** ## Common Mistakes [#common-mistakes] ### Forgetting the Zero-Argument Function Syntax for `initial` [#forgetting-the-zero-argument-function-syntax-for-initial] `initial` must be a function that takes no arguments (`() => ...`), not a bare value — a common typo is writing `initial = 1` instead of `() => 1`, which errors immediately. ### An Off-by-One in the Condition [#an-off-by-one-in-the-condition] Since `condition` is checked *before* each step (including the very first), a boundary condition like `_ <= 10` vs `_ < 10` changes whether the final value is included — worth checking against a known expected count. ### A Condition That Never Becomes False [#a-condition-that-never-becomes-false] If `next` doesn't actually move the state toward whatever `condition` checks, the loop runs indefinitely — the same infinite-loop risk as a `while` loop in any other language, and just as easy to introduce with a typo in the state update. *** ## Best Practices [#best-practices] * Use a record for `initial`/`next` as soon as more than one piece of state needs to be tracked between steps. * Always double-check the `condition` boundary (`<` vs `<=`) against the expected number of results. * Use the `selector` argument to return only what's actually needed in the final list, not the full tracking state. * For simple fixed-size sequences, prefer list range syntax (`{1..10}`) where it fits — reserve `List.Generate` for cases that genuinely need per-step logic. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Web.Contents](/docs/power-query/web-contents) * [M Language](/docs/power-query/m-language) * [M Function Reference](/docs/power-query/functions) * [List.Accumulate()](/docs/power-query/list-accumulate) — the reduce-a-known-list counterpart to generating a new sequence # List.Transform() (/docs/power-query/list-transform) # List.Transform() [#listtransform] `List.Transform()` applies a function to every value in a list, returning a new list of the results — the list equivalent of [Table.TransformColumns()](/docs/power-query/table-transformcolumns), but operating outside the context of a table entirely. ```powerquery lineNumbers List.Transform( list as list, transform as function ) as list ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers List.Transform({1, 2, 3, 4}, each _ * 2) ``` ```text {1, 2, 3, 4} -> {2, 4, 6, 8} ``` `each _` refers to the current list item being processed — `_` is the placeholder for "this value," the list equivalent of `[ColumnName]` for a row in a table. *** ## Using a Named Function Instead of each [#using-a-named-function-instead-of-each] ```powerquery lineNumbers let Double = (x as number) as number => x * 2, Doubled = List.Transform({1, 2, 3, 4}, Double) in Doubled ``` Passing `Double` by name works identically to `each _ * 2` — `each _ * 2` is really just inline shorthand for `(_) => _ * 2`. See [Custom Functions in Power Query M](/docs/power-query/custom-functions) for when naming a function separately is worth it over an inline `each`. *** ## Where List.Transform Actually Comes Up [#where-listtransform-actually-comes-up] Most everyday Power Query work stays inside tables, so `List.Transform()` shows up less often directly — but it appears constantly *inside* other functions, whenever a list needs processing on its way to becoming something else: ```powerquery lineNumbers #"Column Names Upper" = Table.RenameColumns( Source, List.Zip({Table.ColumnNames(Source), List.Transform(Table.ColumnNames(Source), Text.Upper)}) ) ``` ```text Table.ColumnNames(Source) -> {"name", "amount"} List.Transform(..., Text.Upper) -> {"NAME", "AMOUNT"} ``` This pattern — uppercasing every column name at once, regardless of how many columns exist — is something no single `Table.*` function does directly; it needs `List.Transform()` operating on the list of column names first. *** ## List.Transform vs. Table.AddColumn [#listtransform-vs-tableaddcolumn] Both apply a function per element, but at a different level: | | List.Transform | Table.AddColumn | | ----------------- | ------------------------ | ------------------------------------ | | Operates on | A plain list | A table | | Function receives | One list value at a time | An entire row, via `[ColumnName]` | | Result | A new list | The same table, with one more column | Reaching for `List.Transform()` on a table column directly (`List.Transform(Source[Amount], ...)`) works — a column reference like `Source[Amount]` returns a list — but it produces a **standalone list**, disconnected from the rest of the table, not a new column. `Table.AddColumn()` is the one that keeps the result attached to its row. *** ## Common Mistakes [#common-mistakes] ### Expecting It to Return a Table [#expecting-it-to-return-a-table] `List.Transform()` always returns a list, never a table — a common surprise for anyone expecting a `Source[Amount]` transform to stay attached to the original rows. If the result needs to go back into a table, it needs a separate step (`Table.AddColumn` for a new column, or `Table.FromList` to build a fresh table from the list). ### Using each \_ When the Function Needs Extra Arguments [#using-each-_-when-the-function-needs-extra-arguments] ```powerquery lineNumbers List.Transform({1.234, 5.678}, each Number.Round(_, 2)) ``` This is the correct form — `Number.Round` needs a second argument (decimal places), so a bare function reference (`Number.Round` alone) wouldn't work; wrapping it in `each _` supplies the current value as the first argument while fixing the second. ### Forgetting the Transform Function Needs to Handle Every Value's Type [#forgetting-the-transform-function-needs-to-handle-every-values-type] If the list contains a mix of types (a genuinely messy source, or `null` values mixed with real numbers), a transform function assuming one consistent type errors on whichever value doesn't match — see [Error Handling in Power Query](/docs/power-query/error-handling) for wrapping the transform in `try...otherwise` when that's a real possibility. *** ## Next Steps [#next-steps] * [Custom Functions in Power Query M](/docs/power-query/custom-functions) * [List.Accumulate()](/docs/power-query/list-accumulate) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [List.Distinct() & List.Contains()](/docs/power-query/list-distinct-contains) * [M Function Reference](/docs/power-query/functions) # M Language (/docs/power-query/m-language) # M Language [#m-language] M is the formula language that powers Power Query. Every action taken in the Editor's UI — removing a column, filtering rows, merging queries — generates M code behind the scenes. ```text Click "Remove Columns" in the UI | | generates | Table.RemoveColumns(Source, {"Column1"}) ``` Most work happens visually, but M can also be written or edited directly for transformations the UI doesn't expose. *** ## Where M Lives [#where-m-lives] Every query is a single M expression, viewable and editable as a whole from **Home > Advanced Editor**. ```text Power BI Desktop | | Home > Advanced Editor | Full M script for the selected query ``` A single step can also be edited on its own from the formula bar (**View > Formula Bar**). *** ## The let...in Structure [#the-letin-structure] A query is a `let` expression: a series of named steps, followed by an `in` clause naming which step is the final result. ```powerquery lineNumbers let Source = Excel.Workbook(File.Contents("Sales.xlsx")), SalesTable = Source{[Name="Sales"]}[Data], FilteredRows = Table.SelectRows(SalesTable, each [Status] = "Active"), RenamedColumns = Table.RenameColumns(FilteredRows, {{"Amt", "Amount"}}) in RenamedColumns ``` Each step is a named value, and each subsequent step typically refers to the previous one by name — exactly what the Applied Steps pane displays. ```text Source | SalesTable (references Source) | FilteredRows (references SalesTable) | RenamedColumns (references FilteredRows) <- returned by "in" ``` *** ## Referencing Previous Steps [#referencing-previous-steps] Each step is just a variable name. Steps don't have to be used in strict order — a later step could reference an earlier one directly, skipping steps in between, though this is uncommon and can be harder to follow. ```powerquery lineNumbers let Source = Table.Buffer(SourceTable), Step1 = Table.SelectRows(Source, each [Amount] > 0), Step2 = Table.RemoveColumns(Step1, {"Notes"}) in Step2 ``` Step names with spaces are wrapped in `#"..."` — this is why the Advanced Editor often shows names like `#"Changed Type"`. *** ## Core Data Types [#core-data-types] ```text text "Sales" number 1250, 3.14 date #date(2024, 1, 15) datetime #datetime(2024, 1, 15, 9, 30, 0) logical true, false list {1, 2, 3} record [Name = "Sales", Amount = 100] table a full rectangular dataset function (x) => x * 2 ``` Tables, lists, and records are the three structured types most Power Query transformations move between. *** ## Common Functions [#common-functions] ```text Table.SelectRows — filter rows by a condition Table.RemoveColumns — drop columns Table.RenameColumns — rename columns Table.TransformColumns — apply a function to a column's values Table.AddColumn — add a computed column Table.Group — aggregate rows, like SQL's GROUP BY Table.NestedJoin — merge with another table Text.Trim / Text.Upper — common text cleanup List.Select — filter a list ``` Example — filtering rows and transforming a column in one step: ```powerquery lineNumbers Table.TransformColumns( Table.SelectRows(Source, each [Status] = "Active"), {{"Name", Text.Trim}} ) ``` *** ## each and Functions [#each-and-functions] `each` is shorthand for a one-argument function operating on the current row or value. ```powerquery lineNumbers each [Amount] > 100 ``` is equivalent to: ```powerquery lineNumbers (row) => row[Amount] > 100 ``` Custom functions can also be written and reused across steps or queries, useful when the same logic needs to apply in more than one place. ```powerquery lineNumbers let AddTax = (amount as number) as number => amount * 1.08 in AddTax(100) ``` See [Custom Functions in Power Query M](/docs/power-query/custom-functions) for typed parameters, optional parameters, and passing a function as a value to `List.Transform` or `Table.AddColumn`. *** ## Case Sensitivity [#case-sensitivity] M is case-sensitive throughout — function names, step names, and column references all need to match exactly. ```text [Amount] and [amount] are two different column references Table.SelectRows is not the same as table.selectrows ``` A mismatch here is one of the more common causes of a query that looks correct but fails at a specific step. *** ## Best Practices [#best-practices] * Give steps clear, descriptive names in the UI rather than leaving auto-generated ones — the Advanced Editor is far more readable when step names describe what happened. * Build custom functions for transformation logic reused across multiple queries, instead of duplicating the same M expression. * Prefer built-in `Table.*` and `Text.*` functions over manual record/list manipulation where one already does the job. * Review the Advanced Editor periodically, even for UI-built queries, to catch redundant or inefficient steps. *** ## Common Mistakes [#common-mistakes] ### Mismatched Case in References [#mismatched-case-in-references] `[CustomerID]` and `[customerid]` look similar but are not the same column reference, and M won't be forgiving about it. ### Overusing Custom M for Things the UI Already Does Well [#overusing-custom-m-for-things-the-ui-already-does-well] Hand-writing complex logic for something achievable with a couple of clicks in the Editor makes the query harder for others to maintain. ### Breaking Query Folding with Unnecessary Custom Code [#breaking-query-folding-with-unnecessary-custom-code] Complex custom M — especially row-by-row logic — often can't be translated back to the source system's native query language, which stops [query folding](/docs/power-query/query-folding) partway through the query. *** ## M Language Checklist [#m-language-checklist] * Step names are descriptive, not default auto-generated names. * Reused transformation logic is captured in a custom function, not copy-pasted. * Case matches exactly for every column and function reference. * Custom M steps have been checked against **View Native Query** to confirm they don't unexpectedly break folding. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Power Query Editor](/docs/power-query/editor) * [Transformations](/docs/power-query/transformations) * [Query Folding](/docs/power-query/query-folding) * [Custom Functions in Power Query M](/docs/power-query/custom-functions) * [Text.Trim(), Text.Upper() & Text.Lower()](/docs/power-query/text-trim-upper-lower) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.AddColumn()](/docs/power-query/table-addcolumn) * [Error Handling (try ... otherwise)](/docs/power-query/error-handling) A custom function calling another query per row can trigger [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error) — worth knowing before writing one. Getting "We couldn't convert to Number" or a wrong-looking date? See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the locale mismatch that's usually the real cause. # Merge Queries (/docs/power-query/merge-queries) # Merge Queries [#merge-queries] Merge Queries combines two queries into one, matching rows based on one or more common columns — the Power Query equivalent of a SQL join. ```text Sales Customers CustomerID | Amount CustomerID | Name -----------|------- -----------|------ 1 | 100 1 | Alice 2 | 200 2 | Bob ``` ```text Sales | | merge on CustomerID | Customers | Sales + Customer Name ``` *** ## Starting a Merge [#starting-a-merge] From **Home > Merge Queries**, choose the query to merge into (the base table), the query to merge with, and the matching column(s) in each. ```text Merge Queries Base table: Sales Merge with: Customers Match on: CustomerID <-> CustomerID Join kind: Left Outer ``` The result is a new column containing a nested table for each matched row, which is then expanded into regular columns. ```text CustomerID | Amount | NewColumn -----------|--------|---------- 1 | 100 | Table 2 | 200 | Table | | expand -> pick columns (e.g. Name) v CustomerID | Amount | Name -----------|--------|------ 1 | 100 | Alice 2 | 200 | Bob ``` *** ## Join Kinds [#join-kinds] | Join Kind | Keeps | | ----------- | ------------------------------------------------------------------ | | Left Outer | All rows from the base table, matched where possible | | Right Outer | All rows from the merge-with table, matched where possible | | Full Outer | All rows from both tables | | Inner | Only rows that match in both tables | | Left Anti | Rows from the base table with **no** match in the other table | | Right Anti | Rows from the merge-with table with **no** match in the base table | ```text Left Outer: Inner: Left Anti: Sales Customers Sales Customers Sales Customers 1 -> 1 1 -> 1 (no match rows only) 2 -> 2 2 -> 2 e.g. CustomerID 3 3 -> (no match, kept) (3 dropped) with no Customer row ``` **Left Outer** is the default and most common choice — every row from the base table survives, with customer details attached where a match exists. *** ## Left Anti: Finding Unmatched Rows [#left-anti-finding-unmatched-rows] Left Anti is useful specifically for finding rows in one table that have no counterpart in another — sales with no matching customer, or products with no sales at all. ```text Sales (Left Anti against Customers) CustomerID | Amount -----------|------- 99 | 50 <- CustomerID 99 doesn't exist in Customers ``` No columns are added — a Left Anti merge is a filter, not an enrichment, and typically doesn't need expanding. *** ## Merge vs. Append [#merge-vs-append] Merge combines tables **side by side**, adding columns. Append stacks tables **on top of each other**, adding rows. ```text Merge (side by side, adds columns): Sales + Customers -> Sales with Customer Name attached Append (stacked, adds rows): January Sales + February Sales = combined Sales for both months ``` *** ## Fan-Out from Duplicate Keys [#fan-out-from-duplicate-keys] If the merge-with table has more than one row matching a given key, every base row gets duplicated once per match. ```text Sales Customers (CustomerID 1 appears twice) CustomerID | Amount CustomerID | Segment -----------|------- -----------|-------- 1 | 100 1 | Retail 1 | Wholesale After merge on CustomerID: CustomerID | Amount | Segment -----------|--------|---------- 1 | 100 | Retail 1 | 100 | Wholesale <- Amount duplicated ``` This is one of the most common sources of inflated totals after a merge — always confirm the merge-with table's key column is actually unique before merging. *** ## Best Practices [#best-practices] * Confirm the merge-with table's key column is unique before merging, to avoid row duplication (fan-out). * Use **Left Anti** to find unmatched rows as a data-quality check, even outside of a full merge. * Expand only the columns actually needed — every expanded column becomes a new field in the query. * Prefer merging against a query with as few rows and columns as possible, since fewer rows on the merge-with side means less potential for fan-out and faster processing. *** ## Common Mistakes [#common-mistakes] ### Merging Against a Non-Unique Key [#merging-against-a-non-unique-key] Produces duplicated rows and inflated totals downstream, often without any obvious error — just numbers that are silently too high. ### Choosing the Wrong Join Kind [#choosing-the-wrong-join-kind] Using Inner when Left Outer was intended silently drops any base-table row that didn't find a match, which can understate totals just as easily as fan-out overstates them. ### Expanding Every Column "Just in Case" [#expanding-every-column-just-in-case] Expanding unused columns adds width and clutter to the resulting table, and makes the query slower to preview and refresh. *** ## Merge Checklist [#merge-checklist] * The merge-with table's key column has been confirmed unique, or fan-out has been deliberately accounted for. * The join kind matches the intended result (keep all base rows vs. only matches vs. only unmatched rows). * Only the needed columns are expanded from the merged table. * Resulting row counts have been spot-checked against expectations after the merge. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Transformations](/docs/power-query/transformations) * [Query Folding](/docs/power-query/query-folding) * [M Language](/docs/power-query/m-language) * [Merge vs. Append: When to Use Each](/docs/power-query/merge-vs-append) Expanding a Left Outer merge and getting "We cannot convert the value null to type Table"? See [that error explained](/blog/cannot-convert-null-to-type-table-error) — an unmatched row's nested table column is exactly this null. Getting a "Formula.Firewall" error after combining sources? See [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error) for the three usual causes. # Merge vs. Append: When to Use Each (/docs/power-query/merge-vs-append) # Merge vs. Append: When to Use Each [#merge-vs-append-when-to-use-each] **Merge** and **Append** are two of the most commonly confused operations in Power Query — both combine two queries into one, but they solve opposite kinds of problems. Mixing them up produces a table that's either duplicated sideways or stacked incorrectly, usually with no error to flag it. ```text MERGE (Table.NestedJoin) APPEND (Table.Combine) Adds columns, matching on a key Adds rows, stacking tables Sales Customers Sales_Jan Sales_Feb CustID|Amt CustID|Name CustID|Amt CustID|Amt 1 |100 1 |Alice 1 |100 2 |200 | Sales_Jan + Sales_Feb stacked CustID|Amt 1 |100 2 |200 ``` *** ## The Actual Decision [#the-actual-decision] Ask one question: **does the new data add more columns to existing rows, or more rows in the same shape?** | | Merge | Append | | ---------------- | ------------------------------------ | ---------------------------------------------- | | Adds | Columns | Rows | | Requires | A matching key column in both tables | Matching column names/structure across tables | | SQL equivalent | `JOIN` | `UNION ALL` | | Typical scenario | Sales table + Customer lookup table | January sales + February sales, same structure | If the two sources describe **the same kind of thing** at **different times or from different places** (this month's export and last month's, one region's file and another's), it's an Append. If one source is **context or attributes about** what's in the other (an order table and a customer lookup table), it's a Merge. *** ## Append: Stacking Same-Shape Tables [#append-stacking-same-shape-tables] ```powerquery lineNumbers #"Appended" = Table.Combine({SalesJan, SalesFeb, SalesMar}) ``` `Table.Combine()` is the function behind **Home > Append Queries**. It stacks tables that share the same column structure — column names don't need to be in the same order, but a column present in one table and missing from another produces `null` for that column in the rows that came from the table without it, rather than an error. See [Merge Queries](/docs/power-query/merge-queries) for the mechanics of the other operation — matching on a key column, choosing a join kind. *** ## Common Mistakes [#common-mistakes] ### Appending Tables With Slightly Different Column Names [#appending-tables-with-slightly-different-column-names] ```text SalesJan columns: CustomerID, Amount SalesFeb columns: CustID, Amount <- different name for the same thing ``` `Table.Combine()` treats `CustomerID` and `CustID` as two unrelated columns, producing a result with **both**, each half-populated with `null` for the rows from the table that didn't have that exact name. This doesn't error — it silently produces a wider, wrong table. Fix by renaming columns to match before appending. ### Merging When Append Was Actually Needed [#merging-when-append-was-actually-needed] Trying to merge two tables of the same shape (this month's file and last month's) on a shared ID column produces a matched-and-joined result with duplicated/renamed columns (`Amount` and `Amount.1`), not the stacked table that was actually wanted. ### Appending When Merge Was Actually Needed [#appending-when-merge-was-actually-needed] Stacking a Sales table and a Customers table (different shapes entirely) produces a nonsensical result — `Table.Combine()` doesn't try to align them meaningfully by key, it just stacks rows, so unrelated columns collide or produce mostly-blank rows. ### Not Checking Row Counts After Either Operation [#not-checking-row-counts-after-either-operation] An Append should produce roughly the sum of the input row counts (barring genuine duplicates). A Merge with a **Left Outer** join should keep the base table's row count exactly, or grow it if the lookup table has duplicate keys — a row count that doesn't match either expectation usually means the wrong join kind or a genuinely unexpected data issue upstream. *** ## Next Steps [#next-steps] * [Merge Queries](/docs/power-query/merge-queries) * [M Language](/docs/power-query/m-language) * [M Function Reference](/docs/power-query/functions) * [Star Schema](/docs/modeling/star-schema) * [UNION(), EXCEPT() & INTERSECT() in DAX](/docs/dax/union) — the DAX equivalents, matched by column position instead of name # Number.Round(), Number.RoundUp() & Number.RoundDown() (/docs/power-query/number-functions) # Number.Round(), Number.RoundUp() & Number.RoundDown() [#numberround-numberroundup--numberrounddown] These three round a number, but not the same way — `Number.Round()` rounds to the nearest value, while `Number.RoundUp()` and `Number.RoundDown()` always move a fractional value away from or toward zero, regardless of how close it already is. ```powerquery lineNumbers Number.Round(number as number, optional digits as nullable number, optional roundingMode as nullable number) as number Number.RoundUp(number as number, optional digits as nullable number) as number Number.RoundDown(number as number, optional digits as nullable number) as number ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Number.Round(3.14159, 2) ``` ```text 3.14159 -> 3.14 ``` `digits` defaults to `0` if omitted, rounding to the nearest whole number. *** ## "Up" Means Away From Zero, Not Toward Positive Infinity [#up-means-away-from-zero-not-toward-positive-infinity] ```powerquery lineNumbers Number.RoundUp(2.1, 0) Number.RoundUp(-2.1, 0) ``` ```text Number.RoundUp(2.1, 0) -> 3 Number.RoundUp(-2.1, 0) -> -3 <- not -2 ``` This is the single most common surprise with these functions. `RoundUp` doesn't mean "toward positive infinity" (that would make `-2.1` round to `-2`) — it means "away from zero," so a negative value gets *more* negative. `RoundDown` is the mirror image: it always moves toward zero. ```powerquery lineNumbers Number.RoundDown(2.9, 0) Number.RoundDown(-2.9, 0) ``` ```text Number.RoundDown(2.9, 0) -> 2 Number.RoundDown(-2.9, 0) -> -2 <- not -3 ``` Try a negative number with both functions — the direction each one moves is fixed (always away from zero for RoundUp, always toward zero for RoundDown), not dependent on which integer is numerically closer. *** ## Number.Round Rounds Half Away From Zero [#numberround-rounds-half-away-from-zero] Without a `roundingMode`, `Number.Round()` breaks an exact halfway tie by rounding away from zero — the same convention Excel's `ROUND()` uses. ```powerquery lineNumbers Number.Round(2.5, 0) Number.Round(-2.5, 0) ``` ```text Number.Round(2.5, 0) -> 3 Number.Round(-2.5, 0) -> -3 ``` *** ## Negative Digits Round to the Left of the Decimal Point [#negative-digits-round-to-the-left-of-the-decimal-point] The `digits` argument isn't limited to positive values — a negative digit count rounds to the nearest ten, hundred, thousand, and so on, instead of to a decimal place. ```powerquery lineNumbers Number.Round(1234, -2) ``` ```text 1234 -> 1200 (rounded to the nearest hundred) ``` ```text digits = 2 -> nearest 0.01 digits = 0 -> nearest whole number digits = -2 -> nearest 100 digits = -3 -> nearest 1000 ``` This is the feature most people don't know exists — reaching for a manual `divide, round, multiply back` pattern to round to the nearest hundred works, but `Number.Round(value, -2)` does the identical thing directly. *** ## Common Mistakes [#common-mistakes] ### Assuming RoundUp Means "Toward Positive Infinity" [#assuming-roundup-means-toward-positive-infinity] As covered above — `Number.RoundUp(-2.1, 0)` returns `-3`, not `-2`. If the goal is genuinely "always round toward positive infinity" (a true ceiling, regardless of sign), `RoundUp`/`RoundDown` aren't the right tool; that behavior needs an explicit sign check. ### Manually Rounding to Hundreds or Thousands [#manually-rounding-to-hundreds-or-thousands] ```powerquery lineNumbers Number.Round(value / 100, 0) * 100 ``` This works, but `Number.Round(value, -2)` does the same rounding in one call, without the intermediate division and multiplication. ### Forgetting digits Defaults to Whole Numbers [#forgetting-digits-defaults-to-whole-numbers] `Number.Round(3.14159)` with no second argument returns `3`, not `3.14159` unrounded — omitting `digits` doesn't mean "don't round," it means "round to 0 decimal places." *** ## Best Practices [#best-practices] * Reach for `Number.Round()` for normal nearest-value rounding; reserve `RoundUp`/`RoundDown` for when the direction genuinely needs to be fixed regardless of the value's sign. * Use a negative `digits` value to round to tens, hundreds, or thousands directly, instead of a manual divide/round/multiply pattern. * Don't assume `RoundUp` behaves like a mathematical ceiling function on negative numbers — it doesn't. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [Number.ToText()](/docs/power-query/number-totext) — formatting a number for display, including the "P" percent gotcha * [M Function Reference](/docs/power-query/functions) # Number.ToText() (/docs/power-query/number-totext) # Number.ToText() [#numbertotext] `Number.ToText()` converts a number to text, optionally formatted with a standard format code — the same codes .NET number formatting uses. ```powerquery lineNumbers Number.ToText(number as number, optional format as nullable text, optional culture as nullable text) as text ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Number.ToText(1234.5) ``` ```text 1234.5 -> "1234.5" ``` With no `format` argument, the number becomes text with no formatting applied at all — no thousands separators, no fixed decimal places. *** ## "P" (Percent) Multiplies by 100 [#p-percent-multiplies-by-100] ```powerquery lineNumbers Number.ToText(0.5, "P") ``` ```text 0.5 -> "50.00%" <- not "0.50%" ``` This catches people who store a percentage internally as its decimal form (`0.5` meaning 50%) and expect `"P"` to just format that value with a `%` sign appended. It doesn't — `"P"` assumes the number is already a *ratio* (0 to 1 representing 0% to 100%) and multiplies by 100 as part of formatting, the same convention Excel's own Percentage cell format uses. Try switching between formats above — a value like `50` under `"P"` becomes `"5000.00%"`, which is the same mistake in the other direction: a number already meant to read as "50%" gets multiplied again. *** ## Common Format Codes [#common-format-codes] | Code | Meaning | `Number.ToText(1234.5, code)` | | -------- | --------------------------------------------- | ----------------------------- | | *(none)* | Plain text, no formatting | `"1234.5"` | | `"N2"` | Number, thousands-grouped, 2 decimal places | `"1,234.50"` | | `"F2"` | Fixed, 2 decimal places, no grouping | `"1234.50"` | | `"P"` | Percent — multiplies by 100, appends `%` | `"123450.00%"` | | `"D"` | Decimal (whole numbers only, pads with zeros) | errors on a non-integer | *** ## Common Mistakes [#common-mistakes] ### Assuming "P" Just Appends a % Sign [#assuming-p-just-appends-a--sign] As covered above — `"P"` multiplies the value by 100 first. A value already meant to display as a percentage (rather than a 0–1 ratio) needs to be divided by 100 before applying `"P"`, or formatted with a plain `"N2"` plus a literal `"%"` concatenated on afterward instead. ### Using "D" on a Value That Isn't a Whole Number [#using-d-on-a-value-that-isnt-a-whole-number] `"D"` formats an integer, optionally zero-padded — passing a value with a fractional part produces an error rather than rounding it. Round or truncate first if the source value might not already be a whole number. ### Forgetting Number.ToText Doesn't Change the Underlying Value [#forgetting-numbertotext-doesnt-change-the-underlying-value] `Number.ToText()` produces a `text` value for display — it doesn't round or otherwise change the original number. Sorting, filtering, or calculating with the formatted result requires the original numeric column, not the text version. *** ## Next Steps [#next-steps] * [Number.Round(), Number.RoundUp() & Number.RoundDown()](/docs/power-query/number-functions) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [M Function Reference](/docs/power-query/functions) # Query Folding (/docs/power-query/query-folding) # Query Folding [#query-folding] Query folding translates Power Query steps into a query the source system runs itself, instead of Power BI pulling raw data and transforming it locally. ```text Power Query Steps | | folded into | Native Source Query (e.g. SQL) | | source does the work | Only the result is transferred ``` When folding works, the source database does the filtering, sorting, and grouping — and only the final, already-reduced result crosses the network. *** ## Without Query Folding [#without-query-folding] ```text Source Database | | sends entire table | Power BI | | filters, groups locally | Final Result ``` Every row in the source table gets transferred, even if the transformations only end up keeping a small fraction of them. *** ## With Query Folding [#with-query-folding] ```text Power Query Steps | | translated to SQL | Source Database | | filters, groups there | Only final rows sent | Power BI ``` The source system does the heavy lifting, and the network transfer is limited to whatever the final, filtered result actually needs. *** ## Which Steps Fold [#which-steps-fold] Folding support depends on the data source and the specific transformation. | Step | Typically Folds | | ------------------------------- | --------------- | | Filter rows | Yes | | Remove columns | Yes | | Group by | Yes | | Sort | Yes | | Change type | Usually | | Custom column with complex M | Often not | | Merge with a non-foldable query | No | Relational databases (SQL Server, PostgreSQL, Snowflake, and similar) generally support folding well. File sources like Excel or CSV don't fold at all, since there's no query engine on the other end to push work to. *** ## Checking Whether a Query Folds [#checking-whether-a-query-folds] Right-click a step in **Applied Steps** and check whether **View Native Query** is available. ```text Step | | right-click | "View Native Query" available? | +-- Yes -> folding up to this point | +-- No -> folding stopped earlier ``` If the option is greyed out, folding has already stopped by that step — everything from there onward runs locally in Power Query instead of at the source. *** ## What Breaks Folding [#what-breaks-folding] Certain transformations force Power Query to stop folding and start processing locally. ```text Folding | | breaks at | Complex custom M expressions Merging with a non-foldable source Adding an index column Certain text/date functions with no SQL equivalent Table.Buffer, and anything else that forces full materialization ``` See [Table.Buffer](/docs/power-query/table-buffer#the-real-cost-tablebuffer-breaks-query-folding) specifically — it's one of the easiest ways to break folding by accident, since it doesn't look like a transformation at all. Once folding breaks at a step, every step after it also runs locally, even if those later steps individually could have folded. *** ## Ordering Steps to Preserve Folding [#ordering-steps-to-preserve-folding] Because a broken step stops folding for everything after it, putting foldable steps (filters, column removal) before non-foldable ones (custom columns, complex logic) keeps as much work as possible pushed to the source. ```text Good order: Filter -> Remove Columns -> Group By -> Custom Column Worse order: Custom Column -> Filter -> Remove Columns -> Group By ``` In the second example, folding stops at the custom column, so the filter and group-by that follow run locally even though they could have folded. *** ## Why It Matters for Refresh Performance [#why-it-matters-for-refresh-performance] ```text No Folding: entire table transferred, then processed locally Folding: source processes and filters, small result transferred ``` For large source tables, the difference between folding and not folding can turn a refresh from minutes into seconds — or the reverse, if folding is accidentally broken partway through a query. *** ## Best Practices [#best-practices] * Put filtering and column removal steps early, before custom columns or complex transformations. * Check **View Native Query** periodically while building a query against a relational source. * Avoid unnecessary custom M columns when an equivalent built-in transformation would fold instead. * Expect no folding at all from flat file sources (Excel, CSV, JSON) — there's no source query engine to push work to. *** ## Common Mistakes [#common-mistakes] ### Adding Custom Columns Too Early [#adding-custom-columns-too-early] A custom column added before filtering forces every later step, including the filter, to run locally instead of at the source. ### Assuming All Sources Fold [#assuming-all-sources-fold] Folding is a feature of certain connectors, mainly relational databases. Expecting the same performance benefit from a CSV or Excel source will lead to confusion about why refresh is slow. ### Not Checking Native Query [#not-checking-native-query] Without checking **View Native Query**, it's easy to build a query that silently stopped folding several steps ago, with no obvious symptom other than a slower-than-expected refresh. *** ## Query Folding Checklist [#query-folding-checklist] Before finalizing a query against a relational source: * Filtering and column removal happen before custom or complex steps. * **View Native Query** has been checked at key points in the step list. * Any step known to break folding is placed as late as possible. * Refresh performance has been tested against production-scale source data. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Introduction](/docs/power-query/introduction) * [Transformations](/docs/power-query/transformations) * [Merge Queries](/docs/power-query/merge-queries) * [Table.AddColumn()](/docs/power-query/table-addcolumn) * [Table.SelectRows()](/docs/power-query/table-selectrows) — filtering as early as possible keeps more of a query eligible to fold * [Sql.Database()](/docs/power-query/sql-database#native-sql-query-breaks-folding-by-design) — pasting a native query is a deliberate, common way folding stops entirely A query that "worked yesterday" and suddenly throws [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error) is a related class of surprise worth knowing about too. A query that stopped folding is also one of the more common paths to running out of memory in Desktop — see [There Isn't Enough Memory to Complete This Operation](/blog/not-enough-memory-power-bi-desktop). A refresh that's just gotten slower, with no error at all? See [Why Did My Power Query Refresh Suddenly Get Slower?](/blog/refresh-suddenly-slow-query-folding-broke) for how to find exactly which step broke folding. # Sql.Database() (/docs/power-query/sql-database) # Sql.Database() [#sqldatabase] `Sql.Database()` connects to a SQL Server database — the function behind **Get Data > SQL Server**, and one of the most common enterprise data sources in Power BI. ```powerquery lineNumbers Sql.Database( server as text, database as text, optional options as nullable record ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Source = Sql.Database("sqlserver01", "SalesDB") ``` This returns a **navigation table** — every table and view in the database, browsable in the Editor's preview pane before picking which one to actually load. *** ## Named Instances and Non-Default Ports [#named-instances-and-non-default-ports] ```powerquery lineNumbers Sql.Database("sqlserver01\SALESDB01", "SalesDB") ``` A named instance uses the backslash form (`server\instance`), not a colon — mixing this up with the port-number syntax used elsewhere (`server:1433`) is a common typo, especially copying a connection string from a tool that formats it differently. ```powerquery lineNumbers Sql.Database("sqlserver01,1433", "SalesDB") ``` A non-default port uses a comma, not a colon — `server:1433` is not valid syntax here and produces a connection error rather than a clear "wrong syntax" message. *** ## Native SQL Query Breaks Folding by Design [#native-sql-query-breaks-folding-by-design] ```powerquery lineNumbers Source = Sql.Database( "sqlserver01", "SalesDB", [Query = "SELECT CustomerID, SUM(Amount) AS Total FROM Sales GROUP BY CustomerID"] ) ``` Passing a hand-written `Query` through the **Advanced Options** dialog runs exactly that SQL — but it also becomes the entire query as far as [query folding](/docs/power-query/query-folding) is concerned. Every M step added *after* this point runs locally against the query's result set, since Power Query has no way to fold additional M transformations back into a SQL string it didn't generate itself. ```text Sql.Database with native query | | any Table.SelectRows / Table.Group added after this | runs locally, does NOT get pushed back into SQL Server ``` This is the opposite tradeoff of connecting through the standard navigator and building up M steps normally — that path keeps folding all the way through, as long as each individual step is foldable. A native query is the right call when the SQL itself needs to do something M's folding can't express (a specific execution hint, a stored procedure call) — not a shortcut to avoid learning the M side. *** ## Import vs. DirectQuery for SQL Sources [#import-vs-directquery-for-sql-sources] | | Import | DirectQuery | | -------------- | --------------------------------------------- | ----------------------------------------------- | | Data freshness | As of last scheduled refresh | Live, every query hits the database | | Performance | Fast in-report (data is local) | Depends entirely on source query speed | | Row count | Practical limit in the millions, not billions | Effectively unlimited — source does the work | | Refresh load | One heavy pull per scheduled refresh | Continuous, per-visual query load on the source | A reasonable rule of thumb: Import unless there's a specific reason not to — near-real-time freshness requirements, a source too large to reasonably import, or a compliance reason data can't leave the source system. DirectQuery trades import's simplicity and report-time speed for freshness, and shifts performance risk onto the source database being queried repeatedly, often by many concurrent report viewers. *** ## Common Mistakes [#common-mistakes] ### Confusing Named Instance and Port Syntax [#confusing-named-instance-and-port-syntax] As covered above — `server\instance` (backslash) for a named instance, `server,port` (comma) for a non-default port on the default instance. These aren't interchangeable, and using the wrong separator produces a connection failure that doesn't clearly say which syntax was expected. ### A Report That Works From Desktop but Fails Through the Gateway [#a-report-that-works-from-desktop-but-fails-through-the-gateway] The most common real-world SQL Server headache: a query connects fine from Power BI Desktop (using the developer's own Windows credentials, or cached SQL Auth credentials) but fails once scheduled refresh runs through an on-premises gateway. See [Gateway & Refresh Architecture](/docs/governance/gateway-refresh) and [OLE DB or ODBC Error](/blog/ole-db-odbc-connection-error) — the gateway machine needs its own configured credentials against that SQL Server, entirely separate from whatever Desktop is using interactively, and a mismatch here is the single most common cause of "it worked yesterday" refresh failures on a SQL source. ### Pasting a Native Query "Just to Be Safe" [#pasting-a-native-query-just-to-be-safe] Reaching for a hand-written `Query` option out of habit, even for a simple table pull that the standard navigator would handle just as well, gives up folding for every subsequent step with nothing gained in return. Reserve it for cases the navigator genuinely can't express. ### Choosing DirectQuery by Default for "Big" Tables [#choosing-directquery-by-default-for-big-tables] A table with tens of millions of rows is often still a better fit for Import (with appropriate filtering, aggregation, or incremental refresh) than DirectQuery, which shifts *every single visual's query* onto the source database, potentially concurrently across many report viewers. DirectQuery is a deliberate architectural choice for freshness or scale reasons, not a default fallback for "the table felt too big." *** ## Next Steps [#next-steps] * [Query Folding](/docs/power-query/query-folding) * [Gateway & Refresh Architecture](/docs/governance/gateway-refresh) * [Storage Modes](/docs/modeling/storage-modes) Refresh failing with an "OLE DB or ODBC error"? See [OLE DB or ODBC Error](/blog/ole-db-odbc-connection-error) for the four usual causes, including the gateway credential mismatch above. # Table.AddColumn() (/docs/power-query/table-addcolumn) # Table.AddColumn() [#tableaddcolumn] `Table.AddColumn()` adds a new column to a table, computing its value once per row from an expression you provide — it's the function behind **Add Column > Custom Column** in the Power Query Editor. ```powerquery lineNumbers Table.AddColumn( table as table, newColumnName as text, columnGenerator as function, columnType as nullable type ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "Total", each [Quantity] * [UnitPrice] ) ``` ```text Quantity | UnitPrice | Total 5 | 10 | 50 <- computed per row 3 | 20 | 60 ``` The `each` expression runs once for every row, with `[ColumnName]` referring to that row's value in the table the column is being added to — not any other step. *** ## each: A Function in Disguise [#each-a-function-in-disguise] `each [Quantity] * [UnitPrice]` is shorthand for a one-argument function: ```powerquery lineNumbers (row) => row[Quantity] * row[UnitPrice] ``` `each` is the version almost everyone reaches for; the explicit function form matters mainly for understanding error messages that reference "the current row," or for writing a reusable function that takes the row as an explicit parameter. See [M Language](/docs/power-query/m-language#each-and-functions) for the equivalent explanation in the context of filtering rather than adding a column. *** ## The Optional Column Type [#the-optional-column-type] ```powerquery lineNumbers #"Added Custom" = Table.AddColumn( Source, "Total", each [Quantity] * [UnitPrice], Int64.Type ) ``` Left out, the new column's type is inferred as `Any` — which works, but means a later step relying on that column's type (a numeric comparison, a date function) may need an explicit **Changed Type** step anyway. Supplying the type here does that in one step, and is what the Editor's UI does automatically for a custom column with a detectable output type. *** ## Table.AddColumn vs. Table.TransformColumns [#tableaddcolumn-vs-tabletransformcolumns] Both compute a value per row, but for a genuinely different purpose: | | Table.AddColumn | Table.TransformColumns | | --------------- | ------------------------------------------------ | --------------------------------------------------------- | | Result | A **new** column, existing columns unchanged | An **existing** column's values replaced in place | | Expression sees | Any column in the row, via `each [ColumnName]` | Only the single value being transformed | | Typical use | Computing something new from one or more columns | Cleaning up or reformatting one column (trim, case, type) | ```powerquery lineNumbers #"Added Custom" = Table.AddColumn(Source, "FullName", each [First] & " " & [Last]), #"Transformed" = Table.TransformColumns(#"Added Custom", {{"FullName", Text.Trim}}) ``` Reaching for `Table.TransformColumns` to combine two columns into a new one doesn't work — it only ever sees one column's existing value, never the whole row. `Table.AddColumn` is the one with row-wide visibility. *** ## Common Mistakes [#common-mistakes] ### Referencing a Column That Doesn't Exist Yet in This Step [#referencing-a-column-that-doesnt-exist-yet-in-this-step] ```powerquery lineNumbers #"Added Custom" = Table.AddColumn(Source, "Total", each [Quantity] * [Total]) ``` A column can't reference itself, and can't reference a column that a *later* step will add — only columns already present in the table passed as the first argument. This produces a "column not found" error naming the missing column. ### Expecting the New Column to Update Automatically [#expecting-the-new-column-to-update-automatically] The expression runs once per row **at the time this step executes**. If an earlier step's logic changes later, this column doesn't recompute unless the query itself is re-run (which normally happens automatically on refresh, but won't reflect manual edits to the underlying data until then). ### Using Table.AddColumn When Table.TransformColumns Was Meant [#using-tableaddcolumn-when-tabletransformcolumns-was-meant] Adding a column with the same name as an existing one doesn't replace it in place the way `Table.TransformColumns` does — it errors, since the resulting table would have a duplicate column name. If the goal is to overwrite an existing column's values, `Table.TransformColumns` (or `Table.AddColumn` followed by `Table.RemoveColumns` on the original) is the right tool. *** ## Best Practices [#best-practices] * Supply the optional `columnType` when the result is a known type — it saves a separate **Changed Type** step and documents intent. * Keep the `each` expression to genuinely per-row logic; anything needing the whole table (a running total, a rank) belongs in a different pattern, not a single `Table.AddColumn` call. * Give the new column a clear, final name up front — renaming later is an extra step that's easy to forget. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Transformations](/docs/power-query/transformations) * [Number.Round(), Number.RoundUp() & Number.RoundDown()](/docs/power-query/number-functions) * [M Function Reference](/docs/power-query/functions) Adding a column via `Web.Contents` per row? That combination is exactly what triggers [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error) — worth reading before writing one. # Table.AddIndexColumn() (/docs/power-query/table-addindexcolumn) # Table.AddIndexColumn() [#tableaddindexcolumn] `Table.AddIndexColumn()` adds a new column with a sequential number per row — the standard way to generate a surrogate key or a stable row number, and the function behind **Add Column > Index Column** in the Editor UI. ```powerquery lineNumbers Table.AddIndexColumn( table as table, newColumnName as text, optional initialValue as nullable number, optional increment as nullable number, optional columnType as nullable type ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Added Index" = Table.AddIndexColumn(Source, "RowNumber") ``` *** ## The Index Starts at 0 by Default, Not 1 [#the-index-starts-at-0-by-default-not-1] Omitting `initialValue` doesn't mean "start numbering from 1" — it means start from `0`. The Editor UI's **Add Column > Index Column > From 1** menu item exists specifically because the plain default doesn't do that; it's passing `initialValue = 1` explicitly on your behalf. ```text Table.AddIndexColumn(Source, "RowNumber") -> 0, 1, 2, 3, ... Table.AddIndexColumn(Source, "RowNumber", 1) -> 1, 2, 3, 4, ... ``` This matters most when the index is meant to line up with something 1-based outside the query — a row number shown to a user, or a key expected to start at 1 in a downstream system. *** ## The Increment Can Be Anything, Including Negative [#the-increment-can-be-anything-including-negative] ```powerquery lineNumbers Table.AddIndexColumn(Source, "Countdown", 10, -1) ``` ```text 10, 9, 8, 7, ... ``` `increment` isn't limited to `1` — a larger step, a fractional step, or a negative step (counting down instead of up) are all valid, since it's really just "start here, then add this amount each row." *** ## Common Mistakes [#common-mistakes] ### Assuming the Default Index Starts at 1 [#assuming-the-default-index-starts-at-1] As covered above — this is the single most common surprise. A join or lookup built against an assumed 1-based index silently misaligns by one position if the query actually produced a 0-based one. ### Adding the Index Before a Step That Reorders Rows [#adding-the-index-before-a-step-that-reorders-rows] `Table.AddIndexColumn()` numbers rows in whatever order they're in *at that step* in the query. Adding it early and then sorting or filtering afterward means the index no longer reflects the final row order — add it as the last step if the goal is a number matching the final row position. ### Using the Index as a Stable Identifier Across Refreshes [#using-the-index-as-a-stable-identifier-across-refreshes] An index column reflects row order at the time the query ran — it isn't tied to the underlying data the way a real key from the source is. The same row can get a different index value on the next refresh if the source data or its order changed at all. *** ## Best Practices [#best-practices] * Add the index column as the last step if the number needs to reflect final row order. * Use `initialValue = 1` explicitly (or the UI's **From 1** option) whenever the index needs to be 1-based. * Don't rely on an index column as a durable identifier across refreshes — it reflects row order at query time, not a stable source-level key. *** ## Next Steps [#next-steps] * [Table.Sort()](/docs/power-query/table-sort) * [Table.Distinct()](/docs/power-query/table-distinct) * [M Function Reference](/docs/power-query/functions) # Table.Buffer() (/docs/power-query/table-buffer) # Table.Buffer() [#tablebuffer] `Table.Buffer()` loads a table fully into memory the first time it's referenced, rather than re-reading or re-evaluating the source on every subsequent reference. ```powerquery lineNumbers Table.Buffer(table as table) as table ``` *** ## Why Buffer a Table [#why-buffer-a-table] Without buffering, a query can re-evaluate its source more than once — every reference to an earlier step *can* re-trigger evaluation, not just read a cached result. For most sources this doesn't matter, but for a volatile source (data that can change between reads, or a function with side effects), it does. ```text Without Table.Buffer: Source referenced twice -> hit twice, possibly returning different data each time With Table.Buffer: Source wrapped in Table.Buffer, referenced twice -> hit once, same data both times ``` ```powerquery lineNumbers #"Buffered Source" = Table.Buffer( Json.Document(Web.Contents("https://api.example.com/rates")) ) ``` *** ## The Real Cost: Table.Buffer Breaks Query Folding [#the-real-cost-tablebuffer-breaks-query-folding] This is the caveat that matters most. Once a table is buffered, it's fully materialized in memory — any step after it can no longer fold back to the source, even if every step before the buffer would have folded cleanly. ```text Source (SQL, folds) -> Filter (folds) -> Table.Buffer -> Group (does NOT fold, runs locally) ``` See [Query Folding](/docs/power-query/query-folding#what-breaks-folding) for why folding matters for refresh performance — adding `Table.Buffer()` partway through an otherwise-folding query is one of the most common accidental ways folding gets broken. *** ## When It's Actually Worth Using [#when-its-actually-worth-using] * A source with genuine side effects or volatility (an API that returns different data on each call, a function using `DateTime.LocalNow()` internally) that needs to return one consistent result for the rest of the query. * A small reference/lookup table used repeatedly inside a per-row function, where re-evaluating it on every row would be wasteful. * Debugging: buffering a step makes its output deterministic for the rest of the query, useful when tracking down a bug that seems to depend on evaluation order. ## When It's Not [#when-its-not] * "Just in case" performance tuning on a source that already folds — this usually makes performance *worse*, not better, since it forces full materialization instead of letting the source (a database) do the filtering. * Very large tables, where loading everything into memory at once can be slower and more memory-intensive than letting the source handle it natively. *** ## Common Mistakes [#common-mistakes] ### Treating It as a General Performance Button [#treating-it-as-a-general-performance-button] `Table.Buffer()` is a stabilization tool, not a speed-up — on a source that folds well, it typically hurts performance by forcing early materialization instead of letting the source do the work. ### Buffering Large Tables Unnecessarily [#buffering-large-tables-unnecessarily] Loading a genuinely large table fully into memory can be slower and more memory-hungry than the unbuffered alternative — reserve it for tables that are small, or where volatility genuinely requires it. ### Not Knowing Where Folding Already Stopped [#not-knowing-where-folding-already-stopped] Adding `Table.Buffer()` after a step that already breaks folding (a custom column using a non-foldable function, for example) doesn't cost anything extra — but adding it *before* that point, on an otherwise-folding chain, gives up folding that would otherwise have worked. *** ## Best Practices [#best-practices] * Reserve `Table.Buffer()` for genuinely volatile sources or small, repeatedly-referenced lookup tables. * Check whether a query folds (see [Checking Whether a Query Folds](/docs/power-query/query-folding#checking-whether-a-query-folds)) before adding a buffer — don't add it reflexively. * Place it as late as possible in a query, after any steps that would otherwise fold to the source. * Avoid buffering large fact-table-sized data — let the source (a database, a folding-capable connector) handle filtering and aggregation instead. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Query Folding](/docs/power-query/query-folding) * [M Language](/docs/power-query/m-language) * [M Function Reference](/docs/power-query/functions) Diagnosing a refresh that's mysteriously gotten slower? See [Why Did My Power Query Refresh Suddenly Get Slower?](/blog/refresh-suddenly-slow-query-folding-broke). Hitting a memory error in Desktop instead? Table.Buffer is a common accidental culprit — see [There Isn't Enough Memory to Complete This Operation](/blog/not-enough-memory-power-bi-desktop). # Table.Distinct() (/docs/power-query/table-distinct) # Table.Distinct() [#tabledistinct] `Table.Distinct()` removes duplicate rows from a table. Used with no arguments, it only removes rows that are *exact* duplicates across every column — the surprising behavior shows up once it's scoped to specific columns. ```powerquery lineNumbers Table.Distinct(table as table, optional equationCriteria as any) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Removed Duplicates" = Table.Distinct(Source) ``` With no second argument, two rows only count as duplicates if every single column matches. A row that differs in even one column — including one that looks irrelevant — is kept as a separate row. *** ## Scoping to Specific Columns Keeps the First Row's Other Values [#scoping-to-specific-columns-keeps-the-first-rows-other-values] ```powerquery lineNumbers #"Removed Duplicates" = Table.Distinct(Source, {"CustomerID"}) ``` Notice that the second email didn't just get filtered out on its own — the row for that email disappeared entirely, taking whatever else was in that row down with it. This is the part that catches people: `Table.Distinct()` scoped to `{"CustomerID"}` only *decides which rows count as duplicates* using `CustomerID` — it doesn't merge or reconcile the other columns across the "duplicate" rows. It keeps the **first row's** values for every other column and discards the rest of that group entirely, including any other column values that actually differed. *** ## Column Order Matters — the First Match Wins [#column-order-matters--the-first-match-wins] Because the row that's kept is whichever occurs first in the table, the result of a column-scoped `Table.Distinct()` depends on the table's current row order. Sorting the table (with [Table.Sort()](/docs/power-query/table-sort)) before deduping is the way to control *which* row's values get kept, rather than leaving it to whatever order the data happened to arrive in. ```powerquery lineNumbers #"Sorted Rows" = Table.Sort(Source, {{"LastUpdated", Order.Descending}}), #"Removed Duplicates" = Table.Distinct(#"Sorted Rows", {"CustomerID"}) ``` Sorting by a "most recent" column first, then deduping, is the usual fix when the goal is "keep the latest record per customer" rather than "keep whichever record happened to load first." *** ## Common Mistakes [#common-mistakes] ### Assuming Column-Scoped Distinct Merges the Other Columns [#assuming-column-scoped-distinct-merges-the-other-columns] It doesn't reconcile, combine, or flag a conflict — it silently keeps the first row's values and drops the rest. If two "duplicate" rows (by the scoped columns) actually have different values elsewhere, that difference is lost with no error and no indication anything was discarded. ### Not Controlling Row Order Before Deduping [#not-controlling-row-order-before-deduping] Since the kept row is whichever comes first, a column-scoped `Table.Distinct()` without a preceding sort produces a result that depends on the current, possibly arbitrary, row order — sort first if which specific row survives actually matters. ### Using Table.Distinct() When the Real Goal Is Aggregation [#using-tabledistinct-when-the-real-goal-is-aggregation] If the actual goal is combining information across the "duplicate" rows (the most recent value, the sum of some column, and so on), [Table.Group()](/docs/power-query/table-group) is usually the right tool — `Table.Distinct()` can only keep one whole row, never merge values from several. *** ## Best Practices [#best-practices] * Sort by whatever determines "which row wins" before a column-scoped `Table.Distinct()`, don't rely on incoming row order. * Reach for `Table.Group()` instead when the real goal is combining values across rows, not just picking one. * Double-check whether any non-key columns actually vary across what a scoped `Table.Distinct()` treats as duplicates — if they do, confirm the data loss is intentional. *** ## Next Steps [#next-steps] * [Table.Sort()](/docs/power-query/table-sort) * [Table.Group()](/docs/power-query/table-group) * [Table.AddIndexColumn()](/docs/power-query/table-addindexcolumn) * [List.Distinct() & List.Contains()](/docs/power-query/list-distinct-contains) — the list-level equivalent, with its own case-sensitivity trap * [VALUES() vs DISTINCT() in DAX](/docs/dax/values-distinct) — DAX's DISTINCT has its own trap, involving relationships instead of case * [M Function Reference](/docs/power-query/functions) # Table.FirstN() & Table.Skip() (/docs/power-query/table-firstn-skip) # Table.FirstN() & Table.Skip() [#tablefirstn--tableskip] `Table.FirstN()` returns the first N rows of a table; `Table.Skip()` returns everything after skipping the first N. Both also accept a condition function instead of a number — and that form works differently than it looks. ```powerquery lineNumbers Table.FirstN(table as table, countOrCondition as any) as table Table.Skip(table as table, countOrCondition as any) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers Table.FirstN(Source, 3) ``` ```text Returns the first 3 rows, in whatever order the table currently has — see Table.Sort() first if the order matters. ``` *** ## A Condition Function Is "Take While," Not a Filter [#a-condition-function-is-take-while-not-a-filter] Passing a function instead of a number doesn't filter the whole table down to matching rows — it takes rows from the top **only until the condition first fails**, then stops immediately, even if a later row would satisfy it again. That's a fundamentally different operation from `Table.SelectRows()`, which examines every row regardless of position. ```text Table.FirstN(Source, each [Amount] > 100) -- stops at the first non-matching row Table.SelectRows(Source, each [Amount] > 100) -- keeps every matching row, in any position ``` `Table.Skip()` with a condition mirrors this: it skips rows from the top while the condition holds, then returns everything from the first failure onward — including any later row where the condition happens to hold again. *** ## Common Mistakes [#common-mistakes] ### Expecting Table.FirstN's Condition Form to Filter the Whole Table [#expecting-tablefirstns-condition-form-to-filter-the-whole-table] As covered above — this is the single most common mix-up. If the goal is genuinely "every row matching this condition," [Table.SelectRows()](/docs/power-query/table-selectrows) is the right function; `Table.FirstN()`'s condition form is for "the leading run of rows matching this," which silently misses any matching row that comes after the first non-match. ### Assuming Row Order Doesn't Matter for the Condition Form [#assuming-row-order-doesnt-matter-for-the-condition-form] Since `Table.FirstN()`'s condition form stops at the first failure, its result depends entirely on the table's current row order — sorting first with [Table.Sort()](/docs/power-query/table-sort) changes which rows the "take while" actually captures. ### Using the Condition Form When a Plain Count Was Intended [#using-the-condition-form-when-a-plain-count-was-intended] `Table.FirstN(Source, 3)` and `Table.FirstN(Source, each [SomeCondition])` look similar but do genuinely different things — double-check which form is actually being used, especially when the second argument was copied from elsewhere in the query. *** ## Best Practices [#best-practices] * Reach for `Table.SelectRows()` when the goal is every matching row; reserve `Table.FirstN()`'s condition form for a genuine "leading run" scenario. * Sort explicitly before using the condition form of `Table.FirstN()` or `Table.Skip()`, since the result depends on row order. * Prefer the plain numeric form when a fixed row count is really what's needed — it's less ambiguous to read later than a condition that happens to behave like a count. *** ## Next Steps [#next-steps] * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.Sort()](/docs/power-query/table-sort) * [M Function Reference](/docs/power-query/functions) # Table.Group() (/docs/power-query/table-group) # Table.Group() [#tablegroup] `Table.Group()` groups a table's rows by one or more columns and computes an aggregation per group — the M equivalent of SQL's `GROUP BY`. ```powerquery lineNumbers Table.Group( table as table, key as any, aggregatedColumns as list ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Grouped Rows" = Table.Group( Source, {"Category"}, {{"TotalSales", each List.Sum([SalesAmount]), type number}} ) ``` ```text Source (one row per order) Grouped Rows (one row per category) Category | SalesAmount Category | TotalSales Bikes | 500 Bikes | 1,250 Bikes | 750 --> Accessories | 300 Accessories | 300 ``` Each aggregated column is defined as a triple: a name for the result column, a function run against each group's rows, and (optionally) the result type. *** ## Multiple Aggregations at Once [#multiple-aggregations-at-once] ```powerquery lineNumbers #"Grouped Rows" = Table.Group( Source, {"Category"}, { {"TotalSales", each List.Sum([SalesAmount]), type number}, {"OrderCount", each Table.RowCount(_), Int64.Type}, {"AvgSale", each List.Average([SalesAmount]), type number} } ) ``` The underscore `_` inside an aggregation function refers to the current group — a table containing just that group's rows — which is what `Table.RowCount(_)` counts. *** ## Grouping by Multiple Columns [#grouping-by-multiple-columns] ```powerquery lineNumbers #"Grouped Rows" = Table.Group( Source, {"Category", "Region"}, {{"TotalSales", each List.Sum([SalesAmount]), type number}} ) ``` ```text Category | Region | TotalSales Bikes | West | 800 Bikes | East | 450 Accessories | West | 300 ``` One output row per unique combination of the grouping columns — the same shape SQL's `GROUP BY Category, Region` would produce. *** ## Table.Group vs. the Group By Button [#tablegroup-vs-the-group-by-button] The **Group By** button in the Power Query ribbon generates exactly this function — `Table.Group()` is what's actually running underneath, whether it was written by hand or generated by the UI. | | UI (Group By button) | Hand-written `Table.Group` | | -------------- | ---------------------------------------------- | ----------------------------------------------------------------- | | Speed to write | Faster for simple, single-aggregation grouping | Faster once several aggregations are needed at once | | Flexibility | Limited to the dialog's supported aggregations | Any M expression, including custom logic beyond sum/count/average | *** ## Common Mistakes [#common-mistakes] ### Forgetting the Result Type [#forgetting-the-result-type] Omitting the third element of each aggregation triple still works, but leaves the result column typed as `any` — worth adding explicitly (`type number`, `Int64.Type`) so downstream steps don't need an extra `Table.TransformColumnTypes` just to fix it. ### Referencing the Wrong Table Inside an Aggregation [#referencing-the-wrong-table-inside-an-aggregation] Inside an aggregation function, `_` refers to the *current group*, not the original source table — using the original table's name instead of `_` aggregates over everything, not just the current group's rows. ### Grouping Before Cleaning the Data [#grouping-before-cleaning-the-data] Grouping locks in whatever's in the key columns at that point — a typo or inconsistent casing in a grouping column (e.g. `"Bikes"` vs `"bikes"`) produces two separate groups instead of one. Clean and type the grouping columns before grouping, not after. *** ## Best Practices [#best-practices] * Add an explicit result type to every aggregation, rather than leaving it as `any`. * Use `_` (the current group) inside aggregation functions, not the original source table. * Clean and type grouping columns before grouping — a mistake here silently produces extra groups instead of an error. * Prefer `Table.Group` directly (over the UI) once more than one or two aggregations are needed — it's easier to read and maintain as a single step. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Transformations](/docs/power-query/transformations) * [M Language](/docs/power-query/m-language) * [Table.Distinct()](/docs/power-query/table-distinct) — for picking one row per group instead of aggregating * [M Function Reference](/docs/power-query/functions) Reshaping the grouped result between wide and long formats? See [Table.Pivot() & Table.Unpivot()](/docs/power-query/table-pivot-unpivot). # Table.Pivot() & Table.Unpivot() (/docs/power-query/table-pivot-unpivot) # Table.Pivot() & Table.Unpivot() [#tablepivot--tableunpivot] `Table.Pivot()` and `Table.Unpivot()` reshape a table between **wide** (one column per category) and **long** (one row per category) formats — opposite operations, and one of the more genuinely confusing pairs in Power Query for anyone new to it. ```text Wide (Pivoted) Long (Unpivoted) Region | Jan | Feb | Mar Region | Month | Sales West | 100 | 120 | 130 West | Jan | 100 East | 90 | 95 | 105 West | Feb | 120 West | Mar | 130 East | Jan | 90 ... ``` *** ## Table.Unpivot(): Wide to Long [#tableunpivot-wide-to-long] ```powerquery lineNumbers Table.Unpivot( table as table, pivotColumns as list, attributeColumn as text, valueColumn as text ) as table ``` ```powerquery lineNumbers #"Unpivoted" = Table.Unpivot( Source, {"Jan", "Feb", "Mar"}, "Month", "Sales" ) ``` This is the far more common direction in practice — most raw exports (a report with a column per month, per year, or per product) need to become long/tabular before Power BI can model them properly, since a star schema wants one row per fact, not one column per period. *** ## Unpivot Columns vs. Unpivot Other Columns [#unpivot-columns-vs-unpivot-other-columns] The Editor UI offers two buttons that both call `Table.Unpivot`, but with a critical difference in what gets passed as `pivotColumns`: ```text "Unpivot Columns" (columns you selected) | | generates: Table.Unpivot(Source, {"Jan", "Feb", "Mar"}, ...) | A new column added next month ("Apr") is NOT unpivoted — silently missed "Unpivot Other Columns" (right-click, unselected columns) | | generates: Table.UnpivotOtherColumns(Source, {"Region"}, ...) | A new column added next month IS unpivoted automatically ``` `Table.UnpivotOtherColumns()` takes the columns to **keep** as-is, and unpivots everything else — meaning a source that gains a new month column next quarter gets picked up automatically on refresh, with no query changes needed. `Table.Unpivot()` with a fixed list silently ignores any new column instead, since it was never named in the list. **This is the single most common mistake with unpivoting a growing source** — using "Unpivot Columns" on a report that gains a new column periodically means each new period quietly disappears until someone notices the numbers don't add up, rather than erroring visibly. *** ## Table.Pivot(): Long to Wide [#tablepivot-long-to-wide] ```powerquery lineNumbers Table.Pivot( table as table, pivotColumn as list, attributeColumn as any, valueColumn as any, aggregationFunction as nullable function ) as table ``` ```powerquery lineNumbers #"Pivoted" = Table.Pivot( Source, List.Distinct(Source[Month]), "Month", "Sales", List.Sum ) ``` Pivoting is less common in a Power BI model (visuals handle the wide presentation themselves), but comes up when a source needs restructuring to match another system's expected format, or for a specific matrix-style export. *** ## Why Pivot Needs an Aggregation Function [#why-pivot-needs-an-aggregation-function] Going from long to wide only makes sense if there's exactly one value per row/category combination — if there are duplicates, `Table.Pivot()` needs to know how to combine them into the single cell that combination maps to. ```text Region | Month | Sales West | Jan | 100 West | Jan | 50 <- duplicate Region+Month combination Pivoted, West/Jan cell = List.Sum({100, 50}) = 150 ``` Without an aggregation function (or with `null` passed explicitly), Power Query still requires *some* resolution for duplicates — omitting it isn't a shortcut, just an implicit "pick one arbitrarily" that produces inconsistent results depending on row order. *** ## Common Mistakes [#common-mistakes] ### Using "Unpivot Columns" Instead of "Unpivot Other Columns" [#using-unpivot-columns-instead-of-unpivot-other-columns] As covered above — on any source where new columns can appear over time (a new month, a new year, a new category), this is the difference between a query that stays correct automatically and one that silently drops data. ### Forgetting to Retype the Value Column After Unpivoting [#forgetting-to-retype-the-value-column-after-unpivoting] `Table.Unpivot()`'s output value column is typed `Any` by default, since it's now holding whatever mix of types the original wide columns contained. A **Changed Type** step afterward is almost always needed before that column can be used in a measure or comparison. ### Pivoting Without Checking for Duplicate Keys First [#pivoting-without-checking-for-duplicate-keys-first] If duplicates aren't expected but exist due to a data quality issue upstream, `Table.Pivot()` will still run — silently aggregating them per whatever function was supplied, masking a problem that would have been obvious as an error in a stricter tool. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Transformations](/docs/power-query/transformations) * [M Function Reference](/docs/power-query/functions) * [Star Schema](/docs/modeling/star-schema) Getting "There were too many elements in the enumeration to complete the operation"? See [that error explained](/blog/too-many-elements-in-enumeration-pivot-error) — it's this exact duplicate-key situation. # Table.ReplaceValue() (/docs/power-query/table-replacevalue) # Table.ReplaceValue() [#tablereplacevalue] `Table.ReplaceValue()` finds and replaces a value across one or more columns of a table — the function behind **Transform > Replace Values** in the Editor. ```powerquery lineNumbers Table.ReplaceValue( table as table, oldValue as any, newValue as any, replacer as function, columnsToSearch as list ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Replaced Value" = Table.ReplaceValue( Source, "N/A", null, Replacer.ReplaceValue, {"Amount"} ) ``` ```text Amount Amount 1200 1200 N/A -> null <- replaced 850 850 ``` *** ## The Column List Is Not Optional in Practice [#the-column-list-is-not-optional-in-practice] The last argument, `columnsToSearch`, is a required list of column names — the replace only runs against the columns named there, not the whole table. This is the single most common source of confusion with this function. ```powerquery lineNumbers #"Replaced Value" = Table.ReplaceValue( Source, "N/A", null, Replacer.ReplaceValue, {"Amount"} ) ``` If the same placeholder also appears in a `Quantity` column that wasn't listed, it's left completely untouched — no error, no warning, just a silent miss in a column nobody remembered to add to the list. **Fix:** explicitly list every column that could contain the value. ```powerquery lineNumbers #"Replaced Value" = Table.ReplaceValue( Source, "N/A", null, Replacer.ReplaceValue, {"Amount", "Quantity", "Discount"} ) ``` *** ## Replacer.ReplaceValue vs. Replacer.ReplaceText [#replacerreplacevalue-vs-replacerreplacetext] The `replacer` argument controls match behavior, and the two most common options behave differently: | | Replacer.ReplaceValue | Replacer.ReplaceText | | --------------- | ----------------------------------- | ---------------------------------------------- | | Match type | Exact, whole-value match | Substring match, text columns only | | `"N/A"` matches | Only a cell that is exactly `"N/A"` | Any cell **containing** `"N/A"` anywhere in it | | Works on | Any data type | Text only | ```powerquery lineNumbers -- Exact match: only replaces a cell that IS "N/A" Table.ReplaceValue(Source, "N/A", "", Replacer.ReplaceValue, {"Notes"}) -- Substring match: replaces "N/A" wherever it appears within the text Table.ReplaceValue(Source, "N/A", "", Replacer.ReplaceText, {"Notes"}) ``` Using `Replacer.ReplaceValue` on a `Notes` column expecting it to strip `"N/A"` out of a longer sentence like `"Status: N/A for now"` won't do anything — the cell isn't *exactly* `"N/A"`, just contains it. That case needs `Replacer.ReplaceText`. *** ## Common Mistakes [#common-mistakes] ### Assuming It Searches the Whole Table [#assuming-it-searches-the-whole-table] As covered above — a column left off the list is silently skipped, not searched-and-found-nothing. Always double-check the column list against every place the value could actually appear. ### Using ReplaceValue When ReplaceText Was Needed [#using-replacevalue-when-replacetext-was-needed] Expecting an exact-match replacer to catch a substring inside a longer text value — it won't, and it fails silently rather than erroring, since the operation itself is still valid, it just never matches. ### Replacing Text and Non-Text Values With the Same Call [#replacing-text-and-non-text-values-with-the-same-call] `Replacer.ReplaceText` only works on columns typed as `Text` — pointing it at a numeric or date column throws a type error, since there's no "substring" concept for those types. Use `Replacer.ReplaceValue` for anything that isn't text. ### Not Checking for Multiple Placeholder Variants [#not-checking-for-multiple-placeholder-variants] A source with `"N/A"` in some rows often has `"n/a"`, `"-"`, or a blank string elsewhere too, from different people entering data inconsistently. One `Table.ReplaceValue()` call only catches the exact variant it was given — checking for the full set of placeholders actually present (via a quick `Table.Distinct()` on the column) avoids fixing only part of the problem. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [M Function Reference](/docs/power-query/functions) * [Transformations](/docs/power-query/transformations) * [Text.Contains() & Text.Replace()](/docs/power-query/text-contains-replace) — the substring-level version of the same problem Cleaning up placeholder values as part of a broader type-conversion error? See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the fuller pattern, including locale mismatches and hidden whitespace. # Table.SelectColumns() and MissingField (/docs/power-query/table-selectcolumns) # Table.SelectColumns() and MissingField [#tableselectcolumns-and-missingfield] `Table.SelectColumns()` returns a table with only the specified columns, dropping the rest — the function behind **Choose Columns** in the Editor UI. What most people don't discover until a refresh breaks is what happens when one of the requested columns doesn't actually exist. ```powerquery lineNumbers Table.SelectColumns( table as table, columns as any, optional missingField as nullable number ) as table ``` `Table.RemoveColumns()` and `Table.RenameColumns()` accept the same optional `missingField` argument, with the same three behaviors described below. *** ## Basic Example [#basic-example] ```powerquery lineNumbers Table.SelectColumns(Source, {"OrderID", "CustomerID", "Amount"}) ``` If every named column exists in `Source`, this just returns a table with those three columns, in that order. *** ## Requesting a Missing Column Errors by Default [#requesting-a-missing-column-errors-by-default] If `Source` doesn't actually have a column named `"Region"`, requesting it errors the entire step — not just that one column. This is exactly the failure mode when an upstream source quietly drops or renames a column: the query worked yesterday and errors today, with nothing in the query itself having changed. *** ## MissingField.Ignore and MissingField.UseNull [#missingfieldignore-and-missingfieldusenull] ```powerquery lineNumbers Table.SelectColumns(Source, {"OrderID", "CustomerID", "Region"}, MissingField.Ignore) ``` ```powerquery lineNumbers Table.SelectColumns(Source, {"OrderID", "CustomerID", "Region"}, MissingField.UseNull) ``` These are two different fixes for two different intentions: * `MissingField.Ignore` silently drops any requested column that doesn't exist — the result simply has fewer columns than asked for. * `MissingField.UseNull` keeps every requested column, filling a missing one entirely with `null` — the result always has the same column count and names, useful when downstream steps expect a specific, stable column list regardless of what the source actually has. *** ## Common Mistakes [#common-mistakes] ### Reaching for MissingField.Ignore When the Column Is Actually Required [#reaching-for-missingfieldignore-when-the-column-is-actually-required] Silencing the error is easy, but if the query's later steps genuinely depend on that column existing, `MissingField.Ignore` just moves the failure further downstream, to whatever step tries to use a column that's no longer there — usually with a much less obvious error message pointing back to the real cause. ### Not Choosing Between Ignore and UseNull Deliberately [#not-choosing-between-ignore-and-usenull-deliberately] The two options serve different needs — `Ignore` for "this column is genuinely optional," `UseNull` for "this column must exist in the output, even if it's empty." Picking one without considering which guarantee the rest of the query actually needs can just trade one kind of surprise for another. ### Assuming This Also Protects Table.AddColumn or Custom Column References [#assuming-this-also-protects-tableaddcolumn-or-custom-column-references] The `missingField` option only applies to `Table.SelectColumns()`, `Table.RemoveColumns()`, and `Table.RenameColumns()` — a custom column expression that references `[Region]` directly still errors immediately if that column doesn't exist, regardless of how the columns were selected earlier in the query. *** ## Best Practices [#best-practices] * Use `MissingField.UseNull` when downstream steps expect a fixed, stable set of columns regardless of what the source provides. * Use `MissingField.Ignore` only when a column being genuinely optional is the correct behavior for the query, not just to silence an error. * Leave the default (erroring) behavior in place when a missing column should be caught immediately rather than discovered several steps later. *** ## Next Steps [#next-steps] * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [Error Handling in Power Query (try ... otherwise)](/docs/power-query/error-handling) * [M Function Reference](/docs/power-query/functions) # Table.SelectRows() (/docs/power-query/table-selectrows) # Table.SelectRows() [#tableselectrows] `Table.SelectRows()` filters a table down to the rows matching a condition — the function behind every filter arrow in the Power Query Editor. ```powerquery lineNumbers Table.SelectRows( table as table, condition as function ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Filtered Rows" = Table.SelectRows( Source, each [Status] = "Active" ) ``` ```text Status Status Active -> Active <- kept Inactive Active <- kept Active ``` `each [Status] = "Active"` runs once per row, keeping only the rows where it evaluates to `true`. *** ## Combining Multiple Conditions [#combining-multiple-conditions] ```powerquery lineNumbers #"Filtered Rows" = Table.SelectRows( Source, each [Status] = "Active" and [Amount] > 100 ) ``` `and` requires both conditions true; `or` requires at least one. Parentheses control evaluation order exactly like any other logical expression: ```powerquery lineNumbers #"Filtered Rows" = Table.SelectRows( Source, each [Status] = "Active" and ([Region] = "West" or [Region] = "East") ) ``` Without the parentheses, `and` binds tighter than a bare sequence of `or`s might suggest — being explicit with parentheses once a condition has more than two parts avoids relying on precedence rules that aren't always obvious at a glance. *** ## Common Mistakes [#common-mistakes] ### Assuming Text Comparison Is Case-Insensitive [#assuming-text-comparison-is-case-insensitive] ```powerquery lineNumbers each [Status] = "active" ``` M's `=` comparison on text is case-sensitive — `"Active"` and `"active"` are not equal. A source with inconsistent casing silently drops rows that look like they should match. **Fix:** normalize case explicitly when it can't be trusted. ```powerquery lineNumbers each Text.Lower([Status]) = "active" ``` See [Text.Trim(), Text.Upper() & Text.Lower()](/docs/power-query/text-trim-upper-lower) for more on normalizing case, and [Text.Contains() & Text.Replace()](/docs/power-query/text-contains-replace#both-are-case-sensitive-by-default) for the equivalent gotcha with substring checks. ### Confusing null With an Empty String [#confusing-null-with-an-empty-string] ```powerquery lineNumbers each [Notes] = null ``` Unlike SQL, M's `=` comparison works intuitively against `null` — this correctly returns `true` for a genuinely blank cell. The real mistake is assuming every blank-looking cell *is* `null`: a source like a CSV export can produce an empty string `""` instead of a true null for what looks identical in the preview grid. `[Notes] = null` won't match a cell that's actually `""`, and vice versa. **Fix:** check which one is actually present — click a blank-looking cell's value, or add a quick `Table.AddColumn(Source, "Check", each Value.Is([Notes], type null))` — before assuming which comparison applies. ### Filtering After an Expensive Step Instead of Before [#filtering-after-an-expensive-step-instead-of-before] ```text Source -> Added Custom (slow, row-by-row) -> Filtered Rows ``` Placing `Table.SelectRows()` after a slow custom column means the expensive computation runs for rows that get filtered out immediately afterward. Filtering as early as possible — ideally as the very next step after `Source` — reduces the row count before any later step has to process it, and keeps the query eligible for [query folding](/docs/power-query/query-folding) if the filter itself can fold. ### Referencing a Column Before It's Renamed or Created [#referencing-a-column-before-its-renamed-or-created] A filter step referencing `[Status]` fails if it's placed *before* the step that creates or renames that column — M evaluates the table shape as of the step immediately before the filter, not the query's eventual final shape. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Transformations](/docs/power-query/transformations) * [Query Folding](/docs/power-query/query-folding) * [Table.Sort()](/docs/power-query/table-sort) * [Table.FirstN() & Table.Skip()](/docs/power-query/table-firstn-skip) — a real filter over every row, unlike FirstN's condition form * [Value.Type(), Value.Is() & Comparing to null](/docs/power-query/value-type-null) — filtering on `= null` works as expected in M, unlike SQL * [M Function Reference](/docs/power-query/functions) Getting "We cannot convert the value null to type Table"? A filter is rarely the actual cause — see [that error explained](/blog/cannot-convert-null-to-type-table-error) for where it usually really comes from. # Table.Sort() (/docs/power-query/table-sort) # Table.Sort() [#tablesort] `Table.Sort()` orders a table's rows by one or more columns. The most common surprise isn't the function itself — it's that sorting depends entirely on the column's actual type, not on what the values look like. ```powerquery lineNumbers Table.Sort(table as table, comparisonCriteria as any) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Sorted Rows" = Table.Sort(Source, {{"Sales", Order.Descending}}) ``` `comparisonCriteria` is a list of `{"ColumnName", Order.Ascending}` or `{"ColumnName", Order.Descending}` pairs — the function behind the **Sort Ascending** / **Sort Descending** column-header buttons in the Editor UI. *** ## Sorting Text-Typed Numbers Sorts Lexicographically, Not Numerically [#sorting-text-typed-numbers-sorts-lexicographically-not-numerically] A column of numbers stored as `text` sorts character by character, not by numeric value. `"10"` sorts *before* `"2"`, because the character `"1"` comes before `"2"` — the fact that `"10"` is the larger number never enters into it. Try `10, 9, 2, 1` as text — notice `"10"` lands right after `"1"`, not after `"9"`. Then compare it against the same values sorted as actual numbers. *** ## The Fix Is the Column's Type, Not the Sort Step [#the-fix-is-the-columns-type-not-the-sort-step] ```powerquery lineNumbers #"Changed Type" = Table.TransformColumnTypes(Source, {{"OrderID", Int64.Type}}), #"Sorted Rows" = Table.Sort(#"Changed Type", {{"OrderID", Order.Ascending}}) ``` `Table.Sort()` itself has no "numeric mode" switch — it sorts however the column's declared type compares. The fix is converting the column to a real numeric type first with [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes), not adjusting the sort step. *** ## Sorting by Multiple Columns [#sorting-by-multiple-columns] ```powerquery lineNumbers #"Sorted Rows" = Table.Sort(Source, {{"Region", Order.Ascending}, {"Sales", Order.Descending}}) ``` Criteria are applied in list order — this sorts by `Region` first, and only uses `Sales` to break ties within each region, not as an independent second sort. *** ## Common Mistakes [#common-mistakes] ### Sorting a Numeric-Looking Column Before Checking Its Type [#sorting-a-numeric-looking-column-before-checking-its-type] If a sorted column looks obviously wrong (`10` appearing before `2`), the type of that column — not the sort logic — is almost always the actual cause. Check the column header's type icon before assuming `Table.Sort()` is broken. ### Assuming the Sort Step Can Force Numeric Order on Text [#assuming-the-sort-step-can-force-numeric-order-on-text] There's no option on `Table.Sort()` itself to compare text values numerically — the comparison always follows the column's declared type. Converting the type is the only fix. ### Sorting Before a Type Conversion Step That Comes Later in the Query [#sorting-before-a-type-conversion-step-that-comes-later-in-the-query] If a later step converts the column to a number, but the sort happens before that step in the query's step order, the sort still ran against the text values. Move the sort step to after the type conversion. *** ## Best Practices [#best-practices] * Confirm a column's actual type before assuming a sort result is wrong. * Convert a numeric-looking text column with `Table.TransformColumnTypes()` before sorting it, not after. * Order multi-column sort criteria from the most significant column to the least — later criteria only break ties. *** ## Next Steps [#next-steps] * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.Distinct()](/docs/power-query/table-distinct) — sorting first is how you control which row survives a column-scoped dedupe * [Table.AddIndexColumn()](/docs/power-query/table-addindexcolumn) * [Table.FirstN() & Table.Skip()](/docs/power-query/table-firstn-skip) — the condition form depends on row order too * [M Function Reference](/docs/power-query/functions) # Table.SplitColumn() & Table.CombineColumns() (/docs/power-query/table-splitcolumn-combinecolumns) # Table.SplitColumn() & Table.CombineColumns() [#tablesplitcolumn--tablecombinecolumns] `Table.SplitColumn()` and `Table.CombineColumns()` are opposites at the table level — one breaks a single column into several new ones across every row, the other merges several columns back into one. ```powerquery lineNumbers Table.SplitColumn(table as table, sourceColumn as text, splitter as function, optional columnNames as any) as table Table.CombineColumns(table as table, sourceColumns as list, combiner as function, newColumnName as text) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Split Name" = Table.SplitColumn( Source, "Full Name", Splitter.SplitTextByDelimiter(" "), {"First Name", "Last Name"} ) ``` ```text "Full Name" -> "First Name", "Last Name" "Alice Chen" -> "Alice", "Chen" ``` This is the function behind **Split Column > By Delimiter** in the Editor UI when the result is new columns rather than a list — [Text.Split()](/docs/power-query/text-split-combine) does the same splitting on a single value; this applies it across an entire column. Try a row with only one word — the second declared column doesn't disappear or shift the other rows around, it fills with `null` for that row specifically. Try a row with three or more words too — see the section below on why that case is flagged differently. *** ## Fewer Parts Than Declared Columns Fills With null [#fewer-parts-than-declared-columns-fills-with-null] When `columnNames` declares more output columns than a particular row's split actually produces, the missing columns for that row become `null` rather than causing an error or shifting later rows out of alignment. ```text "Full Name" -> "First Name", "Last Name" "Alice Chen" -> "Alice", "Chen" "Madonna" -> "Madonna", null <- only one word, second column is null ``` This is easy to miss in a preview that only shows the first several rows — a column that looks fully populated in the preview can still be silently sparse further down. *** ## More Parts Than Declared Columns Depends on the Splitter [#more-parts-than-declared-columns-depends-on-the-splitter] Unlike the missing-parts case, what happens with *extra* parts isn't a single fixed rule — it depends on which splitter function was used and how the delimiter count was configured (unlimited, or capped to a specific number of pieces). Test this scenario directly against a real example with more delimiters than expected, rather than assuming a universal behavior. *** ## Table.CombineColumns(): The Reverse, With No Ambiguity [#tablecombinecolumns-the-reverse-with-no-ambiguity] ```powerquery lineNumbers #"Combined Name" = Table.CombineColumns( Source, {"First Name", "Last Name"}, Combiner.CombineTextByDelimiter(" "), "Full Name" ) ``` ```text "First Name", "Last Name" -> "Full Name" "Alice", "Chen" -> "Alice Chen" ``` Unlike splitting, combining is always deterministic — every row produces exactly one merged value, regardless of what the source columns contain. A `null` value in one of the source columns becomes an empty string in the merged result rather than propagating as `null` or causing an error. *** ## Common Mistakes [#common-mistakes] ### Assuming Every Row Splits Into the Same Number of Parts [#assuming-every-row-splits-into-the-same-number-of-parts] As covered above — a shorter-than-expected value doesn't shift columns or error, it silently leaves `null` in place. Downstream logic that assumes every row's split columns are all populated should check for `null` explicitly rather than assuming the preview's first few rows represent every row. ### Not Testing the More-Parts-Than-Expected Case [#not-testing-the-more-parts-than-expected-case] Since this behavior depends on the splitter configuration rather than a single fixed rule, it's worth testing directly against a row with more delimiters than expected before trusting the result across a full dataset. ### Forgetting Table.CombineColumns Turns null Into an Empty String [#forgetting-tablecombinecolumns-turns-null-into-an-empty-string] A `null` in one of the source columns doesn't make the combined result `null` — it contributes nothing (an empty string) to that position, which can look like a legitimate value was combined when one was actually missing. *** ## Best Practices [#best-practices] * After `Table.SplitColumn()`, check for unexpected `null` values in the new columns rather than trusting the preview's visible rows. * Test the more-parts-than-expected case directly against your own data before assuming a specific outcome. * Remember `Table.CombineColumns()` always succeeds per row — it can't be used to detect which rows had a missing value in one of the source columns. *** ## Next Steps [#next-steps] * [Text.Split() & Text.Combine()](/docs/power-query/text-split-combine) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [M Function Reference](/docs/power-query/functions) # Table.TransformColumns() (/docs/power-query/table-transformcolumns) # Table.TransformColumns() [#tabletransformcolumns] `Table.TransformColumns()` applies a function to every value in one or more existing columns, replacing each value in place — the workhorse function behind most data-cleaning steps: trimming text, changing case, rounding numbers. ```powerquery lineNumbers Table.TransformColumns( table as table, transformOperations as list, defaultTransformation as nullable function, missingField as nullable number ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Transformed Column" = Table.TransformColumns( Source, {{"Name", Text.Trim}} ) ``` ```text Name Name " Alice " -> "Alice" " Bob" -> "Bob" ``` The second argument is a list of `{"ColumnName", function}` pairs — `Text.Trim` here is passed as a function reference (no parentheses), since `Table.TransformColumns` calls it once per value itself. *** ## Transforming Multiple Columns at Once [#transforming-multiple-columns-at-once] ```powerquery lineNumbers #"Transformed Columns" = Table.TransformColumns( Source, { {"Name", Text.Trim}, {"Email", Text.Lower}, {"Amount", each Number.Round(_, 2)} } ) ``` Each column in the list gets its own transformation, applied independently — `Amount` uses an `each _` expression here specifically because `Number.Round` needs a second argument (decimal places), which a bare function reference can't supply. *** ## Bare Function Reference vs. each \_ [#bare-function-reference-vs-each-_] ```text {"Name", Text.Trim} <- bare reference: Text.Trim takes exactly one argument {"Amount", each Number.Round(_, 2)} <- each _: needed when extra arguments are required ``` A function that takes exactly one argument (the value being transformed) can be passed directly by name. Anything needing additional fixed arguments needs the `each _` form, where `_` stands for the current value. *** ## Table.TransformColumns vs. Table.AddColumn [#tabletransformcolumns-vs-tableaddcolumn] Both run a function per row/value, but for different purposes — covered in more depth in [Table.AddColumn()](/docs/power-query/table-addcolumn#tableaddcolumn-vs-tabletransformcolumns): ```text Table.TransformColumns — replaces an EXISTING column's values, sees only that one value Table.AddColumn — creates a NEW column, sees the whole row via [ColumnName] ``` Trying to combine two columns into a new one with `Table.TransformColumns` doesn't work — the function it calls only ever receives the single value from the column being transformed, never the rest of the row. *** ## Common Mistakes [#common-mistakes] ### Calling the Function Instead of Referencing It [#calling-the-function-instead-of-referencing-it] ```powerquery lineNumbers {"Name", Text.Trim()} ``` This is a syntax error — `Text.Trim()` calls the function immediately with no arguments, rather than passing the function itself for `Table.TransformColumns` to call later, once per value. The bare name `Text.Trim` (no parentheses) is what's needed. ### Using the Wrong Column Name [#using-the-wrong-column-name] ```powerquery lineNumbers Table.TransformColumns(Source, {{"name", Text.Trim}}) ``` Column names are case-sensitive — `"name"` and `"Name"` are different references. This produces a "column not found" error naming the exact (wrong) string used. ### Expecting It to Change the Column's Type Automatically [#expecting-it-to-change-the-columns-type-automatically] ```powerquery lineNumbers {"Amount", Number.From} ``` Converting text to a number doesn't automatically make Power Query treat the column's declared type as numeric afterward — a **Changed Type** step (or the optional fourth argument in some cases) is usually still needed, since `Table.TransformColumns` transforms values, not the column's type metadata. ### Passing a Function That Doesn't Handle Every Existing Value [#passing-a-function-that-doesnt-handle-every-existing-value] ```powerquery lineNumbers {"Amount", each Number.Round(_, 2)} ``` If any value in `Amount` is currently `null` or text instead of a number, `Number.Round` errors on that row. Combining with [try...otherwise](/docs/power-query/error-handling) handles this without failing the whole step: `each try Number.Round(_, 2) otherwise null`. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.AddColumn()](/docs/power-query/table-addcolumn) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) — changing a column's declared type, not its values * [Table.SplitColumn() & Table.CombineColumns()](/docs/power-query/table-splitcolumn-combinecolumns) * [Table.SelectColumns() and MissingField](/docs/power-query/table-selectcolumns) * [Transformations](/docs/power-query/transformations) * [M Function Reference](/docs/power-query/functions) # Table.TransformColumnTypes() (/docs/power-query/table-transformcolumntypes) # Table.TransformColumnTypes() [#tabletransformcolumntypes] `Table.TransformColumnTypes()` sets the data type of one or more columns — the function behind the **Changed Type** step that appears in nearly every query, usually generated automatically the moment a new source is connected. ```powerquery lineNumbers Table.TransformColumnTypes( table as table, typeTransformations as list, culture as nullable text ) as table ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Changed Type" = Table.TransformColumnTypes( Source, {{"OrderDate", type date}, {"Amount", type number}, {"CustomerID", Int64.Type}} ) ``` ```text OrderDate Amount CustomerID "2026-01-15" "150.5" "1001" | | | date number whole number ``` Each entry in the list is a `{"ColumnName", type}` pair — every column not mentioned keeps its existing type. *** ## The Third Argument: culture [#the-third-argument-culture] ```powerquery lineNumbers #"Changed Type with Locale" = Table.TransformColumnTypes( Source, {{"Amount", type number}}, "de-DE" ) ``` This is the fix behind [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) — without an explicit `culture`, Power Query parses text using its own default locale, which can silently misread a number like `"1.234,56"` (valid in most of continental Europe) or parse an ambiguous date like `"03/04/2026"` into the wrong day, with no error at all for the date case. ```text No culture argument -> uses Power Query's default locale Explicit culture -> parses using that locale's actual number/date format ``` Supplying `culture` explicitly is the difference between a conversion that happens to work because the source's format matches the default, and one that's correct regardless of what locale the machine running the refresh is set to. *** ## Table.TransformColumnTypes vs. Table.TransformColumns [#tabletransformcolumntypes-vs-tabletransformcolumns] These two names differ by exactly one word, and are the single most commonly confused pair of function names in Power Query. | | Table.TransformColumnTypes | Table.TransformColumns | | --------------- | ------------------------------------- | ----------------------------------------------- | | Changes | The column's declared **type** | The column's **values**, via a function | | Second argument | `{{"Column", type}}` — a type literal | `{{"Column", function}}` — a function reference | | Typical use | Setting/fixing a column's data type | Cleaning up values (trim, case, rounding) | ```powerquery lineNumbers -- Sets the TYPE to number; doesn't change the underlying text formatting Table.TransformColumnTypes(Source, {{"Amount", type number}}) -- Applies a FUNCTION to every value; doesn't touch the column's declared type Table.TransformColumns(Source, {{"Amount", each Number.Round(_, 2)}}) ``` Passing a function where a type is expected (or vice versa) produces an error naming the mismatch — the two functions aren't interchangeable despite the similar signatures. See [Table.TransformColumns()](/docs/power-query/table-transformcolumns) for the value-transforming half of this pair. *** ## Common Mistakes [#common-mistakes] ### Assuming the Default Locale Always Matches the Source [#assuming-the-default-locale-always-matches-the-source] As covered above — this is the root cause behind most silent or loud "couldn't convert" errors. If the source data's number or date format doesn't match Power Query's current default locale, converting without an explicit `culture` argument is the actual bug, not the data itself. ### Confusing This With Table.TransformColumns by Name [#confusing-this-with-tabletransformcolumns-by-name] Reaching for `Table.TransformColumnTypes` when the goal is actually to clean up values (not change type), or vice versa, produces confusing errors about type mismatches rather than the intended transformation. ### Referencing a Column That's Been Renamed or Removed Upstream [#referencing-a-column-thats-been-renamed-or-removed-upstream] ```powerquery lineNumbers Table.TransformColumnTypes(Source, {{"CustAmount", type number}}) ``` If an earlier step renamed `CustAmount` to `Amount`, this fails with a column-not-found error — the type list has to match the table's actual column names as of the step immediately before this one. ### Setting a Type That Doesn't Match What's Actually There [#setting-a-type-that-doesnt-match-whats-actually-there] Declaring a column `type date` when the source occasionally contains genuinely non-date text (not just a formatting mismatch) still errors on those specific rows — `Table.TransformColumnTypes()` doesn't silently coerce unparseable values, it fails on them, same as any other type conversion. See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the other three causes beyond locale. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [Number.Round(), Number.RoundUp() & Number.RoundDown()](/docs/power-query/number-functions) * [Csv.Document()](/docs/power-query/csv-document) * [Table.Sort()](/docs/power-query/table-sort) — why a numeric-looking text column sorts in the wrong order * [M Function Reference](/docs/power-query/functions) Getting "We couldn't convert to Number" or a date that's off by a few days? See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error) for the full set of causes, including the locale mismatch above. # Text.Contains() & Text.Replace() (/docs/power-query/text-contains-replace) # Text.Contains() & Text.Replace() [#textcontains--textreplace] `Text.Contains()` checks whether a text value holds a given substring; `Text.Replace()` swaps every occurrence of one substring for another. Both are case-sensitive by default, which is the single most common surprise with either. ```powerquery lineNumbers Text.Contains(text as text, substring as text, optional comparer as nullable function) as logical Text.Replace(text as text, oldText as text, newText as text) as text ``` *** ## Text.Contains(): Substring Check [#textcontains-substring-check] ```powerquery lineNumbers Text.Contains("Invoice #12345", "12345") ``` ```text Result: true ``` ```powerquery lineNumbers #"Filtered Rows" = Table.SelectRows(Source, each Text.Contains([Notes], "urgent")) ``` A common use inside [Table.SelectRows()](/docs/power-query/table-selectrows) — flagging or filtering rows based on a keyword appearing anywhere within a longer text field. *** ## Text.Replace(): Substring Swap [#textreplace-substring-swap] ```powerquery lineNumbers Text.Replace("2026-01-15", "-", "/") ``` ```text "2026-01-15" -> "2026/01/15" ``` Every occurrence of `oldText` gets replaced — there's no built-in "replace only the first occurrence" option; a specific occurrence needs to be isolated first (via `Text.PositionOf` and substring extraction) if only one instance should change. *** ## Both Are Case-Sensitive by Default [#both-are-case-sensitive-by-default] ```powerquery lineNumbers Text.Contains("URGENT REVIEW", "urgent") ``` ```text Result: false <- exact case doesn't match ``` **Fix:** normalize case on both sides before comparing, or pass an explicit comparer. ```powerquery lineNumbers Text.Contains("URGENT REVIEW", "urgent", Comparer.OrdinalIgnoreCase) ``` `Comparer.OrdinalIgnoreCase` is the argument most people don't know exists — it does the case-insensitive comparison directly, without needing a separate `Text.Lower()` call on both sides first. *** ## Common Mistakes [#common-mistakes] ### Assuming Text.Contains Is Case-Insensitive [#assuming-textcontains-is-case-insensitive] As covered above — this is the most common false negative in a filter step: rows that clearly contain the keyword in a different case get silently excluded, with no error to flag it. ### Using Text.Replace When the Match Needs to Be Exact-Whole-Value [#using-textreplace-when-the-match-needs-to-be-exact-whole-value] ```powerquery lineNumbers Text.Replace([Status], "N/A", "") ``` If `[Status]` is genuinely just `"N/A"` and the goal is to blank it out entirely, this works — but if `[Status]` could be a longer string that merely *contains* `"N/A"` somewhere (`"Status: N/A pending review"`), this replaces just that substring, leaving the rest of the text intact, which may or may not be the intended behavior. For a whole-value replacement instead of substring, [Table.ReplaceValue()](/docs/power-query/table-replacevalue) with `Replacer.ReplaceValue` is the more precise tool. ### Forgetting Text.Contains Returns a Logical, Not the Match Itself [#forgetting-textcontains-returns-a-logical-not-the-match-itself] `Text.Contains()` answers "does it contain this," as `true`/`false` — it doesn't return *what* matched or *where*. Extracting the actual matching portion needs `Text.PositionOf()` combined with `Text.Middle()`, not `Text.Contains()` alone. ### Chaining Multiple Text.Replace Calls Instead of a Single Pass [#chaining-multiple-textreplace-calls-instead-of-a-single-pass] ```powerquery lineNumbers Text.Replace(Text.Replace(Text.Replace([Notes], "N/A", ""), "TBD", ""), "-", "") ``` Each nested call scans the full string again — fine for a handful of replacements, but worth being aware this isn't a single combined pass; a longer chain of replacements on a large text column is doing that many full string scans per row. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.ReplaceValue()](/docs/power-query/table-replacevalue) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [List.Distinct() & List.Contains()](/docs/power-query/list-distinct-contains) — the same case-sensitivity trap, for lists * [M Function Reference](/docs/power-query/functions) # Text.Split() & Text.Combine() (/docs/power-query/text-split-combine) # Text.Split() & Text.Combine() [#textsplit--textcombine] `Text.Split()` and `Text.Combine()` are opposites: one breaks a single text value into a list of pieces, the other joins a list of text values back into one. ```powerquery lineNumbers Text.Split(text as text, separator as text) as list Text.Combine(list as list, optional separator as nullable text) as text ``` *** ## Text.Split(): One Value Into Many [#textsplit-one-value-into-many] ```powerquery lineNumbers Text.Split("2026-01-15", "-") ``` ```text "2026-01-15" -> {"2026", "01", "15"} ``` This is the function behind **Split Column > By Delimiter** in the Editor UI, when splitting into a list rather than directly into table columns. *** ## Text.Combine(): Many Values Into One [#textcombine-many-values-into-one] ```powerquery lineNumbers Text.Combine({"2026", "01", "15"}, "-") ``` ```text {"2026", "01", "15"} -> "2026-01-15" ``` The separator argument is optional — omitting it concatenates with no separator at all, which is rarely what's actually wanted. *** ## Splitting Into Table Columns vs. a List [#splitting-into-table-columns-vs-a-list] `Text.Split()` on its own produces a list, not new table columns. Splitting a table column directly uses [Table.SplitColumn()](/docs/power-query/functions), which wraps the same splitting logic but reshapes the result back into the table: ```powerquery lineNumbers #"Split Column" = Table.SplitColumn( Source, "FullDate", Splitter.SplitTextByDelimiter("-"), {"Year", "Month", "Day"} ) ``` `Splitter.SplitTextByDelimiter()` here is doing conceptually the same job as `Text.Split()`, just packaged for `Table.SplitColumn()`'s specific interface rather than called directly. *** ## Common Mistakes [#common-mistakes] ### Assuming a Fixed Number of Parts [#assuming-a-fixed-number-of-parts] ```powerquery lineNumbers Text.Split("A-B-C-D", "-") ``` ```text "A-B" -> {"A", "B"} (2 parts) "A-B-C-D" -> {"A", "B", "C", "D"} (4 parts) ``` `Text.Split()` returns however many parts the delimiter actually produces — a downstream step assuming exactly 2 or 3 elements breaks silently or errors the moment a row has a different number of delimiters than expected. `Table.SplitColumn()` with a fixed column-name list has the same issue: extra parts get dropped, and missing parts leave `null` in the unfilled columns. ### Consecutive Delimiters Producing Empty Strings [#consecutive-delimiters-producing-empty-strings] ```powerquery lineNumbers Text.Split("A,,B", ",") ``` ```text "A,,B" -> {"A", "", "B"} ``` A double delimiter (from a source with genuinely missing values in a delimited field) produces an empty string element, not a skipped one — worth accounting for explicitly if blank entries shouldn't just become `""` downstream. Try typing two separators back to back, or changing how many appear — the item count and the empty-string chips update live instead of just being described. ### Combining a List That Contains null [#combining-a-list-that-contains-null] ```powerquery lineNumbers Text.Combine({"A", null, "B"}, ", ") ``` This errors — `Text.Combine()` requires every list element to be text, and `null` isn't text. A list built from a column that can contain blanks needs those `null` values converted to `""` first, typically via `List.Transform(theList, each if _ = null then "" else _)`. ### Forgetting the Separator Is Optional, Not Automatic [#forgetting-the-separator-is-optional-not-automatic] ```powerquery lineNumbers Text.Combine({"John", "Doe"}) ``` ```text Result: "JohnDoe" <- no space, no separator at all ``` Omitting the second argument doesn't insert a sensible default like a space — it concatenates with nothing between elements. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.ReplaceValue()](/docs/power-query/table-replacevalue) * [Table.SplitColumn() & Table.CombineColumns()](/docs/power-query/table-splitcolumn-combinecolumns) — the same split/combine idea applied to whole table columns * [M Function Reference](/docs/power-query/functions) # Text.Start(), Text.End(), Text.Middle() & Text.Length() (/docs/power-query/text-substring-functions) # Text.Start(), Text.End(), Text.Middle() & Text.Length() [#textstart-textend-textmiddle--textlength] These four functions extract a portion of a text value based on position and length — the basic toolkit for pulling a fixed-format code, a prefix, or a suffix out of a larger text field. ```powerquery lineNumbers Text.Start(text as text, count as number) as text Text.End(text as text, count as number) as text Text.Middle(text as text, start as number, optional count as nullable number) as text Text.Length(text as text) as number ``` *** ## Text.Start() and Text.End() [#textstart-and-textend] ```powerquery lineNumbers Text.Start("INV-2026-0042", 3) Text.End("INV-2026-0042", 4) ``` ```text Text.Start(..., 3) -> "INV" Text.End(..., 4) -> "0042" ``` Both count from the respective end of the string — `Text.Start` from the beginning, `Text.End` from the end — and simply return fewer characters than requested if the text is shorter than `count`, rather than erroring. *** ## Text.Middle(): Zero-Indexed Starting Position [#textmiddle-zero-indexed-starting-position] ```powerquery lineNumbers Text.Middle("INV-2026-0042", 4, 4) ``` ```text Position: 0123456789... Text: INV-2026-0042 ^^^^ Text.Middle(..., 4, 4) -> "2026" ``` The `start` argument is **zero-indexed** — position `4` is the 5th character, not the 4th. This is the single most common off-by-one mistake with this function: counting positions starting from 1 (as most people naturally do) produces a result shifted by one character from what was intended. The `count` argument is optional — omitting it returns everything from `start` to the end of the string, equivalent to a variable-length `Text.End()`. Try changing Start to `5` instead of `4` — the highlighted range shifts one character late, which is exactly the off-by-one mistake described below. *** ## Text.Length(): Combining With a Variable End Position [#textlength-combining-with-a-variable-end-position] ```powerquery lineNumbers Text.Middle([Code], 4, Text.Length([Code]) - 4) ``` This extracts everything from position 4 onward, computing the length dynamically rather than hardcoding it — useful when the meaningful part of a text value has a fixed *start* but a variable total length across rows. *** ## Common Mistakes [#common-mistakes] ### Off-by-One From Treating Position as 1-Indexed [#off-by-one-from-treating-position-as-1-indexed] ```powerquery lineNumbers Text.Middle("INV-2026-0042", 5, 4) -- meant to start at the 5th character ``` ```text Expected: "2026" (starting at the 5th character, position 4 zero-indexed) Actually got: "026-" (started one character too late) ``` Counting the 5th character as `start = 5` instead of `start = 4` is the recurring error here — the fix is remembering position `0` is the first character, the same convention `List` and `Table` row/column indexing uses elsewhere in M. ### Assuming a Fixed Length That Doesn't Hold for Every Row [#assuming-a-fixed-length-that-doesnt-hold-for-every-row] `Text.Start([Code], 3)` assumes every value in `[Code]` has at least a 3-character meaningful prefix — a shorter value doesn't error, it just returns less than expected, which can silently produce wrong-looking results rather than an obvious failure. ### Using Text.Middle When Text.End Would Be Simpler [#using-textmiddle-when-textend-would-be-simpler] ```powerquery lineNumbers Text.Middle([Code], Text.Length([Code]) - 4, 4) ``` This correctly extracts the last 4 characters, but `Text.End([Code], 4)` does the identical thing more directly — worth reaching for the simpler function when the extraction is genuinely anchored to one end of the string, saving the `Text.Length` arithmetic for cases that actually need a computed middle position. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Text.Split() & Text.Combine()](/docs/power-query/text-split-combine) * [LEFT(), RIGHT() & MID() in DAX](/docs/dax/mid-left-right) — the DAX equivalents, 1-indexed instead of 0-indexed * [M Function Reference](/docs/power-query/functions) # Text.Trim(), Text.Upper() & Text.Lower() (/docs/power-query/text-trim-upper-lower) # Text.Trim(), Text.Upper() & Text.Lower() [#texttrim-textupper--textlower] These three are the most-used text cleanup functions in Power Query — removing stray whitespace and normalizing case before a value is compared, grouped, or joined against another source. ```powerquery lineNumbers Text.Trim(text as text, optional trimChars as any) as text Text.Upper(text as text) as text Text.Lower(text as text) as text ``` *** ## Basic Example [#basic-example] ```powerquery lineNumbers #"Trimmed" = Table.TransformColumns(Source, {{"Name", Text.Trim}}) ``` ```text " Alice " -> "Alice" ``` Passed as a bare function reference to [Table.TransformColumns()](/docs/power-query/table-transformcolumns), `Text.Trim` runs once per value with no extra arguments needed — the default behavior trims standard spaces, tabs, and line breaks from both ends. *** ## Text.Trim Doesn't Catch Everything That Looks Like Whitespace [#texttrim-doesnt-catch-everything-that-looks-like-whitespace] A non-breaking space (Unicode `00A0`) — common in data copy-pasted from a web page or exported from certain legacy systems — looks identical to a normal space but isn't one, and plain `Text.Trim()` leaves it in place. ```text "1234 " <- trailing normal space, Text.Trim removes it "1234 " <- trailing non-breaking space, Text.Trim does NOT remove it ``` **Fix:** replace the non-breaking space explicitly before trimming. ```powerquery lineNumbers each Text.Trim(Text.Replace(_, "#(00A0)", " ")) ``` See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error#cause-4-hidden-whitespace-or-non-breaking-spaces) for this exact pattern in the context of a type-conversion error it silently causes. *** ## Text.Upper / Text.Lower for Comparison, Not Just Display [#textupper--textlower-for-comparison-not-just-display] ```powerquery lineNumbers each Text.Lower([Status]) = "active" ``` Comparisons in M are case-sensitive by default — `"Active"` and `"active"` don't match. Converting both sides of a comparison to a consistent case is the standard fix, covered in more depth in [Table.SelectRows()](/docs/power-query/table-selectrows#assuming-text-comparison-is-case-insensitive). *** ## The Optional trimChars Argument [#the-optional-trimchars-argument] ```powerquery lineNumbers Text.Trim("**Featured**", {"*"}) ``` ```text "**Featured**" -> "Featured" ``` Supplying a list of characters trims those specific characters from both ends instead of whitespace — useful for stripping a consistent wrapping character (asterisks, quotes) that a source system adds around certain values. *** ## Common Mistakes [#common-mistakes] ### Assuming Text.Trim Handles Every Whitespace-Like Character [#assuming-texttrim-handles-every-whitespace-like-character] As covered above — non-breaking spaces are the most common exception, but any Unicode character that merely *looks* like whitespace in a preview grid needs its own explicit `Text.Replace()` before `Text.Trim()` will catch it. ### Overwriting the Original Case When Only Comparison Needed It [#overwriting-the-original-case-when-only-comparison-needed-it] ```powerquery lineNumbers #"Changed Case" = Table.TransformColumns(Source, {{"CustomerName", Text.Upper}}) ``` Doing this to enable a case-insensitive match elsewhere permanently destroys the original casing in the output — if the value still needs to display in its original form, convert case only inside the comparison expression itself (`Text.Lower([Status]) = "active"`), not as a standing transformation of the column. ### Using Text.Upper/Text.Lower for Proper Case [#using-textuppertextlower-for-proper-case] Neither function produces "Title Case" or "Proper Case" (capitalizing just the first letter of each word) — that needs `Text.Proper()`, a related but different function, not a combination of `Text.Upper` and `Text.Lower`. *** ## Next Steps [#next-steps] * [M Language](/docs/power-query/m-language) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [TRIM(), UPPER() & LOWER() in DAX](/docs/dax/trim-upper-lower) — DAX's TRIM() also collapses internal spaces * [M Function Reference](/docs/power-query/functions) # Transformations (/docs/power-query/transformations) # Transformations [#transformations] A transformation is one step that changes the shape or content of a table — removing a column, filtering rows, splitting text, and so on. ```text Raw Table | | transformation | Cleaner Table ``` Every transformation applied through the Power Query Editor's UI is recorded as a step in the **Applied Steps** pane. *** ## Removing and Reordering Columns [#removing-and-reordering-columns] ```text Before: ID | Name | Notes | Status After: ID | Name | Status ``` Right-click a column header and choose **Remove**, or select the columns to keep and choose **Remove Other Columns**. Column order can be changed by dragging headers. *** ## Filtering Rows [#filtering-rows] Row filters work like a spreadsheet's column filter, keeping only rows that match a condition. ```text Before: Status ------- Active Inactive Active After filtering Status = "Active": Status ------- Active Active ``` Applied from the dropdown arrow on a column header, the same way filtering works in Excel. *** ## Changing Data Types [#changing-data-types] Power Query infers a type for each column, but it's often necessary to correct it — a date stored as text, or a number stored as text with formatting. ```text "1,250" (text) | | Change Type -> Whole Number | 1250 (number) ``` Set from the column header's type icon, or the **Transform > Data Type** menu. *** ## Splitting Columns [#splitting-columns] A single column can be split into multiple columns, by a delimiter or a fixed number of characters. ```text Before: Full Name ------------ Alice Smith After splitting by space: First Name | Last Name -----------|------------ Alice | Smith ``` Available from **Transform > Split Column**. *** ## Merging Columns [#merging-columns] The reverse of splitting — combining two or more columns into one. ```text First Name | Last Name Full Name -----------|------------ -> ------------ Alice | Smith Alice Smith ``` Available from **Transform > Merge Columns**, with a chosen separator. *** ## Pivoting and Unpivoting [#pivoting-and-unpivoting] **Unpivot** turns columns into rows, commonly needed when source data arrives in a wide, spreadsheet-style format. ```text Before (wide): Product | Jan | Feb | Mar --------|------|------|------ Tire A | 100 | 120 | 90 After unpivoting: Product | Month | Sales --------|-------|------- Tire A | Jan | 100 Tire A | Feb | 120 Tire A | Mar | 90 ``` Unpivoted data fits Power BI's star schema model far better than a wide, one-column-per-period layout. **Pivot** does the reverse — turning row values into columns — though it's used less often, since most Power BI models prefer the long/unpivoted shape. *** ## Grouping [#grouping] **Group By** aggregates rows, similar to a `GROUP BY` in SQL. ```text Before: Category | Sales ---------|------- Bikes | 100 Bikes | 150 Gear | 80 After grouping by Category, summing Sales: Category | Total Sales ---------|------------- Bikes | 250 Gear | 80 ``` Available from **Transform > Group By**, with a choice of aggregation (sum, count, average, and others). *** ## Replacing Values [#replacing-values] Replaces specific values throughout a column, useful for standardizing inconsistent source data. ```text Before: "USA", "U.S.A", "United States" After: "United States", "United States", "United States" ``` Applied from **Transform > Replace Values**. *** ## Adding Custom Columns [#adding-custom-columns] A new column can be computed from an M expression referencing other columns. `Custom Column: [Quantity] * [Unit Price]` Available from **Add Column > Custom Column**, useful for calculations that should happen at load time rather than as a DAX measure. *** ## Best Practices [#best-practices] * Filter rows and remove unneeded columns as early as possible, so later steps process less data. * Prefer unpivoted (long) data over wide, one-column-per-period layouts for anything feeding a star schema. * Use **Group By** in Power Query for aggregations needed at load time, not as a workaround for DAX. * Name each step something meaningful instead of leaving the auto-generated "Changed Type1" style names. *** ## Common Mistakes [#common-mistakes] ### Transforming Wide Data Without Unpivoting [#transforming-wide-data-without-unpivoting] Loading spreadsheet-style wide data (one column per month) directly into the model makes it awkward to filter, slice, and aggregate. Unpivoting first fixes this at the source. ### Reordering Steps Without Checking Dependencies [#reordering-steps-without-checking-dependencies] Moving a step earlier or later in the Applied Steps list can break later steps that assumed a certain column name or type was already in place. ### Doing Heavy Transformation Work After Load [#doing-heavy-transformation-work-after-load] Trying to fix a poorly-shaped table with DAX after it's already loaded is usually far more complex than fixing the shape in Power Query before load. *** ## Transformation Checklist [#transformation-checklist] Before loading a query into the model: * Unnecessary columns and rows are removed. * Data types are explicitly set, not left to automatic inference alone. * Wide, spreadsheet-style data has been unpivoted where appropriate. * Step names are descriptive enough for someone else to follow later. *** ## Next Steps [#next-steps] Continue learning Power Query: * [Power Query Editor](/docs/power-query/editor) * [Merge Queries](/docs/power-query/merge-queries) * [Query Folding](/docs/power-query/query-folding) * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.AddColumn()](/docs/power-query/table-addcolumn) * [Table.TransformColumns()](/docs/power-query/table-transformcolumns) * [Table.Pivot() & Table.Unpivot()](/docs/power-query/table-pivot-unpivot) * [Table.ReplaceValue()](/docs/power-query/table-replacevalue) * [Working with Dates in Power Query](/docs/power-query/date-functions) Type conversion failing on numbers or dates that look fine? See [We Couldn't Convert to Number (or Date)](/blog/couldnt-convert-to-number-date-error). # Value.Type(), Value.Is() & Comparing to null (/docs/power-query/value-type-null) # Value.Type(), Value.Is() & Comparing to null [#valuetype-valueis--comparing-to-null] `Value.Type()` and `Value.Is()` are the two functions for inspecting and checking a value's type at runtime — mainly useful for debugging an unexpected-type error rather than everyday transformation logic. ```powerquery lineNumbers Value.Type(value as any) as type Value.Is(value as any, type as type) as logical ``` *** ## Value.Type() for Debugging [#valuetype-for-debugging] ```powerquery lineNumbers Value.Type([OrderDate]) ``` ```text [OrderDate] is a date column -> type date [OrderDate] is actually text -> type text ``` Dropping this into a custom column temporarily is a quick way to confirm what a value's *actual* type is when a downstream step is failing in a way that suggests it isn't what the column header claims. *** ## Value.Is() for Checking a Type [#valueis-for-checking-a-type] ```powerquery lineNumbers Value.Is([OrderDate], type date) ``` `Value.Is()` is the idiomatic way to check whether a value matches a given type — more direct than trying to compare `Value.Type()`'s result against a type literal. *** ## null = null Is true — Unlike SQL [#null--null-is-true--unlike-sql] In SQL, comparing `NULL = NULL` produces `UNKNOWN`, not `TRUE` — SQL's three-valued logic treats an unknown value as never equal to another unknown value, even itself. Power Query M doesn't work this way: `null` is a genuine, comparable value, and `null = null` evaluates to `true`. ```text null = null -> true <- different from SQL null = 5 -> false 5 = 5 -> true ``` This matters most when filtering or checking for blanks: `each [Column] = null` in a `Table.SelectRows()` call works as most people intuitively expect in M, without needing a SQL-style `IS NULL` construct. *** ## Common Mistakes [#common-mistakes] ### Assuming null Behaves Like SQL's NULL [#assuming-null-behaves-like-sqls-null] Bringing SQL's "NULL is never equal to anything, including itself" assumption into M leads to overcomplicating a null check — a plain `= null` comparison already does the right thing here. ### Using = to Compare Value.Type() Results Instead of Value.Is() [#using--to-compare-valuetype-results-instead-of-valueis] Comparing `Value.Type()`'s output directly against a type literal with `=` is less idiomatic and less reliable across more complex or nested types than just calling `Value.Is()`, which is built specifically for this check. ### Treating Value.Type() as Something to Use in Normal Transformation Logic [#treating-valuetype-as-something-to-use-in-normal-transformation-logic] `Value.Type()` and `Value.Is()` are debugging and validation tools, not typically something a production query's main transformation logic depends on — reaching for them repeatedly in a query's core steps is usually a sign the underlying column types need fixing upstream instead. *** ## Next Steps [#next-steps] * [Table.SelectRows()](/docs/power-query/table-selectrows) * [Table.TransformColumnTypes()](/docs/power-query/table-transformcolumntypes) * [Error Handling in Power Query (try ... otherwise)](/docs/power-query/error-handling) * [M Function Reference](/docs/power-query/functions) # Web.Contents() (/docs/power-query/web-contents) # Web.Contents() [#webcontents] `Web.Contents()` fetches raw content from a URL — the function behind every web-based connector, from a simple public JSON endpoint to an authenticated REST API. ```powerquery lineNumbers Web.Contents( url as text, optional options as nullable record ) as binary ``` It returns raw binary content — almost always piped straight into `Json.Document()` or `Xml.Tables()` to parse the response into something Power Query can work with. *** ## Basic Example [#basic-example] ```powerquery lineNumbers Source = Json.Document(Web.Contents("https://api.example.com/products")) ``` ```text Web.Contents fetches raw bytes | | Json.Document parses them | A record or list, ready to convert to a table with Table.FromRecords or similar ``` *** ## Query Parameters: Use the Query Option, Not String Concatenation [#query-parameters-use-the-query-option-not-string-concatenation] ```powerquery lineNumbers Source = Json.Document( Web.Contents( "https://api.example.com/products", [Query = [category = "bikes", limit = "50"]] ) ) ``` This builds `?category=bikes&limit=50` correctly, including proper URL encoding — string-concatenating query parameters onto the URL directly works for simple cases but breaks silently the moment a value contains a character that needs encoding. *** ## RelativePath: Why It Matters for the Service [#relativepath-why-it-matters-for-the-service] ```powerquery lineNumbers Source = Json.Document( Web.Contents( "https://api.example.com", [RelativePath = "products", Query = [category = "bikes"]] ) ) ``` Splitting the URL into a base (`https://api.example.com`) and a `RelativePath` looks equivalent to writing the full URL directly, but it isn't — the Power BI Service's data source credentials are matched against the **base URL only**. A query with the full URL baked in for every different endpoint registers as a separate data source per endpoint; using `RelativePath` keeps every call to the same API registered under one data source, with one set of credentials. *** ## Headers and Authentication [#headers-and-authentication] ```powerquery lineNumbers Source = Json.Document( Web.Contents( "https://api.example.com/products", [Headers = [#"Authorization" = "Bearer " & apiKey, #"Accept" = "application/json"]] ) ) ``` For anything beyond a public, unauthenticated endpoint, credentials typically belong in **Data Source Settings** (Web API / Anonymous / API Key credential types) rather than hard-coded directly in the query — hard-coding a key here means it travels with the query text itself, including into version control if the .pbix is stored there. *** ## Handling Non-200 Responses [#handling-non-200-responses] By default, any HTTP response outside the 200 range throws an error, which stops the query entirely. `ManualStatusHandling` opts out of that, letting the query inspect the response itself. ```powerquery lineNumbers Response = Web.Contents( "https://api.example.com/products", [ManualStatusHandling = {404, 500}] ), StatusCode = Value.Metadata(Response)[Response.Status] ``` Useful for an API where a 404 is a meaningful, expected response (e.g., "no data for this date") rather than a genuine failure that should stop the refresh. *** ## Common Mistakes [#common-mistakes] ### Concatenating Query Parameters Into the URL String [#concatenating-query-parameters-into-the-url-string] `"https://api.example.com/products?category=" & category` works until `category` contains a space, an `&`, or another character that needs URL encoding — the `Query` record option handles this automatically. ### Using the Full URL Instead of RelativePath [#using-the-full-url-instead-of-relativepath] Baking every endpoint into a full literal URL means the Power BI Service sees each one as a separate data source needing its own credentials — a query that calls ten different endpoints on the same API ends up needing ten credential entries instead of one. ### Not Handling Formula.Firewall When Combining With Other Sources [#not-handling-formulafirewall-when-combining-with-other-sources] Passing a value from another query or source into `Web.Contents()` per row (inside a custom column, for example) is exactly the cross-source pattern that triggers a privacy-level error — see [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error) for why, and the fix. *** ## Best Practices [#best-practices] * Use the `Query` record option for query parameters, never manual string concatenation. * Split the URL into a base and `RelativePath` so the Service registers one data source per API, not one per endpoint. * Keep credentials in Data Source Settings, not hard-coded into the query text. * Use `ManualStatusHandling` only for response codes that are genuinely expected and meaningful, not to silently swallow real failures. *** ## Next Steps [#next-steps] Continue learning Power Query: * [List.Generate](/docs/power-query/list-generate) * [M Language](/docs/power-query/m-language) * [M Function Reference](/docs/power-query/functions) * [Json.Document()](/docs/power-query/json-document) — parsing the response this usually feeds into Getting a "Formula.Firewall" error combining this with another source? See [Formula.Firewall and Privacy Level Errors](/blog/formula-firewall-privacy-level-error). Getting "Web.Contents can only accept a literal string as the first parameter"? See [that error explained](/blog/web-contents-literal-string-parameter-error) for the RelativePath fix. # Bookmarks & Interactivity (/docs/visuals/bookmarks) # Bookmarks & Interactivity [#bookmarks--interactivity] Power BI reports are not static images. Bookmarks, drill-through, and tooltips let users navigate and explore data without leaving the page. ```text Interactivity | +-- Bookmarks | +-- Drill-through | +-- Tooltips ``` *** ## What a Bookmark Captures [#what-a-bookmark-captures] A bookmark saves the current state of a report page. ```text Bookmark | +-- Filter and slicer selections | +-- Visibility of visuals | +-- Sort order | +-- Focus mode / spotlight state ``` Clicking a bookmark restores that exact state, even if the user has since changed filters or navigated around the page. *** ## Creating a Bookmark [#creating-a-bookmark] Bookmarks are managed from the **View > Bookmarks** pane. Steps: 1. Set the report page to the state you want to capture (filters, visible visuals, slicers). 2. Open the **Bookmarks** pane. 3. Select **Add**. 4. Rename the bookmark to something descriptive, such as `Regional View` or `Q4 Only`. Each bookmark is a snapshot. Updating the report later does not update existing bookmarks automatically. *** ## Bookmark Options [#bookmark-options] Each bookmark can optionally capture: | Option | Captures | | ------------ | --------------------------------- | | Data | Filters, slicers, and selections | | Display | Which visuals are shown or hidden | | Current page | Which report page is active | Turning off **Data** creates a bookmark that only changes visual layout, without altering filters — useful for building simple show/hide toggles. *** ## Using Bookmarks for Navigation [#using-bookmarks-for-navigation] A common pattern uses bookmarks to build in-report navigation buttons. ```text Button: "Sales" | | linked to | Bookmark: Sales View ``` Each button on a navigation bar is linked to a different bookmark, letting users switch between curated views without separate report pages. *** ## Bookmark Groups [#bookmark-groups] Related bookmarks can be organized into a group. ```text Bookmark Group: Regions | +-- West | +-- East | +-- Central ``` Grouping keeps a set of mutually exclusive bookmarks (like regional views) organized and easy to wire up to buttons. *** ## Drill-through [#drill-through] Drill-through sends a user from a summary page to a detail page, automatically filtered to what they clicked. ```text Summary Page | | right-click a data point | Detail Page (filtered) ``` Setting it up: 1. Create a detail page. 2. Add the field to filter by (such as `Product`) to the **Drill through** field well. 3. Right-click a value on the summary page and select **Drill through**. The detail page opens already filtered to the value that was clicked. *** ## Tooltips [#tooltips] Tooltips show extra information when hovering over a data point. ```text Hover over a bar | | Tooltip appears ``` Power BI supports two kinds: * **Default tooltips** — automatically show the field values behind a data point. * **Report page tooltips** — a small custom report page, shown as a tooltip, that can include its own visuals and formatting. Report page tooltips are commonly used to show a mini trend chart or breakdown without adding clutter to the main visual. *** ## Combining Bookmarks and Buttons [#combining-bookmarks-and-buttons] Buttons and bookmarks are often paired to build interactive elements like toggles and navigation menus. ```text Button: "Show Details" | | Action: Bookmark | Bookmark: Details Visible ``` A second button, paired with a second bookmark, can toggle back to the summary state — creating a simple show/hide interaction with no DAX required. *** ## Best Practices [#best-practices] * Name bookmarks descriptively; "Bookmark 1" becomes unmanageable quickly. * Use bookmark groups to keep mutually exclusive views organized. * Turn off **Data** capture for bookmarks that should only affect layout. * Keep drill-through detail pages focused on one entity (a single product, customer, or order). * Use report page tooltips sparingly, since each one adds a small amount of report complexity. *** ## Common Mistakes [#common-mistakes] ### Forgetting to Update Bookmarks [#forgetting-to-update-bookmarks] Bookmarks capture a snapshot. Adding a new visual or filter to a page later does not automatically appear in bookmarks created before the change — they need to be updated manually. ### Overusing Report Page Tooltips [#overusing-report-page-tooltips] A custom tooltip page still needs to render every time it's shown. Overusing them across many visuals can noticeably slow down report interactions. ### Missing Drill-through Filters [#missing-drill-through-filters] If the field used for drill-through isn't present on the summary visual, right-click drill-through won't be available for that data point. *** ## Interactivity Checklist [#interactivity-checklist] Before publishing an interactive report: * Bookmarks are named clearly and grouped logically. * Navigation buttons are linked to the correct bookmarks. * Drill-through pages are filtered correctly and focused on one entity. * Report page tooltips are used only where they add real value. * Bookmarks have been re-tested after any layout or filter changes. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals and reports: * [Slicers](/docs/visuals/slicers) * [Formatting](/docs/visuals/formatting) * [Charts](/docs/visuals/charts) # Charts (/docs/visuals/charts) # Charts [#charts] The right chart makes a pattern obvious. The wrong one hides it behind decoration. ```text Data | | shaped by | Chart Type | | determines | What the audience notices first ``` Power BI includes dozens of visual types, but most reports only need a handful of them used well. *** ## Bar and Column Charts [#bar-and-column-charts] Bar and column charts compare values across categories. ```text Category A |████████ Category B |████████████ Category C |█████ ``` Use column charts (vertical bars) for a small number of categories, and bar charts (horizontal bars) when category names are long or there are many categories to list. *** ## Line Charts [#line-charts] Line charts show a value changing over a continuous axis, almost always time. ```text Sales | * | * * | * * +------------------- Time ``` Line charts are the default choice for trends: monthly revenue, daily active users, year-over-year growth. *** ## Combo Charts [#combo-charts] A combo chart overlays a line and columns on the same axis, useful for comparing two related measures with different scales. ```text Columns: Sales Line: Target | ██ ██ | ██ ─●─ ██ ─●─ +---------------------- ``` Common pairing: actual values as columns, target or budget as a line. *** ## Pie and Donut Charts [#pie-and-donut-charts] Pie and donut charts show parts of a whole. ```text ___ / \ | 60% | Category A | 25% | Category B \15%/ Category C ``` They work well with 2-4 categories. Beyond that, the slices become too thin to compare accurately — a bar chart communicates the same data more clearly. *** ## Scatter Charts [#scatter-charts] Scatter charts plot two numeric measures against each other, revealing correlation or clusters. ```text Profit | * * | * * * | * * +------------------- Revenue ``` Adding a third measure as bubble size turns a scatter chart into a bubble chart. *** ## KPI and Card Visuals [#kpi-and-card-visuals] Cards and KPI visuals show a single number, optionally with a trend or target comparison. ```text ┌─────────────┐ │ $1.2M │ │ Total Sales│ │ ▲ 12% vs LY│ └─────────────┘ ``` Best for the one or two numbers that matter most on a page — an executive summary metric, not a detailed breakdown. *** ## Choosing a Chart Type [#choosing-a-chart-type] | Question | Recommended Chart | | ------------------------------------------ | ----------------- | | Comparing categories? | Bar or column | | Showing a trend over time? | Line | | Comparing two related measures? | Combo | | Showing parts of a whole (few categories)? | Pie or donut | | Showing correlation between two numbers? | Scatter | | Highlighting one key number? | Card / KPI | *** ## Adding a Chart [#adding-a-chart] Steps: 1. Select a chart type from the **Visualizations** pane. 2. Drag fields into the appropriate wells (**Axis**, **Legend**, **Values**). 3. Adjust formatting under the paint-roller icon in the Visualizations pane. Power BI also supports **Q\&A**-driven chart creation — typing a question generates a matching chart automatically, which can be swapped to a different visual type afterward. *** ## Best Practices [#best-practices] * Match the chart type to the question being answered, not to what looks most interesting. * Limit the number of categories or series a single chart tries to show at once. * Use consistent colors for the same category across every chart on a page. * Avoid 3D and heavily decorated chart styles; they make values harder to compare accurately. * Add clear titles that state the takeaway, not just the field names being plotted. *** ## Common Mistakes [#common-mistakes] ### Using Pie Charts for Too Many Categories [#using-pie-charts-for-too-many-categories] Beyond four or five slices, a pie chart becomes difficult to read accurately. A bar chart handles more categories clearly. ### Dual Axes That Mislead [#dual-axes-that-mislead] Combo charts with two differently-scaled axes can visually suggest a relationship between two measures that doesn't actually exist. Label both axes clearly, or reconsider the pairing. ### Too Many Chart Types on One Page [#too-many-chart-types-on-one-page] Mixing many different visual types on a single page increases the cognitive effort needed to read it. A consistent, limited set of chart types is easier to scan. *** ## Chart Checklist [#chart-checklist] Before publishing a report page: * Each chart type matches the question it's answering. * Colors are consistent for the same category across all charts. * Axes and legends are clearly labeled. * No chart is trying to show more categories than it can display clearly. * Titles communicate the takeaway, not just the underlying fields. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals: * [Tables](/docs/visuals/tables) * [Formatting](/docs/visuals/formatting) * [Bookmarks & Interactivity](/docs/visuals/bookmarks) # Field Parameters (/docs/visuals/field-parameters) # Field Parameters [#field-parameters] A field parameter lets a report viewer switch what a visual is showing — which measure, which column, which dimension — using a slicer, instead of needing a separate visual for every combination. ```text Slicer: "Sales" / "Profit" / "Quantity" | | selection swaps | Chart's Y-axis ``` One chart, driven by a field parameter, can replace what would otherwise be several nearly-identical visuals. *** ## What a Field Parameter Contains [#what-a-field-parameter-contains] A field parameter is a small table where each row represents one field the user can choose. ```text Field Parameter: "Metric" Field | Order ---------------|------- [Total Sales] | 0 [Total Profit] | 1 [Total Units] | 2 ``` Despite looking like a table, it isn't real data — it's a list of references to existing measures or columns already in the model. *** ## Creating a Field Parameter [#creating-a-field-parameter] Field parameters are created from **Modeling > New Parameter > Fields** in Power BI Desktop. ```text New Parameter (Fields) | +-- Add measures/columns to include | +-- Creates a table + a slicer visual automatically ``` Power BI automatically adds a slicer to the current page, bound to the new parameter. *** ## Using a Field Parameter in a Visual [#using-a-field-parameter-in-a-visual] Instead of dragging a specific measure into a chart's Values well, drag the field parameter itself. ```text Chart Values: [Metric parameter] | | resolves to whichever field is selected | [Total Sales] or [Total Profit] or [Total Units] ``` Whichever row the user selects in the paired slicer determines what the chart actually plots. *** ## Swapping Dimensions, Not Just Measures [#swapping-dimensions-not-just-measures] Field parameters aren't limited to measures — they can also list columns, letting a report swap the axis or grouping field itself. ```text Field Parameter: "Breakdown By" Field | Order -------------------|------- DimProduct[Category] | 0 DimStore[Region] | 1 DimDate[Year] | 2 ``` A single chart can then be grouped by Category, Region, or Year, depending on which one the viewer picks. *** ## Combining Multiple Field Parameters [#combining-multiple-field-parameters] A report page can use more than one field parameter at once — for example, one controlling the measure and another controlling the breakdown dimension. ```text Metric parameter: [Total Sales] / [Total Profit] Breakdown parameter: Category / Region / Year | | both feed the same chart | 2D matrix of chart configurations from one visual ``` This turns a handful of report pages into a single flexible page, at the cost of some added setup complexity. *** ## Renaming Parameter Labels [#renaming-parameter-labels] The labels shown in the slicer don't have to match the underlying field names — a calculated column on the parameter table can rename them for a friendlier display. ```text Field | Order | Display Name ---------------|-------|--------------- [Total Sales] | 0 | "Revenue" [Total Profit] | 1 | "Profit" ``` Edited directly in the field parameter table, similar to any other Power Query or DAX table edit. *** ## Field Parameters vs. Bookmarks [#field-parameters-vs-bookmarks] | Aspect | Field Parameters | Bookmarks | | -------- | ----------------------------------------- | --------------------------------- | | Swaps | Which field a visual uses | Filter state, visibility, layout | | Best for | "Show me this metric instead of that one" | "Show me this view of the report" | | Setup | One parameter table + slicer | One bookmark per state | The two are often combined: bookmarks for overall report views, field parameters for flexibility within a single visual. *** ## Best Practices [#best-practices] * Use descriptive display names in the parameter table instead of raw measure names. * Keep the number of fields in a single parameter manageable — a slicer with 20 options isn't very usable. * Pair field parameters with a clear slicer label so users understand what they're switching. * Test that every field included in the parameter actually makes sense in the visual it's driving (a text column plotted as a numeric axis won't work). *** ## Common Mistakes [#common-mistakes] ### Mixing Incompatible Field Types [#mixing-incompatible-field-types] Including both measures and text columns in the same field parameter, when a visual expects consistent field types, produces confusing or broken results when certain options are selected. ### Too Many Options in One Slicer [#too-many-options-in-one-slicer] A field parameter with a long list of fields turns a helpful slicer into a hard-to-scan dropdown. Group related fields into separate parameters instead. ### Forgetting to Rename Display Labels [#forgetting-to-rename-display-labels] Leaving raw measure names like "Sum of SalesAmount2" in the slicer, instead of a clean display name, makes the switcher harder to use than it needs to be. *** ## Field Parameters Checklist [#field-parameters-checklist] Before publishing a report using field parameters: * Display names are clean and understandable, not raw measure/column names. * All included fields are compatible with how the visual will use them. * The number of options per parameter stays manageable for a slicer. * The paired slicer has a clear label explaining what it controls. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals: * [Slicers](/docs/visuals/slicers) * [Bookmarks & Interactivity](/docs/visuals/bookmarks) * [Charts](/docs/visuals/charts) # Formatting (/docs/visuals/formatting) # Formatting [#formatting] Good formatting makes a report easier to read. It doesn't make it prettier for its own sake. ```text Raw Visual | | formatting | Clear, scannable Visual ``` Power BI's Format pane controls everything from colors and fonts to axis labels and data labels. *** ## The Format Pane [#the-format-pane] Every visual has its own Format pane, opened from the paint-roller icon next to the Visualizations pane. ```text Visual | | paint-roller icon | Format pane | +-- General | +-- Visual-specific options ``` Options vary by visual type — a chart has axis and legend settings that a table doesn't, and vice versa. *** ## Data Labels [#data-labels] Data labels display the underlying value directly on a chart, instead of requiring the reader to compare against an axis. ```text ██ 1,200 ████ 2,400 ███ 1,800 ``` Useful when exact values matter more than the general shape of the trend. Overusing them on a dense chart can clutter it — sometimes the axis alone communicates the pattern better. *** ## Color [#color] Consistent color use across a report helps readers recognize the same category everywhere it appears. ```text "West" region | | same color | Every chart in the report ``` Power BI supports theme-based coloring, so a single color palette can apply automatically across all visuals. *** ## Report Themes [#report-themes] A theme is a saved set of colors, fonts, and default visual styles, applied report-wide. ```text Theme | +-- Color palette | +-- Font family | +-- Default visual styles ``` Applied from **View > Themes**, either using a built-in theme or a custom JSON theme file for brand-matched colors. *** ## Conditional Formatting [#conditional-formatting] Conditional formatting changes a visual element's appearance based on its value — a data bar, a color scale, or an icon. ```text Value > target -> green Value near target -> yellow Value < target -> red ``` Available on table/matrix cells, and on some chart elements, from the field's context menu. *** ## Axis Formatting [#axis-formatting] Axis settings control scale, gridlines, and label formatting. ```text Y-axis: 0 -- 500K -- 1M X-axis: Jan Feb Mar Apr ``` Common adjustments include starting the Y-axis at zero (to avoid exaggerating differences), and formatting large numbers with K/M abbreviations for readability. *** ## Titles and Subtitles [#titles-and-subtitles] A clear title states what the visual shows — ideally the takeaway, not just the field names. ```text Weak title: "Sales by Region" Better title: "West Region Leads in Q4 Sales" ``` Subtitles can add supporting context, like the date range being shown. *** ## Alignment and Spacing [#alignment-and-spacing] Consistent alignment and spacing between visuals makes a page feel intentional rather than assembled ad hoc. ```text +--------+ +--------+ | Chart | | Chart | +--------+ +--------+ +--------+ +--------+ | Chart | | Chart | +--------+ +--------+ ``` Power BI's **Format > Align** and **Distribute** tools (with multiple visuals selected) snap objects into consistent rows and columns. *** ## Best Practices [#best-practices] * Apply a single report theme instead of manually coloring every visual individually. * Keep color meaning consistent — the same category should always be the same color. * Start numeric axes at zero unless there's a specific, clearly labeled reason not to. * Write titles that state the takeaway, not just the fields being plotted. * Use alignment and distribution tools instead of manually eyeballing visual placement. *** ## Common Mistakes [#common-mistakes] ### Inconsistent Colors Across Visuals [#inconsistent-colors-across-visuals] If "West" is blue on one chart and green on another, readers have to relearn the color mapping on every visual. A shared theme prevents this. ### Truncated Axes That Mislead [#truncated-axes-that-mislead] A Y-axis that doesn't start at zero can make a small difference look dramatic. This is one of the most common ways a chart accidentally misleads its audience. ### Over-Formatting [#over-formatting] Excessive borders, shadows, background colors, and decorative elements compete with the data for attention. Formatting should support the data, not distract from it. *** ## Formatting Checklist [#formatting-checklist] Before publishing a report: * A consistent theme is applied across the whole report. * The same category uses the same color everywhere it appears. * Numeric axes start at zero unless there's a clear, labeled reason not to. * Titles communicate the takeaway, not just the field names. * Visuals are aligned and evenly spaced using Power BI's alignment tools. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals: * [Charts](/docs/visuals/charts) * [Tables](/docs/visuals/tables) * [Slicers](/docs/visuals/slicers) # Visuals (/docs/visuals) # Visuals [#visuals] The right calculation still needs the right visual to be understood. This section covers choosing between chart types, configuring tables and slicers well, and the interactivity features (bookmarks, drill-through, field parameters) that make a report feel designed rather than assembled. ## Start Here [#start-here] # Slicers (/docs/visuals/slicers) # Slicers [#slicers] A slicer is a visible, clickable filter placed directly on a report page. ```text Slicer: Region [ West ] [ East ] [ Central ] | | selection filters | Every visual on the page ``` Unlike filters hidden in the Filters pane, slicers put filtering directly in front of the report's audience. *** ## Adding a Slicer [#adding-a-slicer] Steps: 1. Select the **Slicer** visual from the **Visualizations** pane. 2. Drag a field into the **Field** well. 3. Resize and position it on the page. Any field can back a slicer — categories, dates, or numeric ranges. *** ## Slicer Types [#slicer-types] Power BI supports several slicer layouts, chosen from the **Format** pane. ```text Slicer | +-- List (checkboxes) | +-- Dropdown | +-- Between (numeric range) | +-- Relative date ``` List slicers work well for a small number of options; dropdown slicers save space when there are many. *** ## Date Slicers [#date-slicers] A date field backing a slicer can use a range slider or relative date options. ```text [====●========●====] Jan 1 Dec 31 ``` Relative date slicers ("Last 30 days," "This year") are especially useful for reports that should always show recent data without manual adjustment. *** ## Single vs. Multi-Select [#single-vs-multi-select] Slicers can allow either one selection at a time or multiple selections. ```text Single-select: only "West" active Multi-select: "West" and "East" both active ``` Multi-select is the default; single-select is useful when a report is designed around viewing exactly one category at a time. *** ## Syncing Slicers Across Pages [#syncing-slicers-across-pages] A slicer normally only filters the page it's on. **Sync slicers** extends its filter to other pages too. ```text Slicer (Page 1) | | Sync Slicers | Page 2, Page 3 also filtered ``` Configured from **View > Sync slicers**, useful for a consistent filter (like a date range) that should apply everywhere in the report. *** ## Slicer Panels [#slicer-panels] A dedicated slicer panel — a strip of slicers along one edge of the page, synced across every page — gives a report a consistent global filtering experience. ```text +------------------------------------------+ | [Region] [Year] [Category] | +------------------------------------------+ | | | Report content | | | +------------------------------------------+ ``` This is a common pattern for enterprise reports where users expect the same filters available on every page. *** ## Clearing Slicer Selections [#clearing-slicer-selections] Slicers include a small eraser icon (when enabled) that resets the selection. `Slicer [selected: West] [⟲ clear]` Enabled from the Format pane under **Slicer header**, this saves users from manually deselecting every option. *** ## Best Practices [#best-practices] * Use a small, consistent set of slicers rather than one for every possible field. * Sync commonly-needed slicers (like date range) across all report pages. * Prefer relative date slicers for reports meant to always show current data. * Enable the clear/reset button so users can easily return to an unfiltered view. * Keep slicer panels visually distinct from the report content, so users can immediately tell what's a filter versus what's data. *** ## Common Mistakes [#common-mistakes] ### Too Many Slicers [#too-many-slicers] A page covered in a dozen slicers overwhelms users and takes up space better used for visuals. Keep it to the handful of filters people actually need. ### Forgetting to Sync [#forgetting-to-sync] A date slicer that only filters one page, when the report has five pages, creates a confusing experience where "today's date range" silently stops applying after the first page. ### Hardcoded Date Ranges [#hardcoded-date-ranges] A slicer set to a specific date range (like "2025") requires manual updates every year. A relative date slicer avoids that maintenance entirely. *** ## Slicer Checklist [#slicer-checklist] Before publishing a report with slicers: * Slicer count is kept small and purposeful. * Commonly-needed slicers are synced across all relevant pages. * Date slicers use relative ranges where the report should stay current. * A clear/reset option is available. * Slicer panels are visually distinct from report content. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals: * [Charts](/docs/visuals/charts) * [Tables](/docs/visuals/tables) * [Bookmarks & Interactivity](/docs/visuals/bookmarks) # Tables (/docs/visuals/tables) # Tables [#tables] Charts show patterns. Tables show exact numbers. ```text Chart | | "sales are trending up" | Table | | "March was exactly $84,213.50" ``` Power BI offers two grid-style visuals: **Table** and **Matrix**. *** ## Table Visual [#table-visual] A Table lists rows and columns, similar to a spreadsheet. ```text Product | Category | Sales ----------|----------|------- Tire A | Bikes | 250 Tire B | Bikes | 400 Helmet A | Gear | 180 ``` Best for a flat list of detailed records — no grouping, no hierarchy. *** ## Matrix Visual [#matrix-visual] A Matrix adds row and column grouping, similar to a pivot table. ```text | 2025 | 2026 --------------|---------|-------- Bikes | 45,000 | 52,000 Tire A | 20,000 | 24,000 Tire B | 25,000 | 28,000 Gear | 12,000 | 15,000 ``` Best when data needs to be summarized and drilled into by category, region, or time period. *** ## Table vs. Matrix [#table-vs-matrix] | Aspect | Table | Matrix | | ---------- | --------------------- | ----------------------------- | | Structure | Flat rows | Grouped rows and columns | | Drill-down | No | Yes | | Subtotals | No | Yes | | Best for | Detailed record lists | Summarized, hierarchical data | *** ## Adding a Table or Matrix [#adding-a-table-or-matrix] Steps: 1. Select **Table** or **Matrix** from the **Visualizations** pane. 2. Drag fields into **Rows** (and **Columns**, for a Matrix). 3. Drag numeric fields into **Values**. For a Matrix, dragging multiple fields into **Rows** creates a drill-down hierarchy (for example, Category, then Product). *** ## Drilling in a Matrix [#drilling-in-a-matrix] Matrix rows built from a hierarchy can be expanded or collapsed. ```text Category (collapsed) | | expand | Category +-- Product ``` Users can drill down one level at a time, or expand everything at once using the drill controls above the visual. *** ## Conditional Formatting [#conditional-formatting] Both Table and Matrix support conditional formatting, applying color scales, data bars, or icons based on cell values. ```text Sales 1,200 ██████████ (green) 450 ████ (yellow) -80 █ (red) ``` Configured from the field's context menu under **Conditional formatting**, directly on the Values field. *** ## Totals and Subtotals [#totals-and-subtotals] A Matrix can show subtotals for each group, and a grand total for the whole visual. ```text Bikes | 45,000 ... Gear | 12,000 ... Total | 57,000 ``` Totals and subtotals can be toggled independently in the **Format** pane, under **Subtotals**. *** ## Best Practices [#best-practices] * Use Table for detailed, non-hierarchical lists; use Matrix when grouping and drilling matter. * Turn off unnecessary subtotal levels to reduce visual clutter. * Use conditional formatting sparingly, on the one or two columns where it actually helps the reader. * Sort columns in the order that best supports the reader's task, not just alphabetically. * Avoid cramming so many columns into a table that it requires horizontal scrolling on a typical screen. *** ## Common Mistakes [#common-mistakes] ### Using a Table When a Matrix Fits Better [#using-a-table-when-a-matrix-fits-better] A flat Table with repeated category values in every row is often better expressed as a grouped Matrix, which avoids the repetition and adds useful subtotals. ### Too Many Columns [#too-many-columns] A wide table that requires horizontal scrolling is hard to scan. Consider whether every column is necessary, or whether some belong in a drill-through detail page instead. ### Overusing Conditional Formatting [#overusing-conditional-formatting] Applying color scales to every numeric column turns the table into visual noise instead of highlighting what actually matters. *** ## Table Checklist [#table-checklist] Before publishing a report page with tables: * Table vs. Matrix was chosen based on whether grouping is needed. * Subtotals are shown only where they add value. * Conditional formatting highlights the columns that matter most. * Column count and width fit without requiring horizontal scrolling. * Sort order supports how the reader will actually use the table. *** ## Next Steps [#next-steps] Continue exploring Power BI visuals: * [Charts](/docs/visuals/charts) * [Slicers](/docs/visuals/slicers) * [Formatting](/docs/visuals/formatting) # Aggregations (/docs/modeling/aggregations) # Aggregations [#aggregations] An aggregation table stores pre-summarized data at a coarser grain than the detailed fact table, so common queries can be answered from a small Import table instead of hitting a large DirectQuery source every time. ```text Detail (DirectQuery) FactSales: 500 million rows, one per transaction Aggregation (Import) AggSalesByMonth: 5,000 rows, one per month/product/store ``` A query that only needs monthly totals can be answered from the small aggregation table almost instantly, instead of scanning half a billion detail rows. *** ## Where Aggregations Fit [#where-aggregations-fit] Aggregations are most valuable in composite models where the fact table is too large to import in full. ```text FactSales (DirectQuery, 500M rows) | | too large to import | AggSalesByMonth (Import, 5K rows) | | answers common summary queries instantly ``` See [DirectQuery vs. Import](/docs/modeling/storage-modes) for the broader storage mode picture aggregations build on. *** ## How Query Matching Works [#how-query-matching-works] When a visual's query can be fully answered by the aggregation table, Power BI uses it automatically. When it needs more detail than the aggregation provides, Power BI falls back to the DirectQuery fact table. ```text Visual needs: Sales by Month | | matches aggregation grain | Answered by AggSalesByMonth (fast) Visual needs: Sales by individual Transaction ID | | too detailed for the aggregation | Falls back to FactSales (DirectQuery) ``` This fallback happens transparently — report authors don't need to manually pick which table a visual should query. *** ## Setting Up an Aggregation Table [#setting-up-an-aggregation-table] An aggregation table is built like any other table (often via Power Query, grouping the detail source), then configured through **Manage Aggregations** on the table itself. ```text Manage Aggregations | +-- Summarization: Sum, Count, Min, Max, GroupBy | +-- Detail Table: FactSales | +-- Detail Column: which column each agg column summarizes ``` Each column in the aggregation table is mapped to a summarization type and the detail-table column it summarizes. *** ## Example Mapping [#example-mapping] ```text Aggregation Column | Summarization | Detail Table Column -------------------------|----------------|---------------------- Month | GroupBy | FactSales[DateKey] ProductKey | GroupBy | FactSales[ProductKey] Total Sales | Sum | FactSales[SalesAmount] Transaction Count | Count | FactSales[TransactionID] ``` `GroupBy` columns define the aggregation's grain; `Sum`/`Count`/etc. columns define what gets pre-calculated. *** ## Aggregation Table Storage Mode [#aggregation-table-storage-mode] The aggregation table itself is typically set to Import (or Dual), while the detail table stays DirectQuery. ```text AggSalesByMonth: Import FactSales: DirectQuery ``` This is the same Import/DirectQuery split used in composite models generally — the aggregation just adds a specific, pre-summarized shortcut on top. *** ## Hiding the Aggregation Table [#hiding-the-aggregation-table] End users shouldn't need to know an aggregation table exists — it's an internal performance optimization, not something to browse directly. ```text AggSalesByMonth | | hidden from report view | Still used automatically behind the scenes ``` Hidden from the Fields list via the table's **Is Hidden** property, while still participating in query matching. *** ## Aggregations vs. Plain Import [#aggregations-vs-plain-import] | Aspect | Full Import | Aggregation Table | | ---------------- | ---------------------- | ------------------------------------ | | Data volume | Entire fact table | Pre-summarized subset | | Detail available | Full detail | Only down to the aggregation's grain | | Refresh cost | High, for large tables | Low, since it's much smaller | | Fallback needed | No | Yes, for queries needing more detail | Aggregations trade some detail-level flexibility for dramatically faster common queries, while DirectQuery still covers the cases that need full detail. *** ## Best Practices [#best-practices] * Build aggregations at the grain that matches the most common report queries (often by month, region, or category). * Keep the aggregation table hidden from report authors and consumers. * Validate that fallback to DirectQuery actually works correctly for queries needing more detail than the aggregation provides. * Monitor which queries hit the aggregation vs. fall back, to confirm the aggregation grain matches real usage. *** ## Common Mistakes [#common-mistakes] ### Aggregating at the Wrong Grain [#aggregating-at-the-wrong-grain] An aggregation table that doesn't match how reports actually query the data (too fine or too coarse) provides little benefit — most queries still fall back to the slow DirectQuery path. ### Leaving the Aggregation Table Visible [#leaving-the-aggregation-table-visible] Exposing the aggregation table directly to report authors invites confusion about which table to use, and risks building visuals against the wrong grain. ### Assuming Aggregations Replace DirectQuery Entirely [#assuming-aggregations-replace-directquery-entirely] Aggregations only accelerate queries that match their grain. Detail-level analysis still depends on the underlying DirectQuery source performing reasonably well. *** ## Aggregations Checklist [#aggregations-checklist] Before relying on an aggregation table in production: * The aggregation's grain matches the most common report query patterns. * Detail table fallback has been tested and returns correct results. * The aggregation table is hidden from the Fields list. * Refresh performance for the (much smaller) aggregation table has been validated. *** ## Next Steps [#next-steps] Continue exploring Power BI data modeling: * [DirectQuery vs. Import](/docs/modeling/storage-modes) * [Star Schema](/docs/modeling/star-schema) * [Fact Tables](/docs/modeling/fact-tables) # Bridge Tables (/docs/modeling/bridge-tables) # Bridge Tables [#bridge-tables] A bridge table is a junction table that sits between two tables with a many-to-many relationship, turning two ambiguous joins into two clean one-to-many joins. ```text DimSalesperson (one) | | one : many | BridgeSalespersonAccount (many) | | many : one | DimAccount (one) ``` Instead of connecting `DimSalesperson` and `DimAccount` directly, the bridge table stores one row per valid pairing between them. *** ## Why Bridge Tables Exist [#why-bridge-tables-exist] Some relationships genuinely can't be expressed as one-to-many. A salesperson can cover several accounts, and an account can be covered by several salespeople. ```text DimSalesperson DimAccount | | +---- many : many ------+ ``` Power BI can create this as a native many-to-many relationship directly, but a bridge table keeps every relationship in the model one-to-many, which is easier to filter correctly and easier to extend later. See [Many-to-Many Relationships](/docs/modeling/many-to-many) for a comparison of the two approaches. *** ## Structure of a Bridge Table [#structure-of-a-bridge-table] A bridge table is narrow — typically just the keys from both sides, plus any attributes specific to the pairing itself. ```text BridgeSalespersonAccount SalespersonKey AccountKey AssignmentStartDate ``` Example data: | SalespersonKey | AccountKey | AssignmentStartDate | | -------------- | ---------- | ------------------- | | 1 | 100 | 2024-01-01 | | 1 | 101 | 2024-03-15 | | 2 | 100 | 2024-06-01 | Account 100 has two salespeople assigned to it; salesperson 1 has two accounts. Neither side is unique on its own — the bridge table is what makes each individual relationship one-to-many. *** ## How Filtering Flows Through a Bridge Table [#how-filtering-flows-through-a-bridge-table] ```text DimSalesperson | | filter: Salesperson = "Alice" v BridgeSalespersonAccount | | rows where SalespersonKey matches Alice v DimAccount | | only Alice's accounts remain v FactRevenue ``` Selecting a salesperson filters the bridge table down to that salesperson's rows, which in turn filters the accounts — and any fact table connected to those accounts — down to a matching set. *** ## A Second Common Case: Products in Multiple Categories [#a-second-common-case-products-in-multiple-categories] ```text DimProduct (one) | BridgeProductCategory (many) | DimCategory (one) ``` | ProductKey | CategoryKey | | ---------- | ----------- | | 1001 | 10 | | 1001 | 11 | | 1002 | 10 | Product 1001 belongs to two categories. Filtering by `CategoryKey = 11` returns only product 1001, without needing a many-to-many relationship set directly on `DimProduct`. *** ## Bridge Tables and Double-Counting [#bridge-tables-and-double-counting] Because a single fact row can be reachable through more than one bridge row, a naive `SUM` can double-count if the fact table sits behind the bridge rather than in front of it. ```text FactRevenue (one row per sale) | BridgeSalespersonAccount (many rows per account) | DimAccount ``` If revenue is placed on the many side of the bridge, the same revenue row can be counted once per matching bridge row. Keep the fact table upstream of the bridge — filtered by it, not joined through it as if it were another dimension — to avoid this. *** ## Best Practices [#best-practices] * Keep bridge tables narrow: just the keys needed to connect both sides, plus attributes specific to the relationship. * Give bridge tables a clear name that states what they connect, like `BridgeSalespersonAccount` or `BridgeProductCategory`. * Watch for double-counting when a fact table sits on the many side of a bridge relationship — validate totals against a known-correct number. * Prefer a bridge table over a native many-to-many relationship whenever the relationship itself carries attributes, or more than two tables are involved. *** ## Common Mistakes [#common-mistakes] ### Putting a Fact Table on the Many Side of a Bridge [#putting-a-fact-table-on-the-many-side-of-a-bridge] This can silently double-count measures whenever a single fact row is reachable through more than one bridge row. ### Making the Bridge Table Too Wide [#making-the-bridge-table-too-wide] A bridge table that accumulates unrelated descriptive columns starts acting like a second dimension table, which defeats the purpose of keeping it a narrow join table. ### Skipping Validation Against Known Totals [#skipping-validation-against-known-totals] Many-to-many patterns are one of the easier places to introduce silent double-counting. Always check a bridged measure against a total computed independently. *** ## Bridge Table Checklist [#bridge-table-checklist] * The bridge table contains only keys and relationship-specific attributes. * Both relationships to the bridge table are one-to-many, not many-to-many. * Measures passing through the bridge have been validated against a known-correct total. * The bridge table's name clearly states which two tables it connects. *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Many-to-Many Relationships](/docs/modeling/many-to-many) * [Relationships](/docs/modeling/relationships) * [Dimension Tables](/docs/modeling/dimension-tables) See it applied end to end: [Build a Requirements Traceability Matrix Dashboard](/tutorials/build-a-requirements-traceability-matrix) uses a real bridge table to connect requirements to test cases, start to finish. # Composite Models (/docs/modeling/composite-models) # Composite Models [#composite-models] [DirectQuery vs. Import](/docs/modeling/storage-modes) introduced composite models as mixing storage modes within a single source. Composite models go further than that — they can also combine **multiple, independent data sources** in one model, each queried on its own terms. ```text Power BI Model | +-- Sales (DirectQuery -> SQL Server) | +-- Budget (Import -> Excel) | +-- DimDate (Dual) ``` This page covers the multi-source case, and the specific limitations and performance considerations that come with it. *** ## Single-Source vs. Multi-Source Composite Models [#single-source-vs-multi-source-composite-models] A single-source composite model mixes storage modes on tables that all come from the same underlying database — the common Dual-dimension-plus-DirectQuery-fact pattern from [Storage Modes](/docs/modeling/storage-modes). ```text Single-Source Composite | SQL Server | +-- FactSales (DirectQuery) +-- DimDate (Dual) Multi-Source Composite | +-- SQL Server: FactSales (DirectQuery) +-- Excel File: Budget (Import) +-- SQL Server: DimDate (Dual) ``` A multi-source composite model pulls tables from genuinely different systems into one model, which is where composite modeling's real power — and its extra constraints — show up. *** ## Why Combine Multiple Sources [#why-combine-multiple-sources] Multi-source composite models solve a problem neither Import nor DirectQuery alone can: reporting across systems that were never designed to share a database. ```text Question: "Sales vs. Budget by Region" FactSales lives in the transactional SQL Server Budget lives in a Finance-owned Excel file | | composite model joins them on DimDate / DimRegion | One report, two sources ``` Common cases: * A large transactional fact table stays in DirectQuery, joined against a small Import table (budget, targets, manual adjustments) that a business team maintains outside the source system. * Two DirectQuery sources (e.g., two regional SQL Server instances) are combined through shared Dual dimension tables. * A Fabric semantic model is extended locally with an Import table, without needing write access to the upstream model. *** ## Relationships Across Sources [#relationships-across-sources] A relationship between tables from different sources is evaluated differently than a same-source relationship — Power BI can't push the join down to a single database, so it handles the join itself. ```text DimDate (Dual) | | relationship crosses sources | FactSales (DirectQuery, SQL Server) ``` Power BI issues separate queries to each source and combines the results locally. This works well when the "many" side of the relationship is filtered down first — badly when it isn't. *** ## Limiting Relationship Direction [#limiting-relationship-direction] Cross-source relationships default to single-direction filtering. Bidirectional filtering across sources is possible but should be used deliberately, since it multiplies the number of cross-source queries a single visual can trigger. ```text DimProduct --filters--> FactSales (single direction: safe default) DimProduct <--filters--> FactSales (bidirectional: use only when needed) ``` Start single-direction. Only enable bidirectional filtering across a source boundary once a specific report requirement calls for it, and after checking query performance. *** ## Performance in Multi-Source Models [#performance-in-multi-source-models] Every visual that touches tables from more than one source triggers multiple backend queries, evaluated separately and merged by Power BI. ```text Visual: Sales by Region, filtered by Budget Category | +-- Query 1 -> SQL Server (Sales) +-- Query 2 -> Excel-backed Import table (Budget) | Power BI merges both results ``` This merge step has a cost. The more cross-source joins a single visual requires, and the larger the intermediate result sets, the slower that visual gets — independent of how fast either source is individually. *** ## Reducing Cross-Source Query Cost [#reducing-cross-source-query-cost] * Filter DirectQuery tables down early (report-level filters, RLS) so less data crosses the source boundary. * Keep shared dimension tables (the ones relationships cross through) in Dual or Import mode, not DirectQuery. * Add [aggregation tables](/docs/modeling/aggregations) on the DirectQuery side for the queries that matter most. * Avoid bidirectional cross-source relationships unless a report genuinely requires them. *** ## Composite Models vs. a Single Source [#composite-models-vs-a-single-source] | Aspect | Single-Source Model | Multi-Source Composite | | ----------------------- | ------------------------------- | ---------------------------------------------------- | | Data location | One database | Multiple independent systems | | Relationship evaluation | Pushed to the source | Merged locally by Power BI | | Setup complexity | Lower | Higher | | Typical use | One system, mixed storage modes | Reporting across systems that don't share a database | *** ## Best Practices [#best-practices] * Reserve multi-source composite models for cases a single source genuinely can't solve — they add real query complexity. * Keep shared dimension tables in Dual mode so cross-source joins stay cheap. * Filter aggressively before crossing a source boundary, not after. * Validate performance with production-scale data on every source involved, not just the smallest one. *** ## Common Mistakes [#common-mistakes] ### Treating Every Cross-Source Join Like a Local One [#treating-every-cross-source-join-like-a-local-one] A relationship between two sources is not free the way a same-database relationship is. Each cross-source query adds real latency that compounds across visuals on a page. ### Leaving Shared Dimensions in DirectQuery [#leaving-shared-dimensions-in-directquery] If a dimension table used to relate two different sources is itself in DirectQuery, every cross-source query pays for three round trips instead of two. Dual or Import mode on the shared dimension avoids this. ### Enabling Bidirectional Filtering by Default [#enabling-bidirectional-filtering-by-default] Bidirectional filtering across a source boundary multiplies the number of queries a visual can trigger. Turn it on only where a specific report need justifies the cost. *** ## Composite Models Checklist [#composite-models-checklist] Before publishing a multi-source composite model: * Shared dimension tables are Dual or Import, not DirectQuery. * Cross-source relationships are single-direction unless bidirectional is specifically required. * Filters are applied as early as possible, before data crosses source boundaries. * Report performance has been tested with realistic data volumes on every source. *** ## Next Steps [#next-steps] Continue exploring Power BI data modeling: * [DirectQuery vs. Import](/docs/modeling/storage-modes) * [Aggregations](/docs/modeling/aggregations) * [Relationships](/docs/modeling/relationships) # Date Tables (/docs/modeling/date-tables) # Date Tables [#date-tables] Date tables are one of the most important dimensions in a Power BI data model. They provide the calendar structure required for time-based analysis. A date table allows users to analyze data by: * Year * Quarter * Month * Week * Day * Fiscal periods Example: ```text DimDate | | DimCustomer --- FactSales --- DimProduct ``` The date table filters the fact table through a relationship. *** ## Why Use a Date Table? [#why-use-a-date-table] Power BI can automatically create hidden date tables when using date columns. However, professional Power BI models use dedicated date tables. Benefits include: * Consistent date filtering * Better DAX calculations * Time intelligence support * Fiscal calendar support * Reusable date logic *** ## Date Table Structure [#date-table-structure] A typical date table contains: ```text DimDate DateKey Date Year Quarter Month Month Number Week Day ``` Example: | Date | Year | Month | Quarter | | ---------- | ---: | -------- | ------- | | 2026-01-01 | 2026 | January | Q1 | | 2026-02-01 | 2026 | February | Q1 | | 2026-03-01 | 2026 | March | Q1 | *** ## Date Keys [#date-keys] Many models use a numeric date key. Example: ```text DateKey 20260101 20260102 20260103 ``` Fact tables store the key: ```text FactSales DateKey ProductKey SalesAmount ``` The relationship: ```text DimDate DateKey | | FactSales DateKey ``` connects dates to transactions. *** ## Creating a Date Table in DAX [#creating-a-date-table-in-dax] Example: ```dax lineNumbers DimDate = CALENDAR( DATE(2020,1,1), DATE(2030,12,31) ) ``` Additional columns can be added: ```dax lineNumbers Year = YEAR(DimDate[Date]) ``` ```dax lineNumbers Month = FORMAT( DimDate[Date], "MMMM" ) ``` ```dax lineNumbers Month Number = MONTH(DimDate[Date]) ``` *** ## Mark as Date Table [#mark-as-date-table] After creating a date table: 1. Select the table in Power BI. 2. Choose **Table tools**. 3. Select **Mark as date table**. 4. Choose the Date column. This tells Power BI the table represents the official calendar. *** ## Date Hierarchies [#date-hierarchies] Date tables often contain natural hierarchies. Example: ```text Year | Quarter | Month | Day ``` Users can drill down: ```text 2026 | Q1 | January | January 15 ``` *** ## Time Intelligence [#time-intelligence] Date tables enable common DAX calculations. Example: ### Total Sales [#total-sales] ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` ### Year-to-Date Sales [#year-to-date-sales] ```dax lineNumbers Sales YTD = TOTALYTD( [Total Sales], DimDate[Date] ) ``` The calculation automatically responds to date filters. *** ## Fiscal Calendars [#fiscal-calendars] Many businesses do not follow January to December calendars. Examples: * Manufacturing fiscal years * Retail calendars * 4-4-5 calendars A date table can include: ```text Fiscal Year Fiscal Quarter Fiscal Period ``` Example: | Date | Fiscal Year | Period | | ---------- | ----------- | -------- | | 2026-07-01 | FY2027 | Period 1 | *** ## Multiple Date Relationships [#multiple-date-relationships] Fact tables may contain multiple dates. Example: ```text FactSales OrderDateKey ShipDateKey DeliveryDateKey ``` A single date table can support all of them. Example: ```text DimDate | | FactSales Order Date Ship Date Delivery Date ``` Only one relationship is active. Other relationships can be activated with DAX. *** ## Date Table Best Practices [#date-table-best-practices] Follow these guidelines: * Create one official date table. * Use it across all fact tables. * Include future dates for planning. * Add fiscal calendar columns when needed. * Mark it as a date table. * Sort month names by month number. *** ## Common Date Table Mistakes [#common-date-table-mistakes] Avoid: ### Using Automatic Date Tables [#using-automatic-date-tables] Problems: * Creates hidden tables * Duplicates logic * Makes models harder to maintain *** ### Sorting Months Alphabetically [#sorting-months-alphabetically] Incorrect: ```text April August December February ``` Correct: ```text January February March ``` Use: ```text Month Name sorted by Month Number ``` *** ## Date Table Checklist [#date-table-checklist] A good date table should: * Contain continuous dates * Have a unique date column * Connect to fact tables * Support time intelligence * Include business calendar requirements *** ## Next Steps [#next-steps] Continue learning Power BI modeling: * [Measures](/docs/modeling/measures) Advanced Topics: * [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) * [CALENDAR() vs CALENDARAUTO()](/docs/dax/calendar-calendarauto) — why CALENDARAUTO() can silently widen this date table Building date-derived columns directly in Power Query instead of DAX? See [Working with Dates in Power Query](/docs/power-query/date-functions). A date table doesn't have to relate to a fact table to be useful — [Build an Earned Value Management Dashboard](/tutorials/build-an-evm-dashboard) uses a disconnected date table to project planned value over time. # Dimension Tables (/docs/modeling/dimension-tables) # Dimension Tables [#dimension-tables] Dimension tables provide context for the data stored in fact tables. While fact tables store business events and measurements, dimension tables describe the people, products, locations, and dates involved in those events. A star schema typically contains: ```text DimDate | | DimCustomer --- FactSales --- DimProduct | | DimStore ``` The fact table answers: > What happened? The dimension tables answer: > Who, what, where, and when did it happen? *** ## What Is a Dimension Table? [#what-is-a-dimension-table] A dimension table contains descriptive information used for: * Filtering reports * Grouping data * Creating hierarchies * Adding business context Example: ```text DimProduct ProductKey ProductName Category Brand Size ``` The dimension table does not contain transactions. It describes the entities connected to transactions. *** ## Dimension Table Structure [#dimension-table-structure] A typical dimension table contains: * A unique key column * Descriptive attributes * Business categories Example: ### DimProduct [#dimproduct] | Column | Description | | -------------------------- | ------------------------- | | `ProductKey` | Unique product identifier | | `ProductName` | Product description | | `Category` | Product grouping | | `Brand` | Manufacturer | Example data: | ProductKey | ProductName | Category | Brand | | ---------- | ---------------- | ---------- | ------- | | 1001 | All Terrain Tire | Off Road | Brand A | | 1002 | Highway Tire | Commercial | Brand B | *** ## Common Dimension Tables [#common-dimension-tables] Most Power BI models contain several common dimensions. ## Date Dimension [#date-dimension] A date table supports time-based analysis. Example: ```text DimDate DateKey Date Year Month Quarter ``` Used for: * Year-to-date calculations * Monthly trends * Period comparisons * Time intelligence functions Example: ```dax lineNumbers Sales YTD = TOTALYTD( [Total Sales], DimDate[Date] ) ``` *** ## Product Dimension [#product-dimension] A product dimension describes items being analyzed. Example: ```text DimProduct ProductKey ProductName Category Brand ``` Allows analysis by: * Product * Category * Brand * Product group Example: ``` Category = "Off Road" filters: FactSales ``` *** ## Customer Dimension [#customer-dimension] A customer dimension stores customer attributes. Example: ```text DimCustomer CustomerKey CustomerName Region Segment ``` Allows analysis such as: * Sales by region * Revenue by customer segment * Customer performance *** ## Dimension Keys [#dimension-keys] Dimension tables require a unique key. Example: ```text DimProduct ProductKey | ProductName -----------|------------ 1001 | Tire A 1002 | Tire B ``` The key connects to the fact table: ```text DimProduct | | ProductKey | FactSales ``` The fact table stores the key, not the description. Example: FactSales: | ProductKey | Quantity | | ---------- | -------: | | 1001 | 5 | | 1001 | 3 | | 1002 | 8 | *** ## Why Not Store Everything in One Table? [#why-not-store-everything-in-one-table] A common beginner mistake is creating one large flat table. Example: ```text SalesData Date Customer Name Customer Region Product Category Brand Quantity Sales Amount ``` Problems: * Duplicate information * Larger model size * More difficult maintenance * More complicated DAX * Poorer performance Instead: ```text DimCustomer Customer information DimProduct Product information FactSales Sales transactions ``` Each table has a clear responsibility. *** ## Dimension Table Best Practices [#dimension-table-best-practices] Follow these guidelines: ### Use Clear Names [#use-clear-names] Recommended: ```text DimDate DimCustomer DimProduct ``` Avoid: ```text Table1 Customer_Final Product_New ``` *** ### Keep Attributes in Dimensions [#keep-attributes-in-dimensions] Good: ```text DimProduct Category Brand Size ``` Avoid: ```text FactSales Category Brand Size ``` *** ### Create Reusable Dimensions [#create-reusable-dimensions] A shared dimension can support multiple fact tables. Example: ```text DimDate | ---------------- | | FactSales FactInventory ``` The same date table can analyze multiple business processes. *** ## Slowly Changing Dimensions [#slowly-changing-dimensions] In enterprise models, dimension values can change over time. Examples: * Customer changes region * Product changes category * Employee changes department Slowly changing dimensions preserve historical information. Learn more: [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) *** ## Dimension Table Checklist [#dimension-table-checklist] A good dimension table should: * Have a unique key * Contain descriptive attributes * Avoid transaction data * Support filtering and grouping * Connect to fact tables through relationships *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Relationships](/docs/modeling/relationships) * [Date Tables](/docs/modeling/date-tables) Advanced Topics: * [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) # Fact Tables (/docs/modeling/fact-tables) # Fact Tables [#fact-tables] Fact tables are the center of a Power BI star schema. They store measurable business events and connect to dimension tables through relationships. A fact table answers questions such as: * How many units were sold? * What was the total revenue? * How many products were produced? * How much inventory changed? Example: ```text DimDate | | DimCustomer --- FactSales --- DimProduct | | DimStore ``` The fact table contains the business events. The dimension tables provide the context needed to analyze those events. *** ## What Is a Fact Table? [#what-is-a-fact-table] A fact table contains: * Numeric measurements * Transaction records * Foreign keys connecting dimensions Example: ```text FactSales SalesKey DateKey CustomerKey ProductKey StoreKey Quantity SalesAmount ``` Each row represents a business event. Example: | DateKey | ProductKey | Quantity | SalesAmount | | -------- | ---------- | -------: | ----------: | | 20260101 | 1001 | 5 | 250 | | 20260102 | 1002 | 3 | 180 | | 20260103 | 1001 | 8 | 400 | *** ## Fact Table Types [#fact-table-types] Power BI models commonly use several types of fact tables. ## Transaction Fact Tables [#transaction-fact-tables] Transaction facts record individual business events. Examples: * Sales transactions * Customer orders * Production records * Inventory movements Example: ```text FactSales TransactionID DateKey ProductKey CustomerKey Quantity SalesAmount ``` Each row represents one transaction. *** ## Snapshot Fact Tables [#snapshot-fact-tables] Snapshot facts capture a point-in-time measurement. Examples: * Daily inventory levels * Monthly account balances * Employee headcount Example: ```text FactInventorySnapshot DateKey ProductKey LocationKey InventoryQuantity ``` A snapshot answers: "How much inventory existed on a specific date?" *** ## Accumulating Snapshot Fact Tables [#accumulating-snapshot-fact-tables] Accumulating snapshots track a process through multiple stages. Examples: * Order processing * Manufacturing workflow * Shipping process Example: ```text FactOrderProcess OrderKey OrderDate ShipDate DeliveryDate CompleteDate ``` The row is updated as the process moves through stages. *** ## Fact Table Keys [#fact-table-keys] Fact tables usually contain foreign keys. Example: ```text FactSales DateKey ProductKey CustomerKey StoreKey ``` These connect to dimension tables: ```text DimDate | | FactSales | | DimProduct ``` Keys allow Power BI to filter and analyze facts by different perspectives. *** ## Measures Are Created From Facts [#measures-are-created-from-facts] Most DAX measures calculate values from fact table columns. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Example: ```dax lineNumbers Total Quantity = SUM(FactSales[Quantity]) ``` The measure changes based on report filters. Example: | Filter | Total Sales | | ------------ | ----------: | | All Products | $500,000 | | Tires | $250,000 | | Off Road | $150,000 | *** ## Fact Table Design Principles [#fact-table-design-principles] Follow these guidelines: ### Keep Facts Focused on One Business Process [#keep-facts-focused-on-one-business-process] Good: `FactSales` Contains sales events. Good: `FactInventory` Contains inventory measurements. Avoid combining unrelated processes: `Sales + Inventory + Production` *** ### Store Keys Instead of Descriptions [#store-keys-instead-of-descriptions] Preferred: `ProductKey = 1001` Avoid: `ProductName = "All Terrain Tire"` Descriptions belong in dimension tables. *** ### Avoid Calculated Columns When Possible [#avoid-calculated-columns-when-possible] Instead of storing: `TotalAmount` create a reusable measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Measures provide more flexibility. *** ## Common Fact Table Mistakes [#common-fact-table-mistakes] Avoid: ### Mixing Different Granularities [#mixing-different-granularities] Example: ```text FactSales One row per transaction + Monthly totals ``` These represent different levels of detail. Create separate fact tables instead. *** ### Too Many Columns [#too-many-columns] Avoid adding: * Customer descriptions * Product descriptions * Category names * Region names These belong in dimensions. *** ## Fact Table Checklist [#fact-table-checklist] A well-designed fact table should: * Represent one business process * Have a clear grain * Contain numeric measurements * Use dimension keys * Support reusable DAX measures *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Dimension Tables](/docs/modeling/dimension-tables) * [Relationships](/docs/modeling/relationships) * [Date Tables](/docs/modeling/date-tables) Advanced Topics: * [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) See it applied end to end: [Build a Reliability (MTBF/MTTR) Dashboard](/tutorials/build-a-reliability-mtbf-mttr-dashboard) builds real reliability measures directly from a fact table's own timestamp columns, no date table required. # Data Modeling (/docs/modeling) # Data Modeling [#data-modeling] A well-shaped data model is what makes DAX simple and reports fast — this section covers star schemas, the fact/dimension split, relationships, and the patterns (bridge tables, slowly changing dimensions) that come up as a model grows. ```text Fact Table (transactions, events — the numbers) | | relationships | Dimension Tables (who, what, where, when — the descriptive context) ``` ## Start Here [#start-here] ## Where to Go Next [#where-to-go-next] * [Bridge Tables](/docs/modeling/bridge-tables) and [Many-to-Many Relationships](/docs/modeling/many-to-many) — for relationships a simple one-to-many can't represent. * [DAX](/docs/dax/introduction) — the calculations this model shape is built to support. # Introduction (/docs/modeling/introduction) # Data Modeling Introduction [#data-modeling-introduction] A Power BI data model defines how data is structured, connected, and prepared for analysis. A well-designed model creates a strong foundation for: * Report performance * Simple DAX calculations * Accurate analytics * Reusable reporting solutions Power BI models are built using tables, relationships, columns, and measures. Common model objects include: * `Fact Tables` — Store measurable business events. * `Dimension Tables` — Provide descriptive information for filtering. * `Relationships` — Connect tables together. * `Measures` — Calculate business logic using DAX. *** ## The Power BI Data Model [#the-power-bi-data-model] A typical Power BI solution follows this flow: ```text Data Sources | v Power Query | v Data Model | v DAX Measures | v Reports & Dashboards ``` Each layer has a specific purpose: | Layer | Purpose | | -------------------- | ---------------------------------------------------------------------- | | Data Sources | Connect to databases, files, APIs, Excel workbooks, and cloud services | | Power Query | Clean, transform, and prepare data using M | | Data Model | Create tables, columns, relationships, and semantic structures | | DAX Measures | Create calculations and business logic | | Reports & Dashboards | Present insights through interactive visualizations | *** ## Why Data Modeling Matters [#why-data-modeling-matters] Data modeling is the foundation of a successful Power BI solution. A well-designed model improves: * Report performance * DAX simplicity * Data accuracy * Reusable analytics A poorly designed model can create: * Complex DAX formulas * Slow report performance * Duplicate data * Incorrect filtering behavior For example, a sales analysis may require reporting by: * Product * Customer * Region * Date Instead of storing everything in one large table, Power BI separates information into related tables. This creates a structured model that is easier to maintain and analyze. *** # Star Schema Design [#star-schema-design] Most Power BI models use a star schema. A star schema contains: * One central `Fact Table` * Multiple surrounding `Dimension Tables` Example: ```text DimDate | | DimCustomer ---- FactSales ---- DimProduct | | DimStore ``` The fact table stores business events. The dimension tables provide descriptive information used for filtering and grouping. # Fact Tables [#fact-tables] Fact tables contain measurable business events and transactions. A fact table usually contains: * Numeric values * Transaction records * Foreign keys that connect to dimensions Example: `FactSales` | Column | Description | | ------------- | ----------------------- | | `SalesAmount` | Revenue generated | | `Quantity` | Units sold | | `DateKey` | Links to calendar table | | `ProductKey` | Links to product table | | `CustomerKey` | Links to customer table | Example rows: | DateKey | ProductKey | Quantity | SalesAmount | | -------- | ---------- | -------- | ----------: | | 20260101 | 1001 | 5 | $250 | | 20260102 | 1002 | 3 | $180 | Fact tables answer questions such as: * How many units were sold? * What was the total revenue? * How many transactions occurred? *** # Dimension Tables [#dimension-tables] Dimension tables provide descriptive information used for filtering, grouping, and analysis. *** # Fact Tables [#fact-tables-1] Fact tables contain measurable business events and transactions. A fact table usually contains: * Numeric values * Transaction records * Foreign keys that connect to dimensions Example: `FactSales` | Column | Description | | ------------- | ----------------------- | | `SalesAmount` | Revenue generated | | `Quantity` | Units sold | | `DateKey` | Links to calendar table | | `ProductKey` | Links to product table | | `CustomerKey` | Links to customer table | Example rows: | DateKey | ProductKey | Quantity | SalesAmount | | -------- | ---------- | -------- | ----------: | | 20260101 | 1001 | 5 | $250 | | 20260102 | 1002 | 3 | $180 | Fact tables answer questions such as: * How many units were sold? * What was the total revenue? * How many transactions occurred? *** # Dimension Tables [#dimension-tables-1] Dimension tables provide descriptive information used for filtering, grouping, and analysis. ## DimDate [#dimdate] A date dimension supports time-based reporting. Example: | Column | Description | | --------- | ---------------------- | | `DateKey` | Unique date identifier | | `Date` | Calendar date | | `Year` | Reporting year | | `Month` | Month name | | `Quarter` | Calendar quarter | Common analysis: * Sales by year * Sales by month * Year-to-date calculations *** ## DimProduct [#dimproduct] Product dimensions describe items being analyzed. Example: | Column | Description | | ------------- | ------------------------- | | `ProductKey` | Unique product identifier | | `ProductName` | Product description | | `Category` | Product grouping | | `Brand` | Product manufacturer | Example filtering: `Category = Bikes` returns only related sales records from `FactSales`. *** ## DimCustomer [#dimcustomer] Customer dimensions describe who is purchasing products. Example: | Column | Description | | -------------- | -------------------------- | | `CustomerKey` | Unique customer identifier | | `CustomerName` | Customer description | | `Region` | Geographic area | | `Segment` | Customer category | Dimensions allow users to slice and filter reports without duplicating information in fact tables. *** # Relationships [#relationships] Relationships connect tables together inside the Power BI data model. A common relationship pattern is: ```text DimProduct 1 | | * FactSales ``` This represents a one-to-many relationship: * One product can have many sales records. * Each sales record belongs to one product. The dimension table filters the fact table. Example: Selecting: `Category = Bikes` filters: `FactSales` and returns only bike-related sales. *** # Measures and DAX [#measures-and-dax] Measures calculate values dynamically based on the current filter context. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The same measure can return different results depending on report selections. Example: | Filter | Result | | ------------ | -------: | | All Products | $500,000 | | Bikes Only | $150,000 | | 2026 Only | $220,000 | Measures are preferred over repeating calculations throughout reports because they provide reusable business logic. *** ## Data Modeling Best Practices [#data-modeling-best-practices] A strong Power BI model follows a few important design principles. ### Use a Star Schema [#use-a-star-schema] Whenever possible, organize your model using: * One central fact table * Multiple dimension tables * Simple relationships Example: ```text DimDate | | DimCustomer ---- FactSales ---- DimProduct | | DimStore ``` This structure improves: * Query performance * DAX simplicity * Model understanding * Report scalability *** ## Naming Conventions [#naming-conventions] Clear naming makes models easier to maintain. Recommended naming patterns: | Object | Example | | --------------- | ------------- | | Fact table | `FactSales` | | Dimension table | `DimCustomer` | | Date table | `DimDate` | | Measure | `Total Sales` | | Key column | `CustomerKey` | Avoid unclear names such as: * Table1 * Query2 * Column123 A well-named model is easier for both developers and report users to understand. *** ## Common Modeling Mistakes [#common-modeling-mistakes] ### One Large Flat Table [#one-large-flat-table] A common beginner approach is storing everything in one table. Example: ```text Sales ├── Sales Amount ├── Customer Name ├── Product Name ├── Product Category ├── Store └── Date ``` This may work for small datasets, but creates problems as models grow. Problems: * Duplicate information * Larger file size * Slower refresh times * More complicated DAX calculations A better approach is separating data into related tables: ```text FactSales + DimCustomer + DimProduct + DimDate ``` *** ## Avoid Unnecessary Relationships [#avoid-unnecessary-relationships] Too many relationships can make a model difficult to understand. Problems include: * Ambiguous filter paths * Unexpected calculation results * Difficult troubleshooting A simple model with clear relationships is usually easier to maintain. *** ## Semantic Models [#semantic-models] Power BI semantic models provide a reusable analytical layer between data and reports. A semantic model contains: * Tables * Relationships * Measures * Calculated columns * Business definitions Multiple reports can use the same semantic model. Example: ``` Semantic Model | | ----------------- | | | Sales Finance Operations Report Report Report ``` This creates consistency because all reports use the same business logic. *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Star Schema Design](/docs/modeling/star-schema) * [Fact Tables](/docs/modeling/fact-tables) * [Dimension Tables](/docs/modeling/dimension-tables) * [Relationships](/docs/modeling/relationships) * [Date Tables](/docs/modeling/date-tables) # Many-to-Many Relationships (/docs/modeling/many-to-many) # Many-to-Many Relationships [#many-to-many-relationships] A many-to-many relationship exists when rows in one table can match multiple rows in another table, in both directions. ```text DimSalesperson DimAccount | | +---- many : many ------+ ``` A salesperson can be assigned to many accounts, and an account can be covered by many salespeople — neither side has a single unique key the other can filter through cleanly. *** ## Why It's Different From One-to-Many [#why-its-different-from-one-to-many] A star schema's dimension-to-fact relationships are normally one-to-many: one row in `DimProduct` matches many rows in `FactSales`. ```text DimProduct (one) ----> FactSales (many) ``` A many-to-many relationship breaks that assumption on both sides, so Power BI has to handle filter propagation differently. ```text DimSalesperson (many) <----> DimAccount (many) ``` *** ## Native Many-to-Many Relationships [#native-many-to-many-relationships] Power BI supports setting a relationship's cardinality directly to **Many to Many** in the relationship editor, without an intermediate table. ```text DimSalesperson[AccountID] (many) | | many : many | DimAccount[AccountID] (many) ``` This works when neither column is unique, and is useful for quick, ad hoc connections — but it comes with tradeoffs: ambiguous filter behavior once more than two tables are involved, and no natural place to store attributes about the relationship itself, like "since when" a salesperson covered an account. *** ## Bridge Tables: The More Robust Option [#bridge-tables-the-more-robust-option] For anything beyond a simple two-table relationship, a **bridge table** models the many-to-many relationship explicitly as its own table. ```text DimSalesperson (one) | | one : many | BridgeSalespersonAccount (many) | | many : one | DimAccount (one) ``` Every relationship in the bridge design is one-to-many, which Power BI filters more predictably than a native many-to-many join. See [Bridge Tables](/docs/modeling/bridge-tables) for the full pattern. *** ## Example [#example] Products can belong to multiple sales categories, and categories can contain multiple products: ```text DimProduct DimCategory ProductKey CategoryKey ProductName CategoryName ``` Native many-to-many: ```text DimProduct[CategoryKey] (many) <----> DimCategory[CategoryKey] (many) ``` Bridge table version: ```text DimProduct (one) | BridgeProductCategory (many) | DimCategory (one) ``` `BridgeProductCategory` stores one row per product/category pairing: | ProductKey | CategoryKey | | ---------- | ----------- | | 1001 | 10 | | 1001 | 11 | | 1002 | 10 | *** ## Performance and Filtering Considerations [#performance-and-filtering-considerations] * Native many-to-many relationships only handle a single, non-ambiguous cross-filter direction cleanly; adding a third related table often introduces ambiguity Power BI can't resolve automatically. * Bridge tables keep every join one-to-many, which is easier for the DAX engine to optimize and easier for a developer to reason about. * Neither column in a many-to-many relationship needs to be unique — unlike a standard one-to-many relationship, where the "one" side must be unique. *** ## Best Practices [#best-practices] * Prefer a bridge table over a native many-to-many relationship once more than two tables, or any attributes of the relationship itself, are involved. * Keep bridge tables narrow — just the keys needed to connect the two sides, plus any attributes specific to the relationship, like an assignment date. * Test filter behavior from both directions before shipping a many-to-many model; ambiguous propagation is easy to miss until a specific report page surfaces it. *** ## Common Mistakes [#common-mistakes] ### Defaulting to Native Many-to-Many Everywhere [#defaulting-to-native-many-to-many-everywhere] Native many-to-many is convenient for a quick, isolated case, but scales poorly once other tables need to filter through the same relationship. ### Forgetting Cross-Filter Direction [#forgetting-cross-filter-direction] Many-to-many relationships often need **Both** cross-filter directions to behave as expected, which increases the risk of ambiguous or circular filter paths elsewhere in the model. ### No Bridge Table for Relationship Attributes [#no-bridge-table-for-relationship-attributes] If the relationship itself carries information — an assignment start date, a percentage split — a native many-to-many relationship has nowhere to store it. A bridge table does. *** ## Many-to-Many Checklist [#many-to-many-checklist] * The relationship is genuinely many-to-many on both sides, not a one-to-many relationship modeled incorrectly. * A bridge table is used whenever more than two tables, or relationship-specific attributes, are involved. * Cross-filter direction has been tested from both sides of the relationship. * Performance has been validated on production-scale data, since many-to-many joins are more expensive than standard one-to-many joins. *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Bridge Tables](/docs/modeling/bridge-tables) * [Relationships](/docs/modeling/relationships) * [Dimension Tables](/docs/modeling/dimension-tables) See it applied end to end: [Build a Requirements Traceability Matrix Dashboard](/tutorials/build-a-requirements-traceability-matrix) models a real many-to-many relationship between requirements and test cases. # Measures (/docs/modeling/measures) # Measures and DAX [#measures-and-dax] Measures are calculations created using Data Analysis Expressions (DAX). In Power BI, measures are used to calculate business results dynamically based on the current filter context. Examples: * Total Sales * Profit * Average Price * Year-to-Date Revenue * Customer Counts A typical model follows this pattern: ```text Dimension Tables | | v Fact Tables | | v DAX Measures | | v Reports & Dashboards ``` *** ## What Is a Measure? [#what-is-a-measure] A measure is a calculation stored in the Power BI model. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The measure does not store a value. Instead, Power BI calculates the result when the measure is used in a visual. *** ## Measures vs Columns [#measures-vs-columns] Power BI has two common ways to create calculations: * Calculated columns * Measures They serve different purposes. ### Calculated Column [#calculated-column] Calculated columns are computed when data is loaded. Example: ```dax lineNumbers Sales Amount = FactSales[Quantity] * FactSales[Unit Price] ``` The result is stored in the table. *** ### Measure [#measure] Measures are calculated when a report is viewed. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` The result changes based on filters. *** ## Why Use Measures? [#why-use-measures] Measures provide: * Reusable calculations * Smaller models * Better performance * Dynamic results * Centralized business logic Instead of creating: ```text Sales This Year Sales by Region Sales by Product Sales by Customer ``` Create one measure: `Total Sales` and allow report filters to control the result. *** ## Filter Context [#filter-context] Filter context is one of the most important concepts in DAX. A measure responds to filters applied in a report. Example: Measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Without filters: `Total Sales = $500,000` Filter: `Category = Off Road` Result: `Total Sales = $150,000` The DAX formula did not change. The filter context changed. *** ## Basic Aggregation Measures [#basic-aggregation-measures] Common aggregation functions: ### SUM [#sum] ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Adds all values. *** ### COUNT [#count] ```dax lineNumbers Order Count = COUNT(FactSales[OrderNumber]) ``` Counts rows containing values. *** ### DISTINCTCOUNT [#distinctcount] ```dax lineNumbers Customer Count = DISTINCTCOUNT( FactSales[CustomerKey] ) ``` Counts unique customers. *** ### AVERAGE [#average] ```dax lineNumbers Average Sales = AVERAGE( FactSales[SalesAmount] ) ``` Calculates the average value. *** ## CALCULATE Function [#calculate-function] CALCULATE is one of the most important DAX functions. It changes filter context. Example: ```dax lineNumbers Sales 2026 = CALCULATE( [Total Sales], DimDate[Year] = 2026 ) ``` The measure calculates sales only for 2026. *** ## Measures Using Relationships [#measures-using-relationships] Measures automatically use model relationships. Example: Model: ```text DimProduct Category | | FactSales SalesAmount ``` Measure: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` Filter: `Category = Tires` Power BI automatically filters FactSales. *** ## Time Intelligence Measures [#time-intelligence-measures] Date tables enable advanced calculations. Example: ```dax lineNumbers Sales YTD = TOTALYTD( [Total Sales], DimDate[Date] ) ``` Other common calculations: * Previous year sales * Month-over-month growth * Rolling averages * Year-to-date totals *** ## Variables in DAX [#variables-in-dax] Variables improve readability. Example: ```dax lineNumbers Profit Margin = VAR Revenue = [Total Sales] VAR Profit = [Total Profit] RETURN DIVIDE( Profit, Revenue ) ``` Benefits: * Easier debugging * Cleaner formulas * Better performance *** ## DIVIDE vs Division Operator [#divide-vs-division-operator] Recommended: ```dax lineNumbers Profit Margin = DIVIDE( [Profit], [Sales] ) ``` Instead of: `[Profit] / [Sales]` DIVIDE safely handles zero values. *** ## Measure Naming Best Practices [#measure-naming-best-practices] Good: ```text Total Sales Gross Profit Customer Count Sales YTD ``` Avoid: ```text Measure1 Calc_New Sales_Final2 ``` Clear names make models easier to maintain. *** ## Measure Tables [#measure-tables] Many professional models organize measures into dedicated tables. Example: ```text Measures Total Sales Gross Profit Margin % Sales YTD ``` Benefits: * Cleaner models * Easier navigation * Better organization *** ## Common DAX Mistakes [#common-dax-mistakes] Avoid: ### Creating Too Many Calculated Columns [#creating-too-many-calculated-columns] Problems: * Larger model size * Slower refresh * Duplicate calculations *** ### Repeating Logic [#repeating-logic] Avoid: ```dax lineNumbers Sales by Region Formula Sales by Product Formula Sales by Customer Formula ``` Instead: `Total Sales` and use dimensions for filtering. *** ## Measure Checklist [#measure-checklist] Good Power BI models: * Use measures for calculations * Use dimensions for filtering * Keep business logic centralized * Use clear naming * Avoid unnecessary calculated columns *** ## Next Steps [#next-steps] Continue learning DAX: * [DAX Fundamentals](/docs/dax/introduction) * [Filter Context](/docs/dax/filter-context) * [CALCULATE Function](/docs/dax/calculate) * [Time Intelligence](/docs/dax/time-intelligence) # Relationships (/docs/modeling/relationships) # Relationships [#relationships] Relationships define how tables connect inside a Power BI data model. They allow filters and calculations to move between tables. In a star schema: * Dimension tables filter fact tables. * Fact tables store the business events. * Relationships connect the two. Example: ```text DimDate | | DimCustomer --- FactSales --- DimProduct | | DimStore ``` Without relationships, Power BI treats tables as separate datasets. *** ## How Relationships Work [#how-relationships-work] A relationship connects two tables using matching columns. Example: ```text DimProduct ProductKey ProductName Category ``` connects to: ```text FactSales ProductKey Quantity SalesAmount ``` The shared column: `ProductKey` creates the relationship. *** ## One-to-Many Relationships [#one-to-many-relationships] The most common Power BI relationship is: ```text Dimension Table | | 1 : Many | Fact Table ``` Example: ```text DimProduct ProductKey | ProductName -----------|------------ 1001 | Tire A 1002 | Tire B ``` Fact table: ```text FactSales ProductKey | SalesAmount -----------|------------ 1001 | 250 1001 | 400 1002 | 180 ``` One product can have many sales records. *** ## Relationship Cardinality [#relationship-cardinality] Cardinality describes how rows match between tables. Power BI supports: ## One-to-Many (1:\*) [#one-to-many-1] Most common relationship. Example: ```text DimCustomer | | 1 : Many | FactSales ``` One customer can have many sales transactions. *** ## One-to-One (1:1) [#one-to-one-11] Both tables contain unique values. Example: ```text Employee | | 1 : 1 | EmployeeDetails ``` This is less common in analytical models. *** ## Many-to-Many (*:*) [#many-to-many-] Both tables contain duplicate values. Example: ```text Customers Customer A Customer B Products Product A Product B ``` Many-to-many relationships can create: * Ambiguous filtering * Unexpected calculations * Complex DAX Use carefully. *** ## Filter Direction [#filter-direction] Relationships control how filters travel. Example: ```text DimProduct | v FactSales ``` Selecting: `Category = "Off Road"` filters: ```text DimProduct ↓ FactSales ``` The result: `Only Off Road sales are calculated.` *** ## Single Direction Filtering [#single-direction-filtering] Recommended for most star schemas. Example: ```text DimProduct | v FactSales ``` Filters move from dimension to fact. Benefits: * Easier to understand * Better performance * Fewer ambiguous paths *** ## Both Direction Filtering [#both-direction-filtering] Power BI allows filters to travel both directions. Example: ```text DimProduct ↕ FactSales ``` Use carefully. Problems can include: * Circular relationships * Ambiguous filter paths * Incorrect results Most enterprise models avoid unnecessary bidirectional filtering. *** ## Active and Inactive Relationships [#active-and-inactive-relationships] A table can contain multiple relationships. Example: ```text FactSales OrderDateKey ShipDateKey ``` Both connect to: `DimDate` However, only one relationship can be active. Example: ```text FactSales[OrderDateKey] Active DimDate[DateKey] ``` The other relationship becomes inactive. Use DAX to activate inactive relationships: ```dax lineNumbers Sales by Ship Date = CALCULATE( [Total Sales], USERELATIONSHIP( FactSales[ShipDateKey], DimDate[DateKey] ) ) ``` *** ## Relationship Best Practices [#relationship-best-practices] Follow these guidelines: * Use one-to-many relationships whenever possible. * Filter from dimensions to facts. * Avoid unnecessary bidirectional filtering. * Use clear key columns. * Maintain a star schema structure. Recommended: ```text DimCustomer | | FactSales | | DimProduct ``` Avoid: ```text FactSales | | FactInventory ``` Instead: ```text DimProduct | FactSales ---+ | FactInventory ``` *** ## Common Relationship Mistakes [#common-relationship-mistakes] ### Missing Relationships [#missing-relationships] Symptoms: * Incorrect totals * Filters do not work * Visuals show unexpected results *** ### Incorrect Cardinality [#incorrect-cardinality] Example: Setting: `Many-to-Many` when the model should be: `One-to-Many` can create calculation problems. *** ### Multiple Filter Paths [#multiple-filter-paths] Example: ```text DimCustomer | FactSales | DimProduct ``` plus another path between tables can create ambiguity. *** ## Relationship Checklist [#relationship-checklist] A good Power BI model should have: * Clear relationships * Proper cardinality * Dimension-to-fact filtering * Minimal bidirectional relationships * Consistent key columns *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Date Tables](/docs/modeling/date-tables) * [Measures](/docs/modeling/measures) Advanced Topics: * [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) Hitting an error building a relationship? See [Column Contains a Duplicate Value](/blog/column-contains-duplicate-value-error) for how to find and fix it. A visual failing with "Couldn't load the data for this visual"? An inactive or ambiguous relationship is one of the four usual causes — see [Couldn't Load the Data for This Visual](/blog/couldnt-load-data-for-this-visual). # Slowly Changing Dimensions (/docs/modeling/slowly-changing-dimensions) # Slowly Changing Dimensions [#slowly-changing-dimensions] A slowly changing dimension (SCD) is a dimension whose attributes change occasionally rather than never — a customer moves region, a product gets reassigned to a new category, an employee changes department. ```text DimCustomer CustomerKey | CustomerName | Region 1001 | Alice Smith | West ``` The question an SCD strategy answers: when that row changes, should the history behind it change too? *** ## Type 0: Fixed, No Changes [#type-0-fixed-no-changes] The attribute never changes after the row is created — a date of birth, an original signup date. ```text No updates. The value written once stays as-is forever. ``` No special handling is needed; this is the default for anything that genuinely can't change. *** ## Type 1: Overwrite [#type-1-overwrite] The old value is simply replaced with the new one, and history is not preserved. ```text Before: After Alice moves to East: CustomerKey | Region CustomerKey | Region 1001 | West 1001 | East ``` Every past and future report using this dimension now shows Alice in the East, even for sales that happened while she was in the West. ```text FactSales (unchanged) | | joins to | DimCustomer[CustomerKey] = 1001, Region = East (always, even for old sales) ``` Type 1 is simple and appropriate when historical accuracy for that specific attribute doesn't matter — correcting a misspelled name, for example. *** ## Type 2: Preserve History with New Rows [#type-2-preserve-history-with-new-rows] Instead of overwriting the row, a new row is added, and the old row is marked as no longer current. ```text CustomerKey | CustomerName | Region | EffectiveDate | ExpiryDate | IsCurrent 1001 | Alice Smith | West | 2020-01-01 | 2024-06-01 | No 1002 | Alice Smith | East | 2024-06-01 | (null) | Yes ``` Two structural changes make this work: * A **surrogate key** (`CustomerKey`) that's independent of the business key (Alice's real-world identity), so the same person can have multiple dimension rows. * **Effective/expiry dates** (or an `IsCurrent` flag) marking which row was valid at any point in time. ```text FactSales (DateKey, CustomerKey) | | each historical sale references | the CustomerKey that was current on the sale's date ``` A sale made in 2022 references `CustomerKey = 1001` (West), and a sale made in 2025 references `CustomerKey = 1002` (East) — history stays accurate to how things were at the time. *** ## Type 3: Track Limited History in Columns [#type-3-track-limited-history-in-columns] Rather than adding rows, a Type 3 dimension adds a column to hold the previous value. ```text CustomerKey | Region | PreviousRegion 1001 | East | West ``` This only tracks one prior state, and is used far less often than Type 1 or Type 2 — mostly when a single "what was it before" comparison is enough, and full history isn't needed. *** ## Choosing a Type [#choosing-a-type] ```text Does the attribute's history matter for reporting? | +-- No -> Type 1 (overwrite) | +-- Yes -> Type 2 (new row per change) | +-- Only need the immediately prior value? -> Type 3 ``` Most enterprise models use Type 1 for corrections and low-impact attributes, and Type 2 for anything that affects how past transactions should be grouped or filtered — like sales territory or customer segment. *** ## Implementing Type 2 in Power Query [#implementing-type-2-in-power-query] Detecting a changed row typically means comparing the incoming source row against the current dimension row, and inserting a new row — with a new surrogate key and updated effective date — whenever a tracked attribute differs. This logic usually lives in the ETL/Power Query layer, not in DAX. DAX consumes the resulting historized table; it doesn't create the history. *** ## Best Practices [#best-practices] * Use a surrogate key, not the business key, whenever a dimension needs Type 2 history — the business key alone can't represent "this customer, but as they were before." * Only apply Type 2 to attributes where historical accuracy actually matters for reporting; applying it to every attribute bloats the dimension table with rows that don't add analytical value. * Store effective and expiry dates (or a clear `IsCurrent` flag) so "what was true on this date" can always be answered. * Keep the fact table's foreign key pointing at the surrogate key that was current when the transaction happened, not the current row. *** ## Common Mistakes [#common-mistakes] ### Using Type 1 When History Matters [#using-type-1-when-history-matters] Overwriting a region or segment attribute that reports are sliced by silently rewrites history — a customer who moved regions last month now appears to have always been in the new region. ### Using the Business Key Instead of a Surrogate Key [#using-the-business-key-instead-of-a-surrogate-key] Without a surrogate key, there's no way to have two rows represent "the same customer, at two different points in time." ### Applying Type 2 to Everything [#applying-type-2-to-everything] Tracking full history for attributes nobody analyzes historically, like a phone number, just grows the dimension table and adds unnecessary complexity. *** ## Slowly Changing Dimension Checklist [#slowly-changing-dimension-checklist] * Each attribute has a deliberate choice of Type 0, 1, 2, or 3 — not a default applied blindly. * Type 2 dimensions use a surrogate key, separate from the business key. * Type 2 dimensions store effective/expiry dates or an `IsCurrent` flag. * Fact tables reference the surrogate key that was current at the time of the transaction. *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Dimension Tables](/docs/modeling/dimension-tables) * [Fact Tables](/docs/modeling/fact-tables) * [Relationships](/docs/modeling/relationships) # Star Schema (/docs/modeling/star-schema) # Star Schema Design [#star-schema-design] A star schema is the recommended data modeling pattern for Power BI. It organizes data into: * A central **fact table** containing business events. * Multiple **dimension tables** containing descriptive information. The structure resembles a star: ```text DimDate | | DimCustomer ---- FactSales ---- DimProduct | | DimStore ``` The fact table sits in the center. Dimension tables surround the fact table and provide context for analysis. *** ## Why Use a Star Schema? [#why-use-a-star-schema] A well-designed star schema improves: * Report performance * DAX simplicity * Data consistency * Model maintainability * User understanding Power BI works best when the model structure matches the business process. Example: A sales process contains: * Customers purchasing products * Products being sold * Sales occurring on specific dates * Transactions happening at locations A star schema represents these business events clearly. *** ## Star Schema Components [#star-schema-components] A Power BI star schema contains two main table types: ## Fact Tables [#fact-tables] Fact tables store measurable business events. Examples: * Sales transactions * Production output * Inventory movements * Service calls Example: ```text FactSales SalesKey DateKey CustomerKey ProductKey Quantity SalesAmount ``` Fact tables usually contain: * Numeric values * Transaction records * Foreign keys *** ## Dimension Tables [#dimension-tables] Dimension tables describe the business entities connected to facts. Examples: ```text DimDate DimCustomer DimProduct DimStore ``` They contain descriptive attributes used for: * Filtering * Grouping * Slicing reports Example: ```text DimProduct ProductKey ProductName Category Brand ``` *** ## Relationship Pattern [#relationship-pattern] Star schemas typically use one-to-many relationships. Example: ```text DimProduct | | 1 : Many | FactSales ``` The dimension table contains unique values. The fact table contains repeated transactions. Example: DimProduct: | ProductKey | ProductName | | ---------- | ----------- | | 1001 | Tire A | | 1002 | Tire B | FactSales: | ProductKey | Quantity | | ---------- | -------- | | 1001 | 10 | | 1001 | 5 | | 1002 | 8 | The relationship allows Power BI to filter sales by product. *** ## Star Schema vs Flat Tables [#star-schema-vs-flat-tables] A common mistake is storing everything in one large table. Example: ```text SalesData Date Customer Name Customer Region Product Category Quantity Sales Amount Store ``` Problems: * Duplicate information * Larger file size * More difficult DAX * Poorer model performance A star schema separates these responsibilities. Example: ```text DimCustomer CustomerKey CustomerName Region FactSales CustomerKey ProductKey DateKey SalesAmount DimProduct ProductKey ProductName Category ``` *** ## Benefits for DAX [#benefits-for-dax] Star schemas make DAX easier because filter context flows predictably. Example: ```dax lineNumbers Total Sales = SUM(FactSales[SalesAmount]) ``` A user selecting: ``` Category = "Off Road" ``` automatically filters: ``` DimProduct | v FactSales ``` The measure returns only matching sales. *** ## Star Schema Best Practices [#star-schema-best-practices] Follow these guidelines: * Keep one fact table for each business process. * Use dimension tables for filtering. * Avoid unnecessary relationships. * Create clear table names. * Use surrogate keys when appropriate. * Keep dimensions independent. Recommended naming: ``` FactSales FactInventory DimDate DimProduct DimCustomer ``` *** ## Common Modeling Mistakes [#common-modeling-mistakes] Avoid: ### Multiple Fact Tables Without Purpose [#multiple-fact-tables-without-purpose] Example: ``` Sales Sales_New Sales_Final Sales_Updated ``` This creates confusion and inconsistent reporting. *** ### Direct Fact-to-Fact Relationships [#direct-fact-to-fact-relationships] Avoid: ```text FactSales | FactInventory ``` Instead use shared dimensions: ```text DimDate | FactSales FactInventory ``` *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Fact Tables](/docs/modeling/fact-tables) * [Dimension Tables](/docs/modeling/dimension-tables) * [Relationships](/docs/modeling/relationships) * [CROSSFILTER()](/docs/dax/crossfilter) — temporarily overriding a relationship's filter direction in DAX * [Date Tables](/docs/modeling/date-tables) Advanced Topics: * [Slowly Changing Dimensions](/docs/modeling/slowly-changing-dimensions) * [Bridge Tables](/docs/modeling/bridge-tables) * [Many-to-Many Relationships](/docs/modeling/many-to-many) See it applied end to end: [Build a Complete Sales Analysis Report](/tutorials/build-a-sales-analysis-report) builds this exact pattern from a raw CSV export through to a finished report, [Build a Product Usage Dashboard on a Fabric Lakehouse](/tutorials/build-a-fabric-lakehouse-dashboard) for the same shape built with a Fabric notebook and Direct Lake instead, or [Build a Risk Register and Risk Matrix Dashboard](/tutorials/build-a-risk-register-dashboard) for a star schema paired with a "latest value per entity" DAX pattern. # DirectQuery vs. Import (/docs/modeling/storage-modes) # DirectQuery vs. Import [#directquery-vs-import] Every table in a Power BI model has a storage mode. The storage mode decides where the data actually lives, and how fast the model can query it. ```text Storage Mode | +-- Import | +-- DirectQuery | +-- Dual ``` *** ## Import Mode [#import-mode] Import mode copies data into Power BI's in-memory engine (VertiPaq) when the model refreshes. ```text Source Database | | copied on refresh | Power BI (in-memory) | | Report ``` Once loaded, queries run entirely against the in-memory copy. Characteristics: * Very fast query performance * Full DAX and modeling feature support * Data is only as current as the last refresh * Model size is limited by available memory Import is the default and recommended mode for most reports. *** ## DirectQuery Mode [#directquery-mode] DirectQuery sends queries straight to the source system every time a visual needs data. No data is copied into Power BI. ```text Report | | query sent live | Source Database ``` Characteristics: * Data is always current, with no refresh needed * No practical limit on data volume, since nothing is imported * Query performance depends entirely on the source system * Some DAX functions and modeling features are restricted DirectQuery is commonly used for very large datasets or when near-real-time data is required. *** ## Comparing Import and DirectQuery [#comparing-import-and-directquery] | Aspect | Import | DirectQuery | | -------------- | -------------------- | --------------------- | | Data location | Copied into Power BI | Stays in the source | | Query speed | Very fast | Depends on the source | | Data freshness | As of last refresh | Live | | Data volume | Limited by memory | Limited by the source | | DAX support | Full | Some restrictions | *** ## Dual Storage Mode [#dual-storage-mode] A table set to Dual can behave as either Import or DirectQuery, depending on which is more efficient for a given query. ```text Table (Dual) | +-- acts as Import | +-- acts as DirectQuery ``` Dual mode is most useful for shared dimension tables, such as `DimDate` or `DimProduct`, in a composite model. *** ## Composite Models [#composite-models] A composite model mixes storage modes across different tables in the same Power BI model. ```text FactSales (DirectQuery) | | DimDate (Dual) | | DimProduct (Dual) ``` This lets a very large fact table stay in DirectQuery, while smaller dimension tables use Dual mode for fast filtering and slicers. *** ## Why Composite Models Are Useful [#why-composite-models-are-useful] A single large fact table often does not need to be imported to get good performance, as long as the dimension tables it relates to are fast. Composite models let you: * Keep a huge fact table live in DirectQuery * Keep dimension tables fast using Dual mode * Combine data from multiple sources in one model * Avoid importing data that changes too often to cache usefully *** ## Relationships Across Storage Modes [#relationships-across-storage-modes] Relationships between an Import table and a DirectQuery table are allowed, but Power BI evaluates them differently depending on direction and mode. ```text DimProduct (Dual) | | filters | FactSales (DirectQuery) ``` Filtering from a Dual or Import dimension into a DirectQuery fact table is the most common and best-supported pattern. *** ## Performance Considerations [#performance-considerations] DirectQuery performance depends on: * Indexing and query performance of the source database * Network latency between Power BI and the source * The complexity of the DAX being translated into source queries * Aggregation tables, which can pre-summarize DirectQuery data for common queries Import performance depends on: * Model size and available memory * How well the model follows a star schema * Refresh frequency and duration *** ## Choosing a Storage Mode [#choosing-a-storage-mode] Use Import when: * The dataset fits comfortably in memory * Data does not need to be real-time * Maximum query performance and full DAX support are priorities Use DirectQuery when: * The dataset is too large to import * Data must reflect the source in near real-time * The source system can handle the query load Use a Composite model when: * One large fact table needs to stay live * Supporting dimension tables benefit from Import-level speed *** ## Best Practices [#best-practices] * Default to Import unless there's a specific reason not to. * Use Dual mode for shared dimension tables in composite models. * Add aggregation tables to speed up common DirectQuery queries. * Test report performance against realistic data volumes, not just a small sample. * Confirm the source database can handle concurrent DirectQuery load before rolling out broadly. *** ## Common Storage Mode Mistakes [#common-storage-mode-mistakes] ### Defaulting to DirectQuery for Everything [#defaulting-to-directquery-for-everything] DirectQuery removes the refresh step, but it also removes most of the performance benefit of Power BI's in-memory engine. Import first; move to DirectQuery only when there's a real reason. ### Leaving Dimension Tables in DirectQuery [#leaving-dimension-tables-in-directquery] Dimension tables used for slicers and filters are usually small. Leaving them in DirectQuery inside a composite model adds unnecessary latency for little benefit — Dual mode is almost always a better fit. ### Ignoring Source Performance [#ignoring-source-performance] A DirectQuery model is only as fast as the queries the source database can answer. Missing indexes or an under-provisioned source will make every report feel slow, regardless of how well the Power BI model is designed. *** ## Storage Mode Checklist [#storage-mode-checklist] Before publishing a DirectQuery or composite model: * Fact tables that need to stay live are set to DirectQuery. * Shared dimension tables are set to Dual, not DirectQuery. * Aggregation tables are in place for common queries, if needed. * The source database has been tested under realistic concurrent load. * Report performance has been validated with production-scale data. *** ## A Third Option for OneLake Data: Direct Lake [#a-third-option-for-onelake-data-direct-lake] If the source data lives in Microsoft Fabric's OneLake, a fourth storage mode is available: Direct Lake, which reads Delta tables directly from OneLake without a copy step and without querying a live source on every request. See [Direct Lake Mode](/docs/fabric/direct-lake) for how it works and when it falls back to DirectQuery-like behavior. *** ## Next Steps [#next-steps] Continue learning Power BI data modeling: * [Star Schema](/docs/modeling/star-schema) * [Relationships](/docs/modeling/relationships) * [Fact Tables](/docs/modeling/fact-tables) * [Direct Lake Mode](/docs/fabric/direct-lake) Import mode running out of memory on a large fact table? See [There Isn't Enough Memory to Complete This Operation](/blog/not-enough-memory-power-bi-desktop) — DirectQuery is one of the fixes.