Find External Links and Broken References in a Workbook with Python
A workbook that points at other workbooks is fragile. Move the source file, rename a folder or email the report to someone outside the network, and every linked cell shows stale numbers or #REF!, while Excel greets the reader with a security prompt about links it cannot update. Broken references are the same problem from the inside: a deleted sheet leaves #REF! in every formula and named range that used it. Both are easy to miss by eye in a large file, and both are easy to find with Python. This guide, part of Working with Excel Formulas in Python, builds an audit that looks everywhere links and broken references hide.
Prerequisites
pip install openpyxl pandas
To have something realistic to audit, build a workbook with one external link, formulas that use it, a formula broken by a deleted sheet, a broken named range, and validation and formatting rules that reference both:
from openpyxl import Workbook, load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.packaging.relationship import Relationship
from openpyxl.styles import Font
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.workbook.external_link.external import ExternalBook, ExternalLink
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
ws = wb.active
ws.title = "Report"
link = ExternalLink(externalBook=ExternalBook(id="rId1"))
link.file_link = Relationship(type="externalLinkPath", Target="Budget_2026.xlsx",
TargetMode="External", Id="rId1")
wb._external_links.append(link)
ws["A1"], ws["B1"] = "Budget", "=[1]Data!B2"
ws["A2"], ws["B2"] = "Budget total", "=SUM([1]Data!B2:B13)"
ws["A3"], ws["B3"] = "Variance", "=#REF!-B1"
ws["A4"], ws["B4"] = "Actual", 1250
wb.defined_names["OldRates"] = DefinedName("OldRates", attr_text="#REF!$B$2:$B$9")
wb.defined_names["BudgetLines"] = DefinedName("BudgetLines", attr_text="[1]Data!$A$2:$A$13")
dv = DataValidation(type="list", formula1="=[1]Lists!$A$1:$A$5")
ws.add_data_validation(dv)
dv.add("C1")
ws.conditional_formatting.add("B4", FormulaRule(formula=["B4>#REF!"], font=Font(bold=True)))
wb.save("report.xlsx")
Step 1 — List the linked workbooks
External links live in their own parts of the package. openpyxl loads them into the workbook's external links list, each with the path of the linked file:
wb = load_workbook("report.xlsx")
targets = {i: ln.file_link.Target for i, ln in enumerate(wb._external_links, start=1)}
print(targets) # {1: 'Budget_2026.xlsx'}
The number is what formulas use: [1] means the first entry. The attribute starts with an underscore — it is not part of openpyxl's documented API — so for a check that must not break on an upgrade, read the same information from the zip file directly:
import re
import zipfile
def link_targets(path: str) -> dict[int, str]:
out = {}
with zipfile.ZipFile(path) as z:
for name in z.namelist():
m = re.match(r"xl/externalLinks/_rels/externalLink(\d+)\.xml\.rels$", name)
if m:
rels = z.read(name).decode()
out[int(m[1])] = re.search(r'Target="([^"]+)"', rels)[1]
return dict(sorted(out.items()))
print(link_targets("report.xlsx")) # {1: 'Budget_2026.xlsx'}
Step 2 — Scan every place a formula can live
Cell formulas are only one place. Defined names, data validation and conditional formatting all hold formula text, and all of them can point outside the workbook or at #REF!. One scanner covers them, classifying each hit:
EXTERNAL = re.compile(r"\[(\d+)\]")
def classify(text: str) -> list[str]:
kinds = []
if EXTERNAL.search(text):
kinds.append("external")
if "#REF!" in text.upper():
kinds.append("broken")
return kinds
def audit(path: str) -> list[dict]:
wb = load_workbook(path)
targets = link_targets(path)
found = []
def record(where, location, text):
for kind in classify(text):
ids = [int(i) for i in EXTERNAL.findall(text)]
found.append({"where": where, "location": location, "kind": kind, "formula": text,
"linked_file": ", ".join(targets.get(i, f"[{i}]?") for i in ids)})
for ws in wb.worksheets:
for row in ws.iter_rows():
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
record("cell", f"{ws.title}!{cell.coordinate}", cell.value)
for dv in ws.data_validations.dataValidation:
record("data validation", f"{ws.title}!{dv.sqref}", str(dv.formula1 or ""))
for rng, rules in ws.conditional_formatting._cf_rules.items():
for rule in rules:
for f in rule.formula or []:
record("conditional format", f"{ws.title}!{rng.sqref}", f)
for name, dn in wb.defined_names.items():
record("defined name", name, dn.attr_text or "")
return found
import pandas as pd
report = pd.DataFrame(audit("report.xlsx"))
print(report[["where", "location", "kind", "linked_file"]].to_string(index=False))
The report makes the point of the exercise: of seven findings, three are in ordinary cells. Somebody searching the sheet with Find would never see the validation rule, the formatting rule or the two named ranges, and those are exactly the leftovers that keep Excel's link warning alive after the "last" link has been removed.
Step 3 — Write the audit where people will see it
An audit is only useful if the owner of the workbook reads it. Write the findings to a new workbook alongside the original, sorted with broken references first, since those are already producing wrong numbers:
report["kind"] = pd.Categorical(report["kind"], ["broken", "external"], ordered=True)
report = report.sort_values(["kind", "where", "location"])
with pd.ExcelWriter("report_audit.xlsx", engine="openpyxl") as writer:
report.to_excel(writer, sheet_name="Findings", index=False)
writer.sheets["Findings"].freeze_panes = "A2"
print(len(report), "findings written") # 7 findings written
Step 4 — Repoint a link after a file moves
When the linked workbook has simply moved or been renamed, the fix is to change the path rather than the formulas. Every formula refers to the link by its number, so updating the one relationship target repairs all of them at once:
wb = load_workbook("report.xlsx")
wb._external_links[0].file_link.Target = "Budget_2026_final.xlsx"
wb.save("report_repointed.xlsx")
print(link_targets("report_repointed.xlsx")) # {1: 'Budget_2026_final.xlsx'}
Relative paths like Budget_2026_final.xlsx resolve against the folder of the workbook that holds the link, which is usually what you want when both files travel together. Use an absolute or UNC path only when the source lives in a fixed shared location.
A #REF! cannot be repointed automatically: the reference that used to be there was destroyed when its sheet or range was deleted, and nothing in the file records what it pointed at. Those findings need a person — the audit's job is to make sure somebody knows they exist.
Step 5 — Break links by converting to values
When the report should stand alone — before emailing it outside the organisation, for example — replace every formula that uses an external link with the value Excel last calculated for it. Load the workbook twice: once for formulas, once with data_only=True for the cached values:
def break_links(path: str, out: str) -> int:
formulas = load_workbook(path)
values = load_workbook(path, data_only=True)
changed = 0
for ws in formulas.worksheets:
cached = values[ws.title]
for row in ws.iter_rows():
for cell in row:
if isinstance(cell.value, str) and EXTERNAL.search(cell.value):
cell.value = cached[cell.coordinate].value
changed += 1
formulas._external_links = []
formulas.save(out)
return changed
print(break_links("report.xlsx", "report_standalone.xlsx")) # 2
A file written by Python has no cached values, so on this example the two broken-out cells become empty; on a workbook last saved by Excel they get the numbers Excel displayed. Clear or fix the link-carrying defined names, validation rules and formatting rules as well — the audit from Step 2 lists them — or Excel will keep asking about links. The general technique is covered in Convert Excel Formulas to Values with Python.
Common pitfalls
- Checking cells only. Named ranges, validation and conditional formats are where most orphaned links survive.
- Case of
#REF!. Files saved by some applications store it as#ref!. Compare case-insensitively, asclassifydoes. - Chart series. Charts can reference other workbooks too. openpyxl does not expose chart references reliably; for a complete audit, search the
xl/charts/XML in the zip for[followed by a number. - Hidden defined names. Some add-ins create hidden names that hold links.
wb.defined_namesincludes them; Excel's Name Manager does not show them.
Performance and scale notes
The scan visits every cell once and is fast for typical reports — a few seconds for a workbook of a few hundred thousand formulas. For a folder of files, run it in a loop and concatenate the reports, adding the file name as a column, to produce one inventory of every link in a shared drive; the zip-level link_targets function alone is enough for a quick first pass, because it does not parse any cells. The folder loop pattern is shown in Process Multiple Excel Files in Parallel with Python.
Conclusion
External links are numbered parts of the package that formulas reference as [1]Sheet!A1; broken references show up as #REF!. Both can hide in cells, defined names, data validation and conditional formatting, so audit all four, map link numbers to file paths, and write the findings where the owner will see them. Repoint a moved source by changing one relationship target, and break links for standalone copies by replacing linked formulas with their cached values and clearing the other places links live.
Frequently asked questions
How are external links stored in an xlsx file? Each linked workbook gets an externalLink part inside the package, and formulas refer to it by number — 1Data!B2 means cell B2 on sheet Data of the first linked workbook. The file path lives in the relationship file for that part.
Why does Excel say the workbook contains links when I cannot find any? Links often hide outside cell formulas — in defined names, data validation lists, conditional formatting rules, chart series or leftover named ranges from copied sheets. Scan all of those, not just the cells.
Can openpyxl update the path of an external link? Yes. Each entry in the workbook's external links list has a file_link relationship whose Target is the path; change it and save. It is an undocumented attribute, so test after upgrading openpyxl.
What does it mean to break a link? Replacing each formula that uses the link with the value it last calculated. The workbook then no longer depends on the other file, at the cost of no longer updating.
Related
- Up one level: Working with Excel Formulas in Python — formulas from Python.
- Create a Named Range in Excel with openpyxl — defined names in depth.
- Troubleshooting Common Python Excel Errors — other ways workbooks break.
- What openpyxl Loses When You Save an Existing Workbook — check before saving an audited file.
More on formulas in workbooks
- How-toStore Formula Results with xlsxwriter So Every Viewer Shows Values
- FixHighlight Cells with Formula Errors Using Conditional Formatting
- How-toGoal Seek in Python Instead of Excel
- How-toCheck That Totals in a Report Add Up
- How-toCall Excel Worksheet Functions Through Microsoft Graph
- How-toRecalculate Excel Online Workbooks with Graph Sessions