Test setup and table definition
Bulk loading into PostgreSQL can be done in several ways, and the choice between them shows up clearly in a simple benchmark. The table used here is narrow — two columns — but it carries a primary key index, which is the realistic case: dropping indexes and constraints before a load and recreating them afterwards can be slower when the table already holds data.
|
1 2 3 4 |
CREATE TABLE instest ( id bigint PRIMARY KEY, value text NOT NULL ); |
The test loads 10 million rows of the form (1, '1'), (2, '2') and up to 10000000, against PostgreSQL v16 with its default configuration. Defaults are not ideal for bulk loading, but the comparison between methods is what matters here. The runs were performed on a laptop with Fedora Linux 37, local domain socket connections and ext4 file systems on an NVMe disk.
The six loading methods
One INSERT per transaction
Each statement runs in its own transaction, and every transaction forces PostgreSQL to write WAL out to disk — 10 million I/O requests. This is not a sane bulk-load strategy, but it mirrors how transactional applications insert small amounts of data with no way to bundle requests. Two remedies exist for that case:
- Set
synchronous_committoofffor a large speedup, at the risk of losing committed transactions after an operating system crash. - Set
commit_delayabove 0 and tunecommit_siblingsto cut the number of I/O requests. The gain is smaller than withsynchronous_commit, but no committed transaction can be lost.
All INSERTs in a single transaction
The same statements wrapped in one transaction across all 10 million rows — a substantial improvement over per-statement commits.
Prepared INSERT in a single transaction
Here the statement is prepared:
|
1 2 |
PREPARE stmt(bigint,text) AS INSERT INTO instest (id, value) VALUES ($1, $2); |
and executed repeatedly:
|
1 |
EXECUTE stmt(1, '1'); |
Because PostgreSQL reuses the execution plan instead of planning the INSERT 10 million times, short statements gain noticeably.
Multi-line INSERTs in a single transaction
Several rows go into one statement:
|
1 2 3 4 5 |
INSERT INTO instest (id, value) VALUES (1, '1'), (2, '2'), ... (1000, '1000'); |
This test inserts 1000 rows per statement, giving 10000 statements in total. Fewer client-server round trips and fewer statements to plan and execute are the payoff.
Prepared multi-line INSERTs in a single transaction
The same approach with a prepared statement:
|
1 2 3 4 5 6 |
PREPARE stmt(bigint,text,bigint,text,...) AS INSERT INTO instest (id, value) VALUES ($1, $2), ($3, $4), ... ($1999, $2000); |
The intent is to combine both advantages.
COPY
COPY has the reputation of being the fastest route into PostgreSQL. All 10 million rows are loaded with a single COPY statement. Its drawback is that it is non-standard SQL: not every API supports it, and code intended to run against other database systems cannot use it. When the table is created or truncated in the same transaction, COPY (FREEZE) is available — it does not accelerate loading itself, but it avoids hint bit overhead on the rows from the first reader and anti-wraparound autovacuum on the table.
Results
| Bulk load method | Duration |
|---|---|
single INSERTs, many transactions |
8954 s |
single INSERTs |
841 s |
single INSERTs, prepared statement |
688 s |
bulk INSERTs |
52 s |
bulk INSERTs, prepared statement |
57 s |
COPY |
14 s |
The general picture matches expectations: separate transactions are unbearably slow, prepared statements help small INSERTs, a few large statements beat many small ones, and COPY stands alone at the top. One surprise is that preparing the multi-line statements makes them slower rather than faster. The exact cause is unclear, though overhead from the extended query protocol is a plausible explanation.
Reducing redundant writes
Little can be done to make writes themselves faster, but redundant ones can be avoided:
max_wal_sizeis the most important setting, with a default of 1GB. Once more than that amount of WAL has been written since the last checkpoint, PostgreSQL triggers another checkpoint in order to keep crash recovery short. During a bulk load 1GB is quickly exhausted, and checkpoints can arrive every few seconds — overhead that is especially wasteful because the same index pages may be flushed to disk repeatedly.checkpoint_timeoutshould be raised for the same reason. Fewer checkpoints mean fewer redundant writes, and the WAL shrinks as well, since fewer full-page images are written.
In this particular test, the effect of those settings is small: the table only grows, the index values only increase, and B-tree indexes are optimized for exactly that pattern. With UUID primary keys the index modifications would not be localized, and cutting the number of full-page images would matter much more.
Takeaway
For bulk load, COPY is the way to go.



