Troubleshooting
Tenant could not be resolved
Section titled “Tenant could not be resolved”better-tenant: tenant could not be resolved from requestThis means none of your configured resolver strategies found a tenant identifier on the incoming request. The middleware responds with a 404 (or 401 if configured via missingTenantStatus).
Common causes
Section titled “Common causes”Header is missing or misspelled. If you use header: "x-tenant-id", the request must include that exact header:
# Wrong — header name doesn't matchcurl -H "X-Tenant: abc" http://localhost:3000/projects
# Correctcurl -H "x-tenant-id: abc" http://localhost:3000/projectsSubdomain not detected on localhost. The subdomain resolver needs at least 3 hostname segments. localhost and example.com don’t have a subdomain:
| Host | Extracted subdomain |
|---|---|
acme.app.com | acme |
acme.app.localhost | acme |
acme.localhost | nothing — only 2 segments |
localhost | nothing — too few segments |
example.com | nothing — only 2 segments |
Path resolver doesn’t match. The path pattern must include :tenantId. Check that the segment index is correct:
// Matches /t/acme/projects — tenantId is "acme"tenantResolver: { path: "/t/:tenantId/*";}
// Does NOT match /projects/acme — wrong segmentJWT claim is missing or not a string. The resolver decodes the JWT payload and reads the configured claim. It returns nothing if the claim doesn’t exist, is not a string, or the token is malformed.
Resolution order matters. Strategies are tried in this order: header → path → subdomain → JWT → custom. The first one that returns a non-empty value wins. If you configure multiple strategies, an earlier one may match before the one you expect.
Customizing the error response
Section titled “Customizing the error response”By default, all framework adapters return a 404 with a JSON body. You can change the status or handle it yourself:
// Change status to 401createHonoMiddleware(tenant, { missingTenantStatus: 401 });createExpressMiddleware(tenant, { missingTenantStatus: 401 });withTenant(tenant, handler, { missingTenantStatus: 401 });
// Full custom handling (Hono)createHonoMiddleware(tenant, { onMissingTenant: (c) => c.json({ error: "Unknown workspace" }, 404),});
// Full custom handling (Express)createExpressMiddleware(tenant, { onMissingTenant: (req, res) => { res.status(403).json({ error: "Unknown workspace" }); },});RLS returns all rows
Section titled “RLS returns all rows”Queries return data from all tenants instead of filtering to the current tenant — tenant isolation is not working.
Check if you’re connecting as a superuser
Section titled “Check if you’re connecting as a superuser”PostgreSQL superusers bypass all RLS policies, even with FORCE ROW LEVEL SECURITY. The default postgres user in most Docker and cloud setups is a superuser.
Check your current role:
SELECT current_user, usesuper FROM pg_user WHERE usename = current_user;If usesuper is true, RLS will never filter rows for that connection. Create a non-superuser application role:
CREATE ROLE app_user WITH LOGIN PASSWORD '**********';GRANT CONNECT ON DATABASE mydb TO app_user;GRANT USAGE ON SCHEMA public TO app_user;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;Then update your DATABASE_URL to use the new role.
Middleware is not applied
Section titled “Middleware is not applied”If the middleware is not on the route’s path, no transaction with SET LOCAL is opened and RLS has no tenant to filter by. Verify your middleware is applied to the correct routes (see Framework Adapters).
RLS blocks all rows
Section titled “RLS blocks all rows”Queries return empty results even though rows exist in the database. This usually means the RLS policy can’t match app.current_tenant to the rows’ tenant_id.
Run the check command first
Section titled “Run the check command first”npx @usebetterdev/tenant-cli check --database-url $DATABASE_URLThe check command runs 10+ validations and pinpoints the exact issue. Here’s what each failure means and how to fix it:
| Failure | Meaning | Fix |
|---|---|---|
tenants table not found | The tenants table doesn’t exist | Run the migration: psql $DATABASE_URL -f ./migrations/*_better_tenant.sql |
tenants table missing column: <col> | The tenants table is missing a required column (id, name, slug, created_at) | Re-run the migration or add the column manually |
table <name> not found | A table listed in tenantTables doesn’t exist in the database | Create the table first, then re-run the migration |
column tenant_id not found | The table is missing the tenant_id column | Re-run the migration or add: ALTER TABLE <name> ADD COLUMN tenant_id UUID NOT NULL REFERENCES tenants(id) |
ROW LEVEL SECURITY not enabled | RLS is not turned on for this table | Run: ALTER TABLE <name> ENABLE ROW LEVEL SECURITY |
FORCE ROW LEVEL SECURITY not enabled | RLS can be bypassed by the table owner role | Run: ALTER TABLE <name> FORCE ROW LEVEL SECURITY |
no RLS policy found | No policy exists on this table | Re-run the migration to generate the policy |
policy missing USING expression | The policy doesn’t filter reads | Re-create the policy with a USING clause |
policy missing WITH CHECK expression | The policy doesn’t validate writes | Re-create the policy with a WITH CHECK clause |
policy should allow bypass_rls for runAsSystem | The policy doesn’t include the app.bypass_rls escape hatch | Re-create the policy with OR current_setting('app.bypass_rls', true) = 'true' in both clauses |
trigger set_tenant_id_trigger not found | The auto-populate trigger is missing | Re-run the migration to create it |
Slug lookup fails silently
Section titled “Slug lookup fails silently”If you use subdomain or path-based resolution, the resolver extracts a slug (like "acme") and looks it up in the tenants table to get the UUID. If no tenant with that slug exists, resolution returns undefined and you get a “tenant could not be resolved” error — not an “empty results” error.
Make sure the tenant exists:
npx @usebetterdev/tenant-cli seed --name "Acme Corp" --slug "acme" --database-url $DATABASE_URLConnection pooling
Section titled “Connection pooling”UseBetter Tenant uses set_config('app.current_tenant', '<uuid>', true) where the third argument true means transaction-local. The session variable is automatically cleared when the transaction commits. This makes it safe with connection pools — the next request gets a fresh transaction with no leftover state.
PgBouncer
Section titled “PgBouncer”PgBouncer must run in transaction mode (the default) for SET LOCAL / set_config(..., true) to work correctly. In session mode, the session variable persists across transactions on the same connection, which can leak tenant context between requests.
pool_mode = transaction # correct — SET LOCAL is cleared per transaction# pool_mode = session # wrong — session variables persist across requestsNo cross-request leakage
Section titled “No cross-request leakage”Each request gets its own database transaction. The app.current_tenant variable is scoped to that transaction and invisible to other concurrent requests on the same connection. When the transaction ends (commit or rollback), the variable is gone.
Context is undefined
Section titled “Context is undefined”tenant.getContext() and tenant.getDatabase() return undefined when called outside a tenant scope.
Common causes
Section titled “Common causes”Calling outside middleware. These methods only work inside a request handled by tenant middleware, or inside a runAs / runAsSystem block:
// This works — inside middleware scopeapp.get("/projects", async (c) => { const db = tenant.getDatabase(); // returns the scoped database});
// This does NOT work — outside any scopeconst db = tenant.getDatabase(); // undefinedCalling inside setTimeout or detached async. UseBetter Tenant uses AsyncLocalStorage to propagate context. Some patterns break the async chain:
app.get("/projects", async (c) => { // Works — same async context const db = tenant.getDatabase();
// Does NOT work — setTimeout creates a new async context setTimeout(() => { const db = tenant.getDatabase(); // undefined }, 1000);});If you need to run tenant-scoped work outside the request lifecycle, capture the tenant ID and use runAs:
app.get("/projects", async (c) => { const ctx = tenant.getContext(); const tenantId = ctx.tenantId;
// Schedule work with explicit tenant scope setTimeout(async () => { await tenant.runAs(tenantId, async (db) => { // tenant context is available here }); }, 1000);});Prisma: tenant_id column not found
Section titled “Prisma: tenant_id column not found”column "tenant_id" of relation "User" does not existThe set_tenant_id() trigger and RLS policies reference the column as tenant_id (snake_case). Prisma’s default field mapping uses camelCase (tenantId → column tenantId).
Fix: Add @map("tenant_id") to every tenantId field in your Prisma schema:
model User { tenantId String @map("tenant_id") @db.Uuid // ...}Prisma: table name mismatch in CLI config
Section titled “Prisma: table name mismatch in CLI config”table "users" not foundThe CLI config tenantTables must use the actual PostgreSQL table name, not the Prisma model name:
- Prisma model
User(no@@map) → PG table"User"→ config:"User" - Prisma model
User+@@map("users")→ PG tableusers→ config:"users"
Recommendation: Add @@map("lowercase") to all models and use lowercase names in config. See CLI & Migrations — Configuration.
Prisma: getDatabase() has no model methods
Section titled “Prisma: getDatabase() has no model methods”const db = tenant.getDatabase();db.project.findMany(); // TS error: Property 'project' does not exist on type 'unknown'getDatabase() is fully typed via generics when you pass a typed PrismaClient to prismaDatabase(). If you’re seeing unknown, make sure the generic is inferred correctly — pass the PrismaClient instance directly to prismaDatabase(prisma).
See the Prisma guide for details.
Prisma: seed script fails after adding tenancy
Section titled “Prisma: seed script fails after adding tenancy”null value in column "tenant_id" violates not-null constraintAfter adding RLS, the set_tenant_id() trigger needs app.current_tenant to be set in the transaction. Direct prisma.user.create() calls outside a tenant context have no transaction with SET LOCAL, so the trigger can’t populate tenant_id.
Fix: Use tenant.runAs() or tenant.api.createTenant() for seeding:
const acme = await tenant.api.createTenant({ name: "Acme", slug: "acme" });
await tenant.runAs(acme.id, async (db) => {});See the Prisma guide — Seeding for full examples.
Prisma: RLS migration not tracked
Section titled “Prisma: RLS migration not tracked”Prisma doesn’t support custom SQL migration files like Drizzle. If you apply RLS via psql directly, Prisma’s migration history won’t know about it, and prisma migrate dev may warn about drift.
Fix: Embed RLS SQL in a Prisma migration directory. See Prisma guide — Migration workflow for step-by-step instructions.
Drizzle migration journal out of sync
Section titled “Drizzle migration journal out of sync”Error: No file ./drizzle/0001_better_tenant_rls.sql found in ./drizzle folderThis means Drizzle Kit’s journal (drizzle/meta/_journal.json) references a migration file that doesn’t exist on disk. This typically happens when a previous drizzle-kit generate run was partially completed or when migration files were manually deleted.
How to fix
Section titled “How to fix”- Open
drizzle/meta/_journal.jsonand remove the entry referencing the missing file. - Delete the corresponding snapshot file from
drizzle/meta/if one exists. - Re-run the workflow from the
drizzle-kit generatestep.
Prevention
Section titled “Prevention”Always use --prefix=none when creating custom RLS migrations. Without it, Drizzle Kit adds a numeric or timestamp prefix to the filename, and the tenant-cli migrate -o path won’t match. See CLI & Migrations — Workflow for the correct steps.
CLI errors
Section titled “CLI errors”No config found
Section titled “No config found”better-tenant: No config found.The CLI looks for configuration in this order:
better-tenant.config.jsonin the current directory- A
"betterTenant"key inpackage.json
Fix: run init to create the config interactively, or create it manually:
{ "tenantTables": ["projects", "tasks"]}Invalid config
Section titled “Invalid config”better-tenant: Invalid JSON in better-tenant.config.json: ...better-tenant: config must have tenantTables (string[])The config must be a valid JSON object with a tenantTables array of table name strings. Check for trailing commas, missing quotes, or a non-array value.
Database URL required
Section titled “Database URL required”check requires --database-url or DATABASE_URL environment variableseed requires --database-url or DATABASE_URL environment variablePass the URL via flag or environment variable. It must use a postgres:// or postgresql:// protocol:
# Via flagnpx @usebetterdev/tenant-cli check --database-url postgres://user:pass@localhost:5432/mydb
# Via environment variableexport DATABASE_URL=postgres://user:pass@localhost:5432/mydbnpx @usebetterdev/tenant-cli checkNext steps
Section titled “Next steps”- CLI & Migrations — all CLI commands and workflows
- Configuration — resolver strategies and tenant API
- Architecture — how RLS and session variables work under the hood