Guide
Getting Started With Python Excel AutomationDeep dive

Fix "Worksheet Does Not Exist" KeyError in openpyxl

Why openpyxl raises KeyError on a sheet you can see in Excel: hidden whitespace, case, renamed tabs and index confusion — plus a resilient sheet lookup helper.

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.

What Excel shows on a tab versus what the file stores Excel renders a trimmed tab label, so a sheet stored as space-Sales-space looks identical to Sales; openpyxl compares the stored string and the lookup misses. What you see in Excel What the file stores Sales 'Sales ' Q3 Summary 'Q3 Summary' wb["Sales"] misses on a trailing space; wb["Q3 Summary"] misses on a non-breaking space

Prerequisites

Only openpyxl, and optionally pandas for the second half:

Bash
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:

Python
from openpyxl import load_workbook

wb = load_workbook("sales.xlsx")
print(wb.sheetnames)
print([repr(name) for name in wb.sheetnames])
Text
['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:

Python
"""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:

Python
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:

Python
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.

Four ways to address a worksheet, and when each is stable Exact name is brittle, normalised name survives whitespace and case changes, position survives renames, and a pattern match survives dated tab names like Jan 2026. Pick the handle that survives how the producer edits the file exact name wb["Sales"] breaks on a space, a case change, a rename normalised name NFKC + casefold survives whitespace and capitalisation position wb.worksheets[0] survives any rename, breaks on reordering pattern match startswith / regex handles dated tabs like "Jan 2026" Whichever you choose, raise an error that lists the sheet names the file really had

For dated tabs, a pattern match beats both name and position:

Python
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:

Python
"""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.

Comparing required tabs against the tabs the file has Set arithmetic between the required sheet names and the names present shows both what is missing and what arrived unexpectedly, which together identify a rename. A rename shows up as one miss plus one extra required sales targets raw data present in file sales targets 2026 raw data diagnosis missing: targets extra: targets 2026 so: a rename

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:

Python
"""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. Use wb["Name"] — old tutorials still show the removed API and produce an AttributeError that looks unrelated.
  • Creating a sheet by accident. wb.create_sheet("Sales") when Sales already exists gives you Sales1, and the next read finds the wrong tab. Check if "Sales" in wb.sheetnames first.
  • 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 sales and Sales in 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:

Python
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.