How-to
Advanced Data Transformation And CleaningDeep dive

Update One Excel File from Another with pandas

Apply a changes file to a master list — new prices, corrected emails, customers added — the way people do with copy-paste and VLOOKUP. Compare DataFrame.update, combine_first and an explicit upsert, keep a change log of old and new values, and write the updates into the existing workbook without destroying its formatting.

A master list — products, customers, cost centres — lives in one workbook, and changes arrive in another: new prices from purchasing, corrected contact details from sales, a few new items. The manual process is a VLOOKUP column, a filter, careful copy-paste and hope. pandas can apply the changes in one step, but which step depends on what "update" means: overwrite with every new value, fill only the blanks, or overwrite and add new rows. The three have different rules about missing values, and a naive write-back throws away the master's formatting. This page, part of Merging and Joining Excel DataFrames with Pandas, covers all of it.

A changes file applied to a master list Left: the master list with four products. Right: after applying the changes, B-2's price is 12.50, C-3 has the supplier Initech filled in, C-3's price is unchanged because the change file left it blank, and a new product E-5 has been added. Changed and added cells are highlighted. A B C 1 sku price supplier 2 A-1 2.50 Acme 3 B-2 12.00 Acme 4 C-3 40.00 5 D-4 7.25 Globex A B C 1 sku price supplier 2 A-1 2.50 Acme 3 B-2 12.50 Acme 4 C-3 40.00 Initech 5 D-4 7.25 Globex 6 E-5 3.10 Acme master.xlsx after the changes upsert

Prerequisites

Bash
pip install pandas openpyxl

master.xlsx has four products with a formatted header row and a widened column. changes.xlsx has three rows: a new price for B-2, a supplier for C-3 (with the price left blank), and a new product E-5.

Python
import pandas as pd

master = pd.read_excel("master.xlsx").set_index("sku")
changes = pd.read_excel("changes.xlsx").set_index("sku")

Setting the key as the index on both frames is what lets pandas line rows up by SKU rather than by position.

Step 1 — DataFrame.update: overwrite with the non-missing values

update copies every non-missing value from the changes into the master, aligned on index and columns, and modifies the master in place:

Python
updated = master.copy()
updated.update(changes)
print(updated)

B-2's price becomes 12.50 and C-3 gets its supplier. Two rules decide the rest. C-3's price stays 40.00 because the change file's price cell is blank — update never overwrites with a missing value, so a changes file cannot clear a field this way. And E-5 does not appear: update only touches rows that already exist in the master. Both rules are sensible for applying corrections, and both surprise people who expected a full sync.

Step 2 — combine_first: fill only the gaps

combine_first works the other way round: existing values win, and the other frame only fills blanks. It also adds rows that exist only in the other frame:

Python
filled = master.combine_first(changes)
print(filled)

C-3 gets its supplier, because the master had none, and E-5 is added — but B-2 keeps the old price of 12.00, because the master already had a value. That makes combine_first right for enriching a list from a secondary source ("fill in missing suppliers from the purchasing file") and wrong for applying corrections. Note that the columns come back in alphabetical order, the union of both frames' columns; reorder with filled[master.columns].

Three ways to apply one file to another A comparison of update, combine_first and an explicit upsert. update overwrites existing values with new ones, never with blanks, and ignores new rows. combine_first keeps existing values, fills only blanks and adds new rows. The upsert overwrites with new values, can be told whether blanks clear fields, adds new rows and produces a change log. Overwrites values Blank in changes Adds new rows Change log master.update(changes) ignored master.combine_first(changes) only blanks ignored Explicit upsert your rule

Step 3 — An explicit upsert with a change log

For a real master list you usually want both behaviours — update existing rows and add new ones — plus a record of what changed. Build it explicitly: stack the non-missing changes into long form, compare with the current values, and apply:

Python
def upsert(master, changes):
    result = master.copy()
    log = []
    new_keys = changes.index.difference(master.index)
    for key, row in changes.drop(index=new_keys).iterrows():
        for col, new in row.dropna().items():
            old = result.at[key, col]
            if pd.isna(old) or old != new:
                log.append({"sku": key, "column": col, "old": old, "new": new})
                result.at[key, col] = new
    added = changes.loc[new_keys].reindex(columns=master.columns)
    for key in new_keys:
        log.append({"sku": key, "column": "(new row)", "old": None, "new": None})
    return pd.concat([result, added]), pd.DataFrame(log)

result, change_log = upsert(master, changes)
print(result)
print(change_log.to_string(index=False))

The result has the new price, the filled supplier and the new product, and the change log lists every modification with its old and new value — B-2's price from 12.0 to 12.5, C-3's supplier from missing to Initech, and E-5 as a new row. Only changed values are logged: a change file that repeats an unchanged value produces no entry. For large tables, the loop can be replaced by a merge-and-compare, but the explicit version is easy to read and fast for thousands of changes.

Step 4 — Decide whether a blank means "no change" or "clear"

The upsert above treats a blank in the changes as "no change", like update. Sometimes a change file uses a blank to mean "clear this field" — a customer who no longer has a fax number. The file format cannot tell the two apart, so agree on a marker with whoever sends the changes, such as the text CLEAR:

Python
CLEAR = "CLEAR"
marked = changes.astype(object)
marked.loc["C-3", "price"] = CLEAR          # as typed into the changes sheet

cleared, log2 = upsert(master.astype(object), marked)
cleared = cleared.replace(CLEAR, float("nan")).infer_objects()
print(cleared.loc["C-3"].to_dict())
print(log2[log2["new"] == CLEAR].to_string(index=False))

C-3's price is now missing, deliberately, and the change log records that it was cleared. Working in object columns while applying the marker is necessary in pandas 3, which refuses to put the text CLEAR into a float column with a TypeError instead of silently converting the column as older versions did; infer_objects() restores the numeric type afterwards. In a real file the marker arrives the same way — a price column containing the word CLEAR is read as text, and has to be converted back once the marker is applied. An explicit marker is far safer than treating every blank as a deletion, which would wipe out every field the sender simply did not fill in.

Step 5 — Write the changes into the formatted master

result.to_excel("master.xlsx") would replace the workbook with a plain one: the formatted header, column widths and anything else in the file would be lost. To keep them, open the master with openpyxl and write only the changed cells and the new rows:

Python
from openpyxl import load_workbook
from openpyxl.styles import PatternFill

wb = load_workbook("master.xlsx")
ws = wb["Products"]
headers = {c.value: c.column for c in ws[1]}
rows = {ws.cell(row=r, column=headers["sku"]).value: r for r in range(2, ws.max_row + 1)}
mark = PatternFill("solid", fgColor="FFF2CC")

for entry in change_log.itertuples(index=False):
    if entry.column == "(new row)":
        values = result.loc[entry.sku]
        ws.append([entry.sku, *[None if pd.isna(values[c]) else values[c] for c in master.columns]])
        for cell in ws[ws.max_row]:
            cell.fill = mark
    else:
        cell = ws.cell(row=rows[entry.sku], column=headers[entry.column], value=entry.new)
        cell.fill = mark

wb.save("master_updated.xlsx")
print(pd.read_excel("master_updated.xlsx").to_string(index=False))
print(load_workbook("master_updated.xlsx")["Products"]["A1"].font.b)

The header keeps its bold white-on-blue style, and each changed or added cell gets a light highlight so reviewers can see what moved. Saving to a new file name first, and replacing the original only after a check, protects the master if anything goes wrong. Populate an Excel Template Without Losing Formatting covers the same technique for templates.

Updating a master workbook safely Five steps: read both files with the key as index; check the changes for duplicate or unknown keys; upsert and build a change log; write only the changed cells into the master with openpyxl, highlighting them; save to a new file and keep the change log alongside. Read with key index .set_index("sku") Check the changes duplicates, unknown keys Upsert + log old and new values Write changed cells formatting kept Save a copy with the change log

Check the changes file first

A changes file with a duplicated key — two different new prices for B-2 — makes the result depend on row order. Check before applying:

Python
dups = changes.index[changes.index.duplicated()].tolist()
unknown_cols = sorted(set(changes.columns) - set(master.columns))
print("duplicate keys:", dups, "| unknown columns:", unknown_cols)

Both lists are empty here. In a scheduled job, fail when either is not: a column the master does not have usually means a renamed header in the sender's template. Validate Excel Columns Before Import with pandas extends this to full column checks.

Common pitfalls

  • Forgetting the index. update aligns on the index; without the key as index it aligns rows by position.
  • Expecting blanks to clear values. update and the upsert ignore them; use an agreed marker.
  • combine_first for corrections. It keeps old values; use it only for filling gaps.
  • to_excel over the master. It discards formatting; write changed cells with openpyxl.

Performance notes

update and combine_first are vectorised and fast on large frames. The explicit upsert loops over changed cells, which is fine for thousands of changes; writing cells with openpyxl is similarly quick. For hundreds of thousands of changes, compute the changed cells with a vectorised comparison of the aligned frames, then write them in one pass.

Conclusion

update overwrites existing rows with non-missing new values and ignores new rows; combine_first fills only blanks and adds new rows; an explicit upsert does both and can log every change. Decide whether a blank means "no change" or "clear" and agree a marker for the latter, check the changes for duplicate keys and unknown columns, and write the changes into the master with openpyxl — highlighting what changed — instead of overwriting the formatted workbook with to_excel.

Frequently asked questions

What does DataFrame.update do? It overwrites values in one DataFrame with the non-missing values of another, aligned on the index and columns, in place. Missing values in the update never overwrite, and rows that exist only in the update are ignored.

How is combine_first different? combine_first fills the gaps in one DataFrame with values from another, and keeps existing values. It also adds rows and columns that exist only in the other frame. It is for filling blanks, not for applying corrections.

How do I both update existing rows and add new ones? Set the key as the index on both frames, apply update for the existing rows, then append the rows whose keys are new. Or merge with an indicator and build the result explicitly, which also gives you a change log.

Will writing the result with to_excel keep the master file's formatting? No. to_excel writes a new workbook and discards fonts, fills, widths, formulas outside the data and other sheets' formatting. To keep them, open the master with openpyxl and write only the changed cells.

More on joins, lookups & comparisons

More on joins, lookups & comparisons →