Skip to content
All posts

Databases

Stop Using Publish Timestamps as Ordering Keys

Md Shamim Hossain October 15, 2026 3 min read 1,678 views Updated Nov 2, 2026

Sorting by published_at looks correct until two posts share a timestamp, and then pagination starts silently duplicating and dropping rows.

More ways to share

Link copied to clipboard

This one is subtle enough that it survives load testing, code review, and production for months before anyone notices.

The symptom

A reader reports a post they saw on page two is now missing. You check: the post is published, the filters are correct, and the row exists. Nothing is broken — the reader saw a phantom.

The cause

Pagination with an offset assumes a stable total order. Ordering by published_at alone does not provide one.

SELECT * FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 9 OFFSET 9;

If three posts share the same published_at — which happens constantly with scheduled publishing or a batch import — the database is free to return them in any order within the tie. Page one might show post A before post B; page two might show B before A and therefore also include A again.

The reader did not misremember. The database genuinely returned the row twice.

Why it is hard to catch

Three properties conspire:

  1. It requires a tie. With distinct timestamps the order is stable and everything works.
  2. It requires a page boundary. A tie in the middle of a page is invisible.
  3. It requires a second request. A single page load is always self-consistent.

So the bug needs a specific combination of data shape and user behaviour. Your test suite creates three posts with fake()->dateTimeBetween() and never hits it.

The fix

Make the sort key total by appending a tiebreaker:

#[Scope]
protected function recent(Builder $query): void
{
    $query->orderByDesc('published_at')->orderByDesc('id');
}
SELECT * FROM posts
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 9 OFFSET 9;

Now every row has a deterministic position. id is unique, so the ordering is total, and pagination is stable.

Why not just use id

Because the fallback has to agree with the primary key. If you order by published_at DESC, id DESC, a later post with a lower id still sorts first. If you ordered by id DESC alone, a backdated post — one you published today but dated last month — would appear at the top, which is wrong.

The tiebreaker must be monotonically increasing in insert order, not in publish order, so that the immediately-next-inserted row wins a tie consistently.

The test that catches it

test('pagination is stable when posts share a publish timestamp', function () {
    $timestamp = now()->subDay();

Post::factory()->count(20)->published()->create([
'published_at' => $timestamp,
]);

$page> ->viewData('posts')->pluck('id');

$pageTwo = $this->get(route('blog.index', ['page' => 2]))->assertOk()
->viewData('posts')->pluck('id');

expect($pageOne->intersect($pageTwo))->toBeEmpty();
});
```

The assertion is intersection-emptiness, not a specific ordering. That way the test does not encode an implementation detail — it encodes the property that actually matters: a row appears on exactly one page.

The general rule

Any time you paginate with an offset, the ORDER BY must be a total order. If the columns in it can tie, the pagination is not stable, and "not stable" means "users will see duplicated and missing rows under a load pattern you have not tested yet."

Add the tiebreaker on the day you write the query. It costs one column in the sort and removes a class of bug that is genuinely painful to reproduce.

Keep reading

Discussion

3 comments

Sign in to join the discussion. Comments are published under your name.

Markdown-style emphasis and `code` are supported. 0 / 2000
Held for moderation
  1. A

    Anika Sorensen

    Disagree slightly on the ranking approach — a window function would be cheaper here. But I take the portability argument.
    1. T

      Tomas Delgado

      reply
      Fair push-back. If I could guarantee PostgreSQL I would reach for `row_number()` every time. SQLite in dev is the constraint.
  2. P

    Priya Raman

    Ran the same experiment on our staging dataset and got a comparable curve. Nice to have it validated independently.
Enter your credentials to continue to your workspace.
Email address
Remember me
Don't have an account? Sign up
Enter your details below to create your account
Name
Email address
Password
Confirm password
Already have an account? Log in
Enter your email and we will send you a reset link
Email address
Or, return to log in