Drizzle ORM

Drizzle Overview

Database settings and TypeFormats metadata expressed in TypeScript types.

Mion's Drizzle integration follows RunTypes: TypeScript types express database settings and TypeFormats metadata. Those types describe your schema and generate validation, mock data and JSON conversion for server and client.

One package per database gives you the Drizzle ORM table functions for that database, with the same names, config, indexes and constraints. A column takes all its settings in one object instead of a chain of calls.

DatabasePackage
PostgreSQL@mionjs/drizzle-orm-pg-core
MySQL@mionjs/drizzle-orm-mysql-core
SQLite@mionjs/drizzle-orm-sqlite-core
Cloudflare D1, Durable Objects@mionjs/drizzle-orm-sqlite-core

Cloudflare's two databases are SQLite and only change the driver: see Cloudflare D1 and Durable Objects.

Package versions follow drizzle-orm, not mion: version 0.45.x works with drizzle-orm 0.45.x and declares that range as a peer dependency. Pick the version that matches your drizzle-orm. A new version ships only when the package changes, so one version can stay current across several mion releases.

Install the database package with its shared core. drizzle-orm is an optional peer: your schema, models and validators work without it.

npm install @mionjs/drizzle-orm-pg-core @mionjs/drizzle-orm @mionjs/run-types
npm install drizzle-orm   # only needed to run queries or migrations

These builders record calls instead of running Drizzle. Their models keep column TypeFormats parameters, including string lengths and numeric bounds.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';

// a recorded table, not drizzle's PgTable: toDrizzle() builds that on demand
export const users = DZ.pgTable('users', {
  id: DZ.uuid('id', {primaryKey: true}),
  name: DZ.varchar('name', {length: 100, notNull: true}),
  age: DZ.integer('age', {notNull: true}),
  role: DZ.text('role', {enum: ['admin', 'user'], notNull: true}),
  createdAt: DZ.timestamp('created_at', {defaultNow: true, notNull: true}),
});

// { id: UUID; name: String<{maxLength: 100}>; age: Int32; role: 'admin' | 'user'; createdAt: Date }
export type User = InferSelectModel<typeof users>;

// checks every captured param, with no extra runtime checks
export const validateUser = createValidateFn<User>();

export const checks = [
  validateUser({
    id: 'not-a-uuid',
    name: 'ann',
    age: 30,
    role: 'admin',
    createdAt: new Date(),
  }), // false
  validateUser({
    id: '793aff46-42ac-4372-b7fa-c48ba48ed94f',
    name: 'x'.repeat(101), // false: maxLength 100 is IN the compiled function
    age: 30,
    role: 'admin',
    createdAt: new Date(),
  }),
];

To type mion routes with these models, see Drizzle Tables.

Column Settings

A column builder takes the database column name, then one object with drizzle's config and its modifiers. A modifier with no arguments is true, one with arguments is its argument list.

// drizzle
varchar('name', {length: 100}).notNull().default('anon');
// mion
varchar('name', {length: 100, notNull: true, default: ['anon']});
Leave out the database name when it matches the key: name: varchar({length: 100}).

Writing a Table as a Type

Each column builder has a column type of the same name, first letter uppercased, that takes the same settings object: Varchar<{length: 100; notNull: true}>. A builder table's typeof users equals the same table written by hand, so you can mix both forms per table. mion convert translates a table between the two forms in either direction.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';

// nothing runs where the table is declared
export type UsersTable = DZ.PgTable<
  'users',
  {
    id: DZ.Uuid<{primaryKey: true}>;
    name: DZ.Varchar<{length: 100; notNull: true}>;
    age: DZ.Integer<{notNull: true}>;
    role: DZ.Text<{enum: ['admin', 'user']; notNull: true}>;
    createdAt: DZ.Timestamp<{defaultNow: true; notNull: true}>;
  },
  [],
  {createdAt: 'created_at'}
>; // database names that differ from the key

// the same object pgTable returns; the build resolves the type, nothing is repeated
export const users = DZ.tableFromType<UsersTable>();

// identical to the builders model, formats included
export type User = InferSelectModel<UsersTable>;

// checks every captured param, with no extra runtime checks
export const validateUser = createValidateFn<User>();

export const checks = [
  validateUser({
    id: 'not-a-uuid',
    name: 'ann',
    age: 30,
    role: 'admin',
    createdAt: new Date(),
  }), // false
  validateUser({
    id: '793aff46-42ac-4372-b7fa-c48ba48ed94f',
    name: 'x'.repeat(101), // false: maxLength 100 is IN the compiled function
    age: 30,
    role: 'admin',
    createdAt: new Date(),
  }),
];

Building the Drizzle Table

pgTable records a table; toDrizzle builds its Drizzle table once and returns it on later calls. Call it in your database file. toDrizzle<UsersTable>() builds the same table from its type alone, with no other schema imports in your query file.

import {gte} from 'drizzle-orm';
import {drizzle} from 'drizzle-orm/pg-proxy';
import {toDrizzle} from '@mionjs/drizzle-orm-pg-core/drizzle';
import {users, type User} from './drizzle-proxy-pg-example.ts';

// a genuine drizzle table, also what a drizzle-kit schema file exports
export const usersDb = toDrizzle(users);

// any drizzle driver works; swap in node-postgres, pglite or neon in a real app
const db = drizzle(async () => ({rows: []}));

export async function listAdults(): Promise<User[]> {
  // typed rows, formats intact: the same User the routes use
  return db.select().from(usersDb).where(gte(usersDb.age, 18));
}

A drizzle-kit schema file exports the same call (and the materialized enums, for pgEnum). Operators, relations, getTableConfig and views built from a query builder all work on the result as usual.

Organising Schema and Database Files

Keep slim tables and models in a schema file, and toDrizzle, relations, the database connection and queries in a database file that imports it. The linter fails the build when a file defining slim tables or models imports Drizzle or toDrizzle (drizzle-mixed-types). It warns when a route takes or returns a Drizzle type (drizzle-type-not-allowed).

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {tableRef, type InferSelectModel} from '@mionjs/drizzle-orm';

export const authors = DZ.pgTable('authors', {
  id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
  name: DZ.varchar('name', {length: 100, notNull: true}),
});

export const posts = DZ.pgTable('posts', {
  id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
  authorId: DZ.uuid('author_id', {
    notNull: true,
    references: [() => tableRef(authors, 'id'), {onDelete: 'cascade'}],
  }),
  title: DZ.varchar('title', {length: 200, notNull: true}),
});

// route and client models
export type Author = InferSelectModel<typeof authors>;
export type Post = InferSelectModel<typeof posts>;

// start-nested-model
export interface AuthorWithPosts extends Author {
  posts: Post[];
}
// end-nested-model

A client importing a model then type-checks only the schema file. With both in one file, a full type check also checks every conversion, relation and query, and a value import bundles drizzle-orm. Your editor costs the same either way.

Own model file: 20× less type work
Grey: models next to toDrizzle. Olive: models in their own file.
drizzle-type-cost
20×less type work in a full check
11×smaller bundle on a value import
Full check, 60 tables
197,349 → 9,820 steps-95%
tsgo full check, 60 tables
0.55 → 0.23 s-58%
tsc full check, 60 tables
2.23 → 1.12 s-50%
Files the client loads
559 → 257 files-54%
Bundle, value import
121 → 11 KB-91%
Editor, client file only
641 → 641 steps0%
beforeafter
A client imports one slim model. tsc 6.0.3 and tsgo.
A query or router file may import both slim models and Drizzle if it defines no slim tables or models.

Recorded Tables From a Type

Where you need the recorded table without drizzle (models, refinement, mion routes), use tableFromType<UsersTable>() from the package root. Its options take the two runtime inputs a type cannot hold: the tables a column references ({tables: {parents: parents}}, or {tables: {parents: () => parents}} when that table is declared further down the file) and the runtime callbacks described below.

Refining Tables for Your API

refineTableType adds API rules stricter than the database column: a name that holds 100 characters can require at least 10. It returns the same table object with the extra rules added, and the SQL does not change. Build validators, mock data and JSON round trip functions from the refined models, with or without mion.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {refineTableType} from '@mionjs/drizzle-orm';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
import {
  createJsonDecoderFn,
  createJsonEncoderFn,
  createValidateFn,
} from '@mionjs/run-types';
import {createMockDataFn} from '@mionjs/run-types/mocking';

export const users = DZ.pgTable('users', {
  id: DZ.uuid('id', {primaryKey: true, defaultRandom: true}),
  name: DZ.varchar('name', {length: 100, notNull: true}), // captured as String<{maxLength: 100}>
  age: DZ.integer('age', {notNull: true}),
  createdAt: DZ.timestamp('created_at', {
    mode: 'date',
    notNull: true,
    defaultNow: true,
  }),
});

// stricter types for the API
export const apiUsers = refineTableType(users, {
  name: {minLength: 10},
  age: {min: 18},
});

export type User = InferSelectModel<typeof apiUsers>; // name: String<{maxLength: 100, minLength: 10}>
export type NewUser = InferInsertModel<typeof apiUsers>; // id and createdAt optional (DB defaults)
export type UserPatch = Partial<InferInsertModel<typeof apiUsers>>;

export const validateUser = createValidateFn<User>();
export const mockUser = createMockDataFn<User>(); // realistic rows that pass validateUser
export const encodeUser = createJsonEncoderFn<User>();
export const decodeUser = createJsonDecoderFn<User>();

// checks the captured AND the refined bounds
export const checks = [
  validateUser(mockUser()), // true: mock data respects every bound
  validateUser({...mockUser(), name: 'short'}), // false: refined minLength 10
  validateUser({...mockUser(), age: 17}), // false: refined min 18
];

const wire = encodeUser(mockUser())!; // a JSON string, the Date made wire-safe
export const restored = decodeUser(wire); // restored.createdAt instanceof Date

A refinement that does not fit the column is a compile error, never a silent override: a number rule on a string column, an unknown parameter, or a change to whether the column accepts null (that stays with notNull on the table). Columns without a format (booleans, JSON, enums) cannot be refined.

Refined Table Types

RefinedTable does the same refinement as a type, with no table value: it takes the table type and the per-column rules, so a types-only file can export the API view of a table.

import type {RefinedTable} from '@mionjs/drizzle-orm';
import type {UsersTable} from './schema.ts';

type ApiUsersTable = RefinedTable<UsersTable, {name: {minLength: 10}; age: {min: 18}}>;

Insert, Select and Update Payloads

InferSelectModel and InferInsertModel keep drizzle's names, so a schema file ports by changing only the import. They come from @mionjs/drizzle-orm, with refineTableType and the sql template, and work on any table, refined or not: inserts drop generated columns and make defaulted ones optional. As in drizzle, an update is Partial<InferInsertModel<typeof users>>. From a table type, the file is types only.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';

export const users = DZ.pgTable('users', {
  id: DZ.uuid('id', {primaryKey: true, defaultRandom: true}),
  email: DZ.varchar('email', {length: 254, notNull: true}),
  name: DZ.varchar('name', {length: 100, notNull: true}),
  bio: DZ.varchar('bio', {length: 500}),
  createdAt: DZ.timestamp('created_at', {defaultNow: true, notNull: true}),
});

// every key present, bio is value | null
export type User = InferSelectModel<typeof users>;

// id and createdAt have defaults, so inserts may omit them
export type NewUser = InferInsertModel<typeof users>;

export type UserPatch = Partial<InferInsertModel<typeof users>>;

// a NewUser route input checks the varchar lengths and goes into db.insert(...).values() uncast
A builder table or view also has drizzle's typeof users.$inferSelect, and a table typeof users.$inferInsert. They match InferSelectModel and InferInsertModel. On a table type, write them as UsersTable['$inferSelect'].

Runtime Column Callbacks

A builder takes Drizzle's $default, $defaultFn, $onUpdate and $onUpdateFn callbacks as a one-item list: $defaultFn: [() => crypto.randomUUID()]. A type cannot hold a function, so a table type sets the same prop to true and the callback goes in the runtime option of tableFromType or toDrizzle. A column with any of these props counts as having a default, so it is optional on insert, even in files that only import the type.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferInsertModel} from '@mionjs/drizzle-orm';

export type JobsTable = DZ.PgTable<
  'jobs',
  {
    id: DZ.Uuid<{primaryKey: true}>;
    slug: DZ.Varchar<{length: 80; notNull: true; $defaultFn: true}>;
    updatedAt: DZ.Timestamp<{mode: 'string'; $onUpdate: true}>;
  },
  [],
  {updatedAt: 'updated_at'}
>;

// a missing or extra callback throws at startup, naming the column
export const jobs = DZ.tableFromType<JobsTable>({
  runtime: {
    slug: {$defaultFn: () => crypto.randomUUID().slice(0, 8)},
    updatedAt: {$onUpdate: () => new Date().toISOString()},
  },
});

// slug is notNull, but the $defaultFn flag makes it optional on insert
export type NewJob = InferInsertModel<JobsTable>;
export const minimalInsert: NewJob = {id: crypto.randomUUID()};

mion convert moves the callbacks between the two forms in both directions, text intact.

Custom JSON Types

Columns with no matching format (geometry, vectors, MAC addresses, binary data, plain booleans and JSON) keep their plain data type. For a JSON column, declare your own type with the $type prop.

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {$type} from '@mionjs/drizzle-orm';

const events = DZ.pgTable('events', {
  id: DZ.uuid({primaryKey: true}),
  payload: DZ.jsonb({$type: $type<{kind: string; at: number}>(), notNull: true}),
});
Every column builder's format, per database and column mode, is listed in Column Formats.
Copyright © 2026