Drizzle leftJoin returns nullable pet rows

1 hour ago

Drizzle ORM version zero point forty-five does exactly what SQL says here. The `pets.name` column is required in the table. The left join still returns Linus with `pets` set to null, because Linus has no matching pet row. That is the surprise to keep in your head: a required column only tells you what a pet row looks like after a pet row exists.

Ask

Ask about this presentation

Answers are generated from this presentation.

Chapters

  1. 0:00Required column, missing row
  2. 0:23Use the lab packages
  3. 0:49Two users, one pet
  4. 1:12leftJoin keeps both users
  5. 1:37Joined object becomes nullable
  6. 1:58Generated SQL is a real left join
  7. 2:23Whole-table select gives pets null
  8. 2:48Partial fields become nullable
  9. 3:11Raw sql fields need nullable type
  10. 3:40notNull is a column rule
  11. 4:05Raw SQL nullability is manual
  12. 4:31Issue 1603 grouped object
  13. 5:05Issue 2045 filter gotcha
  14. 5:31The reusable leftJoin pattern
Show transcript

Required column, missing row

A required pet name can still land under `pets: null`
assets/lab/schema.ts
export const pets = pgTable("pets", {
  id: serial("id").primaryKey(),
  name: text("name").notNull(),
  ownerId: integer("owner_id")
    .notNull()
    .references(() => users.id),
});
bun index.ts
{
  users: {
    id: 2,
    name: "Linus",
  },
  pets: null,
}
Lab: Drizzle ORM 0.45, PostgreSQL

Drizzle ORM version zero point forty-five does exactly what SQL says here. The `pets.name` column is required in the table. The left join still returns Linus with `pets` set to null, because Linus has no matching pet row. That is the surprise to keep in your head: a required column only tells you what a pet row looks like after a pet row exists.

Use the lab packages

01
Install
01 Install: use the same packages the lab used
assets/captures/01-install.txt
Saved lockfile

+ drizzle-orm@0.45.3
+ drizzle-kit@0.31.11
+ pg@8.23.0
+ typescript@5.9.2
assets/lab/package.json
"dependencies": {
  "drizzle-orm": "0.45.3",
  "pg": "8.23.0"
},
"devDependencies": {
  "drizzle-kit": "0.31.11",
  "typescript": "5.9.2"
}
Capture: assets/captures/01-install.txt

Start from the package install, not from a copied snippet. This lab uses Drizzle ORM with the PostgreSQL driver, so the code uses `drizzle-orm/node-postgres` and the `pg` package. The install capture is short because the lockfile already pins the project. If you are following along in a different project, the important part is the driver match: the query builder code below is the SQL-like Drizzle API, not the relational query API.

Two users, one pet

01
Install
The lab has two users and one pet
generate
[✓] Your SQL migration file
was generated
migrate
[✓] migrations applied
to drizzle_manual_leftjoin
seed
seeded users [ "Ada", "Linus" ]
seeded pets [ "Byte" ]
Ada has pet `Byte`
Linus has no pet row
`pets.name` has `notNull()`
Captures: generate, migrate, seed

The seed is deliberately tiny. Ada has a pet named Byte. Linus has no pet. That is enough to make the join shape visible, because one result row matches the right table and one result row misses the right table. The schema also gives `pets.name` a `notNull()` rule, so the lab includes the exact mismatch that causes confusion in a real codebase.

leftJoin keeps both users

02
First run
02 First run: `leftJoin` keeps both users
assets/lab/index.ts
const wholeTable = await db
  .select()
  .from(users)
  .leftJoin(pets, eq(users.id, pets.ownerId))
  .orderBy(users.id);
assets/captures/06-leftjoin-result.txt
users: { id: 1, name: "Ada" },
pets: { id: 1, name: "Byte", ownerId: 1 }

users: { id: 2, name: "Linus" },
pets: null
Code: assets/lab/index.ts · output: assets/captures/06-leftjoin-result.txt

This is the smallest real query. A left join is a SQL join that keeps every row from the left table, even when the right table has no matching row. In this query, `users` is the left table. `pets` is the joined table. Read the chain as plain SQL: select, from users, left join pets, on user id equals pet owner id. Drizzle returns Ada with a pet object and Linus with `pets` set to null.

Joined object becomes nullable

03
Shape
03 The shape: the docs make the joined object nullable
Drizzle docs: Joins, Left Join

The manual says the same thing before the lab does. In the full-table select, Drizzle keeps the `users` object present and makes the `pets` object nullable. Nullable means the TypeScript type allows `null` because the query can return no value there. The type is doing you a favor here. TypeScript makes you handle Linus before you reach for `pets.name`.

Generated SQL is a real left join

03
Shape
The generated SQL is a real left join
assets/captures/07-tosql.txt
select "users"."name", "pets"."id", upper("pets"."name")
from "users"
left join "pets" on "users"."id" = "pets"."owner_id"
order by "users"."id"
assets/data/leftjoin.sql
Capture: assets/captures/07-tosql.txt

The generated SQL matters because the TypeScript type follows the SQL rule. Drizzle emits `from users left join pets on users.id equals pets.owner_id`. SQL keeps the user row. SQL leaves the pet columns empty when the pet row is missing. Drizzle carries that empty right side into TypeScript as `null`. Once you connect those two facts, the rest of the API stops feeling mysterious.

Whole-table select gives pets null

04
Real thing
04 The real thing: whole-table select gives `pets | null`
assets/lab/type-evidence.ts
type WholeRow = (typeof wholeTable)[number];
type WholeTableCheck = Expect<
  Equal<
    WholeRow["pets"],
    {
      id: number;
      name: string;
      ownerId: number;
    } | null
  >
>;
assets/captures/08-typecheck.txt
tsc --noEmit

type assertions passed
Type check: assets/lab/type-evidence.ts

Here is the first rule you reuse. When the query selects whole tables, the joined table becomes one nullable object. The type check says `WholeRow["pets"]` is either an object with `id`, `name`, and `ownerId`, or null. `pets.name` can be required inside a pet row. The pet row itself can still be missing. So the safe access pattern is to check the `pets` object first, then read fields from the pet object.

Partial fields become nullable

04
Real thing
Partial select makes joined fields nullable
partial leftJoin result
{
  userName: "Linus",
  petId: null,
  petName: null,
  upperPetName: null,
}
Drizzle docs: Joins, Partial select

The second rule shows up when you select individual fields. The result no longer has a `pets` object. The result has fields like `petId` and `petName`. Those fields are nullable, because Linus has no pet row to supply an id or a name. This is the shape you usually want for tables, exports, and API responses where one flat row is easier to consume than two nested objects.

Raw sql fields need nullable type

04
Real thing
Raw `sql` fields need the nullable type written out
assets/lab/index.ts
const partial = await db.select({
  userName: users.name,
  petId: pets.id,
  petName: pets.name,
  upperPetName: sql<string | null>`upper(${pets.name})`,
});
Drizzle docs: Joins, Partial select

The third rule is the one people usually miss. A raw `sql` expression is a hand-written SQL fragment inside a Drizzle select. Drizzle cannot infer the nullable result of `upper(pets.name)` for you. The docs show the untyped expression as `unknown`. The typed version writes `sql`, and the Linus row returns `upperPetName` as null. If the raw expression depends on the joined table, make the raw expression type nullable unless the SQL expression itself guarantees a value.

notNull is a column rule

05
Gotchas
05 Gotchas: `notNull()` is a column rule
assets/lab/schema.ts
name: text("name").notNull()
partial result for Linus
{
  userName: "Linus",
  petId: null,
  petName: null,
}
Column rule saved pet rows need names
Join rule Linus can miss the pet row
Result rule `petName` can be null
Docs and lab: left join result for Linus

This is the part people get wrong. `notNull()` describes a column on a row that exists. A left join can still produce no joined row. When no pet row exists for Linus, Drizzle has no `pets.name` value to read, so `petName` is null in the partial select. Put another way, the table definition answers one question: can a saved pet row have a missing name? The left join answers a different question: did this user match any pet row?

Raw SQL nullability is manual

05
Gotchas
Raw SQL does not carry nullability for you
assets/lab/type-evidence.ts
unknownUpperName: sql`upper(${pets.name})`,
annotatedUpperName: sql<string | null>`upper(${pets.name})`,

type UnknownRawSqlCheck =
  Expect<Equal<PartialRow["unknownUpperName"], unknown>>;
type AnnotatedRawSqlCheck =
  Expect<Equal<PartialRow["annotatedUpperName"], string | null>>;
Docs and type check: raw `sql` fields

This is the part people get wrong with computed fields. Drizzle tracks table columns through the join. A raw SQL fragment is your own expression, so you write the result type yourself. If the expression reads from a left-joined table, put `null` in that type. The type parameter is documentation for the compiler and for the next person reading the query. It says this computed value disappears when the joined row disappears.

Issue 1603 grouped object

05
Gotchas
Issue #1603: a grouped object can collapse to null
assets/captures/09-gotcha-1603.txt
[
  {
    companyName: "Acme",
    brand: null,
  }
]
brand is null true
logoUrl survived null
GitHub drizzle-team/drizzle-orm #1603 · reproduced in lab

This is the part people get wrong with grouped objects. Drizzle has a helper shape where a nested object can become null as a group, instead of making every field nullable beside each other. GitHub issue sixteen oh three reports an edge case in that shape. The lab still reproduces it on version zero point forty-five. The joined branding row exists, `logoUrl` has a value, and the grouped `brand` object still comes back null. If you use grouped objects around nullable fields, write a small reproduction like this before you trust the shape in application code.

Issue 2045 filter gotcha

05
Gotchas
Issue #2045 is a filter gotcha, not the join rule
filter evidence
where(isNotNull(products.color))

$ bun gotcha-2045.ts
[
  {
    color: "black",
  }
]

FilteredColorCheck:
string | null
Backup source: GitHub #2045, closed

Issue twenty forty-five is a side note, because the issue is about filters. The runtime row after `isNotNull(products.color)` only prints `"black"`, but the selected TypeScript type still includes `null` in the lab. That is useful context for Drizzle nullability, but the left-join rule still comes from the docs and the lab query. Keep the mental model narrow: SQL can remove rows at runtime, while TypeScript sometimes keeps the wider column type.

The reusable leftJoin pattern

The reusable `leftJoin` pattern
assets/lab/index.ts
await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));

await db.select({
  userName: users.name,
  petId: pets.id,
  petName: pets.name,
  upperPetName: sql<string | null>`upper(${pets.name})`,
}).from(users).leftJoin(pets, eq(users.id, pets.ownerId));
Whole table object nullable
Partial field field nullable
Raw SQL annotate `T | null`
Runnable snippet: assets/lab/index.ts

The reusable pattern is small. Select the joined table when you want the right side as one nullable object. Select fields when you want nullable scalar values. For a raw computed field, write the nullable type yourself. In this lab, the same missing pet row produces `pets: null`, `petId: null`, and `upperPetName: null`. That is the whole Drizzle left-join rule you need before you build the larger query.