Coming from Prisma ​
This page maps the things you do every day with Prisma Client to Egon, task by task. The Prisma 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 Prisma calls are those of Prisma ORM 7, the current major release in October 2026; where Prisma 6 differs, the page says so. The examples use a User with many Posts.
The model in one paragraph ​
Prisma generates a client from schema.prisma; Egon has no schema file and no generate step. A table is a TypeScript schema class, and the types of rows, preloads and selects are inferred from it. Reads go through queries (from(User, "u")…), which are values you can keep and extend. Writes go through changesets, which filter and validate input and turn constraint violations into field errors. A repo runs both, like the Prisma client does.
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:
npm install egon-orm @egon-orm/postgres pg
npm install -D typescript tsx @types/node @types/pgwith the tsconfig.json of Getting started, and these imports:
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 models ​
Prisma: a model User { … posts Post[] } block in schema.prisma, then prisma generate.
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"]);
}
}- Fields are non-null unless
.nullable(), as in Prisma (whereString?opts in). @relationis replaced bystaticassociations. The foreign key (authorId) is a field you declare, as in Prisma.@uniqueand@defaultbelong to the database: declare them in a migration..default()on a field is the value a new record starts with in your code.- The changeset functions (
User.changeset) have no Prisma counterpart: they replace validating input by hand beforecreate.
Connecting ​
Prisma 7: new PrismaClient({ adapter: new PrismaPg({ connectionString }) }), with the @prisma/adapter-pg driver adapter (Prisma 6: new PrismaClient()).
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });The tables ​
Prisma: prisma migrate dev, from the @unique and @default attributes in schema.prisma. In Egon, a migration you write creates them, and unique indexes and defaults are part of it:
// 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:
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 ​
Create a record ​
Prisma: prisma.user.create({ data: { email, name } }).
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 invalidHandle a unique violation ​
Prisma: catch Prisma.PrismaClientKnownRequestError and check e.code === "P2002".
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 instead of an exception. See Constraints.
Create with related records ​
Prisma: prisma.user.create({ data: { email, posts: { create: [{ title }] } } }).
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.
Each post is validated by Post.changeset. See Nested changesets.
Sample data ​
The reads below run against these rows, besides the users created above:
// 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 ​
Prisma: prisma.user.findUnique({ where: { id } }) and findUniqueOrThrow.
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 missingFind by a unique field ​
Prisma: prisma.user.findUnique({ where: { email } }).
const byEmail = await repo.getBy(User, { email: "bo@example.com" }); // User | null
byEmail?.id === bo.id; // => truegetBy is strict: it throws MultipleResultsError if more than one row matches.
Filter, order and limit ​
Prisma: prisma.post.findMany({ where: { published: true }, orderBy: { views: "desc" }, take: 10 }).
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"]The first match ​
Prisma: prisma.post.findFirst({ orderBy: { id: "desc" } }) and findFirstOrThrow.
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 fields ​
Prisma: prisma.user.findMany({ select: { id: true, email: true } }).
const emails = await repo.all(from(User, "u").select(({ u }) => ({ id: u.id, email: u.email })));
// type: { id: number; email: string }[]
emails.length; // => 3The result type is the shape of the select.
Operators: endsWith, in, OR, null ​
Prisma: where: { email: { endsWith: "@example.com" }, OR: [{ name: null }, { id: { in: ids } }] }.
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; // => 3Operators are methods on columns. Comparing with null is a compile error: use isNull(). The object form, .where({ name: null }), does mean IS NULL, as in Prisma. See Expressions.
Count ​
Prisma: prisma.post.count({ where: { published: true } }).
await repo.count(from(Post, "p").where({ published: true })); // => 2Paginate ​
Prisma: skip and take for offsets; cursor: { id }, skip: 1, take for cursors.
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)); // => falseFor a cursor over several columns, see Pagination.
Include relations ​
Prisma: include: { posts: true }, or include: { posts: { where, orderBy, take: 1 } }.
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"], []]Related rows are read with one extra query per level of preload, never one per parent, and limit in a preload callback applies to each parent, like take inside an include. See Preloading.
The related rows of one record ​
Prisma: the fluent API, prisma.user.findUnique({ where: { id } }).posts().
// 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 ​
Prisma: prisma.user.findMany({ where: { posts: { some: { published: true } } } }).
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; // => 2joinAssoc writes the join from the association. An exists subquery is the other way to say "some"; see Subqueries.
Group and aggregate ​
Prisma: prisma.post.groupBy({ by: ["authorId"], _count: { id: true } }).
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; // => 3Update a record ​
Prisma: prisma.user.update({ where: { id }, data: { name } }).
const renamed = await repo.updateOrThrow(User.changeset(sure, { name: "Ada Lovelace" }));
renamed.name; // => "Ada Lovelace"An update starts from the row you loaded and writes only the fields that changed.
Update many, and increment ​
Prisma: prisma.post.updateMany({ where: { published: true }, data: { views: { increment: 1 } } }).
const bumped = await repo.updateAll(
from(Post, "p")
.where({ published: true })
.set(({ p }) => ({ views: p.views.add(1) })),
);
bumped.count; // => 2Upsert ​
Prisma: prisma.user.upsert({ where: { email }, update: { name }, create: { email, name } }).
const upserted = await repo.insertOrThrow(change(User, { email: "ada@example.com", name: "Ada L." }), {
onConflict: { target: ["email"], update: ["name"] },
});
upserted.id === ada.id; // => trueThis is a single INSERT … ON CONFLICT statement. See Upserts.
Transactions ​
Prisma: prisma.$transaction(async (tx) => { … }).
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; // => trueAs in Prisma's interactive transactions, only calls on tx are inside. A callback that throws rolls back; one that returns commits, and the result says which happened. See Transactions.
Raw SQL ​
Prisma: prisma.$queryRaw`select … where views > ${min}` .
const { rows } = await repo.raw(fragment`select count(*)::int as n from posts where views > ${5}`);
rows[0]; // => { n: 1 }As with $queryRaw, interpolations are bound parameters. There is no $queryRawUnsafe: a string never becomes SQL text.
Delete ​
Prisma: prisma.post.delete({ where: { id } }) and deleteMany({ where }).
const draft = await repo.getByOrThrow(Post, { title: "Draft" });
await repo.deleteOrThrow(draft);const removed = await repo.deleteAll(from(Post, "p").where(({ p }) => p.views.lt(5)));
removed.count; // => 3Not supported, and what to do instead ​
| Prisma | In Egon |
|---|---|
prisma generate and schema.prisma | Not needed: schema classes are TypeScript. |
prisma migrate dev (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. |
Fluent API (prisma.user.findUnique(…).posts()) | Not supported. Use assoc(User, user, "posts") for a query of the related rows, or preload. |
Nested select of relations (select: { posts: { select: { title: true } } }) | A preload callback with a select: preload("posts", (q) => q.select(…)). |
createMany | repo.insertAll(Post, rows): no changesets, no validation, but defaults and timestamps. |
Client extensions ($extends) | No equivalent. Changeset functions, prepareChanges and query functions cover the common uses. |
| Prisma Accelerate (pooling and caching as a service) | No equivalent. |
| SQL Server, CockroachDB, and MongoDB (Prisma 6; Prisma 7 does not support it yet) | Not supported: Postgres, SQLite and MySQL only. |
What works differently ​
Good to know
- Writes return a result.
repo.insertreturns{ success: false, error }for invalid input and declared constraints instead of throwing;insertOrThrowthrowsInvalidChangesetError. - Input is filtered by a changeset. Only the fields a changeset function permits are taken from
params, so a request cannot setroleby adding it to the body. - Updates need the row. Load it, then
updatea changeset of it; to update by a condition without loading, useupdateAllwith.set(…). - Relations are associations, loaded with
preload. A row's type says which ones were loaded, so a missing one is a compile error. - Queries are values.
from(Post, "p").where(…)can be stored, passed to a function and extended before it runs. - Explicit transactions. As with interactive transactions, pass
txto the code that must run inside.
See also ​
- Introduction: the four concepts and why they are separate.
- Tutorial: a blog, step by step.
- Recipes: pagination, soft deletes, multi-tenancy and more.

