How to Handle Orphan Records in a SQL Query with a Foreign Key

An order import that inserts a CSV row whose customer_id has no matching customers row raises psycopg2.errors.ForeignKeyViolation, and the crash rolls back every order - even the valid ones. The fix is to preload the valid ids and check each customer_id before the INSERT, so 7 good orders load and the 3 orphan rows go to a skip-log instead of killing the run.

Data Engineerpythonpostgresqletl

Why the foreign key crashes the whole import

The orders table has a foreign key that ties every order back to a real customer:

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    order_ref VARCHAR(50) NOT NULL,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    product VARCHAR(200) NOT NULL,
    quantity INTEGER NOT NULL,
    total DECIMAL(10,2) NOT NULL,
    order_date DATE NOT NULL
);

The CSV carries customer_ids that do not exist in customers. The seeded customers are ids 1, 2, 3, 5, 7, 8 - but orders.csv references 4, 6, and 9 as well. The moment the import tries to insert REF-004 (customer_id 4), PostgreSQL rejects it:

psycopg2.errors.ForeignKeyViolation: insert or update on table "orders"
violates foreign key constraint "orders_customer_id_fkey"

Because the original script does one straight INSERT per row with no per-row error handling, that exception aborts the transaction and the script. Every valid order gets rolled back with it - the table ends up with zero rows. First confirm which ids are real:

# Customer ids that actually exist
psql -h localhost -U postgres -d app -c "SELECT id FROM customers ORDER BY id;"
# customer_ids referenced in the CSV - spot the orphans
cut -d, -f2 orders.csv | tail -n +2 | sort -n | uniq -c

Check the foreign key before you insert

The robust pattern is proactive: load every valid customer id into a Python set once, then check each row's customer_id against that set before inserting. Orphans get logged and skipped; valid orders still land.

#!/usr/bin/env python3
import csv, sys, psycopg2

DB_URL = "postgresql://postgres:postgres@localhost:5432/app"

def import_orders(csv_path):
    conn = psycopg2.connect(DB_URL); cur = conn.cursor()
    cur.execute("SELECT id FROM customers")
    valid_ids = {r[0] for r in cur.fetchall()}
    loaded = skipped = 0
    with open(csv_path) as f:
        for row in csv.DictReader(f):
            cid = int(row["customer_id"])
            if cid not in valid_ids:
                print(f"Skipping {row['order_ref']} - unknown customer_id {cid}")
                skipped += 1; continue
            cur.execute(
                """INSERT INTO orders (order_ref, customer_id, product, quantity, total, order_date)
                   VALUES (%s, %s, %s, %s, %s, %s)""",
                (row["order_ref"], cid, row["product"], int(row["quantity"]),
                 float(row["total"]), row["order_date"]))
            loaded += 1
    conn.commit(); cur.close(); conn.close()
    print(f"Imported {loaded} orders ({skipped} skipped)")

if __name__ == "__main__":
    import_orders(sys.argv[1] if len(sys.argv) > 1 else "orders.csv")

Preloading the ids into a set means the check is a fast in-memory lookup, not a query per row. Run it and you get a clean summary instead of a traceback:

python3 import_orders.py orders.csv
# Skipping REF-004 - unknown customer_id 4
# Skipping REF-006 - unknown customer_id 6
# Skipping REF-009 - unknown customer_id 9
# Imported 7 orders (3 skipped)

Confirm the load and the skipped orphans

Seven valid orders should now be in the table, and no row should reference a missing customer:

SELECT COUNT(*) FROM orders;                              -- 7
SELECT COUNT(*) FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id);  -- 0

The orphan customer_ids point at a real upstream problem - a customer that was never created, or a bad export. Skipping keeps the pipeline running, but log the skips so the data team can chase the source. In production you would run the check and insert inside one transaction, or tag each row with an import-batch column so a re-run can delete the batch and reload it idempotently.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What causes a foreign key violation on insert in PostgreSQL?

A foreign key violation happens when you insert a row whose foreign key value has no matching row in the referenced table - here an order with a customer_id that is not in customers(id). PostgreSQL raises psycopg2.errors.ForeignKeyViolation and aborts the transaction, so any earlier inserts in the same transaction roll back too.

How do I check if a foreign key exists before inserting a row?

Load the valid parent keys once with a query like SELECT id FROM customers, put them in a Python set, then test each row with "if cid not in valid_ids". This turns the check into an in-memory lookup instead of a query per row, and lets you skip or log orphans before the INSERT ever runs.

How do I skip bad rows in an ETL import without crashing the whole job?

Validate each row before inserting - check the foreign key, required fields, and types - and route rows that fail to a skip-log while committing the valid ones. Checking before the insert is more predictable than catching the exception afterward, because a raised ForeignKeyViolation aborts the current transaction and forces a rollback.

How can I find orphan records already in a table?

Use a NOT EXISTS anti-join against the parent table: SELECT COUNT(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id). A non-zero count means orphan rows exist. With an enforced foreign key the count is always zero, but it is the right query to audit data loaded before the constraint was added.

Keep learning

Handle Bad Rows in a CSV ImportData projectPush a Report Into a SQL Join QueryData projectLoad Data Incrementally Without DuplicatesData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →