How-to
Advanced Data Transformation And CleaningDeep dive

Upsert Excel Rows into a Database Table with Python

Load a maintained Excel sheet into a database table without duplicating rows — insert new keys, update changed ones and leave unchanged rows alone. Use a staging table and INSERT … ON CONFLICT, a to_sql method for smaller loads, and checks for duplicate keys and rows deleted from the sheet.

A product list, price table or cost-centre mapping often lives in an Excel sheet that someone maintains, while applications read the same data from a database table. Loading the sheet once with to_sql is easy; loading it again next week is where it goes wrong. if_exists="append" duplicates every row or fails on the primary key, and if_exists="replace" drops the table — along with its indexes, constraints and any rows added by other systems. What you want is an upsert: insert rows with new keys, update rows whose values changed, leave the rest alone. This guide, part of Moving Data Between Excel and Databases, shows two ways to do it with pandas and SQLAlchemy, using SQLite so every example runs as written.

The sheet's rows merged into the existing table The table holds A-1 Tea at 2.50, B-2 Coffee at 4.00 and C-3 Cocoa. The Excel sheet has A-1 Tea at 2.50, B-2 Coffee beans at 4.20 and a new D-4 Mate at 3.80. After the upsert, A-1 is unchanged, B-2 is updated, C-3 is kept and D-4 is inserted. A B C 1 sku name price 2 A-1 Tea 2.50 3 B-2 Coffee 4.00 4 C-3 Cocoa 3.00 A B C 1 sku name price 2 A-1 Tea 2.50 3 B-2 Coffee beans 4.20 4 C-3 Cocoa 3.00 5 D-4 Mate 3.80 before after upsert sheet rows

Prerequisites

Bash
pip install pandas openpyxl sqlalchemy

An existing table with a primary key, and the maintained sheet:

Python
import pandas as pd
import sqlalchemy as sa

engine = sa.create_engine("sqlite:///products.db")
with engine.begin() as con:
    con.exec_driver_sql("DROP TABLE IF EXISTS products")
    con.exec_driver_sql("""CREATE TABLE products (
        sku TEXT PRIMARY KEY, name TEXT NOT NULL, price REAL, updated_at TEXT)""")
    con.exec_driver_sql("""INSERT INTO products VALUES
        ('A-1', 'Tea', 2.5, '2026-09-01'), ('B-2', 'Coffee', 4.0, '2026-09-01'),
        ('C-3', 'Cocoa', 3.0, '2026-09-01')""")

pd.DataFrame({"sku": ["A-1", "B-2", "D-4"], "name": ["Tea", "Coffee beans", "Mate"],
              "price": [2.5, 4.2, 3.8]}).to_excel("products.xlsx", index=False)
sheet = pd.read_excel("products.xlsx", dtype={"sku": str})

The table must have a primary key or unique constraint on the key column; the database uses it to decide whether a row is new. Creating tables from Excel data in the first place is covered in Load an Excel File into a SQL Database with pandas.

Step 1 — Check the sheet before touching the table

An upsert trusts the key. Duplicate or blank keys in the sheet either make the statement fail halfway or let the last duplicate silently win, depending on the database. Check first:

Python
problems = []
if sheet["sku"].isna().any():
    problems.append(f"{sheet['sku'].isna().sum()} rows without a sku")
dupes = sheet.loc[sheet["sku"].duplicated(keep=False), "sku"].unique().tolist()
if dupes:
    problems.append(f"duplicate skus: {dupes}")
if problems:
    raise ValueError("; ".join(problems))
sheet["sku"] = sheet["sku"].str.strip()
print(len(sheet), "rows ready")          # 3 rows ready

PostgreSQL rejects a statement that would update the same row twice ("ON CONFLICT DO UPDATE command cannot affect row a second time"); SQLite applies the duplicates one after another, so the last one wins without a warning. Catching duplicates in Python gives the same, clear failure on every database.

Step 2 — Stage the sheet and upsert in one statement

Write the sheet into a staging table, then merge it into the real table with a single INSERT … ON CONFLICT DO UPDATE. Everything runs in one transaction, so a failure leaves the table as it was:

Python
UPSERT = """
INSERT INTO products (sku, name, price, updated_at)
SELECT sku, name, price, :today FROM stage_products WHERE true
ON CONFLICT (sku) DO UPDATE SET
    name = excluded.name,
    price = excluded.price,
    updated_at = excluded.updated_at
WHERE products.name IS NOT excluded.name OR products.price IS NOT excluded.price
"""

with engine.begin() as con:
    sheet.to_sql("stage_products", con, if_exists="replace", index=False)
    changed = con.execute(sa.text(UPSERT), {"today": "2026-09-23"}).rowcount
    con.exec_driver_sql("DROP TABLE stage_products")
print(changed)                                   # 2
print(pd.read_sql("SELECT * FROM products ORDER BY sku", engine))

excluded refers to the incoming row. The WHERE on the update compares old and new values with IS NOT, which treats two NULLs as equal, so A-1 — unchanged — is skipped and keeps its original updated_at. B-2 is updated and D-4 inserted: two rows changed. C-3, which is not in the sheet, is untouched. The odd-looking WHERE true after the SELECT is required by SQLite to tell its parser that ON CONFLICT belongs to the insert, not to a join; PostgreSQL accepts it too.

Upsert through a staging table Validate the sheet's keys, write the rows to a staging table, run one INSERT ON CONFLICT DO UPDATE statement that skips unchanged rows, handle keys missing from the sheet, and drop the staging table, all inside one transaction. Validate keys no blanks, no dupes Stage the rows to_sql replace Upsert ON CONFLICT DO UPDATE Handle removals optional Commit one transaction

Step 3 — The same SQL on other databases

The staging pattern works everywhere; only the merge statement changes. PostgreSQL uses the same ON CONFLICT … DO UPDATE syntax as SQLite, with one difference in the change check: SQLite's null-safe IS NOT is written IS DISTINCT FROM in PostgreSQL, as in WHERE products.price IS DISTINCT FROM excluded.price. MySQL and MariaDB use INSERT … ON DUPLICATE KEY UPDATE, and SQL Server uses MERGE:

Sql
-- MySQL / MariaDB
INSERT INTO products (sku, name, price)
SELECT sku, name, price FROM stage_products
ON DUPLICATE KEY UPDATE name = VALUES(name), price = VALUES(price);

-- SQL Server
MERGE products AS t
USING stage_products AS s ON t.sku = s.sku
WHEN MATCHED AND (t.name <> s.name OR t.price <> s.price)
    THEN UPDATE SET name = s.name, price = s.price
WHEN NOT MATCHED THEN INSERT (sku, name, price) VALUES (s.sku, s.name, s.price);

On server databases, create the staging table as a temporary table so concurrent jobs cannot collide, and give it the same column types as the target: letting to_sql guess types can turn a NUMERIC(10,2) price into a float.

Step 4 — A to_sql method for small loads

For a few hundred rows, you can skip the staging table and pass an upsert function to to_sql's method argument. pandas calls it with batches of rows; the function builds a dialect-specific insert:

Python
from sqlalchemy.dialects.sqlite import insert

def upsert_rows(table, conn, keys, data_iter):
    rows = [dict(zip(keys, row)) for row in data_iter]
    stmt = insert(table.table).values(rows)
    stmt = stmt.on_conflict_do_update(
        index_elements=["sku"],
        set_={col: stmt.excluded[col] for col in keys if col != "sku"},
    )
    return conn.execute(stmt).rowcount

update = pd.DataFrame({"sku": ["C-3"], "name": ["Cocoa"], "price": [3.2]})
with engine.begin() as con:
    update.to_sql("products", con, if_exists="append", index=False, method=upsert_rows)
print(pd.read_sql("SELECT price FROM products WHERE sku = 'C-3'", engine)["price"].item())   # 3.2

For PostgreSQL, import insert from sqlalchemy.dialects.postgresql instead; the rest is identical. This version updates every incoming row, changed or not, so prefer the staging statement when you care about accurate updated_at values or large volumes.

Step 5 — Decide what happens to rows removed from the sheet

An upsert never deletes. If someone removes C-3 from the sheet, it stays in the table. Whether that is right is a policy decision; either way, find those keys explicitly:

Python
in_table = pd.read_sql("SELECT sku FROM products", engine)["sku"]
removed = sorted(set(in_table) - set(sheet["sku"]))
print(removed)                    # ['C-3']

Deleting them makes the table an exact mirror of the sheet, which is dangerous if other systems reference those rows or add their own. Marking them inactive — an active flag or a retired_at date — keeps history and references intact. Most teams choose the flag and report the removed keys so the sheet's owner can confirm the change.

Handling keys that disappeared from the sheet Deleting removed keys keeps the table identical to the sheet but loses history and can break references from other tables. Marking them inactive keeps history and references and lets the removal be reviewed, at the cost of an extra column and filters in queries. Delete the rows table mirrors the sheet history is lost foreign keys may break a mistaken edit deletes data Mark inactive history kept references stay valid removals can be reviewed queries filter on active

Common pitfalls

  • Counting rows as success. rowcount reports inserted plus updated rows. Log it alongside the sheet's row count and the number of removed keys, so a run that changed nothing — or everything — stands out in the log.
  • Blank cells overwriting data. An empty price in the sheet becomes NULL and replaces a real price in the table. Decide whether blanks mean "unknown" or "keep the current value"; for the latter, use COALESCE(excluded.price, products.price) in the update.
  • Using if_exists="replace" on the real table. It drops the table and recreates it with guessed types, losing keys, indexes and permissions.
  • Keys that change format. "A-1 " with a trailing space is a new key to the database. Strip and normalise keys before staging.
  • Numbers read as text or floats. IDs such as 00123 lose their zeros unless read with dtype=str; see Merge Excel Files with Different Column Names in pandas.

Conclusion

Loading a maintained Excel sheet into a database repeatedly needs an upsert, which pandas' to_sql does not provide. Validate the keys, write the sheet to a staging table, and merge it with one INSERT … ON CONFLICT DO UPDATE that skips unchanged rows — or MERGE and ON DUPLICATE KEY UPDATE on other databases. Use a to_sql method callable for small loads, and decide explicitly whether keys removed from the sheet are deleted or marked inactive.

Frequently asked questions

Does pandas to_sql support upserts? Not directly. if_exists="append" inserts every row and fails on primary key conflicts, and "replace" drops and recreates the table. Use a custom method callable or load into a staging table and run an INSERT … ON CONFLICT statement.

What is a staging table? A temporary table that receives the Excel rows unchanged. One SQL statement then merges it into the real table, which is fast, runs inside a transaction and keeps the upsert logic in SQL where the database can enforce it.

How do I update only rows that actually changed? Add a WHERE clause to the DO UPDATE part that compares the existing and incoming values — IS NOT in SQLite, IS DISTINCT FROM in PostgreSQL — so unchanged rows are skipped and their updated_at timestamps stay put.

What about rows deleted from the Excel sheet? An upsert never deletes. Find keys that are in the table but not in the sheet with a left join from the table to the staging table, then delete them or mark them inactive, depending on your policy.

More on databases, apis & sources

More on databases, apis & sources →