27 Load it fast
The import that runs all night and the one that finishes before your coffee cools can load the same million rows into the same table. The difference is what each row drags along: a commit, a round trip, a parsed statement, an entry in every index, a foreign key lookup. Strip those away and Postgres loads rows about as fast as it can write pages.
Then the bill arrives. A big load writes more WAL than the table it fills, and that log forces checkpoints and lands on every replica. Its rows aren’t finished, so the first query to read them writes to every page. And the planner has no idea the table changed.
So do the least work per row while loading, then pay the deferred costs yourself, on purpose, before your users find them.
Load speed ranges from 1,200 rows a second to half a million
We loaded the same events-shaped rows into a fresh copy of events ten ways, each on a server of its own with stock settings, and counted rows per second and WAL, the log every change is written to before it reaches the data files (Write it down first). The two single-row methods load 1,000 rows, because a million would take hours; the rest load 1,000,000:
From about 1,200 rows a second to half a million. Each step down the table removes one kind of per-row overhead, and the rest of the chapter reads it a pair of rows at a time. (Row 8b’s rate is the speed of a table rewrite, not of a load.)
Treat the times as rough. They come from a quiet run; with a dozen other workloads sharing the laptop, some methods ran several times slower. The WAL never moved: within a kilobyte, every time.
Deep dive: How the ten loads were measured
Each server ran wal_level = replica, max_wal_size = 1GB, and data checksums; WAL is counted per session with pg_stat_get_backend_wal() (PostgreSQL 18), after a checkpoint. Every million-row table came out 137 to 138 MB. COPY ranged from 125,000 to 508,000 rows a second across our runs, and autocommit from 15 to about 1,200, so the lab checks ratios, never times.
Commit once per batch, not once per row
Rows 1 and 2 are the same 1,000 inserts. The only difference is a begin and a commit around them. That made the load about 15 times faster here, and over a hundred times faster on busier runs, where every flush waited behind other writers.
An autocommit INSERT is its own transaction, and every commit waits for its WAL to reach disk (Commit means flush). The commit record is tiny. The wait is the cost of the row.
One transaction for a whole load has its own problems: a failure at row 999,999 rolls back everything, and vacuum is held back while it runs. Into a live table, commit every few thousand to few hundred thousand rows, like backfills. Into an empty table, one transaction is fine, and it unlocks the fastest paths below.
Deep dive: One extra record per row, and a flush to wait for
A single-row insert writes two WAL records, heap and primary key; autocommit adds a commit record. For 500 rows: 1,521 records in 12,884 ms against 1,021 in 88 ms. 500 more records, 17 KB more WAL, and nearly all of the 12.9 seconds was flushes on a busy laptop running Docker. A good NVMe drive flushes in well under a millisecond; the shape holds.
Prove it · lab 26-loading
Batch with arrays, not VALUES lists
With the commits gone, the cost per row is the statement: a round trip, a parse, a plan. Batch a thousand rows and that cost is shared a thousand ways. How you batch still matters.
Rows 3 and 4 both send 1,000 rows per statement and write identical WAL. Here unnest was only 6% faster; on busier runs, up to nine times.
The difference is parsing. A multi-row VALUES list of literals, the usual shape of an ORM’s “bulk insert”, is 6,000 values of text that Postgres parses from scratch for every statement, and parsing is the first thing to suffer when CPU is scarce. INSERT ... SELECT * FROM unnest($1, $2, ...) sends one array per column: six parameters whatever the batch size, prepared once.
If your driver can bind arrays, use unnest. Neither form saves disk: both write a heap record and an index record per row.
Deep dive: Why a VALUES list tops out
The unnest form, prepared:
Bound as parameters, a VALUES list needs 6,000 placeholders per thousand rows, and new statement text for every batch size; the protocol caps a statement at 65,535. As literals, it was never faster than unnest in our runs: from about as fast on a quiet server to nine times slower on a busy one.
Prove it · lab 26-loading
COPY is the bulk path
COPY ... FROM STDIN streams rows in a plain format, text, CSV, or binary, with no statement per row at all. It also writes differently: it buffers rows and fills the heap a page at a time, one heap WAL record per page instead of one per row (each index entry is still logged per row). Read the wal/row column: 239 bytes for every INSERT form, 193 for COPY. And row 5 loads several times as fast as unnest.
Use the client side: COPY table FROM '/path' reads a file on the database server and needs special privileges, while psql’s \copy reads it on your machine and sends it as COPY FROM STDIN:
Every serious driver speaks the copy protocol. From Go, pgx’s CopyFrom sends binary COPY; in Node, pg-copy-streams pipes any stream in.
Deep dive: What turns COPY’s page-at-a-time path off
On 100,000 rows, VALUES and unnest each wrote about 203,000 WAL records (2 per row); COPY wrote 105,243, one per primary key entry plus one per heap page, and 19% fewer bytes.
Prove it · lab 26-loading
Three things send COPY back to one row at a time. A BEFORE or INSTEAD OF row trigger, because each row must pass through it before the next. A volatile default expression, since it might read the table being loaded (nextval(), behind identity and serial columns, is the one exception the source makes). And a WHERE clause on the COPY with a volatile function. AFTER triggers, including the ones behind foreign keys, don’t stop the batching, but each row still queues an event.
COPY FREEZE writes the rows finished
A row loaded by a plain COPY isn’t finished. Its header names the loading transaction, so the first reader checks that the transaction committed and saves the answer in the row’s hint bits (A read can write). That dirties the page, and with data checksums on (the PostgreSQL 18 default) logs a full-page image. The first count(*) on 100,000 freshly copied rows, after a checkpoint:
A read-only query dirtied all 1,759 pages that hold rows and wrote 14.4 MB of WAL, on a 14 MB table. If that’s a user’s query, they wait. Later, vacuum must come back to freeze every row, marking it visible to all transactions for good, before the transaction ID counter wraps around.
COPY ... WITH (FREEZE) writes the rows already frozen and, since PostgreSQL 14, marks every page all-visible. The condition: the table was created or truncated in the same transaction, so nobody else could have seen it:
Skip the truncate and Postgres refuses. The same count(*) on the frozen table dirties nothing, even the first time, and row 6 cost the same WAL as row 5. If you can load with FREEZE, do.
Prove it · lab 26-loading
Deep dive: What FREEZE marks, and the one honest catch
pg_visibility_map_summary('l_freeze') reports 1,759 pages all-visible and 1,759 all-frozen: every page that holds rows. (The file is 1,816 pages; COPY extends a table several pages at a time, and an empty page has nothing to mark.) The WAL was 19.4 MB against 19.3 MB for a plain COPY. Without the truncate, the error is cannot perform COPY FREEZE because the table was not created or truncated in the current subtransaction.
The docs flag the catch: frozen rows are visible to every snapshot, so a transaction that started before your load and reads the table after it commits sees the new rows, which normal MVCC would hide. For a table nobody reads until the load is done, that doesn’t matter.
Prove it · lab 26-loading
Unlogged, then SET LOGGED, moves the WAL
A popular trick: create the table unlogged, load it with no WAL, then alter table ... set logged. Rows 8a and 8b: the load wrote 34 KB, then SET LOGGED wrote 181 MB, against 184 MB for a plain COPY.
The detour moves the cost; it doesn’t remove it. SET LOGGED rewrites the table and logs every row, because a replica or a restore has no other record of the data. It holds an ACCESS EXCLUSIVE lock while it does, and until then a crash empties the table.
Unlogged tables earn their place as staging tables that never become permanent: load raw data, clean it with SQL, insert the result into the real table, drop the staging table. Only the final insert pays WAL (Skip the WAL for data you can regenerate).
Deep dive: SET LOGGED writes the table, then its indexes
At 100,000 rows, SET LOGGED wrote 18 MB: 100,190 records for the rows, then the rebuilt indexes. That’s about the size of the table plus its indexes, close to what a plain COPY writes (19.3 MB).
Prove it · lab 26-loading
wal_level = minimal skips WAL for new tables
Row 7 is the trap in common loading advice. “Create the table in the same transaction as the COPY” saved nothing: 184 MB, the same as row 5.
That trick works only under wal_level = minimal, where a transaction that creates or truncates a table and loads it fsyncs the new files at commit instead of logging the rows. On a server started that way, the same load wrote 38 KB instead of 184 MB, and on that server it ran about three times faster than a COPY into a table that already existed.
Everything else about it is expensive: a restart to turn on and off, no replicas, no archiving, so no point-in-time recovery. It fits one job, building a database from scratch before anything depends on it, then taking a fresh base backup. Managed databases usually won’t let you set it.
Deep dive: The minimal-WAL run, and the crash that didn’t lose it
The second server runs wal_level=minimal and max_wal_senders=0 (it won’t start otherwise). There, COPY into a table that existed before the transaction still wrote the full 184 MB (109,841 rows a second); created in the transaction, 38 KB (352,487 rows a second); truncated in it, 2,939 KB. Since PostgreSQL 13 the skip covers any write into a new or truncated table, not only COPY; writes under wal_skip_threshold (default 2 MB) are WAL-logged at commit, which is cheaper than an fsync. The lab then kills the server with SIGKILL and lets crash recovery run: both loads are intact, because the commit’s fsync made the files durable without the log.
Prove it · lab 26-loading.sh
Build indexes and foreign keys after the load
Rows 9 and 10 load the same rows into the same table with a primary key, two secondary indexes, and three foreign keys. With them in place: 64,047 rows a second and 346 MB of WAL. Bare, with everything added afterwards: 431,132 rows a second and 174 MB. Six to seven times faster across our runs, half the WAL, and the indexes came out 10 MB smaller.
During the load, every row writes a WAL record per index and runs a lookup per foreign key. Built afterwards, an index sorts its keys once and is written a page at a time. Give that sort memory in the building session (set maintenance_work_mem = '1GB'; it’s per operation, so a session-level setting is safe). pg_restore works in this order on its own.
That’s for empty tables and staging tables. A live table keeps its indexes and loads in batches.
Foreign keys: NOT VALID, then VALIDATE
A foreign key checks the parent for every loaded row and queues a trigger event per row until the statement ends; on loads of many millions of rows, the docs warn, that queue can exhaust memory.
Add it afterwards in two steps, as migrations do: NOT VALID enforces it for new rows without checking old ones, then VALIDATE CONSTRAINT checks the old rows in one join, under a lock that lets writes continue. Validation is a real check: in the lab, it found an orphan planted before the constraint and left the constraint unvalidated until we deleted it.
Deep dive: Why an index grown row by row comes out bigger
At 100,000 rows, indexes in place cost 348,851 WAL records; built afterwards, 5,194. (The time margin swung between runs; in one early run without a checkpoint between methods, the bare load lost.)
Rows arrive in created_at order, but (project_id, created_at) scatters them across the index, so leaf pages split in half and settle partly empty. A built index is sorted first and packed to its fillfactor (90% for B-tree leaves): 3,104 KB built against 4,328 KB grown, and the grown one stays bigger until a REINDEX. Every index is a tax on writes measures the per-index cost.
CREATE INDEX sorts in maintenance_work_mem (default 64 MB), shared with up to max_parallel_maintenance_workers (default 2), and spills to temporary files past it: the lab’s build spilled with 1 MB and didn’t with 256 MB. Keep the server-wide value modest; each autovacuum worker can take that much too, unless autovacuum_work_mem says otherwise.
Prove it · lab 26-loading
A big load forces checkpoints and floods replicas
A load that writes gigabytes of WAL doesn’t happen alone.
Checkpoints come early. WAL past max_wal_size (1 GB by default) forces a checkpoint (Checkpoints put a limit on recovery). The heaviest load above wrote 346 MB for a million rows, so ten million rows would pass it three times over. Each early checkpoint competes with the load for the disk and makes the next change to every page log a full-page image again (The first change after a checkpoint logs the whole page). Before a planned load, raise max_wal_size (a reload, no restart), make sure the disk under pg_wal has room, and put it back after.
Every replica receives every byte, and replays it in a single process (Replay is one process). Write less WAL (load bare, stage in unlogged tables, turn on wal_compression), load in batches, and pause when replica lag passes your budget. Logical subscribers apply the load row by row and lag longer still.
Deep dive: What two forced checkpoints cost a load
The same 500,000 rows into a table with three indexes, with max_wal_size at 128 MB and at 16 GB:
Two forced checkpoints, 999 page images instead of 3. Only 6 MB here, because a load mostly writes new pages, logged in full either way; a load into a big table with existing indexes re-images thousands of scattered old pages after every checkpoint. With log_checkpoints on (the default since PostgreSQL 15), the log says when a checkpoint was requested by WAL volume.
A long COPY reports progress in pg_stat_progress_copy (PostgreSQL 14+); track tuples_processed, since bytes_total is 0 for STDIN.
Prove it · lab 26-loading.sh
Vacuum and analyze before you open the doors
Pay the deferred costs yourself, right after the load:
One pass sets the hint bits, freezes every row so no anti-wraparound vacuum has to visit these pages again (vacuum’s freeze step), marks pages all-visible so index-only scans work, and gathers statistics. Autovacuum would get there eventually, throttled, on its own schedule, while your users are already querying. On a COPY FREEZE table there’s nothing left to freeze, and the same command writes almost nothing.
Don’t skip the analyze. A table that has never been analyzed has no column statistics, so the planner guesses. Asked how many of 100,000 freshly loaded events were alerts, it estimated 554. The real count was 12,621, off by a factor of 23, and a guess that far off picks nested loops where a hash join belongs. After ANALYZE: 12,550. Make it the last step of every load (Three kinds of change slip past autovacuum’s analyze).
Deep dive: Loading into an existing table goes stale the other way
A table with statistics isn’t safe either: after a big load into it, they describe the table before the load until something analyzes it. Autovacuum analyzes a table once 10% of it has changed (autovacuum_analyze_scale_factor, plus 50 rows), so a load that adds 5% to a large table may never trigger it. And the rows you just loaded are usually the newest, the ones queries ask for, sitting past the end of the histogram the planner still holds. Analyze after every big load, not only into empty tables.
Prove it · lab 26-loading
One bad value fails the whole COPY
By default one malformed value anywhere fails the whole COPY, and nothing is loaded. PostgreSQL 17 added ON_ERROR ignore, which skips rows whose values don’t convert to the column’s type; PostgreSQL 18 added REJECT_LIMIT, so “skip a few” can’t quietly become “skip half the file”. Only data type errors are skipped: a duplicate key or a failed constraint still stops everything.
For dirtier data, COPY into a staging table of text columns, which accepts anything, and move the rows that pass your checks with one INSERT ... SELECT.
Deep dive: What ON_ERROR skips, and what still stops the load
Given four rows, two with a bad integer, on_error ignore loads the other two and, with log_verbosity verbose, prints a notice per skipped row. With reject_limit 1, the second bad row fails the command (skipped more than REJECT_LIMIT (1) rows due to data type incompatibility), and nothing is loaded.
Prove it · lab 26-loading
Freeze the partition, attach it built, analyze the parent
COPY into a partitioned table routes each row to its partition and still batches per partition. Three differences:
FREEZEworks only on a partition created or truncated in the same transaction, never on the parent.- Build the next partition off to the side: load and index an ordinary table, add a
CHECKmatching the bounds, andattach partitionskips its validation scan (Convert a live table without downtime). - Analyze the parent. Autovacuum never does, and its statistics plan queries across partitions.
Deep dive: FREEZE needs the partition itself
Name the partition, not the parent, in the COPY:
Loading a partition directly still checks every row against its bounds: a row for the wrong month fails the command. Truncating a partition takes an ACCESS EXCLUSIVE lock on it, so this fits a partition nobody reads yet, such as next month’s.
Prove it · lab 26-loading
Upserts in bulk: stage, then one statement
Syncing a feed into an existing table usually gets written as one INSERT ... ON CONFLICT per incoming row: the single-row pattern from the top of the chapter, plus an index lookup. Stage it instead:
For a feed of 19,874 rows, one upsert at a time took 2,811 ms, even inside one transaction. Staged, 605 ms, same result.
One rule bites every bulk upsert: a statement can’t update the same row twice. A feed with a key twice fails with ON CONFLICT DO UPDATE command cannot affect row a second time. Deduplicate the staging table first (group by or distinct on), which you can do because it’s a table.
Deep dive: MERGE, and the table autovacuum never analyzes
MERGE (PostgreSQL 15+) from the same staging table produced an identical table; MERGE races where ON CONFLICT waits explains why ON CONFLICT is safer when other sessions write concurrently. Autovacuum never analyzes temporary tables, so run analyze stage before joining against it.
Prove it · lab 26-loading
A planned load takes seven steps
A planned load into a new or empty table:
- Raise
max_wal_size, and checkpg_walhas the disk for it. - Create the table without secondary indexes or foreign keys, or
truncateit in the loading transaction. COPY ... WITH (FREEZE)in that transaction.set maintenance_work_memhigh and build the indexes.- Add foreign keys
NOT VALID, thenVALIDATEthem. vacuum (freeze, analyze), or onlyanalyzeafterFREEZE.- Put
max_wal_sizeback, and watch replica lag until it catches up.
For a live table: batches through a staging table, indexes kept, pausing when lag grows.
The short version
- Commit per batch, not per row: an autocommit insert waits for a disk flush every time.
- Use
COPY(\copy, or your driver’s copy API): it beats anyINSERTon speed and WAL. If you mustINSERT, sendunnestarrays. - Load bare, then build indexes and add foreign keys
NOT VALIDplusVALIDATE: six times faster, half the WAL. COPY ... FREEZEinto a table created or truncated in the same transaction writes rows finished, so the first read doesn’t write.- Unlogged-then-
SET LOGGEDmoves the WAL, it doesn’t save it. Onlywal_level = minimalskips it, at the cost of replicas and PITR. - Raise
max_wal_sizebefore a big load, and finish it withvacuum (freeze, analyze), or at leastanalyze. - Upsert in bulk from a deduplicated staging table.
Further reading
- Populating a Database, PostgreSQL documentation. The official checklist: COPY, dropping indexes and foreign keys,
maintenance_work_mem,max_wal_size,wal_level = minimal, and analyze afterwards. - COPY, PostgreSQL documentation. Every option, including
FREEZE,ON_ERROR,REJECT_LIMIT, andLOG_VERBOSITY, and the file-access rules for server-side copies. - Write Ahead Log settings, PostgreSQL documentation. What
wal_level = minimalremoves, which commands skip WAL under it, andwal_skip_threshold. - INSERT … ON CONFLICT, PostgreSQL documentation. The upsert rules, including why one statement can’t touch a row twice.