Skip to content

Shadow via bulk load: skip indexes during copy, rebuild after - #1

Merged
carldaws merged 1 commit into
carldaws:mainfrom
mhenrixon:fast-shadow-bulk-load
Apr 17, 2026
Merged

carldaws merged 1 commit into
carldaws:mainfrom
mhenrixon:fast-shadow-bulk-load

Conversation

@mhenrixon

Copy link
Copy Markdown
Contributor

Problem

Shadowing a table that already has indexes pays for per-row B-tree maintenance on every insert. CREATE TABLE ... LIKE ... INCLUDING ALL creates all indexes up front, and then INSERT INTO ... SELECT * FROM public.X updates each index for every row. On a table with millions of rows and several indexes this dominates the shadow runtime — and the shadow runs the first time any migration touches a given table, right when a developer is waiting for their feature branch to come up.

Fix

Flip the order:

  1. Create the shadow table with everything except indexes — defaults, constraints, identity, generated columns, statistics, storage, comments.
  2. Bulk-copy the rows into the index-free table.
  3. Recreate each index from public.X on the branch copy in a single sort pass. The primary key is re-attached via ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY USING INDEX so it remains a true PRIMARY KEY rather than just a unique index.

This is the standard Postgres bulk-load pattern: drop indexes, load data, rebuild. Per-row random-I/O becomes per-index sequential sorting.

Numbers

Widgets table with 1 PK + 5 indexes (one unique, one composite, one partial, two plain). Single-node Postgres 16, local SSD.

Rows Current (INCLUDING ALL + INSERT SELECT) This PR Speedup
1,000,000 14.05s 5.98s 2.35x
3,000,000 51.18s 19.25s 2.66x

Speedup grows with scale because sort-based index builds amortize better than per-row B-tree inserts. On tables with more indexes or slower disks the gap widens further. A reproducible benchmark script is included under bench/shadow_bench.rb.

Semantics

Nothing observable changes. Seven new tests in test/shadow_test.rb pin the parts the old code got for free via INCLUDING ALL:

  • Primary key remains a PRIMARY KEY constraint (pg_index.indisprimary), not just a unique index — matters for ActiveRecord's PK lookup
  • Unique indexes stay unique (assertion on pg_index.indisunique + insert that should RecordNotUnique)
  • Non-unique indexes are preserved
  • Multi-column index column order is preserved
  • Check constraints are preserved (with an insert that violates)
  • NOT NULL and column defaults are preserved
  • Primary-key sequence still auto-assigns IDs on insert into the shadow

All existing tests still pass (76 runs, 174 assertions).

Not addressed (can be follow-ups)

  • CREATE TABLE LIKE still doesn't copy foreign keys — same as before. Worth tackling separately.
  • UNLOGGED table option for even more dev-only speedup by skipping WAL. Opening as a separate PR once this one lands.

@carldaws carldaws left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was my next move and you beat me to it. A few things to fix though.

Comment thread lib/active_record/connection_adapters/postgresql/branched/shadow.rb Outdated
Comment thread lib/active_record/connection_adapters/postgresql/branched/shadow.rb Outdated
Comment thread lib/active_record/connection_adapters/postgresql/branched/shadow.rb Outdated
Comment thread bench/probe.sql Outdated
CREATE TABLE LIKE ... INCLUDING ALL creates all indexes up front, so the
subsequent INSERT SELECT pays for per-row index maintenance on every
index. On large tables this dominates shadow time.

Instead, create the shadow with INCLUDING ALL EXCLUDING INDEXES, bulk
copy the rows, then recreate indexes in a single sort pass each. The
primary key is attached via ALTER TABLE ... USING INDEX so it remains a
PRIMARY KEY constraint rather than just a unique index.

Benchmarks on a widgets table with 1 PK + 5 indexes (1 unique, 1
composite, 1 partial, 2 plain):

  1M rows: 14.05s -> 5.98s (2.35x)
  3M rows: 51.18s -> 19.25s (2.66x)

Speedup grows with scale because sort-based index builds amortize better
than per-row B-tree inserts. Semantics are preserved: same indexes, same
PK designation, same uniqueness, same check constraints, same defaults
and NOT NULL — covered by new tests pinning each of these.

Index redirection is done in SQL via REPLACE with quote_ident(), which
matches pg_get_indexdef's own output exactly. Tables whose names need
quoting (uppercase, reserved words) work as a result, and the single
catalog query returns index name, is-primary flag, and pre-rewritten
CREATE INDEX statement together.
@mhenrixon
mhenrixon force-pushed the fast-shadow-bulk-load branch from 7e7488e to 26d037f Compare April 17, 2026 16:23
@mhenrixon

Copy link
Copy Markdown
Contributor Author

Great feedback! Thanks for that

@carldaws

Copy link
Copy Markdown
Owner

Thank you!

@carldaws
carldaws merged commit 69f6459 into carldaws:main Apr 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants