Add Dropdown Lists to Excel with openpyxl
The template needs a dropdown for the cost-centre column. With five values the inline list works; with the real list of 140 cost centres, Excel opens the file with We found a problem with some content in 'budget.xlsx'. Do you want us to try to recover as much as we can? and the repaired workbook has no validation at all. A second requirement — the sub-category dropdown should only offer options for the category chosen in the previous column — has no obvious openpyxl API.
Root Cause
An inline dropdown stores its options inside the validation's formula1 as a single quoted string, "Open,Shipped,Closed", and Excel caps that string at 255 characters. 140 cost-centre codes exceed it, openpyxl writes the file anyway because it does not enforce Excel's limit, and Excel rejects the rule during repair. The correct pattern for anything beyond a handful of short options is a range reference: put the values in cells on a lookup sheet and point formula1 at that range, ideally through a defined name. Dependent dropdowns use the same mechanism with a formula — INDIRECT or OFFSET/MATCH — that returns a different range depending on another cell's value. openpyxl has no special API for any of this because Excel does not either; it is all expressed through formula1.
Minimal Diagnostic
Measure the inline list before writing it, and check any existing validations in the template for the same problem.
# pip install "openpyxl>=3.1"
from pathlib import Path
from openpyxl import load_workbook
COST_CENTRES = [f"CC-{n:04d} {name}" for n, name in enumerate(
["Finance", "Payroll", "Facilities", "IT Service Desk", "Field Sales North"] * 28, start=100)]
TEMPLATE = Path("in/budget-template.xlsx")
def inline_list_length(values: list[str]) -> int:
return len('"' + ",".join(values) + '"')
def check_template(path: Path) -> None:
wb = load_workbook(path)
try:
for ws in wb.worksheets:
for dv in ws.data_validations.dataValidation:
f1 = dv.formula1 or ""
flag = "TOO LONG" if f1.startswith('"') and len(f1) > 255 else "ok"
print(f"[{ws.title}] {dv.sqref} type={dv.type} len(formula1)={len(f1)} {flag}")
finally:
wb.close()
if __name__ == "__main__":
n = inline_list_length(COST_CENTRES)
print(f"inline list would be {n} characters (limit 255): {'TOO LONG' if n > 255 else 'ok'}")
if TEMPLATE.exists():
check_template(TEMPLATE)
inline list would be 3782 characters (limit 255): TOO LONG
Anything over 255 must move to a range. Commas inside values are a second trap: an option such as Smith, J splits into two options in an inline list, which a range reference also avoids.
Fix: Put the Options on a Lookup Sheet Behind a Defined Name
Write the options to a hidden sheet, define a name for the range, and point the validation at the name. Changed lines are commented.
# pip install "openpyxl>=3.1"
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.datavalidation import DataValidation
TEMPLATE = Path("in/budget-template.xlsx")
DEST = Path("out/budget-2027.xlsx")
COST_CENTRES = [f"CC-{n:04d}" for n in range(100, 240)]
def add_cost_centre_dropdown(src: Path, dest: Path, target_range: str) -> None:
try:
wb = load_workbook(src)
except (OSError, KeyError) as exc:
raise SystemExit(f"cannot open template {src}: {exc}")
try:
lists = wb["Lists"] if "Lists" in wb.sheetnames else wb.create_sheet("Lists") # changed
lists["A1"] = "cost_centre"
for row, value in enumerate(COST_CENTRES, start=2):
lists.cell(row=row, column=1, value=value) # changed: options in cells
last = len(COST_CENTRES) + 1
wb.defined_names["CostCentres"] = DefinedName( # changed: named range
"CostCentres", attr_text=f"Lists!$A$2:$A${last}")
lists.sheet_state = "hidden" # changed: out of the way
ws = wb["Budget"]
dv = DataValidation(
type="list",
formula1="=CostCentres", # changed: reference, not literal
allow_blank=True,
showErrorMessage=True,
errorTitle="Unknown cost centre",
error="Choose a cost centre from the list.",
)
ws.add_data_validation(dv)
dv.add(target_range)
dest.parent.mkdir(parents=True, exist_ok=True)
wb.save(dest)
finally:
wb.close()
if __name__ == "__main__":
add_cost_centre_dropdown(TEMPLATE, DEST, "B2:B400")
print(f"wrote {DEST}")
hidden keeps the lookup sheet out of casual view while leaving it unhidable from Excel's menu, which administrators appreciate when a code needs adding. very_hidden removes it from that menu too; use it when people should not edit the list by hand. The defined name matters for two reasons: it keeps the rule readable, and it works in older Excel versions that refuse direct references to another sheet inside validation rules.
Variant Fix 1: A List That Grows Every Month
A fixed range such as $A$2:$A$141 stops at the last value; when someone appends a new cost centre in row 142, it never appears in the dropdown. Two options: define the name with a dynamic formula, or store the list as an Excel table whose column reference expands automatically.
# pip install "openpyxl>=3.1"
from openpyxl import Workbook
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
lists = wb.active
lists.title = "Lists"
codes = [f"CC-{n:04d}" for n in range(100, 240)]
lists.append(["cost_centre"])
for code in codes:
lists.append([code])
# Dynamic name: from A2 down to the last non-empty cell in column A
wb.defined_names["CostCentres"] = DefinedName(
"CostCentres",
attr_text="OFFSET(Lists!$A$2,0,0,COUNTA(Lists!$A:$A)-1,1)", # grows as rows are added
)
budget = wb.create_sheet("Budget")
dv = DataValidation(type="list", formula1="=CostCentres", showErrorMessage=True)
budget.add_data_validation(dv)
dv.add("B2:B400")
wb.save("out/budget-dynamic.xlsx")
OFFSET with COUNTA returns a range exactly as tall as the filled cells below the header. It is volatile — recalculated constantly — which is harmless for a lookup list of a few hundred items. The list must be contiguous; a blank row in the middle truncates it.
Variant Fix 2: Dependent Dropdowns
The sub-category column should offer only the options for the category chosen in column C. Create one named range per category, named after the category, and use INDIRECT to pick the range by name:
# pip install "openpyxl>=3.1"
import re
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.datavalidation import DataValidation
TREE = {
"Travel": ["Flights", "Rail", "Hotels", "Mileage"],
"Equipment": ["Laptops", "Monitors", "Phones"],
"Services": ["Legal", "Audit", "Consulting", "Recruitment"],
}
def safe_name(label: str) -> str:
"""Defined names cannot contain spaces or start with a digit."""
name = re.sub(r"\W", "_", label)
return name if not name[0].isdigit() else f"_{name}"
wb = Workbook()
lists = wb.active
lists.title = "Lists"
for col, (category, options) in enumerate(TREE.items(), start=1):
letter = get_column_letter(col)
lists.cell(row=1, column=col, value=category)
for row, option in enumerate(options, start=2):
lists.cell(row=row, column=col, value=option)
wb.defined_names[safe_name(category)] = DefinedName(
safe_name(category), attr_text=f"Lists!${letter}$2:${letter}${len(options) + 1}")
wb.defined_names["Categories"] = DefinedName(
"Categories", attr_text=f"Lists!$A$1:${get_column_letter(len(TREE))}$1")
lists.sheet_state = "hidden"
ws = wb.create_sheet("Budget")
ws.append(["line", "description", "category", "sub_category", "amount"])
category = DataValidation(type="list", formula1="=Categories", showErrorMessage=True)
sub = DataValidation(type="list", formula1='=INDIRECT(SUBSTITUTE($C2," ","_"))',
allow_blank=True, showErrorMessage=True,
error="Pick the category first, then a sub-category.")
for dv, rng in ((category, "C2:C400"), (sub, "D2:D400")):
ws.add_data_validation(dv)
dv.add(rng)
wb.save("out/budget-dependent.xlsx")
The SUBSTITUTE inside the formula mirrors safe_name, so categories with spaces still resolve to a valid name. Like conditional formatting, the formula is written for the first row with a relative row reference ($C2), and Excel adjusts it per row. One limitation to explain to users: changing the category after picking a sub-category does not clear the old sub-category, so the pair can become inconsistent. Catch that downstream with a check that each sub-category belongs to its category, as in Validating Document Data with Schemas.
Verification
Reopen the output and check that the validation references a name, the name resolves to the expected number of options, and those options match your source list exactly. For dependent lists, check every parent resolves.
# pip install "openpyxl>=3.1"
from pathlib import Path
from openpyxl import load_workbook
def resolve_name(wb, name: str) -> list:
dn = wb.defined_names[name]
values = []
for sheet_name, ref in dn.destinations:
ws = wb[sheet_name]
cells = ws[ref.replace("$", "")]
rows = cells if isinstance(cells, tuple) else ((cells,),)
for row in rows:
for cell in (row if isinstance(row, tuple) else (row,)):
if cell.value is not None:
values.append(cell.value)
return values
def verify_dropdown(path: Path, sheet: str, rng: str, name: str, expected: list[str]) -> None:
wb = load_workbook(path)
try:
dvs = [dv for dv in wb[sheet].data_validations.dataValidation if str(dv.sqref) == rng]
assert dvs, f"no validation on {sheet}!{rng}"
dv = dvs[0]
assert dv.formula1 in (name, f"={name}"), f"formula1 is {dv.formula1!r}"
assert dv.showErrorMessage, "invalid entries would be accepted"
values = resolve_name(wb, name)
assert values == expected, f"{len(values)} options in name, {len(expected)} expected"
print(f"{sheet}!{rng}: {len(values)} options via {name}")
finally:
wb.close()
if __name__ == "__main__":
verify_dropdown(Path("out/budget-2027.xlsx"), "Budget", "B2:B400", "CostCentres",
[f"CC-{n:04d}" for n in range(100, 240)])
destinations only resolves static references; for the dynamic OFFSET name, verify the source column instead and open the file once in Excel or LibreOffice to confirm the dropdown populates. Also confirm the file opens without a repair prompt — libreoffice --headless --convert-to pdf exits cleanly on a valid file and is a convenient automated smoke test.
FAQ
Can the dropdown show a label but store a code?
Not with data validation. The dropdown stores exactly what it shows. Show CC-0104 Payroll and split the code off downstream, or use a lookup formula in a helper column.
Why is there no dropdown arrow even though validation works?showDropDown=True hides the arrow. The attribute name is inverted compared with its meaning; leave it False.
Does pandas to_excel remove my validations?
Writing a DataFrame to an existing sheet name through ExcelWriter in replace mode recreates the sheet and drops them. Load with openpyxl and write values into cells, or add validations after pandas has written.
Can I allow values not in the list?
Set errorStyle="warning" or "information" on the DataValidation; Excel then warns but accepts other input, which suits free-text-with-suggestions fields.
Related
- Conditional Formatting and Data Validation in Excel — the full workbook-building workflow
- Fix openpyxl Data Validation Not Working — when a dropdown is ignored or removed
- Add Excel Tables and Named Ranges with openpyxl — tables that make lookup lists self-extending
- Generate One DOCX per Row from Excel — clean dropdown values make downstream templating reliable
Part of Conditional Formatting and Data Validation in Excel.