How do you load a large volume of rows into a database efficiently?
Batch the rows, wrap the batches in transactions, and use prepared statements. A multi-row INSERT with a few hundred rows per statement, inside an explicit transaction, is the practical sweet spot for a transactional database.
CREATE TABLE events (
id INTEGER PRIMARY KEY,
source VARCHAR(40) NOT NULL,
payload VARCHAR(200) NOT NULL,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
BEGIN;
INSERT INTO events (source, payload) VALUES
('web', 'signup'),
('web', 'login'),
('app', 'purchase'),
('app', 'purchase');
INSERT INTO events (source, payload) VALUES
('batch', 'import-1'),
('batch', 'import-2');
COMMIT;
SELECT COUNT(*) AS total_events FROM events;
SELECT source, COUNT(*) AS events FROM events GROUP BY source ORDER BY source;Six events were loaded, and the second query shows two from each source: app, batch and web.
Why batching wins is about the number of round trips and the number of commits. Six separate INSERT statements in autocommit mode means six transactions and six durable commits. Two statements inside one transaction means one commit and, over a network connection, one round trip. The saving scales with the batch size, which is why the right answer to "how do I load a million rows" is never a loop of single inserts.
Batch size is a trade-off rather than a maximum. Larger batches amortise the per-statement overhead better, but a statement that is too large takes longer to plan, can exceed the maximum packet size on a network connection, and makes a failed batch lose more work. Between a hundred and a thousand rows per batch is the usual range, and the right number for your system is worth measuring rather than guessing.
Prepared statements matter more than people expect. A prepared statement is parsed and planned once and then executed with different parameter values, and it keeps values out of the SQL text entirely, which removes the injection risk. If you build the SQL by concatenating values, the statement text changes on every iteration and the plan cache never hits — so string concatenation costs you both safety and speed.
And for a really large load, use the engine's bulk-load path instead of INSERT. PostgreSQL's COPY, MySQL's LOAD DATA INFILE and SQLite's .import command bypass much of the per-row processing and are dramatically faster. The trade-off is that they usually disable triggers and require the data to be well-formed, so they are a data-loading tool rather than an application write path.