Multi-tenancy with database schemas
Give each tenant its own database schema (a Postgres schema, a MySQL database) with the same tables, and choose the schema per request. The schema classes stay the same for every tenant.
Setup
The code of this recipe depends on these declarations: the imports, the schema classes, the repo, and the migration that creates the tables. Run the recipe in an empty database of its own: each recipe creates its own tables, and some have the same names (posts) with other columns.
import { postgres } from "@egon-orm/postgres";
import { cast, createRepo, from, type Repo, Schema, t } from "egon-orm";
import { createMigrator, defineMigration } from "egon-orm/migration";
class Project extends Schema("projects", {
id: t.integer().primaryKey().dbGenerated(),
name: t.string(),
}) {
static changeset(project: Project | typeof Project, params: unknown) {
return cast(project, params, ["name"]).validateRequired(["name"]).uniqueConstraint("name");
}
}
const adapter = postgres({ connectionString: process.env.DATABASE_URL });Creating a tenant's tables
The same migration runs once per tenant, with the schema in its operations:
// The tables of one tenant, created in that tenant's database schema.
const tenantTables = (schema: string) =>
defineMigration({
up(m) {
m.createSchema(schema, { ifNotExists: true });
m.createTable(
"projects",
(table) => {
table.id({ type: "integer" });
table.text("name").notNull().unique();
},
{ schema },
);
},
down(m) {
m.dropTable("projects", { schema });
m.dropSchema(schema);
},
});
// `tenant` is one of your own identifiers (a slug), never raw user input.
export async function provision(tenant: string) {
const schema = `tenant_${tenant}`;
await createMigrator({
adapter,
table: `${schema}_migrations`, // a history per tenant, beside the tables it creates
migrations: [{ version: 20261020090000, name: "tenant_tables", migration: tenantTables(schema) }],
}).up();
}Each tenant gets its own tracking table, so a new tenant starts with every migration pending, and a migration added later is applied to each tenant by running provision again for each.
Choosing the schema per call
Every read and write takes a schema option:
const repo = createRepo({ adapter });
export function projectsOf(tenant: string) {
return repo.all(
from(Project, "p").orderBy(({ p }) => p.name),
{ schema: `tenant_${tenant}` },
);
}
export function addProject(tenant: string, params: unknown) {
return repo.insert(Project.changeset(Project, params), { schema: `tenant_${tenant}` });
}Or a repo per tenant
A repo created with schema uses it by default. Repos are cheap: several can share one adapter, and so one connection pool.
// One repo per tenant, sharing the adapter (and its pool): every call defaults to the schema.
const tenantRepos = new Map<string, Repo>();
export function repoFor(tenant: string): Repo {
let tenantRepo = tenantRepos.get(tenant);
if (tenantRepo === undefined) {
tenantRepo = createRepo({ adapter, schema: `tenant_${tenant}` });
tenantRepos.set(tenant, tenantRepo);
}
return tenantRepo;
}The examples below start from these rows, after creating the tables:
await provision("acme");
await provision("globex");await repoFor("acme").insertOrThrow(Project.changeset(Project, { name: "Anvil" }));
const acme = await repoFor("acme").all(from(Project, "p").orderBy(({ p }) => p.name));Where the schema comes from, strongest first: a query's own .schema(…), a schema class that declares one, the call's schema option, the repo's default. A write of a loaded row goes back to the schema the row was read from. See Parameters and options.
Good to know
- Names are identifiers. A schema name is written into the SQL as a quoted identifier, never as a parameter. Build it from your own tenant ids (a slug you validated), not from raw input.
- Constraint names are per table, so
uniqueConstraint("name")matches the violation in every tenant's schema. - SQLite has no schemas; the
schemaoption names an attached database there. - Shared tables (users, plans) can live in
public: give their schema classSchema("plans", fields, { schema: "public" })so that no tenant default moves them.

