The PostgreSQL Collation Trap That Breaks Fractional Indexing
January 21, 2026
If you're using the fractional-indexing library with PostgreSQL, there's a subtle bug waiting to bite you in production.
What Makes Fractional Indexing Cool
Traditional array reordering requires updating every item's position after the moved element. Move item 5 to position 2? Now you need to update positions 2, 3, 4, and 5.
Fractional indexing solves this elegantly: instead of integers, you use lexicographically-sortable strings. To insert between "a0" and "a1", you generate "a0V". Move to the beginning? Generate "Zz". The key insight: the client computes the new index, sends it to the server, and the server just stores it. No locks, no position recalculation, no n+1 updates.
import { generateKeyBetween } from "fractional-indexing";
// Insert at the beginning (before "a0")
generateKeyBetween(null, "a0"); // → "Zz"
// Insert between "a0" and "a1"
generateKeyBetween("a0", "a1"); // → "a0V"
// Append to end (after "a1")
generateKeyBetween("a1", null); // → "a2"
The library guarantees these keys sort correctly with standard string comparison—as long as you're comparing them the same way JavaScript does.
The Bug: PostgreSQL Doesn't Sort Strings Like JavaScript
JavaScript uses Unicode code point comparison (essentially ASCII/byte ordering). PostgreSQL, by default, uses locale-aware collation.
The difference:
| Collation | Order | Why |
|---|---|---|
JavaScript / COLLATE "C" | Zz, a0, a1 | ASCII: Z (90) < a (97) |
en_US.utf8 (Postgres default) | a0, a1, Zz | Alphabetic: lowercase before uppercase |
When you prepend an item, fractional-indexing generates "Zz". JavaScript expects this to sort before "a0". But PostgreSQL with en_US.utf8 collation puts it after.
How This Breaks Your App
The failure mode is insidious. Everything works in development (Docker Postgres often defaults to C collation), then breaks in production (cloud Postgres typically uses en_US.utf8).
When you query items ordered by their fractional index:
-- What you expect: Zz, a0, a1
-- What you get: a0, a1, Zz
SELECT * FROM items ORDER BY index;
Now your frontend receives items in the wrong order. Worse, when you try to generate a new key:
// Frontend thinks the order is: Zz, a0, a1
// But database returned: a0, a1, Zz
// User drags to insert between "a1" and "Zz"
generateKeyBetween("a1", "Zz");
// 💥 Error: "a1 >= Zz" - library expects a1 < Zz
The Fix: Explicit Collation
Force ASCII byte ordering with COLLATE "C":
SELECT * FROM items ORDER BY index COLLATE "C";
With Drizzle ORM:
import { asc, sql } from "drizzle-orm";
// ❌ Uses database default collation
orderBy: asc(items.index)
// ✅ Explicit ASCII ordering
orderBy: asc(sql`${items.index} COLLATE "C"`)
For btree indexes, specify collation in the index definition:
index("idx_items_index").on(sql`${table.index} COLLATE "C"`)
Check Your Database
Run this to see available collations:
SELECT collname, collencoding
FROM pg_collation
WHERE collname IN ('C', 'POSIX', 'en_US.UTF-8', 'en_US.utf8');
And check your database's default:
SELECT datcollate FROM pg_database WHERE datname = current_database();
Prevention
- Always use
COLLATE "C"when ordering fractional index columns - Create indexes with explicit collation for query performance
- Test with production-like collation settings in CI
- Document the gotcha for your team—this will bite someone else
The lesson: when a library promises "lexicographic ordering," make sure your database agrees on what that means.