Join CSV Files With SQL

Two files, one key column, one query. Drop a file into each slot below and they become tables you can join with ordinary SQL: inner, left, right, full and anti joins, on one column or several. When both files carry a column with the same name, the join is written for you as a starting point. Nothing is uploaded, and the result downloads as CSV or Excel.

Three or more files, or inputs over 100 MB? Open the full editor, which joins across every loaded table.

A worked example, with the numbers

Press Try with sample data and both slots fill. The left one takes sales-10k.csv, 10,000 orders with region, channel, units, amount and a refunded flag. The right one takes region-targets.csv, five rows carrying each region's manager, its office and its quarterly target. They become the tables sales_10k and region_targets, also reachable as t and t2.

Both carry a region column, so the page writes a join and puts it in the editor for you to read before you run it. That pause is deliberate: joining on the wrong pair of columns produces a number that looks fine and is wrong, and that is worth one click of consent rather than a silent result.

The question worth asking of these two files is whether each region hit its target, which needs the facts from one file and the target from the other:

SELECT
  s.region,
  r.manager,
  r.office,
  ROUND(SUM(s.amount) FILTER (WHERE NOT s.refunded), 2) AS net_revenue,
  r.quarterly_target,
  ROUND(100.0 * SUM(s.amount) FILTER (WHERE NOT s.refunded) / r.quarterly_target, 1) AS pct_of_target
FROM sales_10k AS s
LEFT JOIN region_targets AS r USING (region)
GROUP BY s.region, r.manager, r.office, r.quarterly_target
ORDER BY pct_of_target DESC

Five rows come back, one per region, sorted by how close each came. The LEFT JOIN matters: if a region appeared in the sales file but had no row in the targets file, an inner join would drop it silently and the total revenue on screen would be lower than the real one, with nothing to tell you why.

Before trusting any joined total, run the safety check. It takes ten seconds and it catches the single most common join bug:

SELECT region, COUNT(*) AS n
FROM region_targets
GROUP BY 1
HAVING COUNT(*) > 1

No rows means the key is unique on the right-hand side, which means the join cannot multiply your rows. Rows coming back means it can, and every sum in your result is inflated by exactly the amount of that duplication. This is why a joined revenue figure so often comes out mysteriously higher than the source file's own total.

Choosing the join

LEFT JOIN, the one to start with

Keeps every row of the left file and fills nulls where the right has no match. Your result should have exactly as many rows as the left file. If it does not, you have learned something before you drew any conclusions.

INNER JOIN, once you have checked

Keeps only rows that matched. Correct when unmatched rows are genuinely uninteresting, dangerous when you have not looked at how many there are. Count them first with a left join and a WHERE b.key IS NULL.

FULL JOIN, for reconciliation

Keeps everything from both sides, with nulls on whichever side is missing. This is the shape of a reconciliation: what is in A only, what is in B only, and what is in both.

Anti join, for what is missing

SELECT * FROM t WHERE customer_id NOT IN (SELECT customer_id FROM t2) gives you the orphans. Watch out for nulls in the subquery's column, which make NOT IN return nothing at all; NOT EXISTS or an explicit ANTI JOIN avoid that trap.

Joining on more than one column

USING (store_id, sale_date), or ON a.store = b.store AND a.day = b.day. Whenever a single column is not unique, the real key is a pair, and joining on half of it is the same duplicate-row bug wearing a different hat.

When the key types disagree

One file read customer_id as a number and the other as text, because one of them has a leading zero somewhere. Cast one side in the join condition: ON CAST(a.customer_id AS VARCHAR) = b.customer_id. A join that returns zero rows when you expected thousands is almost always this.

Why a real engine matters for joins in particular

A join is the exact place where hand-written browser SQL falls apart. Recognizing SELECT cols FROM csv WHERE col = value takes an afternoon; implementing a hash join with correct null semantics, four outer variants, multi-column keys and type coercion is a database. Tools built on the first thing either refuse joins outright or hide them behind a mode that downloads a proper engine on demand, which means the join you tested this morning may run on a different engine this afternoon.

This page runs DuckDB compiled to WebAssembly. One engine, the real one, live from the first query, with the same planner and the same SQL dialect as the desktop build. The join above is a plain SQL join with a FILTER clause and a USING key, and none of that is a special case here. The engine downloads once in the background when you hand the page a file, and your browser caches it after that.

It stays read-only. Statements have to start with SELECT, WITH, FROM, DESCRIBE, SUMMARIZE or EXPLAIN; only one runs at a time; and anything that writes, attaches a database or installs an extension is refused in the page before the engine ever sees it.

Frequently asked questions

What is the difference between joining and merging two CSV files?

Merging usually means stacking: two files with the same columns become one longer file, which is a UNION ALL. Joining means widening: two files that share a key become one file with the columns of both, matched row to row. If your files have the same headers you want a merge. If one file has the facts and the other has the labels, you want a join, and this page is the right one.

Which join type should I use?

Start with LEFT JOIN. It keeps every row of the first file and fills in nulls where the second has no match, which means the row count of your result should equal the row count of your input. If it grew, the right-hand file has duplicate keys. INNER JOIN keeps only matched rows and silently drops the rest, which is what you want once you have checked what would be dropped. FULL JOIN keeps everything from both sides.

How do I find rows in one file that are missing from the other?

An anti join. SELECT * FROM t WHERE customer_id NOT IN (SELECT customer_id FROM t2) lists what the first file has and the second does not. DuckDB also has ANTI JOIN as explicit syntax. This is the query that answers questions like which orders reference a customer who is no longer in the customer export.

My joined result has more rows than I started with. Why?

The key is not unique on the right-hand side. A join pairs every matching row with every matching row, so one order matching three price records produces three rows. Run SELECT key, COUNT(*) FROM t2 GROUP BY 1 HAVING COUNT(*) > 1 on the second file first. Nine times in ten this is the bug, and it is the reason a joined total quietly comes out too high.

Do the key columns need the same name in both files?

No. USING (region) is the short form when the names match on both sides. When they do not, name both explicitly with ON a.customer_id = b.cust_id. You can also join on more than one column at once, which is what you need whenever the real key is a pair such as date plus store.

Can I join more than two files?

This page holds two slots, which covers almost every join anyone actually needs. For three or more, open the full editor at /app: it loads any number of files, each becomes a queryable table, and the SQL step joins across all of them at once. The same is true of a join whose inputs are bigger than 100 MB.

Can I join a CSV to an Excel file, or to Parquet?

Yes, in any combination. The two slots do not have to hold the same format. A CSV joined to a workbook sheet, or a Parquet file joined to a JSON Lines export, is one query. Each file is read by the format's own reader and then they are both just tables, which is one of the real advantages of putting a database in the page instead of a CSV parser.

Are my files uploaded to join them?

No. Both files are read by JavaScript in your tab and registered with the in-page database as virtual files. The join runs locally against those. There is no upload endpoint on this page, nothing is retained after you close the tab, and you can watch the network panel to confirm it. That matters more than usual for joins, because a join is normally the point where you combine two things neither of which you would want to post to a stranger's server.

Join your two files

No database, no upload, no account. Drop one file in each slot and write the join.

Back to the workbench