CSV join and merge
Data conversion
Loading
Loading tool
The tool is loaded only when you open it.
All processing for this tool happens in your browser. Your input is not sent to a server.
About this tool
Add customer names to an order export or combine regional lookup tables without uploading either file. Map each left key column to a right key column, even when their names differ, and choose a left, inner or full join. Select the fields to retain; output headers always carry left. or right. prefixes so equally named columns remain distinct. Duplicate keys stop the join by default. For intentional one-to-many or many-to-many joins, explicitly choose expansion, review the preflight counts and approve the resulting Cartesian matches. Parsing, planning and export run locally in a disposable browser worker.
Common uses
- Enrich order rows by mapping customer_id to the customer table’s id, retaining order_id, total, name and tier in a left join.
- Join regional records by a composite key such as region plus customer_id, with independently named key columns on the right.
- Review matching and unmatched records with a full join, then download selected data fields together with source-row provenance.
How to use it
- 1.Paste each CSV or choose a local file, then set its own delimiter, header setting and encoding. Comma, semicolon, tab and pipe delimiters are supported, with UTF-8, UTF-16LE or UTF-16BE file decoding. Inspect the columns and map one or more left keys to right keys in the same tuple order. Column names are exact; headerless inputs use column1, column2 and later generated names. Choose a left, inner or full join and select output columns on either side.
- 2.Keep exact string keys unless trimming or case-insensitive matching is intentional. Choose whether an empty key component should stop the join, match another empty component, or never match. Run preflight and review duplicate groups, matched source rows, unmatched source rows, predicted output rows, cells and serialized bytes. Duplicate keys are rejected by default, even on the many side of a valid lookup. To retain repeated-key relationships, explicitly select Cartesian expansion and approve the current successful preflight before joining. Edits invalidate that approval and the previous result.
- 3.Inspect the paged result and its left/right data-row numbers. A left join keeps every left row, an inner join keeps matching pairs, and a full join also appends unmatched right rows. Download JSON for exact parsed strings and null missing-side values. CSV includes five provenance columns followed by selected prefixed fields. Keep formula protection enabled for spreadsheet review; use raw CSV only after checking the risk and the destination. Pagination and shortened display values do not reduce the download.
Executable CSV join examples
Enrich orders with a left join
{
"left": "order_id,customer_id,total\nA100,001,12.50\nA101,002,0\nA102,009,7",
"right": "id,name,tier\n001,Alice,gold\n002,Bob,silver\n003,Chika,bronze",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id",
"total"
],
"rightColumns": [
"name",
"tier"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"left.total",
"right.name",
"right.tier"
],
"summary": {
"matched": 2,
"leftOnly": 1,
"rightOnly": 0,
"total": 3
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A100",
"12.50",
"Alice",
"gold"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 2,
"values": [
"A101",
"0",
"Bob",
"silver"
]
},
{
"kind": "left-only",
"leftRow": 3,
"rightRow": null,
"values": [
"A102",
"7",
null,
null
]
}
]
}Map customer_id to id and retain only the selected order and customer fields. A100 and A101 match; A102 has no customer, so its right-side fields are null. The string 12.50 remains unchanged.
Keep only matching orders with an inner join
{
"left": "order_id,customer_id,total\nA100,001,12.50\nA101,002,0\nA102,009,7",
"right": "id,name,tier\n001,Alice,gold\n002,Bob,silver\n003,Chika,bronze",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "inner",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id",
"total"
],
"rightColumns": [
"name",
"tier"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"left.total",
"right.name",
"right.tier"
],
"summary": {
"matched": 2,
"leftOnly": 0,
"rightOnly": 0,
"total": 2
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A100",
"12.50",
"Alice",
"gold"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 2,
"values": [
"A101",
"0",
"Bob",
"silver"
]
}
]
}The same inputs yield only A100 and A101. Neither the unmatched order A102 nor the customer Chika appears in an inner join.
Keep unmatched orders and customers with a full join
{
"left": "order_id,customer_id,total\nA100,001,12.50\nA101,002,0\nA102,009,7",
"right": "id,name,tier\n001,Alice,gold\n002,Bob,silver\n003,Chika,bronze",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "full",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id",
"total"
],
"rightColumns": [
"name",
"tier"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"left.total",
"right.name",
"right.tier"
],
"summary": {
"matched": 2,
"leftOnly": 1,
"rightOnly": 1,
"total": 4
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A100",
"12.50",
"Alice",
"gold"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 2,
"values": [
"A101",
"0",
"Bob",
"silver"
]
},
{
"kind": "left-only",
"leftRow": 3,
"rightRow": null,
"values": [
"A102",
"7",
null,
null
]
},
{
"kind": "right-only",
"leftRow": null,
"rightRow": 3,
"values": [
null,
null,
"Chika",
"bronze"
]
}
]
}The full join retains A102 and appends Chika after all left-side rows. Null source-row numbers and values identify the missing side without conflating it with an empty string.
Map a two-part regional customer key
{
"left": "region,customer_id,order_id\nNorth,007,A201\nSouth,007,A202",
"right": "territory,id,name\nSouth,007,Minami\nNorth,007,Kita",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "region",
"right": "territory"
},
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"region",
"customer_id",
"order_id"
],
"rightColumns": [
"name"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.region",
"left.customer_id",
"left.order_id",
"right.name"
],
"summary": {
"matched": 2,
"leftOnly": 0,
"rightOnly": 0,
"total": 2
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 2,
"values": [
"North",
"007",
"A201",
"Kita"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 1,
"values": [
"South",
"007",
"A202",
"Minami"
]
}
]
}Customer 007 appears in both regions. Mapping region to territory and customer_id to id keeps the tuples distinct. Right-side row order does not change left-side output order.
Keep leading-zero keys distinct
{
"left": "customer_id,code\n001,007\n1,7",
"right": "id,name\n1,Alice",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"customer_id",
"code"
],
"rightColumns": [
"id",
"name"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.customer_id",
"left.code",
"right.id",
"right.name"
],
"summary": {
"matched": 1,
"leftOnly": 1,
"rightOnly": 0,
"total": 2
},
"rows": [
{
"kind": "left-only",
"leftRow": 1,
"rightRow": null,
"values": [
"001",
"007",
null,
null
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 1,
"values": [
"1",
"7",
"1",
"Alice"
]
}
]
}The key 001 does not match 1. No number conversion removes its leading zeros; the selected code 007 also remains an exact string.
Normalize keys without changing output text
{
"left": "customer_id,status\n A , Paid ",
"right": "id,name\na,ALICE",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"customer_id",
"status"
],
"rightColumns": [
"id",
"name"
],
"trim": true,
"ignoreCase": true,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.customer_id",
"left.status",
"right.id",
"right.name"
],
"summary": {
"matched": 1,
"leftOnly": 0,
"rightOnly": 0,
"total": 1
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
" A ",
" Paid ",
"a",
"ALICE"
]
}
]
}Trim and ignore-case allow A and a to match, but the original key " A ", status " Paid " and name "ALICE" survive unchanged in the result.
Reject a repeated customer key by default
{
"left": "customer_id,order_id\n001,A1\n001,A2",
"right": "id,name\n001,Alice",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id"
],
"rightColumns": [
"name"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"error": "duplicateKey"
}The left table has two orders for customer 001. The default rejects that repeated join key, even though the right lookup is unique; choose expansion deliberately if both orders should be retained.
Explicitly expand a two-by-two relationship
{
"left": "customer_id,order_id\n001,A1\n001,A2",
"right": "id,contact\n001,home\n001,work",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id"
],
"rightColumns": [
"contact"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "expand"
},
"approveExpansion": true
}{
"headers": [
"left.order_id",
"right.contact"
],
"summary": {
"matched": 4,
"leftOnly": 0,
"rightOnly": 0,
"total": 4
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A1",
"home"
]
},
{
"kind": "matched",
"leftRow": 1,
"rightRow": 2,
"values": [
"A1",
"work"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 1,
"values": [
"A2",
"home"
]
},
{
"kind": "matched",
"leftRow": 2,
"rightRow": 2,
"values": [
"A2",
"work"
]
}
]
}With duplicates set to expand and the successful preflight explicitly approved, two orders and two contacts produce four pairs. Left row order is primary; right contact order is preserved within each order.
Distinguish missing, empty and literal null
{
"left": "customer_id,order_id\n1,A1\n2,A2\n3,A3",
"right": "id,note\n1,\n3,null",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id"
],
"rightColumns": [
"note"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"right.note"
],
"summary": {
"matched": 2,
"leftOnly": 1,
"rightOnly": 0,
"total": 3
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A1",
""
]
},
{
"kind": "left-only",
"leftRow": 2,
"rightRow": null,
"values": [
"A2",
null
]
},
{
"kind": "matched",
"leftRow": 3,
"rightRow": 2,
"values": [
"A3",
"null"
]
}
]
}A1 has an existing empty note, A2 has no matching customer, and A3 contains the four-character text null. JSON preserves these as "", null and "null", with source-row provenance.
Join headerless tables with different delimiters
{
"left": "001;A1\n002;A2",
"right": "001|Alice\n003|Chika",
"leftOptions": {
"delimiter": ";",
"header": false
},
"rightOptions": {
"delimiter": "|",
"header": false
},
"joinOptions": {
"kind": "full",
"keys": [
{
"left": "column1",
"right": "column1"
}
],
"leftColumns": [
"column1",
"column2"
],
"rightColumns": [
"column2"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "reject",
"duplicates": "reject"
}
}{
"headers": [
"left.column1",
"left.column2",
"right.column2"
],
"summary": {
"matched": 1,
"leftOnly": 1,
"rightOnly": 1,
"total": 3
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"001",
"A1",
"Alice"
]
},
{
"kind": "left-only",
"leftRow": 2,
"rightRow": null,
"values": [
"002",
"A2",
null
]
},
{
"kind": "right-only",
"leftRow": null,
"rightRow": 2,
"values": [
null,
null,
"Chika"
]
}
]
}Use semicolon for the left input and pipe for the right, with headers disabled. Map generated column1 on both sides and retain the requested positional fields. Prefixes distinguish both sources.
Never match empty key components
{
"left": "customer_id,order_id\n,A1",
"right": "id,name\n,Alice",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "full",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id"
],
"rightColumns": [
"name"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "never-match",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"right.name"
],
"summary": {
"matched": 0,
"leftOnly": 1,
"rightOnly": 1,
"total": 2
},
"rows": [
{
"kind": "left-only",
"leftRow": 1,
"rightRow": null,
"values": [
"A1",
null
]
},
{
"kind": "right-only",
"leftRow": null,
"rightRow": 1,
"values": [
null,
"Alice"
]
}
]
}Choosing never-match leaves both rows unmatched, even though both key fields are empty. A full join therefore returns a left-only row and a right-only row.
Explicitly match empty key components
{
"left": "customer_id,order_id\n,A1",
"right": "id,name\n,Alice",
"leftOptions": {
"delimiter": ",",
"header": true
},
"rightOptions": {
"delimiter": ",",
"header": true
},
"joinOptions": {
"kind": "left",
"keys": [
{
"left": "customer_id",
"right": "id"
}
],
"leftColumns": [
"order_id"
],
"rightColumns": [
"name"
],
"trim": false,
"ignoreCase": false,
"emptyKeys": "allow",
"duplicates": "reject"
}
}{
"headers": [
"left.order_id",
"right.name"
],
"summary": {
"matched": 1,
"leftOnly": 0,
"rightOnly": 0,
"total": 1
},
"rows": [
{
"kind": "matched",
"leftRow": 1,
"rightRow": 1,
"values": [
"A1",
"Alice"
]
}
]
}Choosing allow makes the two empty string keys equal. This option does not disable duplicate checks or expansion limits; it explicitly changes the meaning of an empty key.
Common CSV join mistakes
- Using customer_id alone when IDs are unique only within a region; add the region-to-territory key mapping.
- Choosing an inner join when orders without a customer must remain in the output.
- Assuming repeated keys imply a single lookup result, or enabling expansion without checking the m × n row count.
- Enabling trim or ignore-case without checking for keys that become equal after normalization.
- Treating 001 and 1 as the same identifier, or assuming exported CSV will force a spreadsheet to preserve leading zeros.
- Dropping provenance columns and then treating a missing-side blank as an existing empty field.
- Opening untrusted raw CSV in a spreadsheet or sharing a downloaded report before checking its contents.
Limits and notes
- Each input is limited to 2 MiB of file bytes and 2 MiB of decoded UTF-8 text, 10,000 data rows, 128 columns and 200,000 data cells. Output is capped at 50,000 rows, 500,000 cells including the five CSV provenance columns per row, and 12 MiB for each serialized JSON or protected CSV representation. Preflight checks cardinality and serialized size before materializing the joined rows; an exceeded limit blocks the join rather than truncating it. Worker cancellation and a deadline bound execution, but do not provide a hard browser memory sandbox. Split larger inputs by a consistent key partition or use a local database.
- Values remain strings: 001 and 1 are different keys, and 1.0 is not rewritten as 1. Optional trimming and lowercase conversion affect only join keys. They do not change output values or header names, and do not provide fuzzy matching, numeric/date coercion, Unicode normalization or locale-aware collation. A composite key is a tuple, not an ambiguous delimiter-concatenated string. Any empty normalized component follows the selected empty-key policy. Duplicate detection uses the same normalized keys, so normalization can create collisions. Never-match empty keys remain unmatched rather than forming a shared empty-key group.
- By default a repeated complete key on either side blocks the join, including an unmatched duplicate group; no first or last row is silently chosen. Explicit expansion creates every pair for a matching key: two left rows and three right rows produce six rows. Repeated left keys with one right row also require that explicit policy and approval. Counts describe the selected join mode; matched is the number of output pairs, which can exceed the number of matched source rows. Output follows left input order, with right matches in right input order, then unmatched right rows for a full join. There is no aggregation, deduplication, fuzzy join or SQL execution.
- Each selected column is named left.<original header> or right.<original header>; both versions of a shared name or key are kept when selected. JSON uses null for fields from a missing side, while an existing empty cell stays "" and literal null text stays "null". JSON also stores row kind and nullable 1-based leftRow/rightRow numbers. CSV renders absent values as blank and includes _join_kind, _left_row, _right_row, _left_present and _right_present to preserve that distinction. CSV formula protection adds an apostrophe to risky fields and changes their exported text. Raw CSV can activate formulas, and spreadsheet software may reinterpret IDs or remove protection when saving and reopening. JSON is the preferred exact-string interchange format; it does not preserve source quoting or line-ending bytes.
- Files must decode strictly in the selected supported encoding; a BOM must agree with that choice. GBK and Shift_JIS require prior conversion. Malformed quoting, uneven rows, blank headers and duplicate exact headers are rejected; headers are never automatically trimmed or renamed. Quoted delimiters, doubled quotes and multiline fields are accepted. Source-row diagnostics count data rows from 1; parser diagnostics count CSV records including the header, not physical lines inside quoted text. Inputs are neither uploaded nor persisted by this tool. Cell content is displayed as text; downloaded files may contain private information and remain outside the page’s control. The preview shows 25 rows per page and the first 12 selected data columns, shortening long headers and values. Exports retain every selected field.
Frequently asked questions
Which join should I choose for orders and customers?
Use orders on the left and map customer_id to id on the right. A left join keeps orders whose customer is missing, with null customer fields in JSON. An inner join removes those unmatched orders. A full join additionally lists customers with no order. If several orders share a customer_id, the default duplicate rejection is expected: choose explicit expansion, check that the customer side is unique if you intend a many-to-one lookup, and approve the predicted result.
How do I map different or composite key columns?
Add one mapping for each key component, for example left.region to right.territory and left.customer_id to right.id. Matching compares the resulting tuples in mapping order. Names do not need to match across the two tables, but each selected name must exactly identify an existing column on its own side. Leading zeros remain significant. Select both key columns for output when you want to inspect their original forms.
Why must I approve expansion after preflight?
Repeated keys can multiply rows unexpectedly. With expansion, a key appearing m times on the left and n times on the right yields m × n matching rows. Preflight shows duplicates, source-row counts and exact planned size within the supported profile before result rows are built. A successful check does not choose the relationship for you. Approval applies to the current inputs and options only; edits require another check and approval.
Can I tell an unmatched value from an empty string?
Yes. In JSON, values from a missing side are null, an existing empty field is "", and the text null is "null". Each output row also includes its kind and source-row numbers. CSV alone has no null type, so this tool writes blank absent fields plus _left_present and _right_present flags and source-row columns. Retain those provenance columns when reviewing a CSV export. Safe CSV may alter formula-like strings; use JSON when exact parsed text matters.