Match Rows by Nearest Date with pandas merge_asof
Transactions happen every day, but exchange rates are published on business days, price lists change on arbitrary dates, and an employee's cost centre changes when they move team. Converting a transaction or costing an order needs the value that was in force at that moment: the latest rate on or before the transaction date. An exact merge on the date fails for every weekend transaction, and a loop that searches the rate table for each row is slow. pd.merge_asof does exactly this join. This guide, part of Merging and Joining Excel DataFrames, uses it to convert transactions with daily FX rates from a second workbook.
Prerequisites
pip install pandas openpyxl
Transactions in two currencies and a rate table, each in its own workbook:
import pandas as pd
pd.DataFrame({
"tx": [1, 2, 3, 4, 5],
"date": pd.to_datetime(["2026-03-04", "2026-03-02", "2026-03-06", "2026-03-09", "2026-02-27"]),
"currency": ["USD", "GBP", "USD", "USD", "GBP"],
"amount": [100, 50, 80, 20, 10],
}).to_excel("transactions.xlsx", index=False)
pd.DataFrame({
"date": pd.to_datetime(["2026-03-02", "2026-03-03", "2026-03-05", "2026-03-02", "2026-03-05"]),
"currency": ["USD", "USD", "USD", "GBP", "GBP"],
"rate": [0.92, 0.93, 0.91, 1.17, 1.18],
}).to_excel("fx_rates.xlsx", index=False)
tx = pd.read_excel("transactions.xlsx")
fx = pd.read_excel("fx_rates.xlsx")
Step 1 — Sort, then merge
merge_asof needs both tables sorted by the key. The transactions come in the order they were entered, so the first attempt fails:
try:
pd.merge_asof(tx, fx, on="date", by="currency")
except ValueError as exc:
print(exc) # left keys must be sorted
matched = pd.merge_asof(tx.sort_values("date"), fx.sort_values("date"),
on="date", by="currency", direction="backward")
matched = matched.sort_values("tx").reset_index(drop=True)
print(matched[["tx", "date", "currency", "rate"]])
by="currency" restricts each match to rates of the same currency, so a USD transaction never picks up a GBP rate even when that is the nearest row by date. direction="backward" — the default — takes the latest rate on or before the transaction date: transaction 1 on 4 March gets the 3 March USD rate, 0.93. Sorting back by tx restores the original order, which merge_asof does not preserve. If the transactions have no ID column, keep the original index with reset_index() before sorting and sort back on it afterwards.
Step 2 — Keep the date of the matched rate
The merged frame shows the rate but not which day it came from. For auditing — "why was this converted at 0.91?" — keep the rate's own date by giving it a different name and using left_on and right_on:
rates = fx.rename(columns={"date": "rate_date"}).sort_values("rate_date")
matched = pd.merge_asof(tx.sort_values("date"), rates, left_on="date", right_on="rate_date",
by="currency").sort_values("tx").reset_index(drop=True)
matched["rate_age_days"] = (matched["date"] - matched["rate_date"]).dt.days
print(matched[["tx", "date", "rate_date", "rate", "rate_age_days"]])
Transaction 4, on 9 March, was converted with a rate four days old, and transaction 5 has no rate at all: it is dated 27 February, before the first GBP rate in the table. Both deserve attention before the numbers are used.
Step 3 — Refuse stale matches with tolerance
Rates older than a few days usually mean the rate file was not updated — a missed download, a holiday feed. tolerance caps how far back a match may reach; rows beyond it get a missing value instead of a quietly stale rate:
strict = pd.merge_asof(tx.sort_values("date"), fx.sort_values("date"), on="date", by="currency",
tolerance=pd.Timedelta(days=3)).sort_values("tx")
print(strict[["tx", "rate"]].values.tolist())
# [[1.0, 0.93], [2.0, 1.17], [3.0, 0.91], [4.0, nan], [5.0, nan]]
missing = strict.loc[strict["rate"].isna(), ["tx", "date", "currency"]]
print(missing.values.tolist())
Choose the tolerance from how the source publishes: three or four days covers weekends and single holidays for daily FX rates. Then fail the run or write the unmatched rows to an exceptions sheet, just as for an ordinary merge — see Validate a pandas Merge to Avoid Duplicate Rows.
Step 4 — Backward, forward or nearest
direction changes which neighbour counts:
for direction in ["backward", "forward", "nearest"]:
m = pd.merge_asof(tx.sort_values("date"), fx.sort_values("date"), on="date", by="currency",
direction=direction).sort_values("tx")
print(direction, m["rate"].tolist())
# backward [0.93, 1.17, 0.91, 0.91, nan]
# forward [0.91, 1.17, nan, nan, 1.17]
# nearest [0.93, 1.17, 0.91, 0.91, 1.17]
backward is right for anything "in force at the time": rates, prices, statuses. forward finds the next event — the first delivery after an order, the next price review. nearest suits measurements, such as matching a sensor reading to the closest weather observation; it is rarely right for financial data, because it can use a rate published after the transaction.
Step 5 — Exclude same-day matches when needed
Some rules require the previous day's value: invoices converted at the prior business day's closing rate, for example. allow_exact_matches=False skips a right row with exactly the same date:
prior = pd.merge_asof(tx.sort_values("date"), fx.sort_values("date"), on="date", by="currency",
allow_exact_matches=False).sort_values("tx")
print(prior["rate"].tolist()) # [0.93, nan, 0.91, 0.91, nan]
Transaction 2 on 2 March no longer takes the 2 March GBP rate, and there is no earlier one, so it is unmatched. The rule belongs in the accounting policy, not in the code; confirm it before choosing.
Step 6 — Fix the dtype errors
merge_asof is strict about the key types. Dates read from different sources can have different resolutions — microseconds from one reader, seconds from another — and the merge refuses them:
fx_seconds = fx.assign(date=fx["date"].astype("datetime64[s]"))
try:
pd.merge_asof(tx.sort_values("date"), fx_seconds.sort_values("date"), on="date", by="currency")
except pd.errors.MergeError as exc:
print(str(exc)[:40]) # incompatible merge keys [1] dtype('<M8[u
aligned = fx_seconds.assign(date=fx_seconds["date"].astype(tx["date"].dtype))
print(len(pd.merge_asof(tx.sort_values("date"), aligned.sort_values("date"), on="date", by="currency")))
Cast one side to the other's dtype. The same applies to by columns: a currency column that is text in one file and categorical in the other must be made to match. Dates stored as text in one workbook need pd.to_datetime first; see Parse Excel Dates into Python datetimes with pandas.
Step 7 — Convert and write an auditable result
With the rate and its date attached, the conversion is one multiplication. Write the rate, its date and its age next to the converted amount, and put the exceptions on their own sheet, so anyone checking a figure can see exactly how it was produced:
final = pd.merge_asof(tx.sort_values("date"), rates, left_on="date", right_on="rate_date",
by="currency", tolerance=pd.Timedelta(days=3)).sort_values("tx")
final["amount_eur"] = (final["amount"] * final["rate"]).round(2)
final["rate_age_days"] = (final["date"] - final["rate_date"]).dt.days
ok = final["rate"].notna()
with pd.ExcelWriter("converted.xlsx", engine="openpyxl", datetime_format="yyyy-mm-dd") as writer:
final[ok].to_excel(writer, sheet_name="Converted", index=False)
final[~ok][["tx", "date", "currency", "amount"]].to_excel(writer, sheet_name="No rate", index=False)
print(final.loc[ok, "amount_eur"].tolist(), int((~ok).sum())) # [93.0, 58.5, 72.8] 2
Keeping the rate date in the output is what makes the result defensible: a reviewer can compare it with the published rate for that day without rerunning anything. The "No rate" sheet tells whoever maintains the rate file exactly which currencies and dates are missing. If rounding must match an existing spreadsheet to the cent, use an Excel-compatible rounding function rather than round; see Match Excel Rounding in pandas.
Common pitfalls
- Missing values in the key. Rows with no date cannot be placed in order; drop or fix them before merging, or the merge raises an error.
- Duplicate dates on the right. If the rate table has two rates for the same currency and day,
merge_asoftakes the last one in sorted order. Deduplicate explicitly first. - Forgetting by. Without it, every transaction takes the latest rate of any currency.
- Timestamps with times of day. A transaction at 09:00 does not match a rate stamped 17:00 on the same day with
backward. Normalise both sides to dates when the rule is "that day's rate", or keep the times when intraday rates matter.
Conclusion
merge_asof joins each row to the latest earlier row of another table — the right tool for rates, prices and statuses that were in force at a date. Sort both tables, match within groups with by, keep the matched date for auditing, cap staleness with tolerance, pick the direction that fits the rule and align key dtypes. Report the unmatched and stale rows rather than letting them through.
Frequently asked questions
What does merge_asof do? For each row on the left, it finds the row on the right with the closest key that is on or before it by default, like a VLOOKUP with approximate match. It is the tool for joining transactions to the rate, price or status that was valid at the time.
Why do I get "left keys must be sorted"? merge_asof requires both tables to be sorted by the key column. Sort both with sort_values before merging, and restore the original order afterwards using an ID or the original index.
How do I match only within the same currency or product? Pass by="currency" (or a list of columns). Each left row is then matched only against right rows with the same values in those columns.
How do I stop a very old rate from being used? Pass tolerance, for example tolerance=pd.Timedelta(days=3). Rows whose nearest match is further away get missing values instead of a stale match, so you can report them.
Related
- Up one level: Merging and Joining Excel DataFrames — combining data from several workbooks.
- XLOOKUP Equivalent in pandas — next smaller and next larger matches on numbers.
- The VLOOKUP Equivalent in pandas for Excel Files — exact lookups and approximate bands.
- EOMONTH and EDATE Equivalents in pandas — date arithmetic for the keys.
More on joins, lookups & comparisons
- How-toCompare Two Excel Files for Differences with Python
- How-toConvert Multi-Currency Transactions with an FX Rate Table
- How-toHighlight Differences Between Two Columns with openpyxl
- How-toMap Free-Text Job Titles to Standard Categories
- How-toStandardise Category Values with a Mapping Table in pandas
- How-toApproximate-Match VLOOKUP for Tiered Rates in pandas