Before you begin
- Python 3.12 or newer
Set up your example
- Save the complete code as csv-left-join.py in a working folder.
- Save the provided sample files in that same folder; run commands from that folder.
Sample files in this download:
- sample.csv
- lookup.csv
- second.csv
- wide.csv
- numbers.csv
- sample.tsv
Download the ZIP, extract it, and open a terminal inside the extracted folder. The source file is csv-left-join.py.
Run the script
python "csv-left-join.py" --input "sample.csv" --lookup "lookup.csv" --key "id" --output "joined.csv"If your system uses python3 instead of python, use that command for Python examples. Keep paths containing spaces in quotes. Required inputs and optional settings are described in the code and setup steps.
What to expect
Enrich every input row with columns from a uniquely keyed lookup CSV. Ada receives Austin, Bo receives Chicago, and Cy's city is blank.
The complete script
#!/usr/bin/env python3
'Add lookup columns to a CSV'
import argparse
import json
from pathlib import Path
def write_json(path, value):
data = json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
with Path(path).open("x", encoding="utf-8", newline="") as handle:
handle.write(data)
import csv
import random
from collections import Counter, defaultdict
from decimal import Decimal
from datetime import datetime
def read_csv(path, delimiter=","):
with Path(path).open(encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle, delimiter=delimiter)
fields = reader.fieldnames
if not fields or any(not f for f in fields) or len(set(fields)) != len(fields):
raise ValueError("Use a nonempty, unique header for every column.")
rows = list(reader)
if any(None in row or None in row.values() for row in rows):
raise ValueError("Every row must have exactly the header's number of cells.")
return fields, rows
def require(fields, names):
missing = set(names) - set(fields)
if missing:
raise ValueError("Missing columns: " + ", ".join(sorted(missing)))
def write_csv(path, fields, rows):
with Path(path).open("x", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
def number(value):
result = Decimal(value or "0")
if not result.is_finite():
raise ValueError("Numbers must be finite.")
return result
def main():
parser = argparse.ArgumentParser(description='Add lookup columns to a CSV')
parser.add_argument("--input", required=True, help="CSV input with a header")
parser.add_argument("--output", required=True, help="New destination file or directory")
parser.add_argument("--lookup", required=True)
parser.add_argument("--key", required=True)
args = parser.parse_args()
fields, rows = read_csv(args.input)
other_fields, other = read_csv(args.lookup)
require(fields, [args.key])
require(other_fields, [args.key])
extra = [f for f in other_fields if f != args.key]
if set(extra) & set(fields):
raise ValueError("Lookup column names overlap the input; rename them first.")
index = {}
for row in other:
if row[args.key] in index:
raise ValueError("Lookup keys must be unique.")
index[row[args.key]] = row
result_fields = fields + extra
result = [dict(row, **{f:index.get(row[args.key], {}).get(f, "") for f in extra}) for row in rows]
write_csv(args.output, result_fields, result)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, TypeError, ArithmeticError, IndexError, LookupError) as exc:
raise SystemExit(str(exc))
Open source as plain text · Download source and sample files
How it works
- read_csv checks unique headers and row widths before processing.
- All input rows survive the left join; unmatched lookup values become blank.
- Ada receives Austin, Bo receives Chicago, and Cy's city is blank.
Make it your own
--key
Use --key name when both files identify records by name.
Adapt the processing
Edit the unmatched-value default in index.get to write UNKNOWN instead of a blank.
Change one setting at a time, run it again on sample data, and compare the output. The customization guide walks through this process.
If something goes wrong
Lookup keys are duplicated.
Deduplicate the lookup deliberately before joining; the script refuses an ambiguous lookup.
The destination already exists.
Choose a new --output path. The script deliberately refuses to replace an existing result.
Reference & next steps
These official references explain the underlying tools. The Scriptage example and guide are original instructional content. See verification notes for what has been checked and the limits of those checks.
Copies a template for your own notes. It does not send anything.