Multi-Tenant
You're running one application for many customers (tenants), and each must see only its own data.
A TenantRouter handles that. It resolves a tenant ID from the request's
context.Context and applies the isolation strategy you chose. You then query
the router exactly like a normal client, through the same quark.For[T]
entry point:
users, _ := quark.For[User](ctx, client).List() // single-tenant
users, _ = quark.For[User](tenantCtx, router).List() // multi-tenant — same call
Pick a strategy
| Strategy | How it isolates | Requires | Best fit |
|---|---|---|---|
DatabasePerTenant | A separate *sql.DB / Client per tenant. | a factory func(tenantID) (*Client, error) | Strongest isolation, custom DSNs, per-tenant scaling. |
SchemaPerTenant | Qualifies tables with the tenant ID as the schema. | BaseClient | One database, schema namespaces. |
RowLevelSecurityClient | Injects WHERE tenant_id = ? into every query Quark builds. Client-side — client.Raw()/Exec() bypass it. | BaseClient, TenantColumn | Shared tables across all six engines. |
On PostgreSQL there's a fourth option,
RowLevelSecurityNative, which pushes the filter into the
database engine so even raw SQL can't escape it. Tenant IDs must match
^[a-z0-9_-]+$ (this keeps schema names and cache keys safe).
Resolve the tenant from context
Keep resolution small and deterministic, and pass it to the router:
type tenantKey struct{}
func WithTenant(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, tenantKey{}, id)
}
func ResolveTenant(ctx context.Context) string {
id, _ := ctx.Value(tenantKey{}).(string)
return id
}
If the resolver returns an empty or invalid tenant ID, the query fails before any SQL is built — it never silently falls back to a "public" tenant.
Row-level isolation
One shared client; Quark adds the tenant predicate to every query and write:
type Document struct {
ID int64 `db:"id" pk:"true"`
TenantID string `db:"tenant_id"`
Title string `db:"title"`
}
cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.RowLevelSecurityClient
cfg.BaseClient = client
cfg.TenantColumn = "tenant_id"
router := quark.NewTenantRouter(cfg, ResolveTenant, nil)
acme := WithTenant(ctx, "acme")
doc := Document{Title: "Invoice 1001"}
quark.For[Document](acme, router).Create(&doc)
fmt.Println(doc.TenantID)
// => acme (writes store the resolved tenant, whatever the field held)
docs, _ := quark.For[Document](acme, router).List()
// docs contains only acme's rows — even though the query never names tenant_id
Or() groups cannot escape it. SQL parses A AND B OR C as (A AND B) OR C,
so Quark ANDs the tenant predicate with your conditions as a whole, and the
OR group carries its own copy as well:
quark.For[Document](acme, router).
Where("status", "=", "draft").
Or(func(q *quark.Query[Document]) *quark.Query[Document] {
return q.Where("status", "=", "published")
}).
List()
// WHERE tenant_id = ? AND (status = ? OR (tenant_id = ? AND status = ?))
Reads and writes by key carry it too, so an id taken from a request cannot reach another tenant's row:
Find(id)ANDs the key with the tenant predicate (and anyWhereof yours): another tenant's id isErrNotFound.Update,UpdateFields,UpdateBatch,Delete,HardDelete,DeleteBatchandRestoredo the same: the write reports zero rows and changes nothing. The associationsUpdatere-saves carry the predicate when their model has the tenant column. See CRUD — Guarding a write by key.Create,CreateBatchand the updates by entity write the resolved tenant into the tenant column, whatever the entity carried; a different value is replaced and logged (quark.tenant.foreign_value_replaced), so a row cannot be planted in another tenant or moved into one.UpdateMapwrites the resolved tenant into the tenant column when its map names it, with the same log line.UpsertandUpsertBatchinsert under the resolved tenant, and their update branch only touches the tenant's row. When the conflict key belongs to another tenant, nothing is written and the call returnsErrConstraintViolation— what aCreateof the key returns; a batch is rolled back whole. A unique key that includes the tenant column ((tenant_id, …)) keeps tenants from colliding at all. See CRUD — Upserts under RowLevelSecurityClient.
Remember the boundary: this is client-side scoping, so client.Raw() and
client.Exec() aren't filtered. When that bypass risk matters and you're on
PostgreSQL, use Native RLS.
Schema-per-tenant
One shared client; Quark qualifies table names with the resolved tenant ID:
cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.SchemaPerTenant
cfg.BaseClient = client
router := quark.NewTenantRouter(cfg, ResolveTenant, nil)
users, _ := quark.For[User](WithTenant(ctx, "tenant_acme"), router).List()
// table references become "tenant_acme"."users"
The resolver's return value is used directly as the schema name.
quark tenant provision <id> creates the schema and registers the tenant in
quark_tenants. It refuses to re-provision an already-registered id, so a retry
never half-executes.
Running migrations into each schema stays your job. The CLI skips that step
explicitly under schema_per_tenant, because it would need a TenantRouter
wired to your models. client.Migrate runs on the base client, and the router
does not orchestrate per-schema provisioning.
Database-per-tenant
Your factory builds a client per tenant; the router caches them (LRU):
cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.DatabasePerTenant
cfg.MaxCachedPools = 100
router := quark.NewTenantRouter(cfg, ResolveTenant,
func(tenantID string) (*quark.Client, error) {
return quark.New("postgres", dsnForTenant(tenantID),
quark.WithMaxOpenConns(10))
},
)
users, _ := quark.For[User](WithTenant(ctx, "acme"), router).List()
active := router.ActiveTenants()
When the cache exceeds MaxCachedPools, the least-recently-used client is
evicted and its connections closed.
Budget the total: 100 active tenants at 10 connections each is up to 1000
connections against the server. Size MaxCachedPools and the per-client limits
together.
What each engine gives you
| Engine | Row-level isolation | Why |
|---|---|---|
| PostgreSQL | RowLevelSecurityNative (engine-enforced) or RowLevelSecurityClient | set_config(..., true) is transaction-scoped and CREATE POLICY filters server-side. |
| MySQL, MariaDB, SQLite | RowLevelSecurityClient | No engine-side row security. |
| SQL Server, Oracle | RowLevelSecurityClient | Both have engine-side row security (CREATE SECURITY POLICY over SESSION_CONTEXT; DBMS_RLS over a session context), but the context is session-scoped: set inside a transaction it survives the commit on the pooled connection, and the next tenant to borrow that connection would inherit it. Quark does not offer a strategy it cannot make safe on a pool. |
A RowLevelSecurityNative router over any engine but PostgreSQL refuses on all
three of its doors — For, Tx and GetClient — with ErrUnsupportedFeature.
SchemaPerTenant and DatabasePerTenant work on every engine.
Transactions
A transaction opened for a tenant is confined to it, and so is every query built inside it:
err := router.Tx(acme, func(tx *quark.Tx) error {
docs, err := quark.ForTx[Document](acme, tx).List() // acme's rows only
if err != nil {
return err
}
return quark.ForTx[Document](acme, tx).Create(&Document{Title: "Invoice 1002"})
})
router.Tx resolves the tenant from the context, opens the transaction on the
right client — the tenant's own pool, or the shared one — and stamps it.
ForTx then applies the same confinement For applies: the schema prefix, the
injected predicate, or, under Native RLS, the session
variable the transaction already set. Relations loaded with Preload inside it
are qualified the same way.
The transaction fixes the tenant. Under Native RLS the engine is already
filtering by the tenant the transaction set; under database-per-tenant the pool
was chosen for it. So a query context inside the transaction that resolves a
different tenant fails with quark.ErrTenantMismatch rather than reading or
writing across tenants, and a context that resolves no tenant inherits the
transaction's — quark.ForTx[T](context.Background(), tx) is fine.
router.GetClient(ctx) followed by client.Tx(ctx, ...) on the shared base
client is the same door: when the context carries a tenant, the transaction is
confined exactly as router.Tx confines it. With no tenant in the context the
transaction stays what the base client always gave — one on the shared pool,
unconfined — which is how migrations and provisioning run.
Earlier releases handed back a bare transaction from router.Tx for every
strategy except Native RLS, so a query built inside it read every tenant's rows
and wrote to the default schema (QK-26). If you worked around that by adding the
predicate or the schema by hand inside router.Tx, the workaround is now
redundant and can go.
Configuration
type TenantConfig struct {
Strategy TenantStrategy // default DatabasePerTenant
MaxCachedPools int // default 100 (database-per-tenant)
BaseClient *quark.Client // required for schema / row-level
TenantColumn string // default "tenant_id" (row-level)
}
(The strategy RowLevelSecurityClient was once called RowLevelSecurity; the old
name still works as a deprecated alias and is removed in v2.)
Relations, cache, and the bigger picture
Tenant context flows into Preload. When a related model has the tenant column,
Quark filters the relation query too, so a parent can't preload another tenant's
children.
Cache keys include the tenant ID and the schema, so two tenants never share a cached result for the same SQL.
| Concern | Guidance |
|---|---|
| Missing tenant | Fail fast — never default to a shared tenant. |
| Tenant-owned tables | Put tenant_id on every one (row-level strategy). |
| Global lookup tables | Use the base client directly. |
| Reporting / cross-tenant | Use an explicit reporting client, not an accidental bypass. |
| Defense in depth | Pair app-level filters with Native RLS where it matters. |
TenantRouter centralizes tenant selection — it's the ORM layer of a tenancy
design, not a replacement for database permissions, audit logging, and backups.