vxcontrol/pentagi · critical

search_path resolved to schema

Error message

search_path resolved to schema %q, expected %q — refusing to start so tenants do not silently share one dataset

What it means

This is the safety net for multi-tenancy: after rewriting the DSN, VerifySearchPath asserts current_schema() equals the tenant's schema name and refuses to start otherwise. The concern is a typo'd or ignored search_path silently routing a tenant onto public where all tenants would share one dataset. The error names both the resolved and expected schema so the misconfiguration is obvious.

Solutions

  1. Check the resolved vs expected schema in the message and align TENANT_ID with the intended schema name.
  2. If using a pooler, set DATABASE_SEARCH_PATH_VIA_OPTIONS=true so search_path survives pooling, or connect directly to Postgres.
  3. Run the bootstrap (EnsureTenantSchema) so the expected schema exists before VerifySearchPath runs.
  4. Verify manually: psql "$DATABASE_URL" -c 'SELECT current_schema();' and compare with the tenant schema.

Example fix

// before
TENANT_ID=AcmeCorp   # schema acmecorp expected, search_path resolves to public via pgbouncer
// after
# .env
TENANT_ID=acmecorp
DATABASE_SEARCH_PATH_VIA_OPTIONS=true
Defensive patterns

Strategy: validation

Validate before calling

// pre-start gate: the resolved schema must equal the expected tenant schema
psql "$DATABASE_URL" -c "SELECT current_schema();"
# compare with the schema derived from TENANT_ID before launching the app

Try / catch

if err := VerifySearchPath(ctx, db, cfg); err != nil {
    // fail closed: never run with a wrong schema, tenants could share data
    log.Fatal().Err(err).Str("expected", cfg.SchemaName()).Msg("search_path misrouting — refusing to start")
}

Prevention

When it happens

Trigger: cfg.SchemaName() (derived from TENANT_ID) differs from the schema current_schema() reports: TENANT_ID casing/typo, pooler stripping the search_path parameter so it defaults to public, the tenant schema was never created (bootstrap skipped), or DSN search_path overwritten by an options block.

Common situations: TENANT_ID=acme but data bootstrapped earlier as tenant_acme (naming-convention change); pgbouncer in transaction mode ignoring startup parameters; two compose stacks sharing a DB with different TENANT_IDs; forgetting DATABASE_SEARCH_PATH_VIA_OPTIONS with Supavisor.

Related errors


AI-assisted analysis of vxcontrol/pentagi@ea665308ba (2026-09-01). Data as JSON: /api/errors/256ba9af04f5d97b. Report an issue: GitHub.

Appendix: source

Thrown at backend/pkg/database/tenant.go:174

	default:
		return schema, nil
	}
}

// VerifySearchPath asserts that connections really do resolve into the expected
// schema. A typo in the DSN would otherwise route a tenant silently onto public,
// where every tenant would share one dataset — a quiet, catastrophic failure.
func VerifySearchPath(ctx context.Context, db *sql.DB, cfg *config.Config) error {
	if !cfg.HasTenant() {
		return nil
	}

	var current sql.NullString
	if err := db.QueryRowContext(ctx, "SELECT current_schema()").Scan(&current); err != nil {
		return fmt.Errorf("failed to resolve current schema: %w", err)
	}
	if current.String != cfg.SchemaName() {
		return fmt.Errorf(
			"search_path resolved to schema %q, expected %q — refusing to start so tenants "+
				"do not silently share one dataset",
			current.String, cfg.SchemaName(),
		)
	}

	return nil
}

// RunMigrations applies pending migrations while holding an advisory lock, so
// that two instances booting simultaneously cannot execute the same migration
// set concurrently. Without a tenant the lock is still taken, which also fixes
// the pre-existing race between two single-instance deployments sharing a
// database.
func RunMigrations(ctx context.Context, db *sql.DB, cfg *config.Config, up func(*sql.DB) error) error {
	return WithAdvisoryLock(ctx, db, "pentagi-migrations-"+cfg.SchemaName(), func(*sql.Conn) error {
		return up(db)
	})

View on GitHub (pinned to ea665308ba)