How PostgreSQL Vacuum Made My Test Return Rows Backwards
Table of Contents
Today I learned something I technically knew already:
Without
ORDER BY, PostgreSQL does not guarantee row order.
But today I learned exactly how that can turn into a flaky test.
And the mechanism is surprisingly interesting.

It starts with how PostgreSQL stores rows
PostgreSQL stores table data in 8 KB blocks, called pages.
Each row occupies a slot inside a page. PostgreSQL exposes this physical location through ctid:
(page, slot)
So a row with:
ctid = (190, 11)
is physically sitting in page 190, slot 11.
Now imagine this query:
SELECT *
FROM certificates
WHERE period_ending_date <= '2024-01-31';
There is no ORDER BY.
For a sequential scan, PostgreSQL reads the table pages roughly like:
page 0 → page 1 → page 2 → ... → page 190 → ...
So the result often looks like it is ordered by physical position.
But that’s not a promise about id, insertion order, or anything else.
It’s just an accident of how the rows happen to be stored.
Then vacuum enters the story
RSpec wraps each example in a transaction and rolls it back afterwards.
The rollback means the test data disappears logically.
But PostgreSQL doesn’t immediately remove the physical row.
It becomes a dead tuple.
So after a long test suite, the table can look something like:
page 0 [dead] [dead] [dead]
page 1 [dead] [row]
page 2 [row] [dead] [row]
...
page 190 [row] [row]
Then autovacuum comes along.
It cleans up those dead tuples and records the newly available space in PostgreSQL’s free space map.
Now an interesting thing can happen.
The insert order can become different from the scan order
Suppose our test does this:
cert1 = create_certificate
cert2 = create_certificate
Normally, both new rows might be appended near the end of the table:
page 190: cert1 → cert2
scan → cert1, cert2
Everything looks nicely ordered.
But suppose autovacuum runs between those two inserts.
It has just discovered some free space on an early page:
page 0: [free]
...
page 190: cert1
The next INSERT can reuse that free space:
page 0: cert2
...
page 190: cert1
Now the sequential scan sees:
page 0 → ... → page 190
and returns:
cert2, cert1
The newer row comes back before the older row.
The insert order and scan order have effectively been reversed.
And that’s exactly what happened in the flaky spec.
The really fun part: reproducing it
The important detail is that vacuum has to happen between the two inserts.
When forced into that timing, the result reversed in 6 out of 6 trials:
trial 0 → [3002, 3001]
["(0,1)", "(166,13)"]
trial 1 → [6004, 6003]
["(0,1)", "(190,11)"]
The second certificate was sitting on page 0.
The first certificate was still sitting on a much later page.
So the sequential scan naturally found the second one first.
Interestingly, running vacuum before both inserts didn’t reproduce it: 8 out of 8 trials kept the expected order.
That’s because both inserts could then reuse the same available area in sequence.
So it’s not simply:
“Vacuum happened → rows got reordered.”
It’s:
“Vacuum happened at exactly the wrong time → the next insert reused an earlier page → sequential scan returned the newer row first.”
Why it only happened in the full suite
This finally explained the weirdest part of the bug.
The spec file alone takes about 1.7 seconds.
The full suite takes around 10 minutes.
Autovacuum’s naptime is around 60 seconds, and the table has enough dead tuples during a full run to cross its vacuum threshold:
50 + 0.2 × number_of_rows
So a single-file run is usually over before autovacuum gets an opportunity.
A full suite gives it roughly ten minutes of opportunities.
But even then, the vacuum has to land in the tiny window between the two inserts.
That’s why:
- running the spec alone always passed
--order defineddidn’t make it reproducible- rerunning the same commit could pass
- the full suite occasionally failed
It wasn’t really a race between the RSpec examples.
It was a race between the test and a background PostgreSQL maintenance process.
The rule I’ll carry forward
I’ve always understood:
“If you care about ordering, use ORDER BY.”
Now I have a much better mental model for why.
Without ORDER BY, row order is a physical accident.
And physical storage can change because of things like:
- VACUUM
- INSERT
- UPDATE
- page reuse
- table rewrites
- maintenance operations
So a test that says:
expect(results).to eq([first, second])
is making a stronger claim than the query actually makes.
If the application only cares that both records are present, the test should say exactly that:
expect(results).to match_array([first, second])
The database never promised me the order. I just happened to get the same accident thousands of times.