Data Analysis with Python (pandas, polars, DuckDB) Module 6 – Combining data: concat and joins
Nearest-key and range joins: merge_asof
Join each row to the latest earlier record with pandas merge_asof, put values into ranges such as tariff slabs, and do the same in polars and DuckDB.
What you will learn
- Join each event to the latest earlier record with merge_asof
- Match within groups with by= and cap the gap with tolerance
- Map values to ranges such as tariff slabs with cut or an IntervalIndex
- Translate the same joins to polars join_asof and DuckDB ASOF JOIN
Before you start
What you’ll learn · Before you start
What you will learn
- Join each event to the latest earlier record with merge_asof
- Match within groups with by= and cap the gap with tolerance
- Map values to ranges such as tariff slabs with cut or an IntervalIndex
- Translate the same joins to polars join_asof and DuckDB ASOF JOIN
Before you start
On this page
Some joins have no equal key to match on. A train’s position report at 06:20 belongs with the last station the
train was due to pass before 06:20, and a meter reading of 250 units belongs with the tariff slab whose range contains
250. A nearest-key join: A join that selects a right-side partner by ordered key distance under a chosen direction, group and eligibility rule. attaches to each row the row of another table whose key is closest in one direction,
usually the latest one at or before it. Range joins attach the interval that contains a value. pandas writes
nearest-key joins with pd.merge_asof, and range joins with pd.cut or an IntervalIndex, because merge only
matches equal keys. This lesson builds both on invented railway and electricity data, then shows the same joins in
polars and DuckDB.
Direction selects the eligible ordered key
merge_asof works like a left join in which “equal” is replaced by “nearest in one direction”. For every left row
it searches the right frame’s key column and takes one row:
Backward selection matches each ping to its prior due time
Text description of the diagram
Two narrow columns show each ping and its backward timetable match. The 05:50 ping has no earlier due time. The 06:20 ping selects XRD due at 06:00, 07:05 selects YLM due at 06:45, and 07:40 selects ZTP due at 07:30.
Every selected due key is at or before its ping key. Forward instead selects the next eligible key, while nearest compares key distance. These are scheduled relationships; the matches do not establish the train's observed position.
direction="backward", the default, takes the latest right key at or before the left key.direction="forward"takes the first right key at or after it.direction="nearest"takes whichever of those two is closer.by=adds an exact match on other columns first, so a train only looks at its own timetable.tolerance=caps how far away a match may be, so an hours-old record is not passed off as current.
Analysts need nearest-key joins whenever one table records events and the other records states that change over
time: the exchange rate in force at each payment, the price list valid on each order date, the last known status of
a parcel at each scan. Every left row stays in the result once, as in a left join, so a nearest-key join never fans
out. The price is a
strict rule: both frames must be sorted by their key, or pandas raises a ValueError
(pandas.merge_asof).
Follow T202’s 06:20 key back to 06:00; key ordering alone does not establish information availability.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Ping. The left row supplies its train and event time.
- Backward candidates. Only eligible right keys at or before the event are candidates under backward direction.
- Selected partner. The latest eligible due time is 06:00. This scheduled relationship does not establish observed train position.
Key idea
Checkpoint
Check: Ordered-key selection
A ping searches only its own train’s timetable
Two invented trains send position pings, and the timetable says when each is due at each station. Each ping is matched with the last station its own train was due to pass:
import pandas as pd
pings = pd.read_csv("rail/pings.csv", parse_dates=["ping_at"])
timetable = pd.read_csv("rail/timetable.csv", parse_dates=["due_at"])
matched = pd.merge_asof(
pings.sort_values("ping_at"),
timetable.sort_values("due_at"),
left_on="ping_at",
right_on="due_at",
by="train", # match within the same train only
)
matched["minutes_since"] = ((matched["ping_at"] - matched["due_at"]).dt.total_seconds() // 60).astype("Int64")
hhmm = {c: matched[c].dt.strftime("%H:%M") for c in ["ping_at", "due_at"]}
print(matched.assign(**hhmm).sort_values(["train", "ping_at"]).to_string(index=False)) Output
train ping_at station due_at minutes_since T101 06:05 AMP 06:00 5 T101 06:41 BLG 06:30 11 T101 07:30 CDP 07:10 20 T101 08:10 DVN 07:45 25 T202 05:50 NaN NaN <NA> T202 06:20 XRD 06:00 20 T202 07:05 YLM 06:45 20 T202 07:40 ZTP 07:30 10
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 train_asof.py
Train T101’s 07:30 ping gets CDP, due at 07:10: its time is 20 minutes after the scheduled stop. A timetable match
does not establish the train’s observed position or that it actually passed the station. The T202 ping at
05:50 comes before its first due time and gets no station: NaN, as a left join gives a row without a partner. The
same pings with each direction:
import pandas as pd
pings = pd.read_csv("rail/pings.csv", parse_dates=["ping_at"]).sort_values("ping_at")
timetable = pd.read_csv("rail/timetable.csv", parse_dates=["due_at"]).sort_values("due_at")
table = pings[["train", "ping_at"]].copy()
for direction in ["backward", "forward", "nearest"]:
m = pd.merge_asof(pings, timetable, left_on="ping_at", right_on="due_at", by="train", direction=direction)
table[direction] = m["station"].to_numpy()
table["ping_at"] = table["ping_at"].dt.strftime("%H:%M")
print(table.sort_values(["train", "ping_at"]).to_string(index=False)) Output
train ping_at backward forward nearest T101 06:05 AMP BLG AMP T101 06:41 BLG CDP BLG T101 07:30 CDP DVN DVN T101 08:10 DVN NaN DVN T202 05:50 NaN XRD XRD T202 06:20 XRD YLM XRD T202 07:05 YLM ZTP YLM T202 07:40 ZTP NaN ZTP
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 asof_directions.py
| Criterion | backward | forward | nearest |
|---|---|---|---|
| Takes | The latest key at or before | The first key at or after | The closer of the two |
| T202 at 06:20 | XRD, due 06:00 | YLM, due 06:45 | XRD (20 min against 25) |
| No partner when | No eligible earlier key | No eligible later key | No eligible key within the rules |
| When to choose | Price, rate or status in force at a time | The next scheduled event | Matching two clocks that drift |
Backward chooses the latest eligible key at or before the event. That models a rate in force only when the key’s meaning and availability rules support it; a backdated correction published later can still introduce information that was unknown at the event. Forward answers “what comes next”, and nearest suits two sensors whose clocks drift a little in both directions. Compare the 06:00 and 06:45 candidates within T202 before selecting a direction.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Event group. The exact train match restricts the candidate timetable rows.
- Direction choice. At 06:20, backward selects XRD and forward selects YLM; nearest chooses XRD because twenty is less than twenty-five.
- Start of the series. The earlier ping remains present with no backward partner. Its real location is still unknown.
Key idea
Checkpoint
Check: Timetable membership
Interval closure decides where a value belongs
Range joins match a value with the interval that contains it. Electricity tariffs are a typical case: the rate depends on which slab a reading falls into. Tax brackets, age bands in a survey, delivery zones by distance and discount tiers by basket value are the same problem with other labels.
import pandas as pd
meters = pd.DataFrame({"meter": ["M1", "M2", "M3", "M4", "M5"], "units": [40, 100, 101, 250, 640]})
edges = [0, 100, 200, 500, float("inf")]
names = ["(0, 100]", "(100, 200]", "(200, 500]", "(500, inf)"]
meters["slab"] = pd.cut(meters["units"], bins=edges, labels=names)
print(meters.to_string(index=False)) Output
meter units slab M1 40 (0, 100] M2 100 (0, 100] M3 101 (100, 200] M4 250 (200, 500] M5 640 (500, inf)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slab_lookup.py
pd.cut takes the slab edges as bins and gives each interval a label. Its intervals are right-closed interval: An interval that includes its upper endpoint. In (a, b], values equal to b are included and values equal to a are excluded. by
default, so a reading of exactly 100 lands in the first slab and 101 in the second
(pandas.cut). An IntervalIndex holds the same
intervals as an index and can look values up directly, and SQL engines such as DuckDB write range joins as a join on
an inequality, units > lo AND units <= hi. Which side of the boundary is closed decides real money, so write it down
with the slab table.
Write continuous boundaries as (0, 100] and (100, 200]. The label “101 to 200” would misdescribe a fractional
reading such as 100.4, even though the numeric interval contains it.
Follow exactly 100 and fractional 100.4 across the shared edges and identify their containing intervals.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Edges. The tariff bands share numeric boundaries rather than separate whole-number labels.
- Right closure. Exactly 100 belongs to the first band; 100.4 belongs to the next band.
- Match one range. Use cut, IntervalIndex or an inequality join under the same closure rule.
Key idea
Checkpoint
Check: Continuous interval edges
Check yourself
Practice that adapts to you
Practise this lesson
8 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
merge_asof in depth: tolerance, exact matches and sorting rules Pro: opens with a Pro, Premium or Ultimate pass
A nearest-key join is only as good as its limits: how stale a match may be, whether an exact tie counts, and what the inputs must look like. This part shows each limit with recorded output, reproduces the errors merge_asof raises, and finishes with an exercise on exchange rates.
In this part:
- Tolerance and exact-match rules limit eligible partners
- Pandas requires globally ordered compatible time keys
- Correction order and rate age determine priced payments
3 diagrams · 3 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Range joins: slab boundaries, IntervalIndex and SQL, with a bill Pro: opens with a Pro, Premium or Ultimate pass
Range joins go wrong at the edges of the ranges. This part pushes awkward readings through pd.cut, an IntervalIndex and DuckDB, reproduces a boundary that matches twice and a gap that matches nothing, and works out a slab-wise bill.
In this part:
- Boundary cases test the tariff’s exact interval contract
- One strict inequality prevents shared-edge double matches
- Slab-wise charges and whole-reading charges are different
3 diagrams · 3 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The same joins in polars and DuckDB, and a silent wrong answer Pro: opens with a Pro, Premium or Ultimate pass
polars and DuckDB both have nearest-key joins, and on sorted input they agree with pandas row for row. This part shows that agreement, then reproduces the case where polars returns a wrong station without an error.
In this part:
- Equivalent rules agree on the eight recorded pings
- An unchecked grouped order can select a future station
- Engine choice includes ordering and failure behavior
3 diagrams · 2 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What do the three directions of merge_asof mean, and which one answers "what was in force at that time"?
Backward takes the latest eligible right key at or before the left key; forward takes the first at or after it; nearest chooses the smaller distance. Backward models a state in force only when the time key and information-availability contract support that interpretation. An unmatched left row remains with missing right values. Group equality restricts candidates to the intended identity, and eligibility rules can remove distant candidates. Pandas requires global ascending order by the time key, while other engines have their own ordering contracts.
-
Mid to senior
Convert 3 million card payments in foreign currencies to rupees at the rate in force when each was paid. Rates are published once per working day per currency. Design the join, and say what happens on weekends, before the first rate and when a published rate is corrected.
-
Fresher to mid
A billing job attaches tariff slabs to meter readings with a SQL join on units BETWEEN lo AND hi. Customers whose readings are exactly 100, 200 or 500 units complain about their bills. What went wrong, and how do you redesign and test the slab join?
-
Mid to senior
A team ports a pandas merge_asof of GPS pings to timetables, by train, to polars join_asof for speed. The tests pass, but in production a few pings get stations that are due in the future. Explain how that can happen and how you would make the port safe.
Key takeaways
- A nearest-key join attaches to each row the closest row of another table in one direction; pandas writes it as
merge_asof, which keeps every left row once. - Sort pandas inputs globally by the time key, use
by=for exact group identity, and choose direction, availability and eligibility rules from the event-state contract. - Range joins attach the interval that contains a value:
pd.cutand anIntervalIndexin pandas, an inequality join in SQL. Decide which end of each interval is closed, and write it down.
Terms in this lesson
- nearest-key join
- A join that selects a right-side partner by ordered key distance under a chosen direction, group and eligibility rule.
- right-closed interval
- An interval that includes its upper endpoint. In (a, b], values equal to b are included and values equal to a are excluded.
Corrections
- Added concept figures and practice, clarified join and schema contracts, and corrected meter alignment; explanations remain in their original reading parts.
References
- pandas.merge_asof (pandas development team (NumFOCUS))
- pandas.cut (pandas development team (NumFOCUS))
- pandas.IntervalIndex (pandas development team (NumFOCUS))
- What's new in 3.0.0 (pandas) (pandas development team (NumFOCUS))
- polars.DataFrame.join_asof (Polars)
- DuckDB: FROM and JOIN clauses (ASOF joins) (DuckDB Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress