A normal join matches rows when keys are equal. Many real questions need something else: the row with the latest timestamp at or before another timestamp. The price of a share at the moment of a trade. The exchange rate in force on the day of an invoice. The customer's plan when they opened a support ticket. That is an ASOF JOIN ("as of" this time). Snowflake, DuckDB, ClickHouse and Polars have it, and Apache Spark added it in version 4.3. This guide explains the idea once, then shows the syntax in each tool.
The idea, with one example
We will use the same two small tables everywhere. Each trade should get the latest quote for the same symbol at or before the trade time.
| trade_id | sym | trade time | Latest quote at or before | price |
|---|---|---|---|---|
| 1 | A | 10:00:05 | 10:00:00 | 10.0 |
| 2 | A | 10:00:20 | 10:00:10 | 10.5 |
| 3 | B | 10:00:01 | none yet (first B quote is 10:00:15) | NULL |
| 4 | B | 10:00:30 | 10:00:15 | 20.0 |
Notice trade 2: there is also a quote at 10:00:25, but it came after the trade, so it must not be used. And trade 3 has no earlier quote at all. What happens to it depends on the join type, which is the most common source of bugs.
The syntax in each engine
Every example below returns the prices 10.0, 10.5, NULL and 20.0. We ran the DuckDB 2.0, Spark 4.3, Polars 2.1 and pandas 3.1 versions ourselves.
SELECT t.trade_id, t.sym, t.ts, q.price
FROM trades t
ASOF JOIN quotes q
MATCH_CONDITION (t.ts >= q.ts)
ON t.sym = q.sym;MATCH_CONDITION holds the one inequality (>=, >, <= or <); ON holds the equal keys. Rows with no match come back with NULLs, like a left join.
SELECT t.trade_id, t.sym, t.ts, q.price
FROM trades t
ASOF LEFT JOIN quotes q
ON t.sym = q.sym AND t.ts >= q.ts;DuckDB puts the inequality in ON. Plain ASOF JOIN is an inner join and drops trade 3; we checked.
SET spark.sql.join.asofJoin.enabled = true;
SELECT t.trade_id, q.price
FROM trades AS t
LEFT ASOF JOIN quotes AS q
MATCH_CONDITION (t.ts >= q.ts)
ON t.sym = q.sym;A preview, off by default. Without LEFT, Spark also drops unmatched rows.
import polars as pl
# DataFrame API: both frames sorted by the time column
result = trades.sort("ts").join_asof(
quotes.sort("ts"), on="ts", by="sym", strategy="backward"
)
# SQL, Snowflake style (Polars 2.1+)
pl.SQLContext(trades=trades, quotes=quotes).execute("""
SELECT trades.trade_id, price
FROM trades
ASOF JOIN quotes MATCH_CONDITION (trades.ts >= quotes.ts)
ON trades.sym = quotes.sym
""").collect()Both kept trade 3 with a null price in our test.
import pandas as pd
result = pd.merge_asof(
trades.sort_values("ts"),
quotes.sort_values("ts"),
on="ts", by="sym",
direction="backward", # latest at or before
tolerance=pd.Timedelta("10s"), # optional: ignore stale quotes
)Both inputs must be sorted by the on column. With the 10-second tolerance, trade 4 gets NaN because its quote is 15 seconds old.
SELECT t.trade_id, q.price
FROM trades AS t
ASOF LEFT JOIN quotes AS q
ON t.sym = q.sym AND t.ts >= q.ts;Same shape as DuckDB: one inequality in ON, and LEFT to keep unmatched rows.
No ASOF JOIN? Use this fallback
BigQuery and PostgreSQL have no ASOF keyword, but two patterns give the same answer. Both returned identical results to the ASOF versions in our tests.
QUALIFY
BigQuery and most engines- Idea
- join every earlier quote, then keep the latest per trade
- Short and readable
- Builds all earlier matches first, so it can be slow on big tables
Fine for thousands to millions of rows.
LATERAL
Best in PostgreSQL- Idea
- for each trade, look up one row with ORDER BY and LIMIT 1
- Uses an index on (sym, ts) in PostgreSQL, so it scales well
- Not supported in BigQuery
The best option in PostgreSQL.
-- QUALIFY version (WHERE TRUE is harmless and keeps BigQuery happy,
-- which has long asked for WHERE, GROUP BY or HAVING next to QUALIFY)
SELECT t.trade_id, q.price
FROM trades t
LEFT JOIN quotes q
ON q.sym = t.sym AND q.ts <= t.ts
WHERE TRUE
QUALIFY ROW_NUMBER() OVER (PARTITION BY t.trade_id ORDER BY q.ts DESC) = 1;
-- LATERAL version (PostgreSQL)
SELECT t.trade_id, q.price
FROM trades t
LEFT JOIN LATERAL (
SELECT price FROM quotes q
WHERE q.sym = t.sym AND q.ts <= t.ts
ORDER BY q.ts DESC
LIMIT 1
) q ON TRUE;Four mistakes to avoid
- 1
Dropping rows by accident
Check whether your engine's plain ASOF JOIN is inner. In DuckDB, ClickHouse and Spark, write LEFT if every left row must survive.
- 2
Using the wrong direction
">=" means the latest value at or before; "<=" means the next value at or after. pandas calls these backward and forward; Polars calls them strategies.
- 3
Matching very old values
A quote from yesterday is still "the latest". Add a tolerance in pandas or Polars, or a filter such as t.ts - q.ts <= INTERVAL 1 HOUR in SQL.
- 4
Forgetting the equal keys
Without ON t.sym = q.sym, a trade in A can pick up a quote for B.
- Point-in-time correct
- Using only data that existed at the moment being analysed.
- Backward match
- The latest right row at or before the left row; the default in most tools.
- Forward match
- The first right row at or after the left row, for example the next delivery after an order.
- Tolerance
- The maximum gap allowed between the two times; older matches return NULL.
Check your understanding
1/3What does an ASOF JOIN return for each left row?
It picks a single closest match per left row.
2/3In DuckDB, what happens to trades with no earlier quote if you write ASOF JOIN without LEFT?
Plain ASOF JOIN is an inner join in DuckDB; use ASOF LEFT JOIN to keep them.
3/3BigQuery has no ASOF JOIN. What is a working alternative?
The QUALIFY pattern keeps the latest earlier match for each row.
You got 0 out of 3.
More SQL: SQL joins explained, window functions explained and the SQL cheat sheet. On the tools: what's new in Polars 2.1 and DuckDB 2.0.
Sources: Snowflake documentation for ASOF JOIN; DuckDB, ClickHouse, pandas (merge_asof) and Polars (join_asof) documentation; Apache Spark 4.3.0 release notes (30 October 2026). Examples tested with DuckDB 2.0.0, PySpark 4.3.0, Polars 2.1.0 and pandas 3.1.0; Snowflake, ClickHouse, BigQuery and PostgreSQL syntax follows their documentation.
