Load an Excel File into a SQL Database with pandas
Reading a spreadsheet is easy. Putting one into a database that other people query is where the care is needed, because a workbook that people fill in contains things a schema does not expect: a blank row left at the bottom, a "Total" line, an ID typed with a leading apostrophe, a date entered two different ways, and the same key twice because someone pasted a block in again.
The pattern that survives all of that is the same one used for any untrusted import: normalise, pin the types, stage, validate, then merge in a single transaction. This guide is part of Moving Data Between Excel and Databases.
Prerequisites
pip install pandas openpyxl sqlalchemy
The examples use SQLite so they run with no server. Everything transfers to Postgres, MySQL or SQL Server by changing the URL — and by remembering that those databases enforce constraints SQLite will happily ignore, which is an argument for testing the load against the real engine before it runs unattended.
Step 1: Read the file on your terms
Two arguments do most of the defensive work: dtype pins the columns whose type pandas would otherwise guess, and usecols limits the read to the columns you actually accept, so an extra column someone added on the right cannot reach the table.
import pandas as pd
RAW = pd.read_excel(
"submissions/july-actuals.xlsx",
sheet_name="Actuals",
skiprows=2, # the file has a title and a blank line first
usecols="A:E", # ignore the working columns to the right
dtype={"Order Ref": str, "Cost Centre": str},
na_values=["", "-", "n/a", "N/A", "TBC"],
)
print(RAW.dtypes)
print(f"{len(RAW):,} raw rows")
na_values is the argument people find late. Spreadsheets are full of human placeholders — a dash, n/a, TBC — and without this every one of them makes its column object, which then fails to insert into a numeric field with an error that names the type rather than the cell.
Step 2: Normalise the shape
Database columns and spreadsheet headings rarely match. Do the renaming and cleaning in one visible place rather than scattering it through the load:
COLUMNS = {
"Order Ref": "order_id",
"Sales Area": "region",
"Booking Date": "order_date",
"Net Value": "amount",
"Cost Centre": "cost_centre",
}
def normalise(raw):
df = raw.rename(columns=COLUMNS)
missing = set(COLUMNS.values()) - set(df.columns)
if missing:
raise ValueError(f"upload is missing column(s): {sorted(missing)}")
df = df.dropna(subset=["order_id"]) # blank trailing rows
df = df[~df["order_id"].astype(str).str.lower()
.isin({"total", "subtotal", "grand total"})] # summary lines
df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
df["amount"] = pd.to_numeric(df["amount"], errors="coerce").round(2)
df["region"] = df["region"].str.strip().str.title()
df["order_id"] = df["order_id"].str.strip()
df["period"] = df["order_date"].dt.strftime("%Y-%m")
return df.reset_index(drop=True)
df = normalise(RAW)
errors="coerce" turns anything unparseable into NaT or NaN rather than raising, which is what you want here: the goal is to collect every bad value and report them together, not to stop at the first one. Rounding the amount at this point rather than at the end avoids a float that ends in .00000000001 being stored in a NUMERIC(12,2) column and rounding somewhere you cannot see.
Step 3: Refuse the file if it is not loadable
Now count what the coercions found, and stop before anything is written:
def check(df):
problems = []
bad_dates = int(df["order_date"].isna().sum())
if bad_dates:
sample = df.loc[df["order_date"].isna(), "order_id"].head(5).tolist()
problems.append(f"{bad_dates} unparseable date(s), e.g. order {sample}")
bad_amounts = int(df["amount"].isna().sum())
if bad_amounts:
problems.append(f"{bad_amounts} non-numeric amount(s)")
dupes = df["order_id"][df["order_id"].duplicated()].unique().tolist()
if dupes:
problems.append(f"{len(dupes)} duplicated order id(s): {dupes[:5]}")
if df["period"].nunique() > 1:
problems.append(f"file spans several periods: {sorted(df['period'].unique())}")
if problems:
raise ValueError("upload rejected:\n - " + "\n - ".join(problems))
return df
Reporting every problem at once, with example values, is the difference between one email to the submitter and five. Naming the offending order IDs matters more than the count — the person fixing the file needs to find the rows, and "three unparseable dates" sends them scrolling. The same reasoning drives Validate Excel Columns Before Import with pandas.
Step 4: Stage, then merge in one transaction
Write to a staging table first. It costs one extra statement and it means the live table never sees a partially loaded or later-rejected file:
from sqlalchemy import create_engine, text
engine = create_engine("sqlite:///sales.db")
DTYPES = None # let SQLAlchemy infer for SQLite; pin explicitly on other engines
def load(df, table="sales_actuals"):
period = df["period"].iat[0]
staging = f"{table}__staging"
df.to_sql(staging, engine, if_exists="replace", index=False,
chunksize=5_000, method="multi", dtype=DTYPES)
with engine.begin() as conn: # commits, or rolls back entirely
staged = conn.execute(text(f"SELECT COUNT(*) FROM {staging}")).scalar()
if staged != len(df):
raise RuntimeError(f"staged {staged} rows, expected {len(df)}")
removed = conn.execute(
text(f"DELETE FROM {table} WHERE period = :p"), {"p": period}).rowcount
conn.execute(text(
f"INSERT INTO {table} (order_id, region, order_date, amount, "
f"cost_centre, period) "
f"SELECT order_id, region, order_date, amount, cost_centre, period "
f"FROM {staging}"))
conn.execute(text(f"DROP TABLE {staging}"))
return {"period": period, "replaced": removed, "inserted": len(df)}
print(load(check(df)))
Three details carry the weight. engine.begin() gives one transaction for the delete, the insert and the drop, so an error at any point leaves the live table untouched. Deleting the period before inserting makes the load idempotent — running the same file twice produces the same table, which is exactly what you need when a retry fires after an ambiguous failure. And naming the columns in the INSERT ... SELECT rather than relying on SELECT * protects the load from a column-order change in either table.
Step 5: Speed the insert up when the file is large
to_sql inserts row by row unless you tell it otherwise. Two arguments change the profile substantially:
df.to_sql("sales_staging", engine, if_exists="replace", index=False,
chunksize=10_000, # rows per round trip
method="multi") # one multi-row INSERT per chunk
method="multi" builds a single statement with many value tuples, which cuts network round trips dramatically on a remote database — typically several times faster for wide files. Keep chunksize bounded, though: some drivers have a parameter limit per statement (SQL Server's is 2,100), and a chunk of 10,000 rows across 8 columns exceeds it. If the load runs into the hundreds of thousands of rows regularly, the database's own bulk loader — COPY in Postgres, LOAD DATA in MySQL, bcp for SQL Server — beats anything pandas can do, and pandas' job becomes writing a clean CSV for it.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| IDs lost their leading zeros | pandas inferred an integer column | dtype={"order_id": str} at the read |
Every column is object | Human placeholders like - and TBC | List them in na_values |
Columns named Unnamed: 0 | A title row above the headers | skiprows, or header= |
| Rows doubled after a rerun | Append without deleting first | Delete the period, then insert, in one transaction |
| A month vanished from the table | Delete committed, insert failed | Wrap both in engine.begin() |
to_sql takes minutes | Row-by-row inserts | chunksize plus method="multi" |
| Too many parameters error | Chunk exceeds the driver's limit | Lower chunksize |
| Amounts differ by a cent | Float rounding at the boundary | Round before writing; store NUMERIC |
Performance and scale notes
Reading the workbook is usually the slow half: openpyxl parses XML, so a 200,000-row upload takes tens of seconds regardless of what happens afterwards. If the same file is loaded repeatedly, convert it once to CSV or Parquet and load from that — Convert Excel to CSV with Python covers the conversion, and it typically cuts the read to a fraction of the time.
On the database side, the expensive part of a large load is index maintenance. For a bulk refresh of a big table it is often faster to drop the non-clustered indexes, load, and rebuild them — but only if the table is not being read during the load, which is another reason for the staging pattern.
Conclusion
Treat an uploaded workbook as untrusted input. Pin the types at the read, normalise the columns in one visible mapping, coerce dates and numbers with errors="coerce" so problems are collected rather than thrown one at a time, and reject the file with a message that names the offending rows. Then stage it, verify the count, and merge with a delete-then-insert inside a single transaction so the load is both atomic and repeatable. That is what makes a spreadsheet import something you can leave running.
Frequently asked questions
Should I use if_exists="append" straight onto the live table?
Only for a table nobody reads while the load runs. Otherwise append into a staging table, validate, and move the rows across inside one transaction so a bad file is never visible.
How do I make re-running the same file safe? Delete the period or batch you are about to load, then insert. That makes the load idempotent — a retry after a half-finished run produces the same table rather than duplicate rows.
Why did my text IDs turn into numbers?
pandas infers types per column, so 00123 becomes 123. Pass dtype={"order_id": str} to read_excel, before any conversion has happened.
to_sql is slow — what are the options?
Pass chunksize and method="multi" to batch the inserts, and drop non-essential indexes during a bulk load. For very large files, most databases have a native bulk loader that beats any row-by-row path.
Related
Up to the parent guide:
- Moving Data Between Excel and Databases — the round trip this is the return leg of.
Related guides:
- Export SQL Query Results to Excel with Python — the outbound direction.
- Validate Excel Columns Before Import with pandas — the checks that belong before the staging write.
- Find Duplicate Rows in Excel with Python — reporting duplicated keys back to the submitter.
- Check Excel Data Types with pandas — why a column arrives as
objectand what to do about it.