Power BI Course
Custom Columns
Custom Columns are where Power Query becomes a programming environment. The point-and-click tools cover common transformations, but the moment you need logic — "if the order is late and the customer is premium, flag it; otherwise leave it blank" — you need to write M code directly. This lesson covers the complete toolkit: conditional logic, nested ifs, switch-style patterns, working with nulls, and combining multiple columns into one expression that would take five separate UI steps to build.
Opening the Custom Column Dialog
Custom Column is always on the Add Column tab — not the Transform tab. This is the correct choice: a Custom Column creates a new column alongside existing ones, leaving the originals untouched. If you want to replace an existing column's values with a formula, use Add Column → Custom Column, then delete the original.
The Working Dataset
| OrderID | Revenue | Quantity | Status | CustomerTier | OrderDate | DueDate |
|---|---|---|---|---|---|---|
| 1001 | 1,200 | 4 | Delivered | Premium | 2024-01-05 | 2024-01-10 |
| 1002 | 850 | 0 | Processing | Standard | 2024-01-18 | 2024-01-23 |
| 1003 | 430 | 2 | Late | Premium | 2024-02-03 | 2024-02-01 |
| 1004 | 95 | 1 | Late | Standard | 2024-02-14 | 2024-02-10 |
| 1005 | 1,540 | 6 | Shipped | Premium | 2024-02-20 | 2024-02-25 |
Simple Calculations
The simplest Custom Columns are arithmetic expressions. Reference columns using square brackets. Wrap any calculation that could fail — division, date arithmetic, type conversion — in try … otherwise null.
// Revenue per unit — safe division
= try [Revenue] / [Quantity] otherwise null
// 1001: 1200/4 = 300 1002: 850/0 → null (not error)
// Gross margin % (assuming a Cost column exists)
= try Number.Round(([Revenue] - [Cost]) / [Revenue] * 100, 1) otherwise null
// Days overdue — how many days past the due date
= try Duration.Days([OrderDate] - [DueDate]) otherwise null
// Positive = order placed after due date (late)
// Negative = order placed before due date (on time)
// Revenue band label — bucketing with Number.Round
= "Band " & Text.From(Number.RoundUp([Revenue] / 500, 0))
// 95 → "Band 1" (95/500 = 0.19, rounds up to 1)
// 430 → "Band 1" (430/500 = 0.86, rounds up to 1)
// 850 → "Band 2" (850/500 = 1.7, rounds up to 2)
// 1200 → "Band 3" (1200/500 = 2.4, rounds up to 3)
// 1540 → "Band 4" (1540/500 = 3.08, rounds up to 4)
if … then … else — Single Condition
M's conditional syntax is if condition then value_if_true else value_if_false. Unlike Excel, there are no parentheses around the condition and no comma separators — the keywords do all the work.
// Basic if — flag late orders
= if [Status] = "Late" then "⚠ Late" else ""
// Numeric threshold — high value order flag
= if [Revenue] >= 1000 then "High Value" else "Standard"
// Null-safe condition — check for null before comparing
= if [CustomerTier] = null then "Unknown"
else if [CustomerTier] = "Premium" then "Priority"
else "Regular"
// Date comparison — is order overdue?
= if [OrderDate] > [DueDate] then "Overdue" else "On Time"
// Boolean flag as number (useful for SUMIF-style DAX measures)
= if [Status] = "Late" then 1 else 0
| OrderID | LateFlag | ValueBand | TierLabel | DueStatus | IsLate (0/1) |
|---|---|---|---|---|---|
| 1001 | (blank) | High Value | Priority | On Time | 0 |
| 1002 | (blank) | Standard | Regular | On Time | 0 |
| 1003 | ⚠ Late | Standard | Priority | Overdue | 1 |
| 1004 | ⚠ Late | Standard | Regular | Overdue | 1 |
| 1005 | (blank) | High Value | Priority | On Time | 0 |
Nested if — Multiple Conditions
Chain conditions by placing another if inside the else branch. M evaluates top to bottom and stops at the first true condition — so put the most specific conditions first.
// Revenue tier — 4 bands using nested if
= if [Revenue] >= 1500 then "Platinum"
else if [Revenue] >= 1000 then "Gold"
else if [Revenue] >= 500 then "Silver"
else "Bronze"
// 1001 (1200) → "Gold"
// 1003 (430) → "Bronze"
// 1005 (1540) → "Platinum"
// Compound condition — AND using nested if
// "Late AND Premium customer" = Priority escalation
= if [Status] = "Late" then
if [CustomerTier] = "Premium" then "Escalate"
else "Monitor"
else "OK"
// 1003: Late + Premium → "Escalate"
// 1004: Late + Standard → "Monitor"
// 1001: Delivered → "OK"
// Compound condition — AND written inline
= if [Status] = "Late" and [CustomerTier] = "Premium"
then "Escalate"
else if [Status] = "Late"
then "Monitor"
else "OK"
// Compound condition — OR
= if [Status] = "Late" or [Status] = "Processing"
then "Needs Attention"
else "Closed"
| OrderID | Revenue | RevenueTier | EscalationFlag | NeedsAttention |
|---|---|---|---|---|
| 1001 | 1,200 | Gold | OK | Closed |
| 1002 | 850 | Silver | OK | Needs Attention |
| 1003 | 430 | Bronze | Escalate | Needs Attention |
| 1004 | 95 | Bronze | Monitor | Needs Attention |
| 1005 | 1,540 | Platinum | OK | Closed |
Switch-Style Pattern — Replacing Long Nested ifs
When you have more than three or four else if branches all checking the same column, the expression becomes hard to read and maintain. M does not have a built-in switch statement, but two patterns produce the same result cleanly.
// Pattern 1 — List.PositionOf lookup table (switch equivalent)
// Map Status values to numeric priority scores
let
statuses = {"Delivered", "Shipped", "Processing", "Late"},
scores = {0, 1, 2, 3},
position = List.PositionOf(statuses, [Status])
in
if position = -1 then null // -1 means not found in list
else scores{position}
// Delivered → 0, Shipped → 1, Processing → 2, Late → 3, anything else → null
// Pattern 2 — Record field lookup (cleanest for text-to-text mapping)
// Map abbreviated region codes to full names
let
lookup = [
NSW = "New South Wales",
VIC = "Victoria",
QLD = "Queensland",
WA = "Western Australia"
]
in
if Record.HasFields(lookup, [RegionCode])
then Record.Field(lookup, [RegionCode])
else "Unknown Region"
// Pattern 3 — nested if with readable indentation (still fine for 4-5 cases)
= if [Status] = "Delivered" then "✓ Complete"
else if [Status] = "Shipped" then "↗ In Transit"
else if [Status] = "Processing" then "⏳ Pending"
else if [Status] = "Late" then "⚠ Overdue"
else "? Unknown"
| OrderID | Status (raw) | StatusLabel (display) |
|---|---|---|
| 1001 | Delivered | ✓ Complete |
| 1002 | Processing | ⏳ Pending |
| 1003 | Late | ⚠ Overdue |
| 1004 | Late | ⚠ Overdue |
| 1005 | Shipped | ↗ In Transit |
Working Safely with Nulls in Custom Columns
Nulls in source columns are the most common cause of Custom Column errors. A column reference to a null value does not crash an if condition — if [x] = null works correctly. But using a null in arithmetic or text concatenation with & produces an error. Three null-handling patterns cover the majority of cases.
// Pattern 1 — null-coalescing: use a default if null
// "Give me the value, or use this default if it's null"
= if [Discount] = null then 0 else [Discount]
// Equivalent, shorter — using the null-coalescing trick:
= [Discount] ?? 0
// Note: ?? is not valid M syntax — use the if form above
// Pattern 2 — null-safe concatenation
// Wrapping each nullable column before &
= (if [FirstName] = null then "" else [FirstName])
& " "
& (if [LastName] = null then "" else [LastName])
// null + null → " " (two spaces — then trim if needed)
// Pattern 3 — propagate null (if any input is null, output is null)
// Use when a null input should mean "unknown result"
= if [Revenue] = null or [Quantity] = null
then null
else try [Revenue] / [Quantity] otherwise null
// Pattern 4 — null check before date arithmetic
= if [OrderDate] = null or [DueDate] = null
then null
else Duration.Days([OrderDate] - [DueDate])
let … in Inside a Custom Column
For longer calculations with intermediate steps, write a mini let … in block inside the Custom Column formula. This keeps the logic readable without splitting it across multiple Add Column steps.
// Multi-step logic inside one Custom Column
// Calculate a delivery performance score combining lateness and tier
= let
// Step 1 — days overdue (negative = early, positive = late)
DaysLate = if [OrderDate] = null or [DueDate] = null
then 0
else Duration.Days([OrderDate] - [DueDate]),
// Step 2 — base score from lateness
BaseScore = if DaysLate <= 0 then 100
else if DaysLate <= 3 then 75
else if DaysLate <= 7 then 50
else 25,
// Step 3 — tier multiplier
Multiplier = if [CustomerTier] = "Premium" then 1.2 else 1.0,
// Step 4 — final score capped at 100
FinalScore = Number.Min(Number.Round(BaseScore * Multiplier, 0), 100)
in
FinalScore
// Results:
// 1001: DaysLate=-5, BaseScore=100, Multiplier=1.2 → min(120,100) = 100
// 1003: DaysLate=+2, BaseScore=75, Multiplier=1.2 → min(90,100) = 90
// 1004: DaysLate=+4, BaseScore=50, Multiplier=1.0 → min(50,100) = 50
| OrderID | DaysLate | BaseScore | Tier | ×Multiplier | PerfScore |
|---|---|---|---|---|---|
| 1001 | -5 | 100 | Premium | ×1.2 → 120 | 100 |
| 1002 | -5 | 100 | Standard | ×1.0 → 100 | 100 |
| 1003 | +2 | 75 | Premium | ×1.2 → 90 | 90 |
| 1004 | +4 | 50 | Standard | ×1.0 → 50 | 50 |
| 1005 | -5 | 100 | Premium | ×1.2 → 120 | 100 |
Teacher's Note: The most common Custom Column mistake is putting complex logic in Power Query that belongs in DAX. The rule is: if the calculation needs the row context of a single row and does not need to aggregate across rows, it can live in Power Query. If it needs to respond to slicers, compare a row to a filtered subset, or calculate running totals, it must be a DAX measure. A revenue tier label belongs in Power Query — it only looks at one row's revenue. A "% of total revenue" column must be a DAX measure — it divides one row's revenue by the sum of all rows, which changes when a slicer is applied.
Practice
Practice 1 of 3
You write the Custom Column formula = [Revenue] / [Quantity] and some rows show error cells because Quantity is 0. To return null instead of an error for those rows, you wrap the formula as: ___ [Revenue] / [Quantity] otherwise null.
Practice 2 of 3
In M's conditional syntax, after the condition you write the keyword ___, then the value to return if true, then else, then the value if false — with no parentheses or commas.
Practice 3 of 3
To write a Custom Column with multiple named intermediate calculation steps — keeping the logic readable without creating several separate Applied Steps — you use a ___ … in block inside the formula.
Lesson Quiz
Quiz 1 of 3
You write a nested if to assign revenue tiers: Platinum ≥ 1500, Gold ≥ 1000, Silver ≥ 500, Bronze for everything else. A colleague reverses the order and writes Bronze first (Revenue < 500), then Silver, then Gold, then Platinum. What is wrong with the reversed version?
Quiz 2 of 3
You want a column that shows each order's revenue as a percentage of total revenue, updating correctly when a slicer filters the report by region. Should you build this in a Custom Column in Power Query or as a DAX measure, and why?
Quiz 3 of 3
A Custom Column formula uses = [FirstName] & " " & [LastName]. For some rows, LastName is null. What error appears and what is the correct fix?
Next up — Lesson 27 covers Conditional Columns, the point-and-click UI for building if/else logic without writing M code, when to use it versus Custom Column, and how to edit the generated M to extend it beyond what the dialog supports.