# Drizzle ORM: Your Database Schema, Written in TypeScript

A small ceramics shop renamed one column. The orders table had a column called customer, and someone renamed it to customer_id. The shop's code built its queries by joining strings together, so nothing complained. The first sign of trouble came from a customer, whose order page showed a blank where her name should have been. The query had failed quietly, and nobody noticed until she did. That is the problem an ORM is supposed to solve: the database and the code should agree about the shape of the data, and a rename should break loudly at build time instead of quietly in production. Drizzle ORM takes an angle on that promise, and this post tests whether it holds up.

## What I would check before using it

My judgement, from years of building small web apps, is that Drizzle is the right call when you like SQL and want the compiler to watch your back, and the wrong call when you want the database to disappear entirely. Drizzle does not hide SQL the way some ORMs do. You write queries that look a lot like SQL, with select, eq, and innerJoin, and the result is typed. That suits a team that already reads SQL, and frustrates one that wants to think in objects and never see a JOIN.

I would check four things before committing. First, the database and the driver. Drizzle supports PostgreSQL, MySQL, and SQLite, but each one needs its own driver package, and the driver decides what works. In this post I used better-sqlite3, because the node-sqlite driver is not exported by drizzle-orm 0.45.4. Check the driver for your database before you write any schema code. Second, migrations. Drizzle Kit generates SQL migration files from your schema; read them before applying. Third, relations. The relational query API, db.query, only works when you define relations between your tables. Without them, the call fails, as the mistakes section shows. Fourth, the ecosystem. Drizzle is younger and smaller than Prisma, so there are fewer tutorials and fewer answers to odd questions.

The verdict: choose Drizzle when your project is TypeScript, your team is comfortable with SQL, and you want type safety without giving up control of your queries. That recommendation flips if the team wants the database abstracted away, if you need the biggest possible ecosystem, or if your database driver is not well supported. Then a heavier ORM, or plain SQL with a thin query builder, is the better bet.

## A small shop's orders, end to end

The worked example is the ceramics shop from the opening: customers, products, orders, and the items on each order. The schema is written in TypeScript, so the tables, columns, and foreign keys sit in one file, and the compiler knows about every column. Here is the schema:

```js
// schema.js
const { sqliteTable, integer, text, real } = require('drizzle-orm/sqlite-core');

const customers = sqliteTable('customers', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
});

const products = sqliteTable('products', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  price: real('price').notNull(),
  stock: integer('stock').notNull(),
});

const orders = sqliteTable('orders', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  customerId: integer('customer_id')
    .notNull()
    .references(() => customers.id),
  status: text('status').notNull(),
});

const orderItems = sqliteTable('order_items', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  orderId: integer('order_id')
    .notNull()
    .references(() => orders.id),
  productId: integer('product_id')
    .notNull()
    .references(() => products.id),
  quantity: integer('quantity').notNull(),
});

module.exports = { customers, products, orders, orderItems };
```

The references calls are the foreign keys: an order belongs to a customer, and each order item points at one order and one product, so the database refuses rows that point nowhere. The demo script creates the tables with plain SQL, inserts real rows, and totals each order with a join across all four tables:

```js
// demo.js (inserts and the totals query; table creation uses plain SQL)
db.insert(customers).values([
  { name: 'Ayesha', email: 'ayesha@example.com' },
  { name: 'Bilal', email: 'bilal@example.com' },
]).run();

db.insert(products).values([
  { name: 'Ceramic mug', price: 18.0, stock: 40 },
  { name: 'Linen tote', price: 24.5, stock: 25 },
  { name: 'Desk lamp', price: 42.0, stock: 12 },
]).run();

db.insert(orders).values([
  { customerId: 1, status: 'ready to ship' },
  { customerId: 2, status: 'awaiting payment' },
]).run();

db.insert(orderItems).values([
  { orderId: 1, productId: 1, quantity: 2 },
  { orderId: 1, productId: 3, quantity: 1 },
  { orderId: 2, productId: 2, quantity: 3 },
]).run();

const totals = db
  .select({
    orderId: orders.id,
    customer: customers.name,
    status: orders.status,
    total: sql`round(sum(${products.price} * ${orderItems.quantity}), 2)`,
  })
  .from(orders)
  .innerJoin(customers, eq(orders.customerId, customers.id))
  .innerJoin(orderItems, eq(orderItems.orderId, orders.id))
  .innerJoin(products, eq(orderItems.productId, products.id))
  .groupBy(orders.id, customers.name, orders.status)
  .orderBy(orders.id)
  .all();

for (const row of totals) {
  console.log(
    'Order ' + row.orderId + ' (' + row.customer + ', ' + row.status + '): $' + row.total
  );
}
```

Running it against a fresh SQLite file gave this real output:

```
Order totals:
Order 1 (Ayesha, ready to ship): $78
Order 2 (Bilal, awaiting payment): $73.5
```

Both totals check out by hand: order 1 is two mugs at $18 plus one lamp at $42, which is $78, and order 2 is three totes at $24.50, which is $73.50. The query reads almost like the SQL it produces, and the rows are typed from the schema, so row.total is a number and row.customer a string, with no extra declarations.

Now the part the tidy tutorials skip: something goes wrong. A line of code adds an item to order 999, an order that does not exist, because the id came from a form field that was typed wrong:

```js
db.insert(orderItems)
  .values({ orderId: 999, productId: 1, quantity: 1 })
  .run();
```

The real error from running this is short and to the point:

```
Insert failed: FOREIGN KEY constraint failed
```

The database refused the row because order 999 does not exist, and the foreign key on order_id caught it. That is the schema doing its job: the bad data never reaches the table. The fix is to use an order id that exists, which here means order 1:

```js
db.insert(orderItems)
  .values({ orderId: 1, productId: 2, quantity: 1 })
  .run();
```

That insert worked at once, adding one linen tote to Ayesha's order. The habit to take away is to let the database enforce the relationships and to treat a constraint error as useful news. Without the foreign key, the orphan row would have sat there quietly until a report or a customer found it.

## Common mistakes and how to fix them

**1. Forgetting .all() or .run().** A select without .all() returns a query builder, not rows, and nothing complains: logging it shows an object, and .length is undefined. End every select with .all() or .get(), and every insert, update, or delete with .run().

**2. Inserting a duplicate value in a unique column.** A second customer with the email ayesha@example.com fails with `UNIQUE constraint failed: customers.email`. Check for the existing customer first, or catch the error and say the email is already registered.

**3. Selecting a column that is not in the schema.** Writing `orders.total` when the orders table has no total column gives `Cannot convert undefined or null to object` in plain JavaScript, while TypeScript catches it before the code runs. The fix is to compute the total in the query, as the worked example does.

**4. Using db.query without defining relations.** Calling `db.query.customers.findMany()` with no relations defined fails with `Cannot read properties of undefined (reading 'findMany')`. The fix is to define relations in the schema, or to use the select query builder above, which needs none.

## Where this leaves you

Drizzle earns its place when you want the compiler to know your database and want to see the SQL you are running. The schema is a file you can read, the queries look like the SQL they become, and the database catches bad rows. Its costs are real: you pick a driver per database, define relations before the query API works, and the ecosystem is smaller than the older ORMs. The next step: take one table from your own project, write its schema in Drizzle, and run one insert and one join against a local database. When a renamed column breaks the build instead of a customer's page, you have your answer.

## Further reading

- [Drizzle ORM, Overview](https://orm.drizzle.team/docs/overview)
- [Drizzle ORM, Get Started with SQLite](https://orm.drizzle.team/docs/get-started/sqlite-new)
- [Drizzle ORM, Relations](https://orm.drizzle.team/docs/relations)
- [SQLite, Foreign Key Support](https://www.sqlite.org/foreignkeys.html)

Originally published at [https://practicaldevblog.blogspot.com/2026/11/drizzle-orm-your-database-schema.html](https://practicaldevblog.blogspot.com/2026/11/drizzle-orm-your-database-schema.html)
