vxcontrol/pentagi · critical

search_path resolved to schema %q, expected %q — refusing to

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.

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)

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.