Drizzle leftJoin returns nullable pet rows

1 month 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 about 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
  1. 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.

    Slide text
    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
  2. 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.

    Slide text
    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
  3. 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.

    Slide text
    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
  4. 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.

    Slide text
    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
  5. 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`.

    Slide text
    03
    Shape
    03 The shape: the docs make the joined object nullable
    Drizzle docs: Joins, Left Join
  6. 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.

    Slide text
    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
  7. 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.

    Slide text
    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
  8. 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.

    Slide text
    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
  9. 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.

    Slide text
    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
  10. 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?

    Slide text
    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
  11. 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.

    Slide text
    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
  12. 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.

    Slide text
    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
  13. 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.

    Slide text
    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
  14. 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.

    Slide text
    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

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built with ❤️ in New York