Skip to main content
Version: 1.6.0

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

StrategyHow it isolatesRequiresBest fit
DatabasePerTenantA separate *sql.DB / Client per tenant.a factory func(tenantID) (*Client, error)Strongest isolation, custom DSNs, per-tenant scaling.
SchemaPerTenantQualifies tables with the tenant ID as the schema.BaseClientOne database, schema namespaces.
RowLevelSecurityClientInjects WHERE tenant_id = ? into every query Quark builds. Client-sideclient.Raw()/Exec() bypass it.BaseClient, TenantColumnShared 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 inherit the tenant; a zero tenant field is filled in)

docs, _ := quark.For[Document](acme, router).List()
// docs contains only acme's rows — even though the query never names tenant_id

Or() groups inherit the predicate too. SQL parses A AND B OR C as (A AND B) OR C, so an OR branch needs its own tenant_id = ? to stay isolated. Quark adds it for you:

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 = ?)

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.

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.

ConcernGuidance
Missing tenantFail fast — never default to a shared tenant.
Tenant-owned tablesPut tenant_id on every one (row-level strategy).
Global lookup tablesUse the base client directly.
Reporting / cross-tenantUse an explicit reporting client, not an accidental bypass.
Defense in depthPair 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.