Massive Data Handling

Reviewed & published by Brayan K

By the end of this lesson you'll be able to load and reshape millions of rows without locking up your database — using COPY/LOAD DATA instead of row-by-row inserts, batching and sizing transactions sensibly, dropping and rebuilding indexes around big loads, running idempotent upserts, and deleting in safe chunks. These are the techniques that turn a 45-minute load into a 15-second one.

Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

The Scenario: 10 Million Orders

Throughout this lesson, picture a nightly job that loads a fresh export of 10 million order rows into an orders table, then deletes last year's events. Every technique below is judged by one question: how do we do that in seconds instead of an hour, without freezing the live database?

Moving house, you don't carry items one at a time across town — you pack boxes and load a truck. Row-by-row INSERT is carrying one item per trip; COPY is the truck. Same boxes, a tiny fraction of the trips.

1. Why Row-by-Row INSERT Is So Slow

Each separate INSERT statement pays a fixed tax: a network round trip to the server, parsing and planning the query, and a transaction commit that forces a write to the write-ahead log (the on-disk journal the database uses to stay crash-safe). For one row that tax is invisible. For ten million rows you pay it ten million times — and that is where your minutes go.

-- The table this lesson uses. Every later block queries it, and the
-- "Try it Yourself" button carries this setup along so each snippet runs.
CREATE TABLE orders (
    id          INTEGER PRIMARY KEY,
    customer_id INTEGER,
    product_id  INTEGER,
    order_date  TEXT,
    quantity    INTEGER,
    total       REAL,
    status      TEXT
);

INSERT INTO orders (id, customer_id, product_id, order_date, quantity, total, status) VALUES
    (1, 101, 1, '2026-01-14', 2,  49.98, 'shipped'),
    (2, 102, 3, '2026-01-22', 1,  79.00, 'shipped'),
    (3, 101, 4, '2026-02-03', 5,  16.25, 'pending'),
    (4, 103, 5, '2026-02-17', 1,  32.00, 'shipped'),
    (5, 104, 6, '2026-02-28', 3,  38.97, 'cancelled'),
    (6, 102, 2, '2026-03-05', 4,  38.00, 'pending'),
    (7, 105, 3, '2026-03-19', 2, 158.00, 'shipped'),
    (8, 101, 6, '2026-03-30', 1,  12.99, 'shipped');

-- ❌ The slow way: one INSERT statement per row
-- Each statement is its own round trip + parse + commit.
INSERT INTO orders (customer_id, total) VALUES (101, 99.99);
INSERT INTO orders (customer_id, total) VALUES (102, 149.50);
INSERT INTO orders (customer_id, total) VALUES (103, 12.00);

-- Two more tables this lesson loads and prunes.
CREATE TABLE inventory (
    sku     TEXT PRIMARY KEY,
    on_hand INTEGER
);
INSERT INTO inventory (sku, on_hand) VALUES ('A-100', 12), ('A-200', 4);

CREATE TABLE events (
    id         INTEGER PRIMARY KEY,
    kind       TEXT,
    created_at TEXT
);
INSERT INTO events (id, kind, created_at) VALUES
    (1, 'login',  '2022-11-30'),
    (2, 'click',  '2022-12-24'),
    (3, 'login',  '2023-03-02'),
    (4, 'signup', '2026-01-09');
-- ... imagine 9,999,997 more of these.

-- Loading 10 million rows like this can take 30–60 MINUTES,
-- because the database pays the overhead 10 million times.

2. COPY & LOAD DATA INFILE — the Fast Path

PostgreSQL's COPY and MySQL's LOAD DATA INFILE are bulk loaders: they stream an entire file into a table in a single operation, skipping the per-row overhead entirely. This is almost always the fastest way to get data in — typically orders of magnitude faster than individual inserts.

-- ✅ The fast way: bulk-load straight from a file with COPY
-- COPY streams rows in one operation — no per-row overhead.

-- PostgreSQL: read a CSV from disk on the server
COPY orders (customer_id, total, order_date)
FROM '/data/orders.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');

-- MySQL does the same job with LOAD DATA INFILE:
-- LOAD DATA INFILE '/data/orders.csv'
-- INTO TABLE orders
-- FIELDS TERMINATED BY ','
-- LINES TERMINATED BY '\n'
-- IGNORE 1 ROWS;            -- skip the header line

-- Same 10 million rows: COPY finishes in ~15 SECONDS.
-- That is roughly 100x faster than row-by-row INSERTs.

Your Turn: complete the COPY

Fill in the blanks to bulk-load customers.csv (which includes a header row). The expected result is in the comments so you can check yourself.

-- 🎯 YOUR TURN — fill in the two blanks, then press "Try it Yourself"
-- Goal: bulk-load the customers.csv file (which HAS a header row)
--       into the customers table using PostgreSQL COPY.

___ customers (name, email, country)   -- 👉 the bulk-load keyword
FROM '/data/customers.csv'
WITH (FORMAT csv, HEADER ___);          -- 👉 the file has a header line

-- ✅ Expected: all rows from customers.csv loaded in one pass,
--    e.g. "COPY 500000"  (Postgres reports the row count loaded).

3. Multi-Row INSERT Batching

Sometimes the data lives in your application, not a file. You still don't want one statement per row — instead, list many rows in a single INSERT ... VALUES. One statement, one round trip, thousands of rows. The sweet spot is usually 1,000–10,000 rows per batch: big enough to amortise the overhead, small enough to keep memory and transaction size sane.

-- When the data comes from your APP (not a file), batch it.
-- ❌ One INSERT per row = one round trip per row.
-- ✅ One INSERT with many VALUES = one round trip per batch.

INSERT INTO orders (customer_id, total) VALUES
    (101, 99.99),
    (102, 149.50),
    (103, 12.00),
    (104, 45.20),
    (105, 7.99);
-- Five rows, ONE round trip. Repeat in batches of
-- 1,000–10,000 rows — that is the sweet spot for most drivers.

4. Transactions & Commit Size

A transaction groups statements into one all-or-nothing unit. Committing once per transaction instead of once per row removes a huge amount of disk-flushing overhead. But don't swing too far the other way: wrapping all ten million rows in a single transaction holds locks for the whole load and balloons the write-ahead log. Commit in chunks — every 5,000–50,000 rows is a good range.

Think of COMMIT as saving a document. Saving after every keystroke is slow; never saving risks losing everything. Save in sensible chunks.

-- Wrap a batch in a transaction so it commits ONCE, not per row.
-- A "transaction" is an all-or-nothing unit of work.
BEGIN;                                    -- start the unit of work
  INSERT INTO orders (customer_id, total) VALUES (201, 10.00);
  INSERT INTO orders (customer_id, total) VALUES (202, 20.00);
  -- ... thousands more inside this same transaction ...
COMMIT;                                   -- flush everything to disk ONCE

-- One giant transaction is just as bad, though: it holds locks
-- and grows the write-ahead log. Commit every 5k–50k rows instead.

5. Drop & Rebuild Indexes Around Big Loads

Indexes make reads fast, but they slow writes: every inserted row must also update every index on the table. During a one-off bulk load that maintenance is pure waste, because you can rebuild the whole index once at the end far more cheaply. The pattern is: drop the indexes, load, recreate the indexes, then ANALYZE so the query planner has fresh statistics.

-- Around a HUGE load, drop the indexes first, then rebuild.
-- Updating an index for every one of 10M rows is the slow part.

DROP INDEX idx_orders_customer;            -- 1) remove the index

COPY orders FROM '/data/orders.csv'        -- 2) bulk-load with no index upkeep
    WITH (FORMAT csv, HEADER true);

CREATE INDEX idx_orders_customer           -- 3) rebuild ONCE at the end
    ON orders (customer_id);

ANALYZE orders;                            -- 4) refresh stats for the planner

-- Building the index once over a full table is far cheaper than
-- maintaining it row-by-row during the load — often 5–10x faster.

6. UPSERT at Scale

An upsert means "insert this row, but if a row with the same key already exists, update it instead". It makes a load idempotent — safe to run twice without creating duplicates or crashing on a duplicate-key error. PostgreSQL spells it ON CONFLICT ... DO UPDATE; MySQL spells it ON DUPLICATE KEY UPDATE. In Postgres, the special table EXCLUDED refers to the row you tried to insert.

-- UPSERT = "insert, but UPDATE the row if it already exists".
-- Perfect for re-running a load without creating duplicates.

-- PostgreSQL: ON CONFLICT (the conflicting column) DO UPDATE
INSERT INTO inventory (sku, on_hand) VALUES
    ('A-100', 50),
    ('A-200', 30),
    ('A-300', 75)
ON CONFLICT (sku) DO UPDATE
    SET on_hand = EXCLUDED.on_hand;       -- EXCLUDED = the row you tried to insert

-- MySQL says the same thing as ON DUPLICATE KEY UPDATE:
-- INSERT INTO inventory (sku, on_hand) VALUES ('A-100', 50)
-- ON DUPLICATE KEY UPDATE on_hand = VALUES(on_hand);

-- Batched + UPSERT = safe, idempotent loads you can run twice.

Your Turn: complete the UPSERT

Fill in the blanks so the load updates the price when a sku already exists, and inserts it otherwise.

-- 🎯 YOUR TURN — fill in the two blanks.
-- Goal: load price updates. If a sku already exists, UPDATE its price;
--       otherwise INSERT it. (sku is the unique/primary key.)

INSERT INTO prices (sku, price) VALUES
    ('B-1', 9.99),
    ('B-2', 4.50)
ON ___ (sku) DO UPDATE                  -- 👉 the keyword pair for "if it clashes"
    SET price = ___.price;              -- 👉 the alias for the row being inserted

-- ✅ Expected: B-1 and B-2 inserted if new, or their price
--    overwritten if they already existed. No duplicate-key error.

7. Chunked Deletes & Updates (and the ETL Pattern)

Deleting or updating millions of rows in a single statement takes one long lock and writes a giant chunk of the write-ahead log — other queries stall and disk usage spikes. Instead, work in chunks: delete a few thousand rows, let the locks release, repeat until nothing is left. The same idea drives ETL/ELT batch jobs (Extract → Transform → Load): pull raw data into a no-constraints staging table, clean and validate it there, then load the good rows into production in sized batches.

-- Deleting/updating MILLIONS of rows in ONE statement locks the
-- table for a long time and bloats the write-ahead log.
-- ❌ DELETE FROM events WHERE created_at < '2023-01-01';  -- 50M rows, one lock

-- ✅ Delete in chunks so each statement is short and releases locks.
DELETE FROM events
WHERE id IN (
    SELECT id FROM events
    WHERE created_at < '2023-01-01'
    LIMIT 10000                            -- 10k rows at a time
);
-- Run this in a loop until 0 rows are affected.
-- Same idea for big UPDATEs: chunk by primary-key ranges.

Common Errors (and the fix)

Frequently Asked Questions

Q: How big should each batch or transaction be?

There's no universal number, but 1,000–10,000 rows per multi-row INSERT and a COMMIT every 5,000–50,000 rows works well for most systems. Measure with your real data — too small wastes round trips, too large holds locks and grows the WAL.

Q: COPY or multi-row INSERT — which should I reach for?

If the data is (or can become) a file the server can read, COPY/LOAD DATA wins by a wide margin. If the data is generated in your application, batched multi-row INSERTs are the practical choice.

Q: Is it always worth dropping indexes before a load?

For large one-off loads into a table that's mostly idle, yes — rebuilding once is cheaper. For small incremental loads into a live, heavily-queried table, no: dropping indexes would slow every other query in the meantime.

Q: What does "idempotent" mean here?

Running the same load twice produces the same final state — no duplicates, no errors. UPSERT gives you that: a re-run updates existing rows instead of failing on a duplicate key.

Mini-Challenge: A Safe, Re-runnable Batch Load

Put it all together — a brief, a blank canvas, and the expected result in the comments. Write it, then copy it into a playground to confirm.

-- 🎯 MINI-CHALLENGE
-- Using ONLY what this lesson covered (transactions, batched multi-row
-- INSERT, and ON CONFLICT upsert):
--   1. Open a transaction with BEGIN
--   2. Insert THREE rows into stock (sku, qty) in ONE multi-row INSERT
--   3. Make it an UPSERT on sku: if the sku exists, set qty to the new value
--   4. COMMIT
--
-- ✅ Expected: three rows inserted-or-updated, committed as one unit,
--    with no duplicate-key errors if you run it twice.

-- your query here

🎉 Lesson Complete

Practice quiz

Why is row-by-row INSERT so slow for millions of rows?

  • INSERT statements cannot use indexes
  • The database sorts the whole table after every insert
  • Each statement pays a round trip, parse/plan, and commit overhead — paid per row
  • Row-by-row inserts always run inside a single giant transaction

Answer: Each statement pays a round trip, parse/plan, and commit overhead — paid per row. Each separate INSERT pays a fixed tax (round trip, parse, commit). For ten million rows you pay it ten million times.

Which command is the fastest way to bulk-load a CSV in PostgreSQL?

  • COPY
  • A loop of single-row INSERT statements
  • MERGE
  • TRUNCATE

Answer: COPY. COPY streams an entire file into a table in one operation, skipping per-row overhead — often around 100x faster than individual inserts.

What is MySQL's equivalent of PostgreSQL's COPY for file loads?

  • BULK INSERT
  • IMPORT TABLE
  • INSERT FROM FILE
  • LOAD DATA INFILE

Answer: LOAD DATA INFILE. MySQL/MariaDB use LOAD DATA INFILE to bulk-load a file straight into a table.

What is the recommended commit size for a big load?

  • Commit after every single row
  • Commit every 5,000–50,000 rows
  • Wrap all 10 million rows in one transaction
  • Never commit until the server restarts

Answer: Commit every 5,000–50,000 rows. Committing once per row is slow; one giant transaction holds locks and bloats the WAL. The lesson recommends committing every 5,000–50,000 rows.

What does an UPSERT (ON CONFLICT DO UPDATE) make a load?

  • Idempotent — safe to run twice without duplicates or errors
  • Faster but non-repeatable
  • Read-only
  • Unable to insert new rows

Answer: Idempotent — safe to run twice without duplicates or errors. An upsert inserts a row or updates it if the key already exists, so re-running the load produces the same final state with no duplicate-key errors.

In a PostgreSQL upsert, what does EXCLUDED refer to?

  • The rows that were filtered out by a WHERE clause
  • Rows deleted by a previous statement
  • The row you tried to insert
  • The set of columns not in the index

Answer: The row you tried to insert. In ON CONFLICT ... DO UPDATE, the special table EXCLUDED refers to the row you tried to insert, e.g. SET on_hand = EXCLUDED.on_hand.

Why drop indexes before a large one-off bulk load?

  • Indexes prevent COPY from running at all
  • Every inserted row must update every index, so rebuilding once at the end is cheaper
  • Dropping indexes frees up disk needed for the CSV file
  • Indexes cause duplicate-key errors during loads

Answer: Every inserted row must update every index, so rebuilding once at the end is cheaper. Maintaining an index for each of millions of rows is the slow part; dropping indexes, loading, then rebuilding once is often 5–10x faster.

Why delete millions of rows in chunks rather than one statement?

  • Chunked deletes can run without a WHERE clause
  • DELETE cannot remove more than 10,000 rows at once
  • Chunking automatically rebuilds indexes
  • A single huge DELETE takes one long lock and bloats the write-ahead log

Answer: A single huge DELETE takes one long lock and bloats the write-ahead log. One giant DELETE holds a long lock and writes a huge chunk of WAL, stalling other queries. Deleting in chunks lets locks release between batches.

Why use a staging table in an ETL flow?

  • It makes COPY skip the header row automatically
  • To validate, deduplicate, and reject bad rows before they touch production data
  • It removes the need to ever commit
  • Staging tables load faster because they have more indexes

Answer: To validate, deduplicate, and reject bad rows before they touch production data. A staging table lets you clean and validate raw, untrusted data before loading the good rows into production.

After bulk-loading and rebuilding indexes, why run ANALYZE?

  • To delete the dropped indexes permanently
  • To compress the loaded data on disk
  • To refresh table statistics so the query planner can choose good plans
  • To re-validate every foreign key

Answer: To refresh table statistics so the query planner can choose good plans. ANALYZE refreshes statistics so the query planner has fresh information after a large load.

Continue this course