drizzle-team / drizzle-team/drizzle-orm
[BUG]: `.desc()` index (DESC NULLS LAST) contradicts `desc()` orderBy (bare desc = NULLS FIRST), so Postgres silently never uses the index
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
### Report hasn't been filed before.
- [X] I have verified that the bug I'm about to report hasn't been filed before.
Two existing issues each describe *half* of this, without the consequence of the two halves combined: #5312 (index builder emits `NULLS LAST` that was never asked for — discussed there as cosmetic) and #1699 (order-by helpers can't express `NULLS FIRST/LAST` at all). This report is about their **interaction**: the two defaults contradict each other, and the contradiction makes Postgres silently refuse to use the index for the exact query the index was created for.
### What version of `drizzle-orm` are you using?
0.45.2 (also reproduced on 1.0.0-rc.4)
### What version of `drizzle-kit` are you using?
0.31.9 (also reproduced on 1.0.0-rc.4)
### Other packages
_No response_
### Describe the Bug
Drizzle's index builder and its order-by helper disagree about NULL ordering for descending columns:
- **Index builder**: `table.col.desc()` defaults the column's `nulls` config to `'last'` (`pg-core/columns/common.js` — `nulls: this.config.nulls ?? "last"`), so drizzle-kit generates `col DESC NULLS LAST`.
- **Order-by helper**: `desc(col)` is just ``sql`${column} desc` `` — no NULLS clause, which Postgres interprets with its `DESC` default: **`NULLS FIRST`**.
Postgres matches indexes to `ORDER BY` clauses by comparing pathkeys *including* the nulls-first flag, and it does **not** consult `NOT NULL` constraints when doing so. So `DESC NULLS LAST` (the index Drizzle creates) can never serve `DESC NULLS FIRST` (the ordering Drizzle emits) — **even on a non-nullable column**, where the distinction is semantically meaningless. The planner falls back to a full scan + sort, and `ORDER BY … LIMIT n` pagination loses early termination.
The failure mode is nasty because it is completely silent: results are correct, only the plan is bad. It's invisible on small/dev tables and shows up as whole-table scans in production. We found 8 of these in our own codebase — every single `.desc()` index we had ever written was unusable by the queries it was written for.
### Reproduction
Schema (this is the natural way to write "index for a latest-first list"):
```ts
import { index, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
export const items = pgTable(
'items',
{
id: uuid().primaryKey().defaultRandom(),
createdAt: timestamp().defaultNow().notNull(),
},
(table) => [index('items_by_created_at').on(table.createdAt.desc())],
);
```
Generated migration (identical output from drizzle-kit 0.31.9 and 1.0.0-rc.4):
```sql
CREATE INDEX "items_by_created_at" ON "items" ("createdAt" DESC NULLS LAST);
```
Query (again the natural pairing):
```ts
db.select().from(items).orderBy(desc(items.createdAt)).limit(50);
-- emits: ORDER BY "createdAt" desc LIMIT 50 (bare desc ⇒ NULLS FIRST)
```
Plans, with `SET enable_seqscan = off` to prove the planner *refuses* the index rather than merely disprefers it (10k rows, freshly analyzed):
```
--- with the drizzle-generated index (DESC NULLS LAST):
Limit
-> Sort
Sort Key: created_at DESC
-> Seq Scan on nulls_demo
--- after adding the same index as DESC NULLS FIRST:
Limit
-> Index Scan using demo_created_at_nf on nulls_demo
```
### Workaround
Write every descending index column as `.desc().nullsFirst()` so the index matches what `desc()` emits. (The inverse — fixing the query side — is not currently possible without raw SQL, per #1699.)
### Suggested fix
Any one of these would resolve the contradiction; the first seems most aligned with user intent:
1. Make the index builder's `nulls` default **track the column direction** (`desc()` ⇒ `'first'`, matching both the Postgres default for `DESC` and what the `desc()` order-by helper emits). Equivalently: emit no `NULLS` clause when the user didn't specify one, letting Postgres apply its direction-appropriate default. Note 1.0.0-rc's generator already omits the clause when it matches the direction default — but the ORM side still pins `'last'` unconditionally, so `.desc()` still produces the mismatching `DESC NULLS LAST`.
2. Implement #1699 so the order-by helpers can express NULLS ordering explicitly.
3. At minimum, have drizzle-kit warn when a `.desc()` index column has no explicit `nullsFirst()`/`nullsLast()`, and document the trap.
Since (1) changes generated DDL, it presumably needs care in the snapshot differ so existing databases don't churn — but as-is, the default produces indexes that the library's own query builder cannot use.
Contributor guide
Research direction
Start in pg-core/columns/common.js and the order-by helper used by desc(), then trace drizzle-kit’s generated DDL and snapshot differ behavior. Reproduce the DESC NULLS LAST versus bare DESC case from the issue and compare the resulting PostgreSQL plans. Done means the chosen fix makes index and query ordering agree without unwanted migration churn.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, typescript
- Domain
- backend-api-design, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100