Power BI Lesson 26 – Custom Columns | Dataplexa
Power Query · Lesson 26

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.

Add Column → Custom Column
New column name
RevenuePerUnit
Custom column formula
= try [Revenue] / [Quantity] otherwise null
✓ No syntax errors have been detected.
Available columns
OrderID«
Revenue«
Quantity«
CustomerID«
Status«
Double-click or click « to insert column name into formula

The Working Dataset

Orders table — used for all examples in this lesson
OrderID Revenue Quantity Status CustomerTier OrderDate DueDate
10011,2004DeliveredPremium2024-01-052024-01-10
10028500ProcessingStandard2024-01-182024-01-23
10034302LatePremium2024-02-032024-02-01
1004951LateStandard2024-02-142024-02-10
10051,5406ShippedPremium2024-02-202024-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
Results for the five if expressions above
OrderID LateFlag ValueBand TierLabel DueStatus IsLate (0/1)
1001(blank)High ValuePriorityOn Time0
1002(blank)StandardRegularOn Time0
1003⚠ LateStandardPriorityOverdue1
1004⚠ LateStandardRegularOverdue1
1005(blank)High ValuePriorityOn Time0

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"
Nested if results — Revenue tier and escalation logic
OrderID Revenue RevenueTier EscalationFlag NeedsAttention
10011,200GoldOKClosed
1002850SilverOKNeeds Attention
1003430BronzeEscalateNeeds Attention
100495BronzeMonitorNeeds Attention
10051,540PlatinumOKClosed

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"
Pattern 3 — Status label results
OrderID Status (raw) StatusLabel (display)
1001Delivered✓ Complete
1002Processing⏳ Pending
1003Late⚠ Overdue
1004Late⚠ Overdue
1005Shipped↗ 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
Delivery performance score — all five orders
OrderID DaysLate BaseScore Tier ×Multiplier PerfScore
1001-5100Premium×1.2 → 120100
1002-5100Standard×1.0 → 100100
1003+275Premium×1.2 → 9090
1004+450Standard×1.0 → 5050
1005-5100Premium×1.2 → 120100
Number.Min caps the score at 100 — the let…in block keeps each calculation step named and readable without creating four separate Applied Steps

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.