diff --git a/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb b/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb
new file mode 100644
index 0000000000000000000000000000000000000000..90254ac3d86f1b4f565528bd2f815155ac00fc5d
--- /dev/null
+++ b/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AddTemporaryIndexForOrphanedInvitedMembers < Gitlab::Database::Migration[2.0]
+  disable_ddl_transaction!
+
+  TMP_INDEX_NAME = 'tmp_idx_orphaned_invited_members'
+
+  def up
+    add_concurrent_index('members', :id, where: query_condition, name: TMP_INDEX_NAME)
+  end
+
+  def down
+    remove_concurrent_index_by_name('members', TMP_INDEX_NAME) if index_exists_by_name?('members', TMP_INDEX_NAME)
+  end
+
+  private
+
+  def query_condition
+    'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL'
+  end
+end
diff --git a/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb b/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c52495101648e0456aa41860f7144ee94c2d64f0
--- /dev/null
+++ b/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class OrphanedInvitedMembersCleanup < Gitlab::Database::Migration[2.0]
+  disable_ddl_transaction!
+
+  restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+  def up
+    # rubocop:disable Style/SymbolProc
+    membership.where(query_condition).each_batch(of: 100) do |relation|
+      relation.delete_all
+    end
+    # rubocop:enable Style/SymbolProc
+  end
+
+  def down
+    # This migration is irreversible
+  end
+
+  private
+
+  def membership
+    @membership ||= define_batchable_model('members')
+  end
+
+  def query_condition
+    'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL'
+  end
+end
diff --git a/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb b/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c6b712da4c0477b4bb5583da1b2af95a5b797c24
--- /dev/null
+++ b/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class RemoveTemporaryIndexForOrphanedInvitedMembers < Gitlab::Database::Migration[2.0]
+  disable_ddl_transaction!
+
+  TMP_INDEX_NAME = 'tmp_idx_orphaned_invited_members'
+
+  def up
+    remove_concurrent_index_by_name('members', TMP_INDEX_NAME) if index_exists_by_name?('members', TMP_INDEX_NAME)
+  end
+
+  def down
+    add_concurrent_index('members', :id, where: query_condition, name: TMP_INDEX_NAME)
+  end
+
+  private
+
+  def query_condition
+    'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL'
+  end
+end
diff --git a/db/schema_migrations/20220830051704 b/db/schema_migrations/20220830051704
new file mode 100644
index 0000000000000000000000000000000000000000..5785862da4fdae9cffed9e74e6396744a56592b2
--- /dev/null
+++ b/db/schema_migrations/20220830051704
@@ -0,0 +1 @@
+aa0b767ad0e38500e0eef83d5c8306054952363166f8cc2076ce48feeac1b0e1
\ No newline at end of file
diff --git a/db/schema_migrations/20220830061704 b/db/schema_migrations/20220830061704
new file mode 100644
index 0000000000000000000000000000000000000000..7a0db1acc651ddb92f7673a8727aea28ede9a042
--- /dev/null
+++ b/db/schema_migrations/20220830061704
@@ -0,0 +1 @@
+badc3556e1dea545bbf8b55fb33065f45598df9b3fda74bffd28e89d7485e0b4
\ No newline at end of file
diff --git a/db/schema_migrations/20220830071704 b/db/schema_migrations/20220830071704
new file mode 100644
index 0000000000000000000000000000000000000000..bc9d7fd0f8b473b01382aac023f42cf5db269def
--- /dev/null
+++ b/db/schema_migrations/20220830071704
@@ -0,0 +1 @@
+85e401f0920c6eb13b6756f191ccdf70494ca40f8133f05bbd5f23ba295b115d
\ No newline at end of file
diff --git a/spec/migrations/orphaned_invited_members_cleanup_spec.rb b/spec/migrations/orphaned_invited_members_cleanup_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4427e707f56b9a79cede4510954452444dedb92e
--- /dev/null
+++ b/spec/migrations/orphaned_invited_members_cleanup_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe OrphanedInvitedMembersCleanup, :migration do
+  describe '#up', :aggregate_failures do
+    it 'removes accepted members with no associated user' do
+      user = create_user!('testuser1')
+
+      create_member(invite_token: nil, invite_accepted_at: 1.day.ago)
+      record2 = create_member(invite_token: nil, invite_accepted_at: 1.day.ago, user_id: user.id)
+      record3 = create_member(invite_token: 'foo2', invite_accepted_at: nil)
+      record4 = create_member(invite_token: 'foo3', invite_accepted_at: 1.day.ago)
+
+      migrate!
+
+      expect(table(:members).all.pluck(:id)).to match_array([record2.id, record3.id, record4.id])
+    end
+  end
+
+  private
+
+  def create_user!(name)
+    email = "#{name}@example.com"
+
+    table(:users).create!(
+      name: name,
+      email: email,
+      username: name,
+      projects_limit: 0
+    )
+  end
+
+  def create_member(**extra_attributes)
+    defaults = {
+      access_level: 10,
+      source_id: 1,
+      source_type: "Project",
+      notification_level: 0,
+      type: 'ProjectMember'
+    }
+
+    table(:members).create!(defaults.merge(extra_attributes))
+  end
+end