SAQL Window Functions Without the Performance Cliff
If you searched something like "SAQL window function slow" or "running total kills my dashboard," you are in the right place. This is a real problem with a real cause. It is not a bug you need to file. It is a pattern that shows up predictably once you know what to look for.
Quick note on naming: this platform has cycled through several brands over the years. Wave Analytics, Einstein Analytics, Tableau CRM, CRM Analytics. The underlying SAQL query language stayed mostly consistent across those vintages. Everything here applies whether your org still says "Tableau CRM" in the nav or has been upgraded to the CRM Analytics branding.
What Actually Happens When a Window Query Goes Slow
The short version: SAQL window functions sort and process the full result set in memory before handing anything back to the widget. There is no streaming partial result. The engine must see every row in the partition before it can assign the first rank or the first running total.
That means the cost scales with two things: the number of distinct partition key values, and the number of rows that fall into each partition. A rank() across ten thousand accounts with ten opportunities each is meaningfully more expensive than the same function across a hundred accounts with a thousand opportunities each, even if the raw row count is identical. The internal sort happens per partition, so cardinality of the partition key is often more important than total row count.
A sliding window makes this worse. If you ask for a running_average over a wide lookback window, the engine has to hold more rows per pass. A window parameter of 1,000 on a large dataset is genuinely expensive. Most people set that number high because they do not know what it costs. Now you do.
The Silent Failure: Missing or Wrong Partitions
This is the failure that wastes the most debugging time because the query does not error. It returns results. They are just wrong.
rank() without a partition key ranks globally across the entire result set. If you loaded opportunities, aggregated by AccountId, and then applied rank(), you might think the rank reflects position within some group. It does not. It reflects position across every row in the query output. The moment you add a filter widget or a second dimension to the grouping, the ranks shift in ways that seem random until you realize they were never partitioned to begin with.
The diagnostic question is: does this ranked or running total result change in a way that makes sense when I apply a filter? If ranks jump around unpredictably when you filter by region or time period, missing partition is almost certainly the cause.
Null values in the partition key make this worse. SAQL window behavior on null partition keys is not well-documented. In practice, rows with a null AccountId may fall into their own implicit group or may interact poorly with the sort, depending on the SAQL version. The safe approach is to filter null keys out before the window step, not after.
How to Read a Slow Dashboard Before You Change Anything
Before touching any SAQL, spend five minutes in the step inspector. Open the dashboard in Analytics Studio, click on the step that feeds your slow widget, and look at the query. Ask yourself:
- How many
windowclauses are chained together? Each one is a separate pass over the data. - What is the partition key, and how many unique values does it have? A partition key that produces hundreds of unique groups is more expensive than one that produces ten.
- Where does filtering happen? A
filterthat runs after awindowstill forces the full pre-filter dataset through the window computation. Push filters above the window step if your logic allows it. - Is there a
limitstatement? If not, the window processes every row the preceding aggregate produces, which can be far more rows than the widget ever displays.
Two window clauses stacked on the same dataset are not twice as expensive as one. They can be meaningfully more than that because each clause requires the engine to re-sort based on its ordering requirements. If you need both a running total and a rank, check whether they can share the same ordering. Sometimes they can be reorganized to avoid a full resort.
Practical Fixes
Filter early. The aggregate that feeds a window step should be as narrow as possible before the window runs. If you only need the last 12 months of data, filter by date before aggregating, not after. Every extra row that passes through the aggregate and into the window increases sort time.
Reduce partition cardinality when you can. A running total partitioned by raw AccountId on an org with 50,000 accounts is expensive. A running total partitioned by Account Tier or Region (four or five distinct values) is not. Sometimes the business question actually calls for the lower-cardinality grouping and the high-cardinality version was a first-draft assumption.
Use limit deliberately. A limit after the window step at least prevents the widget from rendering ten thousand rows. A limit before the window step, when your use case allows it (top-N inputs, for example), reduces the cost of the window itself.
Do not chain window clauses unless you need both computed values. If you built a running total and a lag to back-compute period-over-period change, ask whether you could get the same business answer with a single cogroup or a simpler aggregate pattern instead. SAQL's lag() function is genuinely useful, but it is not always the most efficient path to a period comparison.
Handle nulls in partition keys explicitly. Add and 'AccountId' != null to your filter before the aggregate step. This is a one-line fix that prevents a class of silent incorrect results.
Year-over-Year Without the Lag Tax
The lag() approach for year-over-year comparisons works by looking back N periods in the ordered window. For monthly data, a lag of 12 gives you the same month last year. That approach requires the window to see all 12 preceding rows per partition, which means 12+ months of data must be present in the step output before the lag can compute correctly.
An alternative pattern is to load two separate aggregates filtered by different date ranges, then join them with a cogroup. That avoids the window entirely. Whether it is faster depends on dataset size and how many accounts you are comparing, but it eliminates the sort-in-partition overhead and makes null handling more explicit. It is worth benchmarking against the lag approach on your actual data before committing to either.
When the Problem Is the Dashboard, Not the Query
Sometimes the window function is correct and reasonably fast in isolation, but the dashboard still feels slow. The cause is often step fan-out: multiple widgets sharing a single step that contains a window function, combined with filter widgets that force the step to re-execute on every selection. Every time a user clicks a filter, every widget bound to that step re-queries, including the expensive window computation.
The fix is step isolation. The window step should feed only the widget that needs it. Other widgets that do not need the ranked or running-total column should draw from a separate, cheaper aggregate step. This sounds obvious once stated, but dashboards that start simple and grow organically tend to wire everything to everything, and the window-step re-execution cost compounds quickly.
In one dashboard audit we ran, stripping dead steps and separating over-shared window steps produced a 53% reduction in dashboard JSON size and a 37% reduction in page-one load queries, with no visible change to what the dashboard showed.
The Underlying Lesson
Window functions are not slow by nature. They are slow when fed more data than they need, when partition keys are missing or wrong, when they are chained unnecessarily, or when dashboard wiring forces them to re-run on interactions that should not touch them at all. None of those problems announce themselves loudly. They show up as a spinner that takes a few seconds longer than it should, and then a few seconds longer after the next feature request.
Diagnosing this correctly requires reading the SAQL execution plan, understanding how the step graph is wired, and knowing which patterns interact badly at scale. It is the kind of thing that looks straightforward until you are forty minutes into it and realize the slow step is not the one you expected.
If you have a CRM Analytics dashboard that is noticeably slow and you want a second pair of eyes on it, CRMA Labs offers a $249 teardown on crmalabs.com. You export the dashboard JSON (no org access required on our end), we return a step-level audit and a prioritized fix list with expected query reduction within 48 hours. No commitment to a larger engagement required.