Moving Data Between Excel and Databases
Most reporting sits between two worlds. The numbers live in a database — a warehouse, a Postgres instance behind an application, a SQL Server the finance system writes to — and the people who need them work in Excel. The job is not to choose between the two but to move data across the boundary reliably, in both directions, without losing types, duplicating rows or hard-coding a password into a script.
This topic within Advanced Data Transformation and Cleaning covers that boundary: connecting from Python, exporting query results into a formatted workbook, loading a spreadsheet back into a table without damaging what is already there, keeping dates and identifiers intact through the round trip, and running the whole thing on a schedule.
Connect once, in one place
Every database Python talks to is reachable through a SQLAlchemy URL, which means the only thing that changes between SQLite, Postgres, MySQL and SQL Server is a string:
from sqlalchemy import create_engine
# sqlite:///reports.db
# postgresql+psycopg://user:pw@host:5432/sales
# mysql+pymysql://user:pw@host:3306/sales
# mssql+pyodbc://user:pw@host/sales?driver=ODBC+Driver+18+for+SQL+Server
engine = create_engine(os.environ["REPORT_DB_URL"], pool_pre_ping=True)
Two habits are worth adopting at this line rather than later. Read the URL from the environment, never from the source or a committed config file — it contains a password, and a database URL in version control is the most common credential leak in reporting code. And set pool_pre_ping=True, because a scheduled job holds its engine across a long idle period and a stale connection otherwise surfaces as a confusing error on the first query of the morning.
The engine is created once per process. Creating one inside a loop opens a new pool per iteration and exhausts the server's connection limit long before the report finishes.
Export a query into a workbook people will read
The naive export — pd.read_sql(...).to_excel(...) — produces a correct, unreadable grid. The version worth scheduling adds the formatting that makes the file usable, which is where this topic meets Formatting and Charting Excel Reports with Python:
import pandas as pd
from sqlalchemy import text
QUERY = text("""
SELECT region, order_date, SUM(amount) AS amount
FROM orders
WHERE order_date >= :start AND order_date < :end
GROUP BY region, order_date
ORDER BY region, order_date
""")
with engine.connect() as conn:
df = pd.read_sql(QUERY, conn, params={"start": "2026-07-01", "end": "2026-08-01"})
with pd.ExcelWriter("regional.xlsx", engine="openpyxl",
datetime_format="yyyy-mm-dd") as writer:
df.to_excel(writer, sheet_name="Detail", index=False)
(df.groupby("region", as_index=False)["amount"].sum()
.to_excel(writer, sheet_name="Summary", index=False))
The :start and :end placeholders with a params dictionary are not stylistic. A query assembled with an f-string from a value that came out of a spreadsheet cell is an injection waiting to happen, and bound parameters also let the database cache the plan. datetime_format on the writer saves a formatting pass: without it, dates land as serial numbers or as text depending on the engine. Export SQL Query Results to Excel with Python covers multi-sheet exports, column widths and chunked reads for large results.
Stage the return leg
Loading a spreadsheet into a database is the direction that goes wrong, because a workbook filled in by people contains everything a database schema does not expect: a blank row someone left at the bottom, a total row, an ID typed as text, a date in two formats, the same key twice. Writing that straight into a live table with if_exists="append" publishes all of it.
def load_via_staging(df, engine, table="sales_actuals"):
staging = f"{table}__staging"
df.to_sql(staging, engine, if_exists="replace", index=False, chunksize=5_000)
with engine.begin() as conn: # one transaction, commits or rolls back
dupes = conn.execute(text(
f"SELECT COUNT(*) FROM (SELECT order_id FROM {staging} "
f"GROUP BY order_id HAVING COUNT(*) > 1) d")).scalar()
if dupes:
raise ValueError(f"{dupes} duplicate order_id value(s) in the upload")
conn.execute(text(f"DELETE FROM {table} WHERE period = :p"), {"p": "2026-07"})
conn.execute(text(f"INSERT INTO {table} SELECT * FROM {staging}"))
conn.execute(text(f"DROP TABLE {staging}"))
engine.begin() is the important call: it opens a transaction that commits on a clean exit and rolls back on any exception, so a failure halfway through the delete-and-insert leaves the live table exactly as it was. The delete-then-insert pattern also makes the load idempotent — re-running the same file for the same period produces the same table rather than a doubled one, which matters as soon as anything retries. Load an Excel File into a SQL Database with pandas works through the type mapping and the validation in detail.
Decide where the work happens
The single biggest performance decision at this boundary is not which library you use but which side does the aggregation. A database can group ten million rows without sending any of them anywhere; pandas can only group rows it has already received, which means transferring them across the network, materialising them in memory, and then discarding almost all of them.
The useful division is simple. Anything that reduces rows — filters, joins against reference tables, GROUP BY, DISTINCT — belongs in SQL, where indexes exist and nothing has to be transferred. Anything that reshapes what is left — pivoting for presentation, deriving display columns, formatting, ordering the sheets — belongs in pandas, where the code is easier to read and the result is going into a workbook anyway.
The exception is worth naming, because it is the reason people end up on the wrong side: a query that is awkward to express in SQL, run once a month over a small table, is not worth an hour of window-function debugging. The rule is about magnitude, not principle. When the transfer is a few thousand rows, do whatever is clearest; when it is millions, the database is not optional.
Give the report its own credentials
A reporting job needs to read a handful of tables and, at most, write to one. It does not need the application's account. Creating a dedicated read-only role costs five minutes and removes an entire category of accident — a mistyped statement in a script that only has SELECT cannot delete anything:
CREATE ROLE report_reader LOGIN PASSWORD 'set-from-a-secret-store';
GRANT CONNECT ON DATABASE sales TO report_reader;
GRANT USAGE ON SCHEMA public TO report_reader;
GRANT SELECT ON orders, customers, products TO report_reader;
-- and nothing else: no INSERT, no UPDATE, no DDL
Where the job also loads data back, give it a second role with write access to its own staging schema only, and keep the two connection strings separate in the environment. That way the export half of the pipeline physically cannot modify anything, and the load half is scoped to the tables it owns.
Two operational habits go with this. Set a statement timeout so a runaway report query cannot hold locks or saturate the server for an hour — most databases accept it per session, and a reporting connection is exactly the place for it. And name the connection, through the application name in the URL or a SET application_name, so that when a DBA sees a heavy query at 06:00 they can tell which report it belongs to instead of guessing.
engine = create_engine(
os.environ["REPORT_DB_URL"],
pool_pre_ping=True,
connect_args={"application_name": "monthly-regional-report",
"options": "-c statement_timeout=120000"}, # 2 minutes
)
Both settings turn a report that can quietly become an incident into one that fails on its own terms, in a way that names itself in the server's logs.
Protect the types across the boundary
Excel and SQL disagree about types in ways that bite quietly rather than loudly. Four cases account for nearly all of it:
| What it is | What goes wrong | What to do |
|---|---|---|
An identifier like 00123 | Excel drops the leading zeros; pandas reads it as an integer | Read with dtype={"order_id": str} and store as text |
| An ID column with one blank | Becomes float64, so 1 displays as 1.0 | Use pandas' nullable Int64, or read as str |
| A date | Arrives as a serial number, a string or a Timestamp depending on the path | Coerce with pd.to_datetime(..., errors="coerce") and check for NaT |
| A currency amount | Rounding differences between float and NUMERIC | Round explicitly before writing; store as NUMERIC, not FLOAT |
The fix is always to be explicit at the read, because a wrong type detected at the write is already a corrupted DataFrame. This is the same discipline that Check Excel Data Types with pandas applies to spreadsheets that never touch a database.
Reconcile before anyone else does
The failure that damages a reporting pipeline most is not a crash — it is a workbook that arrives on time, opens cleanly, and disagrees with the system it came from. By the time somebody notices, the number has usually been quoted in a meeting.
Two cheap checks catch nearly all of it, and both belong in the job rather than in a spreadsheet somebody maintains on the side. The first is a count-and-sum reconciliation against the source, run immediately after the extract:
CONTROL = text("""
SELECT COUNT(*) AS rows, COALESCE(SUM(amount), 0) AS total
FROM orders WHERE order_date >= :start AND order_date < :end
""")
with engine.connect() as conn:
control = conn.execute(CONTROL, params).mappings().one()
if len(df) != control["rows"]:
raise ValueError(f"extract has {len(df):,} rows, source has {control['rows']:,}")
if abs(df["amount"].sum() - float(control["total"])) > 0.01:
raise ValueError(f"extract totals {df['amount'].sum():,.2f}, "
f"source totals {float(control['total']):,.2f}")
Running the control query in the same connection and the same transaction as the extract is what makes it meaningful — against a busy table, a second connection can legitimately see different rows, and a check that fails at random gets switched off within a fortnight.
The second is a comparison against the previous run. A month that is 3% up on the last one is unremarkable; one that is 60% down usually means a filter changed, a join lost rows, or the source loaded late. Keeping the control totals from each run in a small table or JSON file makes that check a subtraction:
previous = read_state().get("total")
if previous and abs(df["amount"].sum() - previous) / previous > 0.4:
log.warning("total moved %.0f%% against the previous run — check the source",
(df["amount"].sum() - previous) / previous * 100)
Whether a large movement should stop the job or merely warn depends on the report. For a month-end pack that goes to the board, stopping and asking a human is right. For a daily operational extract where genuine swings happen, a warning in the log and a note on the summary sheet is enough. What is never right is having no opinion at all, because then the first person to see an implausible number is the person least equipped to explain it.
Pull in what has no database at all
Not every source is a table. Rates, holidays, product metadata and half the reference data a report needs live behind an HTTP API, and the pattern is the same: fetch, normalise into a DataFrame, then either write it to Excel or store it alongside the query results.
import requests
resp = requests.get("https://api.example.com/v1/fx", timeout=30,
headers={"Authorization": f"Bearer {os.environ['FX_TOKEN']}"})
resp.raise_for_status()
rates = pd.json_normalize(resp.json()["rates"])
raise_for_status() and an explicit timeout are the two lines people leave out and then debug at 03:00: without the timeout a hung API hangs the report forever, and without the status check an HTML error page is parsed as data. Fetch API Data into Excel with Python requests covers pagination, retries and flattening nested JSON into columns.
Refresh on a schedule, not on request
Once the export works, the last step is making it happen without anyone asking. That is scheduling work rather than database work, with one addition specific to this topic: a refreshed extract should be written atomically, so a reader who opens the file mid-refresh never sees a half-written workbook.
tmp = target.with_suffix(".tmp.xlsx")
export(df, tmp)
os.replace(tmp, target) # atomic on the same filesystem
Keep the schedule itself boring. A refresh that runs a few minutes after the source system's own load window finishes will be right almost every morning and wrong on the one day the load is late — so where the source publishes a completion marker, wait for it rather than for a clock. Where it does not, check the freshness of the newest row before publishing, and skip the run rather than republish yesterday's numbers under today's timestamp.
Add a small "generated at" cell on the summary sheet while you are there. A report that does not say when it was produced gets treated as current forever, which is how a Monday morning decision ends up being made on Thursday's numbers. Refresh an Excel Report from a Database on a Schedule covers incremental refreshes and the freshness stamp.
Key takeaways
- One engine, one URL, from the environment. SQLAlchemy gives every database the same interface; the password belongs in an environment variable and
pool_pre_ping=Truekeeps an overnight job's connection usable. - Bind parameters, never f-strings. Values that came from a spreadsheet must not be able to change the meaning of a statement.
- Stage the inbound direction. Load to a staging table, validate there, then merge inside
engine.begin()so a bad file cannot reach readers. - Make loads idempotent. Delete the period and re-insert, so a retry produces the same table rather than duplicated rows.
- Be explicit about types at the read. Identifiers as
str, nullable integers asInt64, dates coerced with aNaTcheck, money rounded before it is written. - Write extracts atomically and stamp them. A temporary file plus
os.replace, and a visible "generated at" so nobody mistakes a stale workbook for a current one.
Frequently asked questions
Do I need SQLAlchemy, or can pandas talk to the database directly?
pandas accepts a raw DBAPI connection for reading, but to_sql officially supports SQLAlchemy connectables and SQLite connections only. Since SQLAlchemy also gives you one URL format for every database and safe parameter binding, use it for anything beyond a throwaway script.
How do I stop a spreadsheet load from corrupting a live table? Load into a staging table first, validate it there, then move the rows across in one transaction. Writing straight into the reporting table means a bad file is visible to readers before anyone notices.
Why do my IDs come back as 1.0 instead of 1?
A column with any blank cell becomes float64 in pandas, because NaN cannot live in an int column. Read the column with dtype=str, or use pandas' nullable Int64 type.
Is it safe to build the SQL with an f-string?
No. Use bound parameters — SQLAlchemy's text() with :name placeholders — so a value taken from a spreadsheet cell cannot change the meaning of the statement.
How large can an export be before Excel is the wrong answer? Excel's hard limit is 1,048,576 rows per sheet, but the practical limit is far lower: a workbook past a few hundred thousand rows is slow to open and slower to filter. Beyond that, export CSV or Parquet and keep Excel for the summary.
Related
- Parent: Advanced Data Transformation and Cleaning — the cleaning and validation this boundary depends on.
- In this topic: Export SQL Query Results to Excel with Python, Load an Excel File into a SQL Database with pandas, Fetch API Data into Excel with Python requests and Refresh an Excel Report from a Database on a Schedule.
- Sibling topics: Validating Excel Data with Python for the checks a staging table should run, and Working with Large Excel Files in Python when the extract outgrows a comfortable workbook.
- Downstream: Automating Reporting Workflows — scheduling, delivering and monitoring the refresh.
- Presentation: Formatting and Charting Excel Reports with Python — turning a query result into a workbook people will actually read.