Skip to content

Coming from Drizzle ​

Drizzle and Egon have much in common: the schema is TypeScript, the types are inferred without a generate step, and queries read like SQL. This page maps everyday Drizzle code to Egon, task by task. The Drizzle calls are shown inline for reference; every Egon block is a checked, runnable example (it runs in CI against Postgres, and the // => comments are the values it produces).

The Drizzle calls are those of drizzle-orm 0.45 (npm's latest in October 2026): tables with relations(), and the relational query API v1 (db.query). Drizzle 1.0, in release candidates, replaces relations() with defineRelations() (Relational Queries v2, with object filters), and moves drizzle-zod to drizzle-orm/zod. The examples use a User with many Posts.

The model in one paragraph ​

A Drizzle table object becomes a schema class, and the rows you read are instances of it. Drizzle's two query styles (the SQL-like builder and the relational db.query API) are one builder here: from(User, "u") starts a query, the clauses take callbacks that receive the tables by alias, and preload loads related rows. Writes go through changesets, which filter and validate input and turn constraint violations into field errors; Drizzle leaves validation to you (or to drizzle-zod). A repo runs everything, like db.

Setting up ​

The examples are one runnable file, in the order of this page: the imports, the schema classes, the repo, the tables, then the tasks. Run it against an empty database of its own: it creates the tables and the rows the tasks read, so a second run on the same database stops at the first insert (ada@example.com is taken). To follow it in a project of your own:

sh
npm install egon-orm @egon-orm/postgres pg
npm install -D typescript tsx @types/node @types/pg

with the tsconfig.json of Getting started, and these imports:

ts
import { postgres } from "@egon-orm/postgres";
import {
  assoc,
  belongsTo,
  cast,
  change,
  count,
  createRepo,
  fragment,
  from,
  hasMany,
  isLoaded,
  Schema,
  t,
  timestamps,
} from "egon-orm";
import { createMigrator, defineMigration } from "egon-orm/migration";

Defining tables ​

Drizzle: pgTable("users", { id: serial("id").primaryKey(), email: text("email").notNull().unique(), … }), and relations(users, ({ many }) => ({ posts: many(posts) })).

ts
export class User extends Schema("users", {
  id: t.integer().primaryKey().dbGenerated(),
  email: t.string(),
  name: t.string().nullable(),
  role: t.enum(["admin", "member"]).default("member"),
  ...timestamps(),
}) {
  static posts = hasMany(() => Post, { foreignKey: "authorId" });

  static changeset(user: User | typeof User, params: unknown) {
    return cast(user, params, ["email", "name"])
      .validateRequired(["email"])
      .validateFormat("email", /@/)
      .uniqueConstraint("email");
  }
}

export class Post extends Schema("posts", {
  id: t.integer().primaryKey().dbGenerated(),
  authorId: t.integer(),
  title: t.string(),
  published: t.boolean().default(false),
  views: t.integer().default(0),
  ...timestamps(),
}) {
  static author = belongsTo(() => User, { foreignKey: "authorId" });

  static changeset(post: Post | typeof Post, params: unknown) {
    return cast(post, params, ["title", "published"]).validateRequired(["title"]);
  }
}
  • Non-null by default. Drizzle columns are nullable until .notNull(); Egon fields are non-null until .nullable().
  • Field names are camelCase and become snake_case columns (authorId is author_id), like Drizzle's casing: "snake_case" option; .column("…") names one explicitly.
  • Associations are static members of the class, not a separate relations() call.
  • .unique(), indexes and foreign keys belong to the database: declare them in a migration.
  • Rows are instances of the class, so the class can carry getters and methods.

Connecting ​

Drizzle: drizzle({ client: pool }) from drizzle-orm/node-postgres.

ts
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });

The tables ​

Drizzle: drizzle-kit generate, from the .unique() and .default() calls on the table objects. In Egon, a migration you write creates them, and unique indexes and defaults are part of it:

ts
// The tables, with what Prisma's @unique and @default say: in Egon they belong to the database.
const tables = defineMigration({
  up(m) {
    m.createTable("users", (table) => {
      table.id({ type: "integer" }); //              serial: an integer, as User.id says
      table.text("email").notNull().unique(); //           @unique (users_email_key)
      table.text("name");
      table.text("role").notNull().default("member"); //   @default("member")
      table.timestamps();
    });
    m.createTable("posts", (table) => {
      table.id({ type: "integer" });
      table.references("author_id", "users", { type: "integer" }).notNull();
      table.text("title").notNull();
      table.boolean("published").notNull().default(false);
      table.integer("views").notNull().default(0);
      table.timestamps();
    });
  },
  down(m) {
    m.dropTable("posts");
    m.dropTable("users");
  },
});

Run it once, before the rest:

ts
const admin = postgres({ connectionString: process.env.DATABASE_URL });
await createMigrator({
  adapter: admin,
  migrations: [{ version: 1, name: "tables", migration: tables }],
}).up();
await admin.close();

Match the key types

Declare each key with the type of its column. table.id({ type: "integer" }) is a 32-bit serial, for t.integer(), as here. A bare table.id() is a 64-bit bigserial, for t.bigint({ mode: "number" }) (or t.bigint()). The types cannot see a mismatch between a schema class and its table; at run time, a t.integer() field over a bigint column throws CastError when a row is read. See Types.

Common tasks ​

Insert a row ​

Drizzle: db.insert(users).values({ email, name }).returning().

ts
const created = await repo.insert(User.changeset(User, { email: "ada@example.com", name: "Ada" }));
if (!created.success) {
  // Invalid input (an email without "@", say): a form would show these messages instead.
  throw new Error(`invalid: ${JSON.stringify(created.error.errorMessages())}`);
}
const ada = created.data; // type: User, with its id, role and timestamps filled in
ada.role; // => "member"

const bo = await repo.insertOrThrow(User.changeset(User, { email: "bo@example.com" })); // throws if invalid

Handle a unique violation ​

Drizzle (0.44 and later): catch the DrizzleQueryError, which keeps the driver's error as its cause, and check error.cause.code === "23505".

ts
const taken = await repo.insert(User.changeset(User, { email: "ada@example.com" }));
taken.success; // => false
if (!taken.success) {
  taken.error.errorMessages(); // => { email: ["has already been taken"] }
}

The changeset declared uniqueConstraint("email"), so the violation comes back as a field error. See Constraints.

Drizzle: two inserts in a transaction, the second using the first's returned id.

ts
const withPosts = await repo.insertOrThrow(
  User.changeset(User, {
    email: "cy@example.com",
    posts: [{ title: "Hello" }, { title: "Again" }],
  }).castAssoc("posts"),
); // the user and both posts, in one transaction
// type: User. The posts are on the returned row, but its type does not say so: isLoaded
// checks, and narrows the type to Loaded<typeof User, "posts">.
if (isLoaded(User, withPosts, "posts")) {
  withPosts.posts.length; // => 2
}

The returned row holds the saved posts, but its type is a plain User: the type of a write's result does not list associations. To use them, narrow it with isLoaded(User, row, "posts"), as the example does, or read the user again with a preload.

See Nested changesets.

Sample data ​

The reads below run against these rows, besides the users created above:

ts
// The rows the next examples read: Ada has a draft and a launch post, Bo has notes.
await repo.insertAll(Post, [
  { authorId: ada.id, title: "Draft", published: false },
  { authorId: ada.id, title: "Launch", published: true, views: 10 },
  { authorId: bo.id, title: "Notes", published: true, views: 3 },
]);

Find by id ​

Drizzle: db.select().from(users).where(eq(users.id, id)), then take the first row.

ts
const user = await repo.get(User, ada.id); // User | null
user?.email; // => "ada@example.com"
const sure = await repo.getOrThrow(User, ada.id); // throws NoResultsError if missing

Find by a unique column ​

Drizzle: db.query.users.findFirst({ where: eq(users.email, email) }).

ts
const byEmail = await repo.getBy(User, { email: "bo@example.com" }); // User | null
byEmail?.id === bo.id; // => true

Filter, order and limit ​

Drizzle: db.select().from(posts).where(eq(posts.published, true)).orderBy(desc(posts.views)).limit(10).

ts
const popular = await repo.all(
  from(Post, "p")
    .where({ published: true })
    .orderBy(({ p }) => p.views.desc())
    .limit(10),
); // type: Post[]
popular.map((p) => p.title); // => ["Launch", "Notes"]

Where Drizzle uses free functions (eq(a, b), desc(c)), Egon uses methods on the column (p.views.desc()); the logical functions and, or and not exist in both forms.

The first match ​

Drizzle: .orderBy(desc(posts.id)).limit(1), then take the first row.

ts
const latest = await repo.first(from(Post, "p").orderBy(({ p }) => p.id.desc())); // Post | null
latest?.title; // => "Notes"
const oldest = await repo.firstOrThrow(from(Post, "p").orderBy(({ p }) => p.id)); // throws NoResultsError if none
oldest.title; // => "Hello"

Select some columns ​

Drizzle: db.select({ id: users.id, email: users.email }).from(users).

ts
const emails = await repo.all(from(User, "u").select(({ u }) => ({ id: u.id, email: u.email })));
// type: { id: number; email: string }[]
emails.length; // => 3

Operators: like, inArray, or, isNull ​

Drizzle: and(like(users.email, "%@example.com"), or(isNull(users.name), inArray(users.id, ids))).

ts
const matches = await repo.all(
  from(User, "u").where(({ u }) =>
    u.email.like("%@example.com").and(u.name.isNull().or(u.id.in([ada.id, bo.id]))),
  ),
);
matches.length; // => 3

Comparing with null (eq(users.name, null)) is a compile error here: use isNull(). See Expressions.

Count ​

Drizzle: db.$count(posts, eq(posts.published, true)).

ts
await repo.count(from(Post, "p").where({ published: true })); // => 2

Paginate ​

Drizzle: .limit(2).offset(2), or .where(gt(posts.id, afterId)) for a cursor.

ts
const page2 = await repo.all(
  from(Post, "p")
    .orderBy(({ p }) => p.id)
    .limit(2)
    .offset(2),
);

// Keyset: continue after the last row seen, so the next page cannot repeat it.
const afterId = page2.at(-1)?.id ?? 0;
const next = await repo.all(
  from(Post, "p")
    .where(({ p }) => p.id.gt(afterId))
    .orderBy(({ p }) => p.id)
    .limit(2),
);
next.length; // => 1
next.some((p) => page2.some((seen) => seen.id === p.id)); // => false

Load relations ​

Drizzle: db.query.users.findMany({ with: { posts: true } }), or with: { posts: { where, orderBy, limit: 1 } }.

ts
const authors = await repo.all(
  from(User, "u")
    .preload("posts")
    .orderBy(({ u }) => u.id),
); // type: Loaded<typeof User, "posts">[]: each user has its posts, as Post[]
authors[0]?.posts.length; // => 2

const recent = await repo.all(
  from(User, "u")
    .orderBy(({ u }) => u.id)
    .preload("posts", (q) =>
      q
        .where(({ posts }) => posts.published)
        .orderBy(({ posts }) => posts.createdAt.desc())
        .limit(1),
    ), // the latest published post of each user, still one query for all users
);
recent.map((u) => u.posts.map((p) => p.title)); // => [["Launch"], ["Notes"], []]

preload reads related rows with one extra query per level, and its result type says which associations are loaded. A preload can also be filled from a join the query already has: preload({ posts: "p" }). See Preloading.

Drizzle: db.select().from(posts).where(eq(posts.authorId, user.id)).

ts
// The posts of one user, as a query: it can be filtered, ordered and counted like any other.
const adaPosts = await repo.all(assoc(User, ada, "posts").orderBy(({ posts }) => posts.title));
adaPosts.map((p) => p.title); // => ["Draft", "Launch"]

assoc(User, ada, "posts") is a query, run by the repo like any other; it never runs by itself when a property is read. See Querying an association.

Filter by a relation ​

Drizzle: db.selectDistinct().from(users).innerJoin(posts, eq(posts.authorId, users.id)).where(eq(posts.published, true)).

ts
const writers = await repo.all(
  from(User, "u")
    .joinAssoc("u", "posts", "p")
    .where(({ p }) => p.published)
    .distinct(),
); // type: User[]: the users with at least one published post
writers.length; // => 2

joinAssoc writes the ON condition from the association; join(Post, "p", on) takes one written by hand, as Drizzle's innerJoin does.

Group and aggregate ​

Drizzle: db.select({ authorId: posts.authorId, posts: count() }).from(posts).groupBy(posts.authorId).

ts
const perAuthor = await repo.all(
  from(Post, "p")
    .groupBy(({ p }) => p.authorId)
    .orderBy(({ p }) => p.authorId)
    .select(({ p }) => ({ authorId: p.authorId, posts: count(p.id) })),
); // type: { authorId: number; posts: number }[]
perAuthor.length; // => 3

Update a row ​

Drizzle: db.update(users).set({ name }).where(eq(users.id, id)).returning().

ts
const renamed = await repo.updateOrThrow(User.changeset(sure, { name: "Ada Lovelace" }));
renamed.name; // => "Ada Lovelace"

Update many, and increment ​

Drizzle: db.update(posts).set({ views: sql`${posts.views} + 1` }).where(eq(posts.published, true)).

ts
const bumped = await repo.updateAll(
  from(Post, "p")
    .where({ published: true })
    .set(({ p }) => ({ views: p.views.add(1) })),
);
bumped.count; // => 2

Upsert ​

Drizzle: db.insert(users).values(row).onConflictDoUpdate({ target: users.email, set: { name } }).

ts
const upserted = await repo.insertOrThrow(change(User, { email: "ada@example.com", name: "Ada L." }), {
  onConflict: { target: ["email"], update: ["name"] },
});
upserted.id === ada.id; // => true

Transactions ​

Drizzle: db.transaction(async (tx) => { … }).

ts
const moved = await repo.transaction(async (tx) => {
  const author = await tx.getByOrThrow(User, { email: "bo@example.com" });
  await tx.updateAll(from(Post, "p").where({ authorId: ada.id }).set({ authorId: author.id }));
  return tx.count(from(Post, "p").where({ authorId: author.id }));
});
moved.success; // => true

As in Drizzle, only calls on tx are inside. Here the callback's return value comes back as a result, and tx.rollback(reason) ends it with { success: false, error: reason }. See Transactions.

Raw SQL ​

Drizzle: db.execute(sql`select … where views > ${min}`).

ts
const { rows } = await repo.raw(fragment`select count(*)::int as n from posts where views > ${5}`);
rows[0]; // => { n: 1 }

fragment is the counterpart of Drizzle's sql template: interpolations are bound parameters, and fragment.ident(name) quotes an identifier. There is no sql.raw: a string never becomes SQL text.

Delete ​

Drizzle: db.delete(posts).where(eq(posts.id, id)), or with any condition.

ts
const draft = await repo.getByOrThrow(Post, { title: "Draft" });
await repo.deleteOrThrow(draft);
ts
const removed = await repo.deleteAll(from(Post, "p").where(({ p }) => p.views.lt(5)));
removed.count; // => 3

Not supported, and what to do instead ​

DrizzleIn Egon
drizzle-kit generate / push (migrations from schema changes)Not supported. Migrations are written by hand, as in The tables above. egon migrate create <name> writes the empty file (CLI). See Migrations.
drizzle-zod (drizzle-orm/zod in 1.0)@egon-orm/zod derives Zod schemas from schema classes.
sql.raw(…)Not supported on purpose. Build dynamic identifiers with fragment.ident().
Lateral joinsPlanned; see the Roadmap.
Views, sequences, enums as schema objectsCreate them in a migration; map a Postgres enum with t.enum([...]) on the field.
Serverless HTTP drivers (Neon HTTP, PlanetScale, D1, …)Not supported: Postgres through pg, SQLite through better-sqlite3, MySQL through mysql2.

What works differently ​

Good to know

  • Writes go through changesets. insert and update take a changeset, not a plain object, so input is filtered and validated first. For trusted bulk data, insertAll takes plain rows.
  • Writes return a result. Invalid input and declared constraint violations come back as { success: false, error }; the OrThrow twins throw.
  • Rows are class instances. repo.all(User) returns User objects with the class's getters and methods, not plain objects; a select returns plain objects.
  • Fields are non-null by default, the opposite of Drizzle's columns.
  • One query builder. The callbacks receive every table of the query by alias: .where(({ u, p }) => …).

See also ​