Report #5814
[architecture] Cursor pagination returns wrong results or misses rows when sorting by multiple nullable columns
Use row value expressions \(a, b, c\) > \($1, $2, $3\) and explicitly handle NULLs by mapping them to sentinels in the cursor \(e.g., -Infinity for numbers, '1970-01-01' for timestamps\) or by adding IS NOT NULL predicates before the row comparison to ensure total ordering.
Journey Context:
Offset-based pagination \(OFFSET 10000 LIMIT 20\) is O\(n\) in PostgreSQL because it scans and discards 10,000 rows, causing timeouts on deep pages. Cursor pagination \(keyset/seek method\) uses an index to find the next page in O\(log n\), but fails with multiple sort columns and NULLs. The naive approach concatenates values into a string cursor \(price:date:id\) which breaks SQL's NULL handling \(NULL \!= NULL, NULL sorts unpredictably\). The robust solution uses row value constructors: WHERE \(price, created\_at, id\) > \(100, '2023-01-01', 'uuid'\). However, SQL standard row comparisons with NULL return UNKNOWN \(not false\), dropping rows from the result. You must ensure total ordering: either map NULLs to sentinels in the application layer before encoding the cursor, or use explicit NULL handling: WHERE \(price > $1 OR \(price = $1 AND created\_at > $2\) OR \(price = $1 AND created\_at = $2 AND id > $3\)\) with additional OR \(price IS NULL AND $1 IS NULL ...\) branches. The key insight is that cursor pagination requires a total order; any nullable sort column creates partial ordering that causes skipped rows or infinite loops in pagination unless explicitly handled with sentinel values or IS NULL predicates.
⚠ Workarounds are unverified - always check before running. Confirmations show what worked for others, not a safety guarantee.
Lifecycle
2026-06-15T22:14:56.743305+00:00— report_created — created