Fix "Worksheet Does Not Exist" KeyError in openpyxl
KeyError: 'Worksheet Sales does not exist.' is maddening because the tab is visibly there when you open the workbook. The cause is almost always a character you cannot see: a trailing space, a non-breaking space pasted from a web page, or a difference in capitalisation that Excel treats loosely and Python does not. This guide shows how to see the real names, why the mismatch happens, and how to write a lookup that never breaks again — including the pandas variant of the same error. It is part of Troubleshooting Common Python Excel Errors.
Prerequisites
Only openpyxl, and optionally pandas for the second half:
pip install openpyxl pandas
Step 1: print the names exactly as stored
The first move is always the same — list the sheets, and print their repr so invisible characters become visible:
from openpyxl import load_workbook
wb = load_workbook("sales.xlsx")
print(wb.sheetnames)
print([repr(name) for name in wb.sheetnames])
['Sales ', 'Q3\xa0Summary', 'Raw Data']
["'Sales '", "'Q3\\xa0Summary'", "'Raw Data'"]
The plain list looks fine; the repr list shows a trailing space on the first sheet and \xa0 — a non-breaking space — in the second. Both are perfectly legal sheet names, and both defeat an exact lookup. Non-breaking spaces arrive whenever a name is copied from a web page, an email, or a PDF.
Step 2: match names defensively
Rather than fixing the file, fix the lookup. Comparing on a normalised form handles whitespace, case and Unicode variants at once:
"""Find a worksheet by a human-typed name."""
import unicodedata
from openpyxl import load_workbook
def normalise(name: str) -> str:
"""Fold case, collapse every kind of space, and strip the ends."""
text = unicodedata.normalize("NFKC", name) # turns \xa0 into a plain space
return " ".join(text.split()).casefold()
def get_sheet(wb, wanted: str):
target = normalise(wanted)
for name in wb.sheetnames:
if normalise(name) == target:
return wb[name]
raise KeyError(f"{wanted!r} not found. Sheets: {[repr(n) for n in wb.sheetnames]}")
wb = load_workbook("sales.xlsx")
ws = get_sheet(wb, "q3 summary") # matches 'Q3\xa0Summary'
print(ws.title, ws.max_row)
unicodedata.normalize("NFKC", ...) is the piece that does the heavy lifting: it converts non-breaking spaces, full-width characters and other look-alikes into their plain equivalents, which is exactly the class of difference a person copying a name cannot see. The error message deliberately includes every available sheet name, so a failure in production tells you what the file actually contained.
Step 3: know when to use position instead
Names are not the only handle. When a producer renames tabs each month — Jan 2026, Feb 2026 — but never reorders them, position is far more stable:
first = wb.worksheets[0] # first tab, whatever it is called
active = wb.active # the sheet selected when the file was saved
last = wb.worksheets[-1]
print(first.title, active.title, last.title)
The equivalent in pandas is the sheet_name argument, which accepts a name, a zero-based index, a list, or None for every sheet:
import pandas as pd
df_first = pd.read_excel("sales.xlsx", sheet_name=0) # by position
df_named = pd.read_excel("sales.xlsx", sheet_name="Raw Data")
all_sheets = pd.read_excel("sales.xlsx", sheet_name=None) # dict of DataFrames
print(list(all_sheets))
Note the trap in that last form: sheet_name=None returns a dictionary, not a DataFrame, and sheet_name=0 returns a DataFrame. Code that switches between them by configuration should normalise the result before using it. The broader pattern is covered in Read all sheets from an Excel file into DataFrames.
For dated tabs, a pattern match beats both name and position:
import re
month_tab = next(
(n for n in wb.sheetnames if re.fullmatch(r"[A-Z][a-z]{2} \d{4}", n.strip())),
None,
)
ws = wb[month_tab] if month_tab else wb.active
Validate the whole tab set before you read anything
In a reporting job the sheet lookup is rarely alone: the script wants three specific tabs, and discovering the third is missing after twenty seconds of parsing is wasted work. Check the full set up front and report every problem at once:
"""Assert the workbook has the tabs the report needs, before parsing."""
from openpyxl import load_workbook
REQUIRED = {"sales", "targets", "raw data"}
wb = load_workbook("monthly.xlsx", read_only=True)
try:
present = {normalise(n) for n in wb.sheetnames}
finally:
wb.close()
missing = REQUIRED - present
extra = present - REQUIRED
if missing:
raise SystemExit(f"monthly.xlsx is missing: {sorted(missing)}; it has {sorted(present)}")
if extra:
print(f"note: extra sheets present, ignoring {sorted(extra)}")
Reporting the extras as well as the misses catches the other half of the problem — a producer who renamed Targets to Targets 2026 shows up as one missing and one extra tab, which is a much clearer signal than a KeyError on the name you expected.
Clean the names at the source when you own the writer
If your own code writes the workbook, the mismatch is preventable rather than survivable. Sanitise names on the way in, and the readers downstream never need the normalising lookup at all:
"""Write sheets with names that are safe to look up later."""
import re
import unicodedata
from openpyxl import Workbook
INVALID = re.compile(r"[\\/?*\[\]:]")
def clean_sheet_name(name: str) -> str:
text = unicodedata.normalize("NFKC", name)
text = " ".join(text.split()) # collapse and trim every kind of space
return INVALID.sub("-", text)[:31] or "Sheet"
wb = Workbook()
wb.remove(wb.active)
for raw in (" Sales ", "Q3\u00a0Summary", "Raw / Data"):
wb.create_sheet(clean_sheet_name(raw))
print(wb.sheetnames) # ['Sales', 'Q3 Summary', 'Raw - Data']
wb.save("clean.xlsx")
The same function is worth applying to any name derived from data — a region, a customer, a month label — because those are the names most likely to arrive with stray whitespace or a slash. It also protects against the two Excel rules that produce a repair prompt rather than an exception: the 31-character limit and the forbidden characters. Generating one sheet or file per group is exactly where that bites; see Generate one Excel report per region in a loop and Split one Excel sheet into multiple files by value.
Common pitfalls and gotchas
wb.get_sheet_by_name()was removed in openpyxl 3.x. Usewb["Name"]— old tutorials still show the removed API and produce anAttributeErrorthat looks unrelated.- Creating a sheet by accident.
wb.create_sheet("Sales")whenSalesalready exists gives youSales1, and the next read finds the wrong tab. Checkif "Sales" in wb.sheetnamesfirst. - Chart and formula references point at sheet names too. Renaming a sheet after writing a formula leaves
#REF!in the workbook — see Fix "Excel found unreadable content" after writing with Python. - Case-only differences are legal. Excel will not let you create
salesandSalesin the same workbook through the UI, but a file produced by code can contain both, in which case a case-folded match becomes ambiguous. Prefer exact matching if you know that is possible. - A truly missing sheet. Sometimes the sheet really is gone — the export changed. That is why the helper's error message lists what was there.
Performance and scale notes
Sheet lookup is free: wb.sheetnames is read from the workbook's relationship part, not by parsing sheets. What is not free is loading a large workbook just to discover which sheets it contains. Use read_only=True for an inspection pass and close the handle afterwards:
wb = load_workbook("huge.xlsx", read_only=True)
try:
print(wb.sheetnames)
finally:
wb.close()
In a batch that scans hundreds of files for a specific tab, that pattern keeps memory flat — the details are in Speed up openpyxl with read-only mode.
Conclusion
A worksheet KeyError is a string-comparison failure, not a missing sheet. Print the names with repr to reveal invisible characters, then stop comparing raw strings: normalise with NFKC and case-folding, or address the sheet by position or pattern when names are volatile. Whatever you choose, make the error message list the names the file actually had, so the next failure is diagnosed from the log rather than from a rerun.
Frequently asked questions
The tab is right there in Excel — why can't openpyxl find it? Excel displays a trimmed tab label, so a name with a leading space, a trailing space or a non-breaking space looks identical to a clean one. openpyxl compares the raw string, and those characters make the lookup miss.
Does openpyxl see hidden sheets?
Yes. Hidden and very-hidden sheets are in wb.sheetnames like any other, and you can read them normally. Check ws.sheet_state if you need to skip them deliberately.
Why does pandas raise a different message for the same problem?
pandas wraps the lookup and raises ValueError "Worksheet named 'X' not found", while openpyxl raises KeyError. The cause is identical; only the exception type differs, so catch both if your code can take either path.
Can I select a sheet by position instead of name?
Yes. wb.worksheets[0] is the first sheet in tab order and wb.active is whichever sheet was selected when the file was saved. Position is safer when producers rename tabs but never reorder them.
How do I list every sheet name with its exact characters?
Print the repr of the list — print([repr(n) for n in wb.sheetnames]) — so invisible characters and stray spaces appear as escape sequences instead of vanishing into the terminal output.
Related
- Up: Troubleshooting Common Python Excel Errors — every error family in one triage map.
- Fix "Excel file format cannot be determined" in pandas — the failure that happens one step earlier, before any sheet is addressed.
- Rename, reorder and delete Excel sheets with openpyxl — how tab names change in the first place, and how to change them safely.
- Read all sheets from an Excel file into DataFrames — reading every tab instead of guessing which one you need.
- Iterate over rows and columns with openpyxl — what to do with the worksheet once the lookup succeeds.