Skip to content

Coming from Sequelize ​

Sequelize models are active records: an instance knows how to save, reload and destroy itself, and validation lives on the model. Egon splits those jobs apart. This page maps everyday Sequelize code to Egon, task by task. The Sequelize 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 Sequelize calls are those of Sequelize 6 (npm's latest, 6.37, in October 2026). The examples use a User with many Posts.

The model in one paragraph ​

A Model becomes a schema class: it describes the table, and its instances are rows, but a row has no save() or destroy(). To change data you build a changeset: the input a form sent, filtered to the fields it may change, converted and validated. The repo writes the changeset and returns a result, with validation and constraint errors on their fields. Reads are queries, immutable values built with from(User, "u").

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 models ​

Sequelize: class User extends Model {} with User.init({ email: { type: DataTypes.STRING, allowNull: false, unique: true, validate: { isEmail: true } } }, { sequelize }), then User.hasMany(Post, { foreignKey: "authorId" }).

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. Sequelize attributes allow null unless allowNull: false; Egon fields are non-null unless .nullable().
  • Associations are static members, not calls made after both models exist. The foreign key (authorId) is a field you declare: belongsTo does not add it.
  • Validation is per form, not per model. validate: { isEmail: true } becomes a call in a changeset function. A sign-up form and an admin form are two functions that permit and check different fields.
  • unique: true and indexes belong to the database: declare them in a migration, and declare uniqueConstraint("email") on the changeset to get a field error back.
  • timestamps: true is ...timestamps(). What Sequelize calls underscored: true is the default here: createdAt is stored in created_at.

Connecting ​

Sequelize: new Sequelize(process.env.DATABASE_URL).

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

The tables ​

Sequelize: a migration written with queryInterface.createTable, or sequelize.sync(). 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 ​

Create a record ​

Sequelize: User.create({ email, name }), which throws ValidationError for invalid input.

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 ​

Sequelize: catch UniqueConstraintError.

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"] }
}

See Constraints.

Create with associated records ​

Sequelize: User.create({ email, posts: [{ title }] }, { include: [User.associations.posts] }).

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 primary key ​

Sequelize: User.findByPk(id), or with { rejectOnEmpty: true } to throw.

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 one by a column ​

Sequelize: User.findOne({ where: { email } }).

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

getBy is strict: two matches throw MultipleResultsError. repo.first(query) is the "any one" form.

Filter, order and limit ​

Sequelize: Post.findAll({ where: { published: true }, order: [["views", "DESC"]], 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"]

The first match ​

Sequelize: Post.findOne({ order: [["id", "DESC"]] }).

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 attributes ​

Sequelize: User.findAll({ attributes: ["id", "email"] }).

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

The result is plain objects typed by the select, not partly filled model instances.

Operators: Op.like, Op.or, Op.in, null ​

Sequelize: where: { email: { [Op.like]: "%@example.com" }, [Op.or]: [{ name: null }, { 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

The object form of where takes values, null (IS NULL) and arrays (IN), like Sequelize's shorthand; everything else is a method on the column. See Expressions.

Count ​

Sequelize: Post.count({ where: { published: true } }).

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

Paginate ​

Sequelize: limit and offset, or where: { id: { [Op.gt]: 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

Eager loading ​

Sequelize: User.findAll({ include: Post }), or include: [{ model: Post, where: { published: true } }].

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"], []]

Each level of preload is one extra query, whatever the number of parents, and limit in a preload callback applies to each parent. To load from a join instead, as include does, join and pass the alias: .joinAssoc("u", "posts", "p").preload({ posts: "p" }). See Preloading.

Sequelize: await user.getPosts(), a lazy-loading getter.

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 an association ​

Sequelize: include: [{ model: Post, where: { published: true }, required: 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

Group and aggregate ​

Sequelize: Post.findAll({ attributes: ["authorId", [fn("COUNT", col("id")), "posts"]], group: ["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

The result is typed: posts is a number.

Update a record ​

Sequelize: await user.update({ name }), or set the attribute and user.save().

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

The row you loaded is not changed: updateOrThrow returns a new User.

Update many, and increment ​

Sequelize: Post.increment("views", { where: { published: true } }), or Post.update(values, { where }).

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

Upsert ​

Sequelize: User.upsert({ email, name }, { conflictFields: ["email"] }).

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 ​

Sequelize: sequelize.transaction(async (t) => { await Post.update(values, { where, transaction: t }); }).

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

There is no transaction: t option to forget: the queries run on tx, a repo bound to the transaction, and only those are inside. There is no automatic passing through CLS either. See Transactions.

Raw SQL ​

Sequelize: sequelize.query("select … where views > :min", { replacements: { min }, type: QueryTypes.SELECT }).

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

fragment interpolations are bound parameters; a plain string is refused.

Delete ​

Sequelize: await post.destroy(), or Post.destroy({ where }).

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 ​

SequelizeIn Egon
instance.save(), instance.reload(), instance.destroy()Rows have no methods that touch the database: repo.update(changeset), repo.reload(row), repo.delete(row).
Lazy loading with getters (user.getPosts())Not supported. Preload, or query with assoc(User, user, "posts").
Hooks (beforeCreate, afterUpdate, …)prepareChanges(fn) on a changeset runs just before the write, inside its transaction. For work after a write, call it yourself, or use a batch.
Model validators and allowNull checksChangeset validations; see Validation.
Scopes (defaultScope, User.scope("active"))Functions from query to query: see Reusable pieces. There is no default scope.
Paranoid models (paranoid: true)No built-in. See the Soft deletes recipe.
sequelize.sync()Not supported. Use migrations.
CLS transactions (Sequelize.useCLS)Not supported on purpose: pass tx.
Getters, setters and virtual attributes on the modelGetters and methods on the class; .virtual() fields for values that are not stored.
Microsoft SQL Server, MariaDB, Db2, SnowflakeNot supported: Postgres, SQLite and MySQL only.

What works differently ​

Good to know

  • Rows are data. There is no save() and no dirty tracking on a row. A change is a changeset, written by the repo.
  • Writes return a result. Invalid input and declared constraint violations come back as { success: false, error } with messages per field; the OrThrow twins throw.
  • Input is filtered. cast takes only the permitted fields from params, so there is no fields option to remember on each create.
  • No lazy loading. An association is on a row only if you preloaded it, and the type says so.
  • Fields are non-null by default, the opposite of allowNull's default.
  • Explicit transactions, with no transaction option to pass on each call.

See also ​

  • Introduction: the four concepts and why they are separate.
  • Changesets: where model validation goes.
  • Recipes: pagination, soft deletes, multi-tenancy and more.