Skip to content

fix: orderable fractional indexing case-sensitivity issue with PostgreSQL#14867

Merged
paulpopus merged 4 commits intopayloadcms:mainfrom
vladyslavprosolupov:main
Jan 20, 2026
Merged

fix: orderable fractional indexing case-sensitivity issue with PostgreSQL#14867
paulpopus merged 4 commits intopayloadcms:mainfrom
vladyslavprosolupov:main

Conversation

@vladyslavprosolupov
Copy link
Contributor

@vladyslavprosolupov vladyslavprosolupov commented Dec 9, 2025

fix: orderable fractional indexing case-sensitivity issue with PostgreSQL

Problem

When using the orderable feature with PostgreSQL, reordering documents to the "first" position generates keys that break subsequent reordering operations.

Scenario

  1. Create two orderable documents → keys are a0, a1
  2. Drag a1 before a0 (to make it first)
  3. The system generates key Zz for the moved document
  4. Result: Ordering is completely broken - cannot reorder any documents anymore

Root Cause

The fractional indexing algorithm uses:

  • Uppercase A-Z for "smaller" integer keys
  • Lowercase a-z for "larger" integer keys

This relies on ASCII ordering where 'Z' (code 90) < 'a' (code 97), so Zz < a0.

However, PostgreSQL's default collation (en_US.UTF-8) uses case-insensitive comparison, treating 'Z' as 'z'. This means:

  • ASCII: Zz < a0
  • PostgreSQL: Zz treated as zz, so zz > a0

This mismatch causes:

  1. Database queries return incorrect adjacent documents
  2. The generateKeyBetween function receives arguments in wrong order
  3. Subsequent reorder attempts either error out or create more broken keys

Solution

Modified the fractional indexing algorithm to use only characters that sort consistently across all database collations:

Range Old Encoding New Encoding
Small keys A-Z (uppercase) 0-9 (digits)
Large keys a-z (lowercase) a-z (lowercase, unchanged)

Key insight: Digits (0-9) always sort before letters in both ASCII ordering and case-insensitive collations.

Before (broken)

decrementInteger('a0') → 'Zz'
'Zz' < 'a0' in ASCII ✓
'Zz' > 'a0' in PostgreSQL (case-insensitive) ✗

After (fixed)

decrementInteger('a0') → '9z'
'9z' < 'a0' in ASCII ✓
'9z' < 'a0' in PostgreSQL ✓
'9z' < 'a0' in MongoDB ✓
'9z' < 'a0' in SQLite ✓

Changes

packages/payload/src/config/orderable/fractional-indexing.js

  • Changed integer part encoding to use digits for "small" range
  • Maintains backward compatibility with existing a-z keys
  • Legacy A-Z keys are still parsed (for backward compatibility) but won't be generated

Backward Compatibility

  • ✅ Existing keys starting with a-z continue to work correctly
  • ⚠️ Existing keys starting with A-Z (uppercase) will be parsed but may sort incorrectly in case-insensitive databases. Users with such keys should run a migration to regenerate them.

Testing

Verified the algorithm produces correct ordering:

generateKeyBetween(null, null)     // → 'a0'
generateKeyBetween(null, 'a0')     // → '9z' (previously 'Zz')
generateKeyBetween(null, '9z')     // → '9y'

// Sorting works correctly
['9z', 'a0', 'a1'].sort()          // → ['9z', 'a0', 'a1'] ✓

Related

@vladyslavprosolupov vladyslavprosolupov changed the title fix(payload): Orderable fractional indexing case-sensitivity issue with PostgreSQL fix: Orderable fractional indexing case-sensitivity issue with PostgreSQL Dec 9, 2025
@vladyslavprosolupov vladyslavprosolupov changed the title fix: Orderable fractional indexing case-sensitivity issue with PostgreSQL fix: orderable fractional indexing case-sensitivity issue with PostgreSQL Dec 9, 2025
Copy link
Contributor

@DanRibbens DanRibbens left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! I actually thought that we fixed this some time ago.

Assuming all existing tests pass, just a few things need to happen before this can be merged.

  • remove the excessive comments generated by AI.
  • add test in test/sort/int.spec.ts

})
const orders = (related.orderableJoinField1 as { docs: Orderable[] }).docs.map((doc) =>
parseInt(doc._orderable_orderableJoinField1_order, 16),
parseInt(doc._orderable_orderableJoinField1_order, 36),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using base 36 because fractional indexing keys use characters 0-9 and a-z.
Base 16 would fail for keys like '9z' (returning just 9) since 'z' is not a valid hex character.

Also changed it in other tests for cohesion, it breaks nothing.

@hohoaisan
Copy link

The ordering still broken, when will this issue is resolved :(

@paulpopus
Copy link
Contributor

@hohoaisan sorry about that, will bring this up to the team

@andershermansen
Copy link
Contributor

Some of us generates the order outside of payload admin system and would need a changed function like this to be exported.

Copy link
Contributor

@DanRibbens DanRibbens left a comment

Choose a reason for hiding this comment

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

Looks good!

@paulpopus paulpopus merged commit ef27ad9 into payloadcms:main Jan 20, 2026
99 checks passed
@paulpopus
Copy link
Contributor

Exporting the utilities here #15286

@danielwaltz
Copy link
Contributor

danielwaltz commented Jan 21, 2026

To the best of my knowledge and understanding, Postgres's sort actually is case sensitive by default. This feels to me like it may be a case of AI hallucination?

When I have an orderable field in Payload and open the database in my database client and sort by the _order column I see entries that start with Zz above entries with a0.

https://aws.amazon.com/blogs/database/manage-case-insensitive-data-in-postgresql/#:~:text=By%20default%2C%20PostgreSQL%20is%20case,impacts%20how%20they%20are%20sorted.

EDIT: After further digging, I think it may be OS dependent. Postgres seems to defer to the OS on this particular case, and there are differences between macOS and Debian (and perhaps other Linux distros as well).

@github-actions
Copy link
Contributor

🚀 This is included in version v3.73.0

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.

6 participants