
Picture this: you've built a Power Query solution that pulls from a 50-million-row SQL Server database. Your report loads in seconds during development because you're working with a filtered sample. Then you deploy it, remove the row limit, and suddenly the refresh that should take two minutes is taking twenty-five. You open the Query Diagnostics pane and discover the awful truth — every transformation you wrote after that one Table.AddColumn call stopped folding. Power Query is pulling the entire table into memory and processing it locally. Congratulations, you've met the query folding cliff.
Query folding — the process by which Power Query translates M transformations back into native source queries — is one of the most impactful performance levers available to a data professional. When it works, your data source does the heavy lifting: filtering, sorting, grouping, and joining happen inside a database engine optimized for exactly that work. When it breaks, Power Query becomes a local processing engine wrestling with data volumes it was never designed to handle. The difference isn't marginal. It can be the difference between a functional solution and one that brings an organization's reporting infrastructure to its knees.
By the end of this lesson, you'll understand not just what query folding is, but how to actively engineer for it — diagnosing where folding breaks, restructuring queries to preserve it, and in some cases writing custom logic that explicitly hands work back to the source.
What you'll learn:
Value.NativeQueryValue.NativeQuery to inject native SQL when M's folding falls shortYou should be comfortable with:
let...in expressionsBefore you can fix folding problems, you need to understand the mechanics of why they happen. Power Query's folding engine works by maintaining an internal representation of your transformation chain as something it can express in the target query language. As long as every step in your chain maps to an operation the source supports, the engine can keep building that representation.
The moment you introduce something it can't translate — a custom M function, certain text operations, a step that references a value from outside the source — the engine drops the translation entirely. Every subsequent step, no matter how simple, now runs locally. This is the folding cliff, and it's a cliff in the truest sense: once you go over it, you don't climb back up just by adding more foldable steps afterward.
Here's a concrete example. Suppose you're connecting to a SQL Server table called SalesTransactions with 40 million rows. You write this query:
let
Source = Sql.Database("prod-server", "SalesDB"),
SalesTransactions = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
FilteredRows = Table.SelectRows(SalesTransactions, each [TransactionDate] >= #date(2023, 1, 1)),
AddedYear = Table.AddColumn(FilteredRows, "FiscalYear", each Date.Year([TransactionDate])),
CustomFlag = Table.AddColumn(AddedYear, "IsHighValue", each
if [Amount] > 10000 and Text.StartsWith([CustomerCode], "ENT") then true else false
),
FinalGrouped = Table.Group(CustomFlag, {"FiscalYear", "IsHighValue"}, {{"TotalAmount", each List.Sum([Amount]), type number}})
in
FinalGrouped
The FilteredRows step folds perfectly. AddedYear likely folds too — Date.Year has a SQL equivalent. But Text.StartsWith in the CustomFlag step? That may or may not fold depending on the connector version and the specific operation. If it doesn't, FinalGrouped definitely won't fold either, because it depends on CustomFlag. You're now grouping 40 million filtered rows in memory.
Critical insight: Folding isn't just about individual steps — it's about the chain. A non-folding step poisons every step that depends on it.
In Power Query Editor, right-click on any step in the Applied Steps panel. If you see "View Native Query" in the context menu and it's clickable (not grayed out), that step is folding. If it's grayed out, it isn't. This is your first diagnostic.
For steps that are folding, clicking "View Native Query" shows you exactly what SQL (or OData query, or whatever the native language is) Power Query is generating. This is invaluable. You might discover Power Query is generating a 47-join monster when a simple two-table join would suffice, which points you toward restructuring.
For deeper analysis, go to the Tools tab in Power Query Editor and enable "Start Diagnostics." Run your refresh, then "Stop Diagnostics." This generates two tables: a summary and a detailed trace. The detailed trace shows you the actual queries sent to the source and the timing for each step. Look for steps where the Data Source Kind column shows your database but the query sent is unexpectedly broad — this often reveals partial folding where the filter didn't make it down.
Here's a technique experienced M developers use: wrap a suspicious step in Table.IsEmpty(). If this returns quickly, the step is likely folding (the database handles the empty-check efficiently). If it hangs, Power Query is probably pulling data locally. This isn't a production technique — it's a diagnostic probe you use during development and then remove.
For the most precise diagnosis, you can use Value.NativeQuery to check what query the engine would generate for a given table expression:
// This isn't a full query — it's a diagnostic pattern
let
Source = Sql.Database("prod-server", "SalesDB"),
SalesTransactions = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
FilteredRows = Table.SelectRows(SalesTransactions, each [TransactionDate] >= #date(2023, 1, 1)),
// Check what SQL this generates:
NativeSQL = Value.NativeQuery(FilteredRows, "SELECT 1") // This forces the engine to resolve FilteredRows as a subquery
in
NativeSQL
Actually, the more direct approach: after writing a transformation chain, open the Advanced Editor and note which step you're testing. Right-click that step name in Applied Steps and use "View Native Query." The absence of this option is your signal to investigate.
Understanding exactly which M operations break folding helps you make better architectural decisions. The full list varies by connector, but the common culprits fall into a few categories.
Any invocation of a user-defined function breaks folding. The engine has no way to translate your custom M logic into SQL. This is the most common source of unintentional fold breaks:
// This breaks folding — the engine can't translate CleanCustomerName into SQL
CleanCustomerName = (name as text) =>
Text.Trim(Text.Proper(Text.Replace(name, " ", " "))),
AppliedClean = Table.TransformColumns(FilteredData, {{"CustomerName", CleanCustomerName}})
If you reference a value that comes from outside the source — a parameter, a lookup table from a different source, a hardcoded list — this can break folding depending on how you use it. Scalar parameters often fold fine; joining to a local Excel table does not.
// This likely breaks folding — LocalExclusions comes from Excel, not SQL Server
LocalExclusions = Excel.CurrentWorkbook(){[Name="ExclusionList"]}[Content],
Filtered = Table.SelectRows(SQLTable, each not List.Contains(LocalExclusions[CustomerID], [CustomerID]))
Some M functions simply don't have direct equivalents in common SQL dialects. Table.Pivot, Table.UnpivotOtherColumns, complex Table.NestedJoin configurations, and certain date functions fall into this category. The connector's folding capability also matters — the SQL Server connector folds much more aggressively than the OData connector.
Here's a subtle one. If you use Table.TransformColumnTypes to change a column type and the type conversion isn't natively expressible (like converting a text column to a decimal with specific culture settings), the fold chain breaks. Always check whether type transformations are folding.
Now that you understand what breaks folding, let's talk about the most powerful technique: restructuring your query so that all foldable operations happen on the source-side of the chain, and non-foldable operations happen after you've already reduced your dataset as much as possible.
The golden rule is straightforward: push every operation that can fold to the earliest possible point in your chain. Do all your filtering, joining, grouping, and sorting at the source. Then bring the reduced dataset across the network and do your custom transformations locally on the smaller result.
Here's the bad version — transformation interleaved with filterable operations:
// PROBLEMATIC: Custom column added before grouping
let
Source = Sql.Database("prod-server", "SalesDB"),
SalesTransactions = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
FilteredRows = Table.SelectRows(SalesTransactions, each [TransactionDate] >= #date(2023, 1, 1)),
// This custom function breaks folding here
AddedSegment = Table.AddColumn(FilteredRows, "Segment",
each DetermineSegment([Amount], [CustomerTier])),
// These steps now run locally on 15 million rows
GroupedByRegion = Table.Group(AddedSegment, {"Region"},
{{"TotalAmount", each List.Sum([Amount]), type number}}),
SortedResults = Table.Sort(GroupedByRegion, {{"TotalAmount", Order.Descending}})
in
SortedResults
Here's the restructured version:
// BETTER: All foldable operations first, custom logic applied to small result
let
Source = Sql.Database("prod-server", "SalesDB"),
SalesTransactions = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
FilteredRows = Table.SelectRows(SalesTransactions, each [TransactionDate] >= #date(2023, 1, 1)),
// Group and sort fold back to SQL — operating on source data
GroupedByRegion = Table.Group(FilteredRows, {"Region", "Amount", "CustomerTier"},
{{"TotalAmount", each List.Sum([Amount]), type number}}),
SortedResults = Table.Sort(GroupedByRegion, {{"TotalAmount", Order.Descending}}),
// Now apply custom logic to the much smaller aggregated result (maybe 20 rows)
AddedSegment = Table.AddColumn(SortedResults, "Segment",
each DetermineSegment([TotalAmount], [CustomerTier]))
in
AddedSegment
The second version sends a GROUP BY and ORDER BY to SQL Server, brings back maybe 20 grouped rows, and then applies the custom function locally on 20 rows instead of 15 million. The performance difference is orders of magnitude.
Sometimes the right architecture is splitting your work into two separate queries in the Power Query tree. Create one query that does all source-side work and terminates at the fold boundary. Then create a second query that references the first and applies local transformations.
// Query 1: SalesAggregated (folds completely to SQL)
let
Source = Sql.Database("prod-server", "SalesDB"),
SalesTransactions = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
Filtered = Table.SelectRows(SalesTransactions,
each [TransactionDate] >= #date(2023, 1, 1) and [Status] = "Completed"),
Grouped = Table.Group(Filtered, {"CustomerID", "Region", "ProductCategory"}, {
{"TotalRevenue", each List.Sum([Amount]), type number},
{"TransactionCount", each Table.RowCount(_), type number},
{"AvgOrderValue", each List.Average([Amount]), type number}
})
in
Grouped
// Query 2: SalesWithSegments (applies M-only logic to small aggregated result)
let
Source = SalesAggregated,
AddedSegment = Table.AddColumn(Source, "CustomerSegment",
each
if [TotalRevenue] > 500000 then "Enterprise"
else if [TotalRevenue] > 100000 then "Mid-Market"
else "SMB"
),
AddedEngagementScore = Table.AddColumn(AddedSegment, "EngagementScore",
each [TransactionCount] * 0.3 + ([AvgOrderValue] / 1000) * 0.7
)
in
AddedEngagementScore
The second query references the first, and Power Query is smart enough to handle this correctly. The aggregation folds to SQL; the segment logic runs locally on the compact result.
When restructuring isn't enough — or when you need precise control over what the source executes — Value.NativeQuery lets you drop raw native SQL (or the source's native query language) directly into your M expression.
let
Source = Sql.Database("prod-server", "SalesDB"),
NativeResult = Value.NativeQuery(
Source,
"
SELECT
c.CustomerID,
c.CustomerName,
c.Region,
SUM(t.Amount) AS TotalRevenue,
COUNT(*) AS TransactionCount,
AVG(t.Amount) AS AvgOrderValue
FROM dbo.SalesTransactions t
INNER JOIN dbo.Customers c ON t.CustomerID = c.CustomerID
WHERE t.TransactionDate >= '2023-01-01'
AND t.Status = 'Completed'
AND c.AccountType IN ('Premium', 'Enterprise')
GROUP BY c.CustomerID, c.CustomerName, c.Region
HAVING SUM(t.Amount) > 10000
ORDER BY TotalRevenue DESC
"
)
in
NativeResult
This query executes exactly as written on the server. No translation, no ambiguity. You get the result back as a Power Query table and can apply further M transformations to it.
Warning:
Value.NativeQuerybypasses Power Query's type inference. You'll often need to follow this step withTable.TransformColumnTypesto set proper column types. Also note that further steps afterValue.NativeQuerywill not fold — you've already exited the folding chain.
The dangerous version of Value.NativeQuery looks like this — and you should never do it:
// DANGEROUS: SQL injection vulnerability
let
UserInput = "2023-01-01", // imagine this comes from a user parameter
Source = Sql.Database("prod-server", "SalesDB"),
BadQuery = Value.NativeQuery(
Source,
"SELECT * FROM dbo.SalesTransactions WHERE TransactionDate >= '" & UserInput & "'"
)
in
BadQuery
String concatenation into SQL is an injection vulnerability. The safe version uses parameterized queries, which Value.NativeQuery supports natively:
// SAFE: Parameterized query
let
StartDateParam = #date(2023, 1, 1),
EndDateParam = #date(2023, 12, 31),
RegionParam = "APAC",
Source = Sql.Database("prod-server", "SalesDB"),
SafeQuery = Value.NativeQuery(
Source,
"
SELECT
t.TransactionID,
t.CustomerID,
t.Amount,
t.TransactionDate,
t.Region,
t.ProductCategory,
t.Status
FROM dbo.SalesTransactions t
WHERE t.TransactionDate BETWEEN @StartDate AND @EndDate
AND t.Region = @Region
AND t.Status = 'Completed'
",
[
StartDate = StartDateParam,
EndDate = EndDateParam,
Region = RegionParam
]
),
TypedResult = Table.TransformColumnTypes(SafeQuery, {
{"TransactionID", type text},
{"CustomerID", type text},
{"Amount", type number},
{"TransactionDate", type date},
{"Region", type text},
{"ProductCategory", type text},
{"Status", type text}
})
in
TypedResult
The third argument to Value.NativeQuery is a record of named parameters. The M runtime passes these to the data source as proper parameterized query inputs — the values are never string-concatenated into the SQL text. This is the only safe way to incorporate dynamic values into a native query.
Tip: Not all connectors support parameterized
Value.NativeQuery. SQL Server does, Oracle does, PostgreSQL does (via the ODBC connector). Check your connector's documentation. If parameterization isn't supported, move the filtering to M-levelTable.SelectRowscalls instead, which will fold when possible.
In production Power Query solutions, you often need to balance portability with performance. A query that uses Value.NativeQuery with SQL Server-specific syntax won't work if someone later needs to point it at PostgreSQL. Here's a pattern that gives you the best of both worlds:
let
// Configuration: set to true for performance-critical production environments
UseNativeQuery = true,
Source = Sql.Database("prod-server", "SalesDB"),
// Path 1: Native SQL for maximum performance
NativePath = Value.NativeQuery(
Source,
"
SELECT
YEAR(TransactionDate) AS FiscalYear,
DATEPART(QUARTER, TransactionDate) AS FiscalQuarter,
Region,
ProductCategory,
SUM(Amount) AS TotalRevenue,
COUNT(DISTINCT CustomerID) AS UniqueCustomers,
AVG(Amount) AS AvgTransactionValue
FROM dbo.SalesTransactions
WHERE Status = 'Completed'
AND TransactionDate >= DATEADD(YEAR, -2, GETDATE())
GROUP BY
YEAR(TransactionDate),
DATEPART(QUARTER, TransactionDate),
Region,
ProductCategory
"
),
// Path 2: Pure M for portability (folds where possible)
MPath =
let
BaseTable = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
Filtered = Table.SelectRows(BaseTable,
each [Status] = "Completed" and
[TransactionDate] >= Date.AddYears(Date.From(DateTime.LocalNow()), -2)),
Grouped = Table.Group(Filtered, {"Region", "ProductCategory"}, {
{"FiscalYear", each Date.Year(List.Min([TransactionDate])), type number},
{"TotalRevenue", each List.Sum([Amount]), type number},
{"UniqueCustomers", each List.Count(List.Distinct([CustomerID])), type number},
{"AvgTransactionValue", each List.Average([Amount]), type number}
})
in
Grouped,
Result = if UseNativeQuery then NativePath else MPath
in
Result
This pattern lets you toggle between implementations with a single variable change. In practice, you'd often drive UseNativeQuery from a Power Query parameter, making it easy to switch without editing query code.
One pattern that comes up constantly in production solutions is needing to apply a set of dynamic filter conditions to a table. The naive implementation breaks folding; the right implementation preserves it.
Here's a real-world scenario: a reporting solution where users can select multiple filter criteria (date range, region list, product categories), and you need these to fold to the database.
// Helper function: BuildDynamicFilter
// Applies a record of filter conditions in a fold-friendly way
// Conditions record format: [DateFrom, DateTo, Regions, ProductCategories, MinAmount]
let
BuildDynamicFilter = (sourceTable as table, conditions as record) as table =>
let
// Apply each filter only if the condition value is provided
// Each step can fold independently if the previous one folded
AfterDateFrom =
if Record.HasFields(conditions, "DateFrom") and conditions[DateFrom] <> null
then Table.SelectRows(sourceTable, each [TransactionDate] >= conditions[DateFrom])
else sourceTable,
AfterDateTo =
if Record.HasFields(conditions, "DateTo") and conditions[DateTo] <> null
then Table.SelectRows(AfterDateFrom, each [TransactionDate] <= conditions[DateTo])
else AfterDateFrom,
AfterRegions =
if Record.HasFields(conditions, "Regions") and
conditions[Regions] <> null and
List.Count(conditions[Regions]) > 0
then Table.SelectRows(AfterDateTo, each List.Contains(conditions[Regions], [Region]))
else AfterDateTo,
AfterCategories =
if Record.HasFields(conditions, "ProductCategories") and
conditions[ProductCategories] <> null and
List.Count(conditions[ProductCategories]) > 0
then Table.SelectRows(AfterRegions, each List.Contains(conditions[ProductCategories], [ProductCategory]))
else AfterRegions,
AfterMinAmount =
if Record.HasFields(conditions, "MinAmount") and conditions[MinAmount] <> null
then Table.SelectRows(AfterCategories, each [Amount] >= conditions[MinAmount])
else AfterCategories
in
AfterMinAmount
in
BuildDynamicFilter
And the usage:
let
Source = Sql.Database("prod-server", "SalesDB"),
BaseTable = Source{[Schema="dbo", Item="SalesTransactions"]}[Data],
UserFilters = [
DateFrom = #date(2023, 1, 1),
DateTo = #date(2023, 12, 31),
Regions = {"APAC", "EMEA"},
ProductCategories = {"Software", "Services"},
MinAmount = 5000
],
FilteredData = BuildDynamicFilter(BaseTable, UserFilters),
// This grouping will fold because FilteredData folds
Aggregated = Table.Group(FilteredData, {"Region", "ProductCategory"}, {
{"TotalRevenue", each List.Sum([Amount]), type number},
{"DealCount", each Table.RowCount(_), type number}
})
in
Aggregated
Important note about
List.Containsand folding:Table.SelectRowswithList.Containsagainst a list literal does fold on the SQL Server connector — it translates to anIN (...)clause. However, if the list comes from a dynamic source (another table, a function result), it may not fold. Always verify with the "View Native Query" check.
You're building a sales performance dataset for a financial services company. Their transaction database on SQL Server contains a dbo.Trades table (30 million rows) with columns: TradeID, TraderID, TradeDate, Instrument, AssetClass, Notional, GrossProfit, Desk, CounterpartyID, and Status.
There's also a dbo.Traders table with: TraderID, TraderName, SeniorityLevel, DeskManager, and HireDate.
Your task is to build a Power Query solution that:
Status = 'Settled'Desk, AssetClass, and SeniorityLevelThe constraint: the aggregation and filtering must fold to SQL Server. The profitability tier logic runs locally on the aggregated result.
// Step 1: Set up source connections
let
Source = Sql.Database("prod-server", "TradingDB"),
// Step 2: Reference source tables (these reference steps fold)
TradesTable = Source{[Schema="dbo", Item="Trades"]}[Data],
TradersTable = Source{[Schema="dbo", Item="Traders"]}[Data],
// Step 3: Filter trades — this folds to SQL WHERE clause
ThreeYearsAgo = Date.AddYears(Date.From(DateTime.LocalNow()), -3),
FilteredTrades = Table.SelectRows(TradesTable,
each [Status] = "Settled" and [TradeDate] >= ThreeYearsAgo),
// Step 4: Join to traders — this folds to a SQL JOIN
// Note: Power Query uses left outer join by default; specify the join kind
JoinedData = Table.Join(
FilteredTrades, "TraderID",
TradersTable, "TraderID",
JoinKind.Inner
),
// Step 5: Select only needed columns before grouping (reduces data movement)
ColumnsNeeded = Table.SelectColumns(JoinedData, {
"Desk", "AssetClass", "SeniorityLevel",
"Notional", "GrossProfit", "TradeID"
}),
// Step 6: Group and aggregate — this folds to SQL GROUP BY
// At this point we're still in foldable territory
Aggregated = Table.Group(ColumnsNeeded,
{"Desk", "AssetClass", "SeniorityLevel"},
{
{"TotalNotional", each List.Sum([Notional]), type number},
{"TotalGrossProfit", each List.Sum([GrossProfit]), type number},
{"TradeCount", each Table.RowCount(_), Int64.Type},
{"AvgProfitPerTrade", each List.Average([GrossProfit]), type number}
}
),
// Step 7: NOW we leave the folded zone
// Aggregated result is maybe a few hundred rows — local processing is fine
// Add profitability tier — custom logic, won't fold (and doesn't need to)
AddedTier = Table.AddColumn(Aggregated, "ProfitabilityTier",
each
if [AvgProfitPerTrade] >= 50000 then "Tier 1 - Premier"
else if [AvgProfitPerTrade] >= 20000 then "Tier 2 - Strong"
else if [AvgProfitPerTrade] >= 5000 then "Tier 3 - Standard"
else "Tier 4 - Under Review",
type text
),
// Add a return-on-notional metric (simple arithmetic, could fold, but moot at this point)
AddedRON = Table.AddColumn(AddedTier, "ReturnOnNotional",
each if [TotalNotional] = 0 then 0
else Number.Round([TotalGrossProfit] / [TotalNotional] * 100, 4),
type number
),
// Final sort — running locally on small result, that's fine
SortedResult = Table.Sort(AddedRON, {
{"TotalGrossProfit", Order.Descending}
})
in
SortedResult
Verify your work: Right-click the Aggregated step in Applied Steps. You should see "View Native Query" as a clickable option, and the generated SQL should include WHERE, JOIN, and GROUP BY clauses. Right-click AddedTier — it should be grayed out, confirming the local processing boundary is exactly where you intended it.
Challenge extension: Modify this query to use Value.NativeQuery for the entire filtering, joining, and aggregation phase. Make the date range parameterized using the safe parameterization approach. Verify that the result set is identical.
This usually means either your connector doesn't support folding (CSV files, local Excel, SharePoint lists) or you've accidentally broken the fold at step one. Check whether you're actually connecting to a relational source via the correct connector. Sometimes developers connect to SQL Server via ODBC instead of the native connector, losing folding capabilities.
Development environments often use SQL Server Developer Edition with the same feature set. But if production uses a linked server, a view, or a synonym that points to a different database, the connector's behavior can change. Also check whether query folding hints differ between versions of the on-premises data gateway — the gateway version affects which connector behaviors are available during scheduled refresh.
Power Query parameters fold fine when used as scalar comparisons. What doesn't fold is using ParameterName as a list in List.Contains. If you have a multi-value parameter (comma-separated text that you split into a list), you'll need to split it and use it carefully. A safer pattern is to use multiple individual parameters and build your filter conditions explicitly.
When you join two tables from the same source using Table.Join or Table.NestedJoin followed by Table.ExpandTableColumn, this should fold to a SQL JOIN. Common reasons it doesn't:
Table.NestedJoin without expanding immediately afterCheck each table independently — can each one individually "View Native Query"? If so, join them and check again. If the join breaks folding, the problem is likely in the join operation itself.
On some connectors (particularly ODBC), Value.NativeQuery parameterization uses ? placeholders instead of named parameters. Check your connector's documentation. For SQL Server via the native connector, named parameters with @ParameterName syntax work correctly. For ODBC, you may need to use positional parameters.
Table.Buffer forces evaluation and stores the result in memory, breaking any subsequent folding. Many developers use Table.Buffer to fix performance issues (it can help with some re-evaluation problems) without realizing it permanently ends the fold chain for everything downstream. Only buffer tables after you've already done all the source-side work.
// Don't do this mid-chain if you still need downstream operations to fold
EarlyBuffer = Table.Buffer(FilteredRows), // Folding is now dead for everything after this
GroupedResult = Table.Group(EarlyBuffer, ...) // This runs locally
Query folding is one of the few areas in data engineering where the architecture of your solution directly determines whether it's viable at production scale. The principles we've covered aren't edge cases — they're the difference between a solution that handles 50 million rows gracefully and one that times out.
Here's what to carry forward:
The fold chain is everything. A single non-foldable step poisons every downstream step. Your job is to place that boundary intentionally, after all heavy lifting is done on the source side.
Diagnose before you optimize. Use "View Native Query" and Query Diagnostics to understand what's actually happening before you start restructuring. Sometimes what looks like a folding problem is actually a connector quirk with a simple fix.
Value.NativeQuery is a precision tool, not a general solution. Use it when M's automatic translation isn't generating efficient enough SQL, or when you need database-specific features like window functions or CTEs. Always parameterize dynamic values.
The "fold first, transform last" principle applies to every query you write. Make it a habit: before you add a custom column or call a helper function, ask yourself whether there are still foldable operations waiting downstream. If yes, reorder.
The natural continuation from this lesson is exploring incremental refresh in Power BI — which depends entirely on query folding working correctly on your date/time columns. If folding is broken, incremental refresh silently falls back to full refresh and you won't know why.
You should also dig into query plan analysis using SQL Server's Execution Plan viewer in parallel with Power Query's native query output. Understanding what the database does with the SQL Power Query generates gives you the full picture and helps you write better native queries when needed.
Finally, explore connector extensibility — if you're working with a data source that has poor folding support, the Power Query SDK lets you build custom connectors that implement their own folding logic. That's an advanced topic, but understanding the M folding architecture you've learned here is the foundation you'll need to approach it.
Learning Path: Advanced M Language