Export a Google Sheet to .xlsx, hand it to someone, and they open a different file than the one you tested. ARRAYFORMULA and QUERY don't exist in Excel, which is unsurprising — the annoying part is that nothing errors.
I opened a file I'd been shipping with openpyxl and found 203 cells still holding Google-only functions. All 203 were wrapped in IFERROR, so Excel showed "no matches" instead of #NAME?. Here's how to detect that, and how to rewrite it without leaning on dynamic arrays.
Why no error shows up
Google-only functions survive the export as __xludf.DUMMYFUNCTION("original formula"), with the original kept as a string argument. Excel stores it and never evaluates it.
=IFERROR(__xludf.DUMMYFUNCTION("IFERROR(SORTN(FILTER({'パスワード一覧'!A2:A80, …
The IFERROR I'd added on the Sheets side — to print an em dash when nothing matched — also catches "this function does not exist" in Excel. What comes back is an empty string or the fallback text, so the sheet reads "— no matches —" and looks perfectly healthy.
flowchart TD
A["Sheets formula<br/>ARRAYFORMULA / FILTER / SORTN / QUERY"] --> B["Opened in Excel"]
B --> C["Function does not exist"]
C --> D["The outer IFERROR catches it"]
D --> E["Empty string returned"]
E --> F["Screen shows '— no matches —'"]
The display for zero results and the display for a formula that never ran are identical. You can't spot the difference by looking.
Detection is one pass with openpyxl
__xludf.DUMMYFUNCTION persists as text, so you can count it. The same code confirms you reached zero after the rewrite.
import openpyxl
KEYS = ("DUMMYFUNCTION", "REGEXMATCH", "SORTN",
"QUERY(", "ARRAYFORMULA", "UNIQUE(", "FILTER(")
def check_no_google_funcs(path):
wb = openpyxl.load_workbook(path)
bad = []
for sh in wb.worksheets:
for row in sh.iter_rows():
for c in row:
v = c.value
txt = getattr(v, "text", None) or (v if isinstance(v, str) else "")
# formula cells only — prose sheets mention these names too
if not txt.startswith("="):
continue
if any(k in txt for k in KEYS):
bad.append(f"{sh.title}!{c.coordinate}")
return bad
Keep that txt.startswith("=") guard. If the workbook has a how-to sheet, its FAQ answers will use the word ARRAYFORMULA in ordinary prose, and you'll end up with unfixable text in your list of things to fix.
Don't reach for Excel 365's dynamic arrays
FILTER, SORT, and UNIQUE map closely onto the Google formulas, which makes them the obvious swap. They also don't exist in Excel 2019 or 2016. For a file you hand to strangers you can't assume a version, so they were off the table here.
| Approach | Sheets | Excel 365 | Excel 2016 |
|---|---|---|---|
ARRAYFORMULA / QUERY / SORTN
|
works | no | no |
FILTER / SORT / UNIQUE (dynamic arrays) |
works | works | no |
Per-row helper columns + INDEX/MATCH
|
works | works | works |
The approach is to split "sort and take the top N" into two steps: each row computes its own rank into a helper column, and the display side just asks for the row whose rank is n. Remove the sorting operation and the need for dynamic arrays goes with it.
Compute the rank per row with SUMPRODUCT
"Only rows past 90 days elapsed (column L), ordered descending" becomes this. RANK doesn't work because the ranking applies to a filtered subset.
=IF(AND($A2<>"",ISNUMBER($L2),$L2>90),
SUMPRODUCT(($A$2:$A$200<>"")*ISNUMBER($L$2:$L$200)*($L$2:$L$200>90)
*(($L$2:$L$200>$L2)+(($L$2:$L$200=$L2)*(ROW($L$2:$L$200)<ROW($L2)))))+1,"")
Multiply the conditions together, count the rows that beat you, add one. Pasting 200 rows of that by hand isn't realistic, so I generated it per row from Python.
def rank_renew_formula(row):
L, A = f"$L{row}", f"$A{row}"
la, lb = f"$L$2:$L${LAST_ROW}", f"$A$2:$A${LAST_ROW}"
rows = f"ROW($L$2:$L${LAST_ROW})"
return (
f'=IF(AND({A}<>"",ISNUMBER({L}),{L}>90),'
f'SUMPRODUCT(({lb}<>"")*ISNUMBER({la})*({la}>90)*'
f'(({la}>{L})+(({la}={L})*({rows}<ROW({L})))))+1,"")'
)
Gotcha 1: drop ISNUMBER and every blank row joins the ranking. Column L returns "" where there's nothing to measure, and Excel evaluates text as greater than any number — so $L$2:$L$200>90 on its own lets the blanks through. The numeric test has to be part of the product.
Replace REGEXMATCH with an array constant and SUBSTITUTE
Character-class checks like REGEXMATCH(E2, "[A-Z]") don't port either. Instead of matching, count occurrences per class.
def char_class_count(cell, chars):
arr = "{" + ",".join(f'"{c}"' for c in chars) + "}"
return f'SUMPRODUCT(LEN({cell})-LEN(SUBSTITUTE({cell},{arr},"")))'
Handing SUBSTITUTE an array constant of {"A","B",…,"Z"} returns 26 strings, each missing one letter. Sum the length deltas with SUMPRODUCT and you have the uppercase count. SUBSTITUTE is case-sensitive, so [A-Z] and [a-z] stay distinct for free, and symbols fall out of total length minus upper, lower, and digits.
Gotcha 2: avoid splitting the string with INDIRECT. My first version used MID(E2, ROW(INDIRECT("1:"&LEN(E2))), 1) with FIND, and verification rated every single password as weak. INDIRECT array expansion varies across environments, and when it fails it lands in the IFERROR branch — so once again, everything goes wrong quietly.
The display side is one INDEX/MATCH
With ranks in a helper column, the dashboard needs a single formula.
=IFERROR(INDEX('パスワード一覧'!A$2:A$200,
MATCH(1,'パスワード一覧'!$R$2:$R$200,0)),"")
Change the first MATCH argument to 1, 2, 3 … and you walk the list in order. IFERROR appears here too, but now it only carries its intended meaning: no row has rank 3, so there are fewer than three matches.
If you build in Google Sheets and ship .xlsx, open the export with something other than a spreadsheet at least once. It's a twenty-line script, and it doubles as your after-the-fix check.
Breaking ties when two rows share a rank, the time I passed dates as strings and silenced three metrics at once, and the full breakdown of all five broken spots are written up on Aulvem → Aulvem | A Google Sheets .xlsx breaks silently in Excel. The corrected file is live on the password manager sheet.
Top comments (0)