Forum Discussion

PFoleyPEP's avatar
PFoleyPEP
Copper Contributor
Aug 21, 2026

Does a LET variable get computed even if it's only referenced inside IFNA's fallback argument?

In the formula below, expensiveMatch is defined in LET but only referenced as IFNA's fallback argument:

=LET(
  matchRow, XMATCH(1, (A1=Sheet2!$A$1:$A$1000)*(B1=Sheet2!$B$1:$B$1000)),
  x, INDEX(Sheet2!$C$1:$C$1000, matchRow),
  expensiveMatch, INDEX(Sheet3!$C$1:$C$500, XMATCH(A1&B1, Sheet3!$A$1:$A$500&Sheet3!$B$1:$B$500)),
  IFNA(x, expensiveMatch)
)

Question: When x resolves successfully (no #N/A), does expensiveMatch still get computed because it's a top-level LET variable - or does Excel skip it since IFNA's fallback argument is never reached?

I know IFNA short-circuits its fallback argument, and LET avoids recomputing a variable if referenced multiple times - but I haven't found much documentation on the behavior of Let. Is the below a more efficient way to write the formula?

=LET(
  matchRow, XMATCH(1, (A1=Sheet2!$A$1:$A$1000)*(B1=Sheet2!$B$1:$B$1000)),
  x, INDEX(Sheet2!$C$1:$C$1000, matchRow),
  IF(ISNA(x),
    INDEX(Sheet3!$C$1:$C$500, XMATCH(A1&B1, Sheet3!$A$1:$A$500&Sheet3!$B$1:$B$500)),
    x
  )
)

Is this restructuring necessary, or does the first version already skip the unused computation?

Basically I'm wondering if Let computes lazily/defers calculation in the way SQL's optimizer works. Input from anyone with insight or knowledge into the calc engine would be greatly appreciated.

 

Thanks

 

(Currently on Microsoft Excel for Microsoft 365 MSO (Version 2607 Build 16.0.20228.20190) 64-bit)

10 Replies

  • Joycethomasname's avatar
    Joycethomasname
    Copper Contributor

    Yes, your second restructuring is necessary if you want to avoid computing the heavy lookup unnecessarily.

     

    Here is why:

     

    While functions like IFNA and IF support short-circuiting (evaluating fallback arguments only when needed), Excel’s LET function evaluates variable bindings at the time they are declared in the top-level scope.

     

    In your first formula, because expensive Match is assigned as a variable binding inside LET, Excel computes the INDEX/XMATCH calculation immediately during assignment—before IFNA(x, expensive Match) is even executed.

     

    In your second formula:

    =LET(

    matchRow, XMATCH(1, (A1=Sheet2!$A$1:$A$1000)*(B1=Sheet2!$B$1:$B$1000)),

    x, INDEX(Sheet2!$C$1:$C$1000, matchRow),

    IF(ISNA(x), INDEX(Sheet3!$C$1:$C$500, XMATCH(A1&B1, Sheet3!$A$1:$A$500&Sheet3!$B$1:$B$500)), x)

    )

     

    By removing expensiveMatch from the LET definition and placing the lookup inside IF(ISNA(x), ...), Excel defers calculation and only executes the second lookup when x actually results in #N/A.

     

    If you are working with large string lookups or external reference tables (such as processing large custom https://japangeneratorname.com/ or multi-criteria array matches), the second method will eliminate unnecessary recalculation overhead.

  • djclements's avatar
    djclements
    Silver Contributor

    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.

    • PFoleyPEP's avatar
      PFoleyPEP
      Copper Contributor

      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.

      • djclements's avatar
        djclements
        Silver 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.

  • Patrick2788's avatar
    Patrick2788
    Silver Contributor

    For this particular arrangement it's no hindrance. XMATCH works on 1D arrays and even with the concatenation it's still working on 1D.

    Here's your formula arranged as a Lambda with exp_match deferred (thunked):

    LamTestλ=
    LAMBDA(
        vector_1,
        crit_1,
    
        vector_2,
        crit_2,
    
        return_vector,
    LET(
        match_row, XMATCH(1, (crit_1 = vector_1) * (crit_2 = vector_2)),
        x,         INDEX(return_vector, match_row),
    
        // Deferred
        exp_match, LAMBDA(INDEX(return_vector,XMATCH(crit_1 & crit_2,vector_1 & vector_2))),
        
        // Unwrap deferred exp_match if going to fall back
        final,     IFNA(x,exp_match()),
        final
    
    ));

     

    To make things interesting, I extended your vectors down to row 10,000 and planted the matching row at row 7500.

    The timings were close enough to be negligible.

    Vector sizeMatch Term Found
    10kRow 7500
      
    RegularDeferred (thunked)
    0.110.14
    0.110.15
    0.150.15
    0.140.11
    0.130.14
    0.1280.138

     

    There is a very small tax Excel charges for using LET as opposed to arranging your formula as a heavily nested-Excel 2016 style arrangement.  An analogy I use: it's the weight of the bag when you're buying in bulk at the grocery store. At checkout, the weight of bag is subtracted when calculating the total. Sometimes you can recover this by using thunks.

    For this example, that tax is so small it's negligible in my opinion.  Thunking the exp_match didn't make a difference.

    In some rare cases, I've seen Excel "take a peek ahead" in the evaluation process but that seems to only be when there's potentially a very large spill downstream. My theory is it needs to know how much memory to allocate.

     

    Attached is the workbook I used for testing.

  • m_tarler's avatar
    m_tarler
    Silver Contributor

    Without doing some testing I'm not sure but I do know that certain functions will calculate fully regardless.  So I do NOT think this is a function of the LET but rather a function of the IFNA and I haven't looked into the IFNA but the I know that the IF statement will NOT calculate the unused case in 'normal' or basic conditions but WILL calculate fully when it is passed an array for the conditional.  So in your case, I think it depends if that conditional is or is considered an array then I suspect it will fully calculate.  Basically (at least in the case of the IF statement) it goes from a native operator to a function with parameters and excel will calculate those parameters before passing it to the function.  That all said there is a bit of work done using LAMBDA functions as THUNKS to get excel to pass those functions as unresolved functions and then only calculate the result at the end and showing significant performance improvements doing it that way.  I'm not saying it will solve your issue but may make it more efficient if you are in need of performance improvements.