Skip to content
ImpactMojo ImpactMojo
Browse Membership
Back to Code Studio
Code Studio · Guided tool course

Power BI for M&E dashboards

Build a district dashboard in Power BI Desktop from two survey files: clean them in Power Query, model one district to many households, write DAX measures for coverage rates and weighted means, and publish a report that is accessible and does not leak the data behind it. Follow along in your own copy of Power BI Desktop, and check every number in R on this page.

Power BI does not run in a browser tab. The grey boxes are Power Query M and DAX for you to type into your own Power BI Desktop, which runs on Windows. This page does not show Power BI output, because it cannot produce any; each module tells you what to look for on your screen instead. Where the same number can be computed in R, an R cell computes it here on the same data, so you can check your cards and tables against it. The first R run downloads the R engine once (about 7 MB).
Module 1 of 8

Power BI Desktop, the service and your project folder

Power BI is Microsoft's business intelligence software. Most M&E teams meet it as the tool behind a donor dashboard: a page of indicator cards, a district map and a few slicers. Two parts matter. Power BI Desktop is the free Windows application in which you load data, build the model, write the formulas and design the report. The Power BI service (app.powerbi.com) is where a report is published, refreshed and shared.

Cost and requirements, checked 7 October 2026. Microsoft offers Power BI Desktop as a free download from the Microsoft Store or as a 64-bit installer. It needs Windows 10, Windows Server 2016 or later, at least 2 GB of free memory (4 GB recommended) and a screen of at least 1440x900. There is no Mac version; Microsoft supports Desktop on Azure Virtual Desktop and Windows 365, which is one route for Mac users. Microsoft releases a new version every month and supports only the latest. Sharing is what costs money: Microsoft's pricing page lists Power BI Pro at US$14 per user per month and Premium Per User at US$24 (both billed yearly), and says the free account must upgrade to share reports. Ask your organisation first; many already pay for Microsoft 365 licences that include Pro.

Two languages, two jobs

Power BI has two formula languages, and most confusion comes from mixing them up.

The rule of thumb: shape and clean in M, calculate in DAX.

Set up the course folder

  1. Install Power BI Desktop from the Microsoft Store (updates arrive by themselves, and you do not need administrator rights) or the Download Center.
  2. Make a project folder with a short path, for example C:\impactmojo\powerbi.
  3. Download the two course files into it: households.csv (240 households) and districts.csv (10 districts). Illustrative data, invented for teaching The district names are real places; every number is made up.
  4. Open Power BI Desktop, close the start screen and choose File > Save as. Save as type Power BI project files (*.pbip) and name it mne_dashboard.

A .pbix file is one closed package. A Power BI project saves the same report as a folder of plain text files: mne_dashboard.pbip, a mne_dashboard.Report folder and a mne_dashboard.SemanticModel folder, plus a .gitignore. Text files can be compared line by line and kept in Git, so you can see which measure changed between the June and the September dashboard. You can convert back to .pbix at any time with File > Save as.

Exercise. After saving, open the project folder in File Explorer. Find the .gitignore file Power BI wrote, open it in Notepad and note which two files it keeps out of Git: the local settings and the data cache.
Module 2 of 8

Get data and read the M that Power Query writes

Every dataset enters Power BI through Get data. For a CSV the steps are the same whether it came from KoboToolbox, an MIS export or a government portal.

  1. On the Home ribbon choose Get data > Text/CSV and pick households.csv.
  2. A preview opens. Do not press Load. Press Transform data, which opens the Power Query Editor.
  3. Repeat for districts.csv. Both now appear under Queries on the left.

Look at the Applied steps pane on the right. Power Query has already taken three steps for you: Source, Promoted Headers and Changed Type. Open Home > Advanced Editor to see them as M. It will look much like this; your file path, and possibly the encoding number, will differ.

Power Query M: households (as generated, abridged)
let
    Source = Csv.Document(File.Contents("C:\impactmojo\powerbi\households.csv"),
        [Delimiter = ",", Columns = 13, Encoding = 65001, QuoteStyle = QuoteStyle.None]),
    #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars = true]),
    #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers", {
        {"hh_id", Int64.Type}, {"district", type text}, {"area", type text},
        {"caste", type text}, {"head_edu_years", Int64.Type}, {"hh_size", Int64.Type},
        {"monthly_pc_exp", Int64.Type}, {"land_acres", type number},
        {"has_toilet", type text}, {"has_bank_account", type text}})
in
    #"Changed Type"

How to read it

The same import and type check in R, on the same file, runs here. Use it to confirm the row and column counts Power Query shows in its status bar.

Exercise. In Power Query, click the Changed Type step and change land_acres to Whole number. Look at what happens to the values, then delete your change from Applied steps. A wrong type does not raise an error; it rounds your data.
Module 3 of 8

Clean in Power Query: indicators, bands and a district table

Survey exports arrive with Yes/No text, raw years of schooling and stray spaces. Fix them in Power Query, once, so every measure downstream starts from clean columns. Each step below can be done with a menu, and the menu writes the M shown.

Yes/No to 1/0

Select the households query, then Add Column > Custom column. Name it toilet and enter the formula below. Repeat for bank.

Power Query M: custom columns
#"Added toilet" = Table.AddColumn(#"Changed Type", "toilet",
    each if [has_toilet] = "Yes" then 1 else 0, Int64.Type),
#"Added bank" = Table.AddColumn(#"Added toilet", "bank",
    each if [has_bank_account] = "Yes" then 1 else 0, Int64.Type)

each means "for each row", and [has_toilet] is that row's value. The last argument sets the new column's type, so the column arrives as a whole number and not as type any.

Education bands

Power Query M: a banded column
#"Added edu band" = Table.AddColumn(#"Added bank", "edu_band",
    each if [head_edu_years] = 0 then "None"
    else if [head_edu_years] <= 5 then "Primary (1-5)"
    else if [head_edu_years] <= 10 then "Secondary (6-10)"
    else "Higher (11+)", type text)

Tidy text keys before you join on them

District names typed by hand carry trailing spaces and mixed case, and "Gaya " will not match "Gaya" in a relationship. Select the district column in both queries and choose Transform > Format > Trim.

Power Query M: trim
#"Trimmed district" = Table.TransformColumns(#"Added edu band", {{"district", Text.Trim, type text}})

A district summary with Group By

To build a separate one-row-per-district table (for export, or to check totals), right-click the households query, choose Reference, then Transform > Group By, Advanced.

Power Query M: Group By
let
    Source = households,
    Grouped = Table.Group(Source, {"district"}, {
        {"households", each Table.RowCount(_), Int64.Type},
        {"toilet_rate", each List.Average([toilet]), type number},
        {"mean_pc_exp", each List.Average([monthly_pc_exp]), type number}})
in
    Grouped

The R version of the indicator, the bands and the district table. Check the district counts against your Group By result.

Exercise. Add a column shg that is 1 when shg_member is "Yes". Then right-click the has_toilet column and choose Remove: you no longer need the text once the 1/0 column exists. Close Power Query with Home > Close & Apply.
Module 4 of 8

The data model: one district, many households

A Power BI report is only as right as its model. The model for most survey dashboards is a star schema: one fact table with a row per unit observed (here, households), and dimension tables that describe something the facts belong to (here, districts). Facts carry numbers to add up; dimensions carry the labels you slice by.

  1. Open Model view (the third icon on the left edge).
  2. Power BI may already have drawn a line between the two tables on district. If not, drag district from districts onto district in households.
  3. Double-click the line. Check that Cardinality reads Many to one (*:1) from households to districts, and Cross filter direction reads Single.

Wide indicator sheets: unpivot before you load

Programme teams often keep indicators one column per round: district, baseline, midline, endline. Power BI wants one column for the round and one for the value, so a single line chart can draw all three. Select district, then Transform > Unpivot Columns > Unpivot Other Columns.

Power Query M: unpivot
#"Unpivoted" = Table.UnpivotOtherColumns(Source, {"district"}, "round", "value")

Table.UnpivotOtherColumns(table, pivotColumns, attributeColumn, valueColumn) keeps the columns you name and turns every other column into round/value pairs. "Other columns" matters: when an endline column is added next year, it is unpivoted too, with no change to the query.

In R, merge() does what the relationship does, and the all.x check tells you whether any household failed to find its district.

Exercise. In Report view, put a Table visual on the page with districts[state] and a count of households[hh_id]. The counts should add to 240. If a row reads (Blank), some household's district has no match in the districts table.
Module 5 of 8

DAX measures, and why a rate is never a column

DAX gives you two ways to calculate. A calculated column is worked out once per row when the data loads, and stored. A measure is worked out when a visual asks for it, for whatever rows the reader's slicers leave in play. Indicators on a dashboard (rates, means, counts) are measures.

  1. In Report view select the households table in the Data pane, then Home > New measure.
  2. Type each measure below into the formula bar and press Enter. Each one is a separate measure.
  3. Set the format on the Measure tools ribbon: percentage for the rate, whole number for the count.
DAX: first measures (each line is one measure)
Households = COUNTROWS ( households )

Toilet coverage = DIVIDE ( SUM ( households[toilet] ), [Households] )

Mean MPCE = AVERAGE ( households[monthly_pc_exp] )

Bank account rate = DIVIDE ( SUM ( households[bank] ), [Households] )

Why the rate must be a measure

Suppose you stored coverage as a column of group rates and let the total row average them. That gives every group equal weight, so a district of 20 households would count as much as one of 2,000. The measure above divides total toilets by total households for whatever is selected, so the total row is the true pooled rate.

In the course file every district has 24 households, so for districts the two answers happen to agree. Rural and urban households are unequal groups (164 and 76), and there they part company. The R cell shows both.

When a calculated column is right

Use a column for something you will slice by, which belongs to one row and does not change with the filters. An expenditure band is one.

DAX: a calculated column (Table tools > New column)
MPCE band =
SWITCH (
    TRUE (),
    households[monthly_pc_exp] < 2000, "Under 2,000",
    households[monthly_pc_exp] < 4000, "2,000 to 3,999",
    "4,000 and above"
)

SWITCH(TRUE(), ...) tests each condition in order and returns the first that holds, so the bands cannot overlap.

Exercise. Make a Matrix visual with households[area] on rows and the measures [Households] and [Toilet coverage]. Read the total row, then compare it with the two numbers the R cell printed.
Module 6 of 8

CALCULATE and filter context

Every number in a visual is computed under a filter context: the set of filters from the row it sits in, the slicers on the page and any page or report filters. CALCULATE evaluates an expression under a filter context you change. It is the most used function in DAX and the one that explains almost every "why does my total look wrong" question.

DAX: CALCULATE
Rural households = CALCULATE ( [Households], households[area] = "Rural" )

Rural toilet coverage = CALCULATE ( [Toilet coverage], households[area] = "Rural" )

SC/ST households =
CALCULATE ( [Households], households[caste] IN { "SC", "ST" } )

A filter argument such as households[area] = "Rural" replaces any filter already on that column. Put [Rural households] in a matrix by area and the Urban row also shows the rural count, because the measure overrides the row's own filter. Wrap the filter in KEEPFILTERS( ) when you want it to intersect with the row instead.

Share of the total

To show each district's share of all households, the denominator must ignore the district filter. REMOVEFILTERS (or the older ALL) clears it.

DAX: share of total
Share of households =
DIVIDE ( [Households], CALCULATE ( [Households], REMOVEFILTERS ( districts ) ) )

REMOVEFILTERS ( districts ) clears filters from every column of the districts table, so the share still sums to 100% when the reader also slices by state. A state slicer would still filter the numerator; decide whether your share is "of the state" or "of the whole sample", and write it into the visual title.

Reading the selection

DAX: a dynamic title
Selected district title =
"Toilet coverage, " & SELECTEDVALUE ( districts[district], "all districts" )

SELECTEDVALUE returns the value when exactly one is selected and the alternate text otherwise. Use it as a card or as a visual title (Format pane, Title > fx) so a screenshot sent on WhatsApp still says what it shows.

The same three numbers in R, to check your cards against:

Exercise. Make a measure Urban toilet coverage and a third measure for the rural minus urban gap, written as [Rural toilet coverage] - [Urban toilet coverage]. Put all three in a matrix by state and check one state by hand in the R cell.
Module 7 of 8

Population-weighted figures with SUMX

Mean MPCE from Module 5 averages over households: every household counts once, whether it has two members or nine. A per-person figure, the mean expenditure of the average person, weights each household by its size. DAX's iterators do this: SUMX(table, expression) evaluates the expression row by row and adds the results.

DAX: a population-weighted mean
Persons = SUM ( households[hh_size] )

Mean MPCE per person =
DIVIDE (
    SUMX ( households, households[monthly_pc_exp] * households[hh_size] ),
    [Persons]
)

The same pattern takes any weight. If your file carries a survey weight column, as NFHS and PLFS unit-level files do, replace households[hh_size] with it in both places.

Weights give the right estimate. Its uncertainty needs survey software. SUMX gives a correctly weighted point estimate. Power BI has no survey design features, so it cannot give a standard error or confidence interval that allows for stratification and clustering. If your dashboard reports estimates from a sample survey, compute the intervals in Stata (svy:), R (survey) or SPSS Complex Samples, bring them in as a table, and show them beside the estimate. Do not let a dashboard imply that a district difference of two points is real when the interval is ten points wide.

Check both means in R. If larger households here spend less per head, the weighted mean comes out lower, because those households now count once for each member.

Iterators and the total row

SUMX, AVERAGEX and their relatives loop over whatever rows the filter context leaves. That is why the total row of a matrix is computed afresh over all rows, never by adding up the rows above it. For a sum the two agree; for a mean or a rate they do not, and the fresh computation is the correct one.

Exercise. Add [Mean MPCE] and [Mean MPCE per person] to the matrix by district. In which districts do they differ most? Check one in the R cell with subset(hh, district == "Purnia").
Module 8 of 8

A report people can read, and sharing it safely

A dashboard is read by a district officer on a phone, a programme manager on a projector and a donor in a PDF. Build for all three.

Page design

Accessibility

Sharing: the one setting that can leak a dataset

Publish to web makes a report public. Microsoft's documentation says that anyone on the internet can view a report published this way, with no sign-in, and that this includes detail-level data the report aggregates: anyone can reach the underlying data in the model even if no visual shows it. A household survey with names, phone numbers or GPS points must never be published this way, even if the visuals show only district totals. Remove personal identifiers in Power Query before the data reaches the model, and share through a workspace, an app or a secure embed instead.

To publish, choose Home > Publish and pick a workspace. Colleagues need access to that workspace or an app built from it, and both you and they need the licence your organisation's workspace requires. Schedule refresh in the service only when the source is somewhere the service can reach, such as SharePoint or a database; a CSV on your laptop cannot refresh on its own.

Version control

Because you saved as a Power BI project in Module 1, the model is stored as text. Put the project folder in a Git repository (the Git and Quarto course covers the commands) and commit after each working change. A diff then shows exactly which measure definition changed between two versions of a donor report. Keep raw survey files out of the repository; the .gitignore Power BI wrote keeps out its data cache, and you should add your data files to it as well.

Exercise. Build one page: three cards, a sorted bar chart of [Toilet coverage] by district, slicers for state and area, a dynamic title, and alt text on every visual. Then press Tab from the top of the page and check that focus moves in the order you read.

Microsoft's own documentation

Where next

→

Power BI flagship course

Eight modules with labs on NFHS, ASER and World Bank data, and where the free version stops.

→

Power BI Lexicon

Plain definitions of the terms used in this course and many more.

→

Spreadsheets for M&E

Tidy sheets, validation and indicator tables before the data reaches Power BI.

→

SQL for Development Data

When the data lives in a database, Power BI can query it; SQL is how you check what it gets.