Forum Discussion
Does a LET variable get computed even if it's only referenced inside IFNA's fallback argument?
The short answer is "yes", the expensiveMatch variable is evaluated whether the value argument of IFNA evaluates to #N/A or not, because LET uses eager evaluation to compute expressions as soon as they are assigned (with the exception of TYPE 128 variables defined as functions, e.g. LAMBDA, which are deferred until they are called).
The easiest way to illustrate this point is to conduct a basic test with an expression that takes a long time to evaluate. For example:
= LET(
x, 1,
y, SUM(EXPAND(1,5000,5000,1)),
IFNA(x, y)
)There will be a noticeable calculation lag when committing the above-mentioned formula to a cell because LET evaluates SUM(EXPAND(1,5000,5000,1)) as soon as y is defined.
Now, compare that to the following:
= LET(
x, 1,
IF(
ISNA(x),
SUM(EXPAND(1,5000,5000,1)),
x
)
)-OR-
= IFNA(1, SUM(EXPAND(1,5000,5000,1)))Both of these examples will return 1 instantaneously.
When IF receives a scalar as its logical_test, it will only evaluate the applicable value_if_true or value_if_false argument. Likewise, IFNA will only evaluate the value_if_na argument if it receives a scalar as its value, which then evaluates to #N/A.
The exception for both functions would be if they receive an array object (even a single element array), which would trigger all arguments to be evaluated. For example:
= IFNA({1}, SUM(EXPAND(1,5000,5000,1)))Again, there will be a noticeable calculation lag in this case, not because the value argument evaluates to #N/A, but because it received an array object ({1} instead of 1).
This important distinction is not well documented and is the primary source of confusion regarding behavioral differences between functions like IF/IFS and CHOOSE/SWITCH.
When in doubt, use the TYPE function to determine if your variable is returning a scalar or an array object. If TYPE returns 64, it is an array object. For example:
= TYPE(10) // returns 1 (number)
= TYPE({10}) // returns 64 (array)
= TYPE("a") // returns 2 (text)
= TYPE({"a"}) // returns 64 (array)
= TYPE(1>0) // returns 4 (logical)
= TYPE({1}>0) // returns 64 (array)
= TYPE(1/0) // returns 16 (error)
= TYPE({1}/0) // returns 64 (array)
= MAP(SEQUENCE(5), TYPE) // returns all 1's (each element is read as a number/scalar)
= BYROW(SEQUENCE(5), TYPE) // returns all 64's (each row is read as a single element array object)I hope that helps. Kind regards.
Thank you for this answer - definitely explains some behavior I've had in the past with custom formulas using map with spill ranges, will 100% start incorporating TYPE().
I ran a few of the lag tests against my own cases. Results, in case they're useful to anyone else:
1.) Fully-specified INDEX returns a scalar. Both TYPE(INDEX($L$29:$FT$49, 3, 5)) and the same against a larger 2D range return 1. So looks like INDEX(range, scalarRow, scalarCol) is safe as an IFNA/IF branch point.
1.a) INDEX with the column argument omitted returns the whole row, so that one's 64
2.) Array-object-ness propagates through LET, but aggregation collapses it.
2.a) LET(a, {1}, b, a*2, TYPE(b)) returns an array (64)
2.b) LET(a, {1}, b, SUM(a), TYPE(b)) returns a number (1)
2. note) So it does carry downstream through element-wise ops, but anything that aggregates to a single value resets it to scalar
3.) MAP short-circuits, BYROW doesn't. Both return {1,2,3}, but BYROW is noticeably slower which makes sense given your note about MAP returning 1 and BYROW returning 64. So IF/IFNA nested inside BYROW evaluates the expensive branch every row
I've got one follow up I don't know how I'd go about testing right now:
Question: Is scalar vs. array object consistent per function, or could it change between builds? I can test what my version does now, but would rather know whats safe to build around long term. If it can change, is thunking the only way around it?
Thanks again, this answer has been very helpful so far.
- djclementsAug 27, 2026Silver Contributor
Good observations, PFoleyPEP.
In the context of this topic, the scalar vs array object behavior should be fairly consistent between builds.
I have come across a few niche methods over the past few years that seem to work in one build but not in another. In some cases, it was something I wrote that worked for me but not for someone else (e.g. behaved differently in another region and/or with a different language pack installed); other times, it was someone else's solution that I could not reproduce on my system (e.g. created on the Insiders Beta channel but failed with 'Excel ran out of resources' on my Office 365 for Business build). The 3 cases that come to mind actually all involved working with TYPE 128 data in advanced scenarios, so it's probably not something you need to worry about.
"Lazy thunking" is fairly safe regarding compatibility, but there is a time and place for it, in my opinion. Be aware that delaying any expression by placing it directly within a parameter-less LAMBDA will result in that expression being re-evaluated every time the variable is called. It can be beneficial to do this for some expressions (but not all), as it appears to be more efficient to re-evaluate some expressions over and over again than it is to commit their results to (and read from) memory. This is a major contradiction to the LET function's claim to fame, with Microsoft's official documentation stating one of its key benefits is:
Improved Performance If you write the same expression multiple times in a formula, Excel calculated that result multiple times. LET allows you to call the expression by name and for Excel to calculate it once."
Source: LET function | Microsoft Support
Hopefully the following examples will help to demonstrate the performance differences and highlight the contradiction:
// version 1: expensive variable is evaluated once, then accessed 10 times = LET( x, SUM(EXPAND(1,5000,5000,1)), REDUCE(x, SEQUENCE(9), LAMBDA(a,_, a + x )) ) // version 2: expensive variable is delayed with Lambda, then called 10 times = LET( x, LAMBDA(SUM(EXPAND(1,5000,5000,1))), REDUCE(x(), SEQUENCE(9), LAMBDA(a,_, a + x() )) ) // observation: version 2 is approx. 10 times slower than version 1 // and is equivalent to the following: = REDUCE( SUM(EXPAND(1,5000,5000,1)), SEQUENCE(9), LAMBDA(a,_, SUM(a, EXPAND(1,5000,5000,1)) ) )Believe it or not, the 'expensive' part of this example is the aggregation of the array, not the large array itself. If we remove the SUM function and simply return the first element of the array using the implicit intersection operator, the performance differences between the two methods are reversed (somewhat):
// version 1: large array is evaluated once, then accessed 10 times = LET( x, EXPAND(1,5000,5000,1), REDUCE(@x, SEQUENCE(9), LAMBDA(a,_, a + @x )) ) // version 2: large array is delayed with Lambda, then called 10 times = LET( x, LAMBDA(EXPAND(1,5000,5000,1)), REDUCE(@x() ,SEQUENCE(9), LAMBDA(a,_, a + @x() )) ) // observation: version 2 is approx. twice as fast as version 1 // and is equivalent to the following: = REDUCE( @EXPAND(1,5000,5000,1), SEQUENCE(9), LAMBDA(a,_, a + @EXPAND(1,5000,5000,1) ) )As you can see from this example, using LET to evaluate the large array up front, then access it from memory multiple times, was considerably slower than re-evaluating the large array multiple times. I used EXPAND for this demonstration, but the same is also true for SEQUENCE (as well as many other array expressions). So much for LET's ability to improve performance... ;)
Another example:
// eager evaluation of a large array, accessed multiple times (slowest) = LET( x, EXPAND(1,5000,5000,1), MAP(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE), LAMBDA(f, f(x) )) ) // lazy evaluation of a large array, called multiple times (faster) = LET( x, LAMBDA(EXPAND(1,5000,5000,1)), MAP(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE), LAMBDA(f, f(x()) )) ) // no Let statement (same speed as lazy evaluation) = MAP(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE), LAMBDA(f, f(EXPAND(1,5000,5000,1)) )) = VSTACK( SUM(EXPAND(1,5000,5000,1)), AVERAGE(EXPAND(1,5000,5000,1)), COUNT(EXPAND(1,5000,5000,1)), PRODUCT(EXPAND(1,5000,5000,1)), @EXPAND(1,5000,5000,1) ) // lifting functions over the array (fastest) = CHOOSE({1;2;3;4;5},SUM,AVERAGE,COUNT,PRODUCT,SINGLE)(EXPAND(1,5000,5000,1)) = LAMBDA(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE))()(EXPAND(1,5000,5000,1))There's a time and place for everything, though. Just be aware of what "lazy thunking" is actually doing and when it's appropriate to use. If in doubt, run some simple tests like these to determine for yourself if it will benefit your situation.
Kind regards.
- PeterBartholomew1Sep 05, 2026Silver Contributor
I hit difficulties with both
// lifting functions over the array (fastest) = CHOOSE({1;2;3;4;5},SUM,AVERAGE,COUNT,PRODUCT,SINGLE)(EXPAND(1,5000,5000,1)) = LAMBDA(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE))()(EXPAND(1,5000,5000,1))in that they gave "Excel ran out of resources" errors. That was the case even when I replaced 5000s by 10s.
On the other hand
= MAP(VSTACK(SUM, AVERAGE, COUNT, PRODUCT, SINGLE), LAMBDA(FNλ, FNλ(EXPAND(1,5000,5000,1))))with only a few seconds hesitation.
Back to your original discussion, I wonder whether it is the use of REDUCE to generate multiple runs that changes the behaviour in that (as a recursive calculation) it has to create the 5000x5000 array at each step. Had the calculation simply been presented as a list of statements within the original LET function the performance gains may have been closer to those claimed?
- djclementsSep 05, 2026Silver Contributor
Yeah, I used to get the same "Excel ran out of resources" error when trying methods like these, but after a recent update (I'd say within the last 1-2 months), they work on my system. Others have attested to this apparent change in behavior as well. For all I know, it could be an unintended 'bug', and the ability to 'lift multiple functions over an array' may be reversed in future updates. As of right now, though, both of those examples work in Microsoft Excel for Microsoft 365 MSO (Version 2608 Build 16.0.20326.20072) 64-bit.
Good call on the Lambda helper functions possibly affecting the test results. I tried different variants, with and without REDUCE/MAP, and there appeared to be little to no change with the 'lazy evaluation' examples; however, the 'eager evaluation' methods did in fact perform better without REDUCE/MAP.
For example...
= LET( x, EXPAND(1,5000,5000,1), VSTACK(SUM(x), AVERAGE(x), COUNT(x), PRODUCT(x), @x) )...performed better than...
= LET( x, EXPAND(1,5000,5000,1), MAP(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE), LAMBDA(f, f(x) )) )...but was still worse than...
= LET( x, LAMBDA(EXPAND(1,5000,5000,1)), MAP(VSTACK(SUM,AVERAGE,COUNT,PRODUCT,SINGLE), LAMBDA(f, f(x()) )) )...or this...
= LET( x, LAMBDA(EXPAND(1,5000,5000,1)), VSTACK(SUM(x()), AVERAGE(x()), COUNT(x()), PRODUCT(x()), @x()) )I'm not sure what, if anything, that hints towards, but it still seems like LET and 'eager evaluation' can be less efficient than expected in certain situations. Humph.
- PeterBartholomew1Aug 28, 2026Silver Contributor
David
As a variant:
= LET( x, SUM(EXPAND(1,5000,5000,1)), xϑ, LAMBDA(x), REDUCE(xϑ(), SEQUENCE(9), LAMBDA(a,_, a + xϑ() )) )works pretty quickly. I think the eager LET calculation is completed first and then xϑ then simply accesses the pre-calculated result.
- djclementsAug 28, 2026Silver Contributor
Hi Peter,
Absolutely, that also works. The "eager thunking" method you've demonstrated has the same basic effect as version 1 of my first example, whereby LAMBDA is only storing the result of SUM, not the array itself.
The second example was not exactly realistic. The point I was trying to get across here was that re-evaluating a calculated array multiple times can (sometimes) be faster than committing it to memory and referencing the results multiple times from memory.
The third example was a little more realistic, whereby an array of aggregate functions was applied to the same calculated array. The typical LET statement version, which should be faster, was actually slower than re-writing the same array expression multiple times for each aggregate function.
I've also noticed a similar performance difference with various unpivot methods that apply the same conditional expression to multiple return arrays, e.g. TOCOL(IFS(conditional_array,return_array),2). In most cases, it seems to be more efficient to re-evaluate the same conditional array expression multiple times than it is to pre-define it as a LET variable.
I'm not saying a person shouldn't use LET; however, I do question its ability to "improve performance", especially when defining large, calculated arrays. Perhaps it's more of an issue with memory allocation than it is with formula efficiency. Whatever the reason, the cost of committing the resources up front appears to be greater than the cost of evaluating the expression itself. The "lazy thunking" method avoids this by delaying all expressions until they are needed, but it basically has the same effect as not even using a LET statement in the first place. Again, "time and place", though, as there are plenty of situations where "eager thunking" is far more efficient than "lazy thunking".