How-to
Getting Started With Python Excel AutomationDeep dive

Read Apple Numbers Files in Python

pandas cannot open .numbers files, but the numbers-parser package can: list the sheets and tables inside a Numbers document, turn a table into a DataFrame without the padding of empty rows and columns, read typed values and formatted text, handle several tables per sheet, convert a whole document to .xlsx with LibreOffice, and write a Numbers file back.

Mac users send .numbers files the way Windows users send .xlsx, and a reporting script that only understands Excel stops at the first one. pd.read_excel does not read them — a Numbers document is not an Office Open XML package but a ZIP of Apple's own binary format — and asking the sender to export to Excel every time does not scale. The open-source numbers-parser package reads the format directly, on any operating system, without Numbers installed. This page shows how to get from a .numbers file to a clean DataFrame, what the document structure looks like (it differs from Excel's in one important way), how values and formats come through, and when converting to .xlsx with LibreOffice is the simpler route. It belongs to Handling Excel File Formats and Conversions.

Prerequisites

Bash
pip install numbers-parser pandas

numbers-parser is a pure-Python reader and writer for documents saved by Apple Numbers; the examples were run with version 4.19 on Linux. The sample orders.numbers has a Sales sheet with an Orders table, and a Stock sheet with two tables, one per warehouse.

Step 1 — Why pandas cannot read it

Python
import pandas as pd

try:
    pd.read_excel("orders.numbers")
except Exception as exc:
    print(type(exc).__name__, str(exc)[:80])

pandas inspects the file, sees a ZIP that contains none of the parts an Excel workbook has, falls back to the extension and fails with OptionError: No such keys(s): 'io.excel.zip.reader' — the same confusing message it gives for a plain ZIP archive, explained in Read Excel Files Inside a ZIP Archive with Python. There is no engine to pass; a different library is needed.

Step 2 — Sheets contain tables, not cells

Python
from numbers_parser import Document

doc = Document("orders.numbers")
for sheet in doc.sheets:
    for table in sheet.tables:
        print(f"{sheet.name:6} / {table.name:12} {table.num_rows} rows x {table.num_cols} cols, "
              f"header rows: {table.num_header_rows}")

This is the main structural difference from Excel. An Excel sheet is a grid; a Numbers sheet is a canvas holding any number of separate tables, each with its own name, size and header rows. The Stock sheet holds two tables that would be two blocks on one Excel sheet. Address them by name — doc.sheets["Stock"].tables["Warehouse B"] — rather than by position, so a reordered canvas does not break the script.

A Numbers document is sheets that hold tables The document orders.numbers contains two sheets. The Sales sheet holds one table named Orders. The Stock sheet holds two tables, Warehouse A and Warehouse B, each a separate grid with its own header row. orders.numbers Document Sheets: Sales, Stock doc.sheets["Stock"] Tables: Orders · Warehouse A · Warehouse B sheet.tables["Warehouse B"] Cells: NumberCell, TextCell, DateCell, BoolCell, EmptyCell table.cell(row, col)

Step 3 — Turn a table into a DataFrame

table.rows(values_only=True) returns the table as a list of lists of Python values. A table's grid is often larger than its data — new tables start at 12 rows by 8 columns, and people leave empty rows at the bottom — so trim the padding:

Python
def table_to_frame(table):
    rows = table.rows(values_only=True)
    header_rows = max(table.num_header_rows, 1)
    df = pd.DataFrame(rows[header_rows:], columns=rows[header_rows - 1])
    df = df.dropna(how="all").dropna(axis=1, how="all")
    df.columns = [c if c is not None else f"col_{i}" for i, c in enumerate(df.columns)]
    return df.reset_index(drop=True)

orders = table_to_frame(doc.sheets["Sales"].tables["Orders"])
print(orders)
print(orders.dtypes)

The raw rows include five unused columns and seven unused rows filled with None; dropping all-empty rows and columns leaves the four orders. The last header row becomes the column names. Notice the types: Numbers stores every number as a decimal, so Qty arrives as 3.0 rather than 3, dates arrive as datetime objects, and the TRUE/FALSE checkboxes as booleans — with None for the empty Rake row. Whole numbers need one more step than you would expect:

Python
print([float(q).hex() for q in orders["Qty"]])
try:
    orders["Qty"].astype("Int64")
except TypeError as exc:
    print("TypeError:", exc)

The quantity 12 comes back as 12.000000000000002 — the hex form 0x1.8000000000001p+3 shows the extra bit. Numbers keeps values in a 128-bit decimal format, and the conversion to a Python float is not always exact, so pandas refuses the cast with cannot safely cast non-equivalent object to int64. The column prints as 12.0, which hides the problem until the cast or an equality test fails. Round before casting:

Python
orders = orders.astype({"Shipped": "boolean"})
orders["Qty"] = orders["Qty"].round().astype("Int64")
orders["Total"] = orders["Qty"] * orders["Unit price"]
print(orders[["Product", "Qty", "Shipped", "Total"]])
The padded table grid becomes a tidy DataFrame On the left the Orders table as a 12 by 8 grid with data in the top five rows and first five columns and empty cells elsewhere. On the right the DataFrame after dropping all-empty rows and columns, with four orders and five columns. A B C D E 1 Product Qty Unit price 2 Drill 3.0 89.0 3 Saw 5.0 45.5 4 Rake 4.0 22.0 5 6 Product Qty Unit price Drill 3 89.0 Saw 5 45.5 Rake 4 22.0 Numbers table grid DataFrame table_to_frame()

Step 4 — Values versus formatted text

Each cell object carries both the stored value and the text Numbers would display:

Python
price = doc.sheets["Sales"].tables["Orders"].cell(1, 2)
print(type(price).__name__, price.value, repr(price.formatted_value))
print(type(doc.sheets["Sales"].tables["Orders"].cell("D2")).__name__)

The price cell is a NumberCell with value 89.0 and formatted value '€89.00', because the column has a euro currency format. Use value for calculations and formatted_value when a report must reproduce exactly what the sender saw. Cells can be addressed by row and column numbers (zero-based) or by Excel-style references such as "D2". Cells with formulas report is_formula=True and expose the formula text through formula; their value is the result Numbers last calculated.

Step 5 — Read every table at once

To load a whole document, build a dictionary keyed by sheet and table name — the Numbers equivalent of pd.read_excel(path, sheet_name=None):

Python
frames = {f"{s.name}/{t.name}": table_to_frame(t) for s in doc.sheets for t in s.tables}
for key, df in frames.items():
    print(key, df.shape)
stock = pd.concat([frames["Stock/Warehouse A"].assign(Warehouse="A"),
                   frames["Stock/Warehouse B"].assign(Warehouse="B")], ignore_index=True)
print(stock)

The two warehouse tables share a layout, so they stack into one stock list with a column recording which table each row came from — the same idea as combining several Excel sheets, described in Read All Sheets from an Excel File into DataFrames.

Step 6 — Convert the whole document to .xlsx

When the rest of the pipeline expects Excel files, LibreOffice can convert .numbers documents through its Apple import filter, and the result reads with ordinary read_excel:

Bash
soffice --headless --convert-to xlsx --outdir converted orders.numbers > /dev/null 2>&1
ls converted
Python
for name, df in pd.read_excel("converted/orders.xlsx", sheet_name=None).items():
    print(repr(name), df.shape)

Each Numbers table becomes its own Excel sheet, named Sheet_Table — here 'Sales_Orders', 'Stock_Warehouse A' and 'Stock_Warehouse B' — because Excel has no concept of several free-floating tables per sheet. Conversion is handy for batch jobs that feed existing Excel-based code, at the cost of a LibreOffice dependency and a few seconds per file; see Convert Excel to PDF on Linux with LibreOffice for running soffice reliably on a server.

Read directly or convert first? If the script only needs the data, read the .numbers file directly with numbers-parser. If existing code expects .xlsx files, convert with LibreOffice and use read_excel. If the reply must go back as a Numbers file, write it with numbers-parser. What does the rest of the job need? just the data numbers-parser no extra software, any OS xlsx files LibreOffice convert one sheet per table, then read_excel a Numbers reply Document().save() write cells and formats

Step 7 — Write a Numbers file back

numbers-parser also writes documents, which helps when results must go back to a Mac user in the format they sent:

Python
from numbers_parser import Document as NewDoc

out = NewDoc()
table = out.sheets[0].tables[0]
summary = orders.groupby("Shipped", dropna=False)["Total"].sum().reset_index()
table.write(0, 0, "Shipped")
table.write(0, 1, "Total")
for r, (flag, total) in enumerate(summary.itertuples(index=False), start=1):
    table.write(r, 0, "unknown" if pd.isna(flag) else str(bool(flag)))
    table.write(r, 1, float(total))
out.save("summary.numbers")
print(table_to_frame(NewDoc("summary.numbers").sheets[0].tables[0]))

Values are written cell by cell with write(row, col, value); numbers, text, booleans and datetimes are supported, and set_cell_formatting applies number formats such as currency or percentage. pandas' NA must be turned into something Numbers understands first — here the unknown shipping status becomes the text unknown.

Common pitfalls

  • Expecting one grid per sheet. A sheet can hold several tables; always pick the table by name.
  • Padding. rows() returns the full table grid; drop all-empty rows and columns.
  • Floats for whole numbers. Values can carry tiny conversion errors (12 as 12.000000000000002); round before casting counts and IDs, and compare money with a tolerance or after rounding.
  • Header rows. Tables can have zero or several header rows; use num_header_rows instead of assuming one.
  • Very old files. numbers-parser targets documents from recent versions of Numbers; files from very old versions may need re-saving in Numbers first.

Performance notes

numbers-parser decodes the whole document when it opens it, including styles and every table, so opening time grows with the document rather than with the one table you need. Documents of a few thousand rows open in well under a second; for a folder of large files, open each document once and extract every table you need from it, rather than reopening it per table. The LibreOffice route costs a process start per file — a few seconds — but converts many files in one call when you pass several paths to soffice --convert-to.

Conclusion

pd.read_excel cannot open .numbers files, but numbers-parser reads them on any system: walk doc.sheets and each sheet's tables, turn a table into a DataFrame with rows(values_only=True), trim the empty padding and cast whole numbers. Use formatted_value when you need what the sender saw, LibreOffice when downstream code expects .xlsx, and Document().save() to send a Numbers file back.

Frequently asked questions

Can pandas read Apple Numbers files? No. read_excel only reads Excel and OpenDocument formats. Read .numbers files with the numbers-parser package and build a DataFrame from table.rows(values_only=True), or convert the file to .xlsx with LibreOffice first.

Why does my Numbers table have so many empty rows and columns? numbers-parser returns the table's full grid, including unused rows and columns. Drop them with df.dropna(how="all") and df.dropna(axis=1, how="all").

Why are whole numbers read as floats from a .numbers file? Numbers stores all numeric values as decimals, so 3 comes back as 3.0, and some values pick up a tiny conversion error such as 12.000000000000002. Round the column first and then cast it to Int64.

Do I need a Mac or Numbers installed to read .numbers files? No. numbers-parser is a Python package that reads the file format directly on Windows, Linux or macOS.

More on file formats & conversion

More on file formats & conversion →