11.1 Ingest an Excel file from object storage¶
Concept¶
Not every source is a database or a Parquet file. The real world runs on spreadsheets —
a finance export, a partner's customer list, a hand-maintained mapping table. Sooner or later
someone drops an .xlsx in a bucket and asks you to get it into the lakehouse. This recipe lands
one as a Bronze table (raw, as-received — see 4.2).
The catch: Spark can't read Excel natively. There's no spark.read.excel(...) on this stack —
.xlsx is a zipped bundle of XML, not a columnar data format Spark understands. The clean,
dependency-light way is a two-step hop:
flowchart LR
X[(RustFS<br/>new_customers.xlsx)] -->|pandas + openpyxl| P[pandas DataFrame]
P -->|spark.createDataFrame| S[Spark DataFrame]
S -->|writeTo iceberg| B["🥉 my.bronze.customers_excel"]
pandas can read Excel — via the openpyxl engine — so you read the file into a pandas DataFrame, convert that to a Spark DataFrame, then land it in Bronze with the exact write pattern from Unit 4. Excel files are small (they open in a spreadsheet, after all), so pulling one through pandas on the driver is perfectly fine — you're not streaming a billion rows.
Why the pandas detour is the right call here
Spark shines on big, splittable file formats (Parquet, CSV, JSON) it can read in parallel
across the cluster. Excel is neither big nor splittable — it's a single-author office file. So
instead of fighting Spark, you let pandas + openpyxl do the one thing they're great at
(parsing .xlsx), then hand the tidy result to Spark for the part it's great at: storing it
as a governed lakehouse table other engines can query.
Reaching object storage from pandas¶
Spark uses s3a://… to reach RustFS (the S3-compatible object store — inside the stack it's reached at http://storage:9000). pandas uses a different door: the s3fs library, which
teaches pandas to open s3://… URLs. You pass it the RustFS credentials and endpoint through a
storage_options dict, and pandas hands that straight to s3fs. Same bucket, same object — just a
different client on the driver side.
Lab¶
Run this in a Jupyter notebook (it talks to the cluster over Spark Connect). Three cells.
Cell A — connect + config¶
import os
import pandas as pd
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# Your own storage key (set for you in Jupyter) + the in-stack S3 endpoint, for pandas (s3fs).
storage = {"key": os.environ["AWS_ACCESS_KEY_ID"], "secret": os.environ["AWS_SECRET_ACCESS_KEY"],
"client_kwargs": {"endpoint_url": "http://storage:9000"}}
xlsx = "s3://demouser-lake/files/source/new_customers.xlsx"
Read it step by step:
import pandas as pd— pandas is the single-machine DataFrame library that can parse Excel.pdis the conventional alias.SparkSession.builder.getOrCreate()— grab (or create) your handle to the Spark cluster. Over Spark Connect this attaches to the running cluster; the object namedsparkis how you talk to it, exactly as in 4.1.storage = {…}— the credentials + endpoint pandas needs to reach object storage.key/secretare your own storage key — Jupyter sets them for you asAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, and they open only your bucket (never paste keys into code);client_kwargs.endpoint_urlpoints at the store's in-stack address,http://storage:9000(not AWS's real S3). This dict is what makess3://…URLs resolvable from the driver.xlsx = "s3://…"— the object-storage path to the spreadsheet, as ans3://URL (the door s3fs opens — Spark's own reads uses3a://instead).
Produces: no data yet — just the spark handle, the storage creds, and the target path.
Needs openpyxl + s3fs — already baked in
Reading .xlsx from s3:// needs two libraries on the driver: openpyxl (pandas'
engine for parsing Excel) and s3fs (so pandas can open s3:// URLs). Both are already
installed in this stack's Jupyter image, so there's nothing to pip install — the cells
below just work.
Cell B — create a sample Excel upload (setup)¶
So the lab is self-contained, first write a small spreadsheet into the bucket. In real life this file would already be there — someone uploaded it — so treat this cell as the "someone" who did:
pd.DataFrame({"customer_id": [9001, 9002, 9003],
"full_name": ["Ada Byte", "Ravi Kumar", "Mei Chen"],
"country": ["UK", "IN", "SG"]}
).to_excel(xlsx, index=False, storage_options=storage)
Read it step by step:
pd.DataFrame({…})— build a pandas DataFrame from a dict of columns: three customers with an id, a name, and a country..to_excel(xlsx, …)— write that DataFrame out as an.xlsxfile straight to object storage at thes3://path. pandas uses openpyxl to encode the workbook.index=False— don't write pandas' auto row-index as an extra column; you only want your three real columns.storage_options=storage— hand pandas the RustFS creds/endpoint so it can reach the bucket (this is where s3fs kicks in).
Produces: an Excel file new_customers.xlsx sitting in your bucket's files/source/ — the source
you'll ingest next.
Cell C — read the Excel and land it in the lakehouse¶
Now the actual recipe: read the workbook back, convert to Spark, and write it into Bronze:
# 1) read the Excel file straight from object storage (openpyxl reads .xlsx)
pdf = pd.read_excel(xlsx, storage_options=storage)
print(pdf)
# 2) hand it to Spark and land it in the lakehouse as a Bronze table
sdf = spark.createDataFrame(pdf)
sdf.writeTo("my.bronze.customers_excel").using("iceberg").createOrReplace()
spark.sql("SELECT count(*) FROM my.bronze.customers_excel").show()
Read it step by step:
pd.read_excel(xlsx, storage_options=storage)— the heart of it: read the.xlsxfrom object storage into a pandas DataFrame (pdf). openpyxl parses the workbook;storage_optionsgets pandas to the file over s3fs. With nosheet_name, it reads the first sheet.print(pdf)— eyeball the three rows pandas parsed, so you know what you're about to land.spark.createDataFrame(pdf)— convert the pandas DataFrame into a Spark DataFrame (sdf). This is the bridge from the single-machine world to the cluster: Spark infers the schema from pandas' columns and dtypes and takes ownership of the data.sdf.writeTo("my.bronze.customers_excel").using("iceberg").createOrReplace()— the Unit 4 write pattern: target the three-part namemy.bronze.customers_excel(mycatalog →bronzeschema → table), store it as an Iceberg table, and create-or-fully-replace it. Trino can read it immediately — asdemouser_lake.bronze.customers_excel.spark.sql("SELECT count(*) …").show()— confirm the rows actually landed by counting them in the new Bronze table.
Produces: my.bronze.customers_excel — a raw Bronze table holding the three spreadsheet
rows, ready for Silver cleaning later. Grain: one row per customer, exactly as in the sheet.
Multi-sheet workbooks
A workbook can have many tabs. pd.read_excel picks the sheet with the sheet_name
argument:
pd.read_excel(xlsx, sheet_name="Sheet1")— read one named sheet (or pass an integer index, e.g.sheet_name=0for the first).pd.read_excel(xlsx, sheet_name=None)— read all sheets, returned as a dict of{sheet_name: DataFrame}you can loop over — handy for landing each tab as its own Bronze table.
Challenge¶
Your partner sends a workbook s3://demouser-lake/files/source/partners.xlsx with several tabs. You only
care about the accounts sheet, and of its columns you only want account_id and
region. Read just that sheet, keep only those two columns, and land the result as
my.bronze.partner_accounts.
Two arguments and a column selection
Point pd.read_excel at the right tab with sheet_name="accounts", then narrow the
pandas DataFrame to the two columns you want with a column list — pdf[["account_id",
"region"]] — before handing it to Spark. Then it's the same createDataFrame →
writeTo(...).createOrReplace() you already know.
First, create the partner's workbook
There's no real partner, so make the file yourself (run once) — two tabs, accounts and
contacts, saved to your bucket:
partners = "s3://demouser-lake/files/source/partners.xlsx"
with pd.ExcelWriter(partners, engine="openpyxl", storage_options=storage) as xw:
pd.DataFrame({"account_id": [101, 102, 103], "name": ["Acme", "Globex", "Initech"],
"region": ["EU", "US", "APAC"], "tier": ["gold", "silver", "gold"]}
).to_excel(xw, sheet_name="accounts", index=False)
pd.DataFrame({"account_id": [101, 102], "email": ["ops@acme.example", "it@globex.example"]}
).to_excel(xw, sheet_name="contacts", index=False)
Solution
# read only the 'accounts' sheet, then keep only the two columns we need
pdf = pd.read_excel("s3://demouser-lake/files/source/partners.xlsx",
sheet_name="accounts",
storage_options=storage)
pdf = pdf[["account_id", "region"]]
# convert and land in Bronze
sdf = spark.createDataFrame(pdf)
sdf.writeTo("my.bronze.partner_accounts").using("iceberg").createOrReplace()
spark.sql("SELECT count(*) FROM my.bronze.partner_accounts").show()
sheet_name="accounts"— read only that one tab instead of the default first sheet.pdf[["account_id", "region"]]— select just those two columns (a column projection) while still in pandas, dropping everything else before it ever reaches Spark.createDataFrame→writeTo(...).using("iceberg").createOrReplace()— the same land-it pattern, targeting a new Bronze tablemy.bronze.partner_accounts.
🎯 The same recipe on Azure Data Factory, Databricks & Fabric
What you just did: read an Excel file out of object storage and landed it as a Bronze lakehouse table.
- Azure Data Factory — a Copy activity with an Excel dataset pointed at Blob/ADLS, copying into a table — no code.
- Azure Databricks — the same pandas read in a notebook (or the spark-excel library for a native Spark read), then write Bronze the same way (Delta there; Iceberg here).
- Microsoft Fabric — Dataflows Gen2 (Power Query reads
.xlsxnatively) or a Lakehouse notebook, landing into a Lakehouse table.
Only the object-store path changes (abfss:// / s3:// instead of RustFS); the "parse Excel
with pandas, land as a table" shape is identical everywhere.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| pandas DataFrame | Single-machine, in-memory table — can parse Excel; lives on the driver |
| openpyxl | The engine pandas uses to read/write .xlsx files |
| s3fs | Library that lets pandas open s3://… object-storage URLs |
storage_options |
Dict of RustFS creds + endpoint pandas passes to s3fs |
pd.read_excel |
Read a spreadsheet into a pandas DataFrame |
sheet_name |
Which tab(s) to read — a name, an index, or None for all sheets |
spark.createDataFrame(pdf) |
Convert a pandas DataFrame into a Spark DataFrame |
| Spark DataFrame | Cluster-side, lazily-computed distributed table |
writeTo(...).using("iceberg").createOrReplace() |
Create, or fully replace, a Bronze Iceberg table |
| Bronze | Raw, as-received copy of a source — land it, don't transform |
| Column projection | Keep only the columns you need (pdf[["a","b"]]) |
You can now…¶
- Read an
.xlsxfile from object storage with pandas + openpyxl over s3fs - Convert a pandas DataFrame to a Spark DataFrame with
spark.createDataFrame - Land it as an
my.bronze.*table Trino can read immediately - Pick a single sheet, or all sheets, from a multi-tab workbook