multica-ai/multica · error

relation %q exists but is not an index

Error message

relation %q exists but is not an index

What it means

to_regclass resolved the configured index name to an existing relation, but pg_class.relkind says it is not an index (it is a table, view, sequence, or materialized view). The migrate tool refuses to DROP INDEX CONCURRENTLY something that is not an index, because that would either fail or, worse, be the wrong object entirely.

Source

Thrown at server/cmd/migrate/main.go:180

func cleanupInvalidConcurrentIndexHook(indexRegclass string) preMigrationHook {
	return func(ctx context.Context, pool *pgxpool.Pool) error {
		var schemaName, relationName string
		var isIndex, isValid bool
		err := pool.QueryRow(ctx, `
			SELECT n.nspname, c.relname, c.relkind = 'i', COALESCE(i.indisvalid, FALSE)
			FROM pg_class c
			JOIN pg_namespace n ON n.oid = c.relnamespace
			LEFT JOIN pg_index i ON i.indexrelid = c.oid
			WHERE c.oid = to_regclass($1)
		`, indexRegclass).Scan(&schemaName, &relationName, &isIndex, &isValid)
		if errors.Is(err, pgx.ErrNoRows) {
			return nil
		}
		if err != nil {
			return fmt.Errorf("inspect concurrent index %q: %w", indexRegclass, err)
		}
		if !isIndex {
			return fmt.Errorf("relation %q exists but is not an index", indexRegclass)
		}
		if isValid {
			return nil
		}

		qualifiedName := pgx.Identifier{schemaName, relationName}.Sanitize()
		if _, err := pool.Exec(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+qualifiedName); err != nil {
			return fmt.Errorf("drop invalid concurrent index %s: %w", qualifiedName, err)
		}
		slog.Warn("removed invalid index before migration retry", "index", qualifiedName)
		return nil
	}
}

func runTaskUsageHourlyHook(ctx context.Context, pool *pgxpool.Pool) error {
	res, err := taskusagebackfill.Hook(ctx, pool, taskusagebackfill.HookOptions{})
	if err != nil {
		return fmt.Errorf("task_usage_hourly pre-103 hook: %w", err)

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Inspect the object: psql -c "SELECT c.relkind, n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid = to_regclass('<name from error>')"
  2. If it is a stale/wrong relation, rename or drop it deliberately (after backing up), then re-run migrate
  3. If the name is genuinely needed by other code, fix the migration's index name or resolve the collision in the schema

Example fix

-- before: collision blocks migration
-- relation 'public.tasks_created_at_idx' is a table

-- after
ALTER TABLE public.tasks_created_at_idx RENAME TO tasks_created_at_idx_old;
-- then re-run: migrate up
Defensive patterns

Strategy: type-guard

Type guard

-- guard: is the named relation actually an index?
SELECT c.relkind = 'i' AS is_index
FROM pg_class c WHERE c.oid = to_regclass('public.my_idx');

Try / catch

if !isIndex {
    return fmt.Errorf("relation %q exists but is not an index", indexRegclass)
}

Prevention

When it happens

Trigger: A table/view/sequence shares the exact name the migration expects for its index — e.g. a leftover table created by an old or hand-run script with the index's name, or a rename collision after schema drift.

Common situations: Self-hosted databases that diverged from the managed schema; someone created a relation named like the index (e.g. tasks_created_at_idx used as a table name); restored dump with renamed objects.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/b661512fe657ce74. Report an issue: GitHub.