diff --git a/db/docs/batched_background_migrations/delete_orphaned_stage_records.yml b/db/docs/batched_background_migrations/delete_orphaned_stage_records.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9b9b1de53e6cc4e6ccdcd193d2e681ed7d0d7d96
--- /dev/null
+++ b/db/docs/batched_background_migrations/delete_orphaned_stage_records.yml
@@ -0,0 +1,8 @@
+---
+migration_job_name: DeleteOrphanedStageRecords
+description: Delete corrupted rows from p_ci_stages
+feature_category: continuous_integration
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/168750
+milestone: '17.6'
+queued_migration_version: 20241009135743
+finalized_by: # version of the migration that finalized this BBM
diff --git a/db/post_migrate/20241009135743_queue_delete_orphaned_stage_records.rb b/db/post_migrate/20241009135743_queue_delete_orphaned_stage_records.rb
new file mode 100644
index 0000000000000000000000000000000000000000..42b0e5b9fcb69a4fdce2a8b0f03125915b192baa
--- /dev/null
+++ b/db/post_migrate/20241009135743_queue_delete_orphaned_stage_records.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class QueueDeleteOrphanedStageRecords < Gitlab::Database::Migration[2.2]
+  milestone '17.6'
+
+  restrict_gitlab_migration gitlab_schema: :gitlab_ci
+
+  MIGRATION = "DeleteOrphanedStageRecords"
+  DELAY_INTERVAL = 2.minutes
+  BATCH_SIZE = 1000
+  SUB_BATCH_SIZE = 100
+
+  def up
+    queue_batched_background_migration(
+      MIGRATION,
+      :p_ci_stages,
+      :pipeline_id,
+      job_interval: DELAY_INTERVAL,
+      batch_size: BATCH_SIZE,
+      batch_class_name: 'LooseIndexScanBatchingStrategy',
+      sub_batch_size: SUB_BATCH_SIZE
+    )
+  end
+
+  def down
+    delete_batched_background_migration(MIGRATION, :p_ci_stages, :pipeline_id, [])
+  end
+end
diff --git a/db/schema_migrations/20241009135743 b/db/schema_migrations/20241009135743
new file mode 100644
index 0000000000000000000000000000000000000000..462f673c9ecfa56b6b886fa795bc04e9c69c0c2a
--- /dev/null
+++ b/db/schema_migrations/20241009135743
@@ -0,0 +1 @@
+84bd92cf4afd4e72c708ac6b81978928ae560e6eb4a36d96678d0eaf9e51bce4
\ No newline at end of file
diff --git a/lib/gitlab/background_migration/delete_orphaned_stage_records.rb b/lib/gitlab/background_migration/delete_orphaned_stage_records.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ea92c640ef8101aa49e9c519727529803e3ba31b
--- /dev/null
+++ b/lib/gitlab/background_migration/delete_orphaned_stage_records.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module BackgroundMigration
+    class DeleteOrphanedStageRecords < BatchedMigrationJob
+      operation_name :delete_orphaned_stage_records
+      feature_category :continuous_integration
+
+      class CiPipeline < ::Ci::ApplicationRecord
+        self.table_name = :p_ci_pipelines
+        self.primary_key = :id
+      end
+
+      def perform
+        distinct_each_batch do |batch|
+          pipeline_ids = batch.pluck(batch_column)
+          pipelines_query = CiPipeline
+            .where('p_ci_stages.pipeline_id = p_ci_pipelines.id')
+            .where('p_ci_stages.partition_id = p_ci_pipelines.partition_id')
+            .select(1)
+
+          base_relation
+            .where(batch_column => pipeline_ids)
+            .where('NOT EXISTS (?)', pipelines_query)
+            .delete_all
+        end
+      end
+
+      private
+
+      def base_relation
+        define_batchable_model(batch_table, connection: connection, primary_key: :id)
+          .where(batch_column => start_id..end_id)
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/background_migration/delete_orphaned_stage_records_spec.rb b/spec/lib/gitlab/background_migration/delete_orphaned_stage_records_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fc6accee021073fde59aa5cc8b9e4f25f6c18058
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/delete_orphaned_stage_records_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::DeleteOrphanedStageRecords,
+  feature_category: :continuous_integration, migration: :gitlab_ci do
+  let(:pipelines_table) { table(:p_ci_pipelines, database: :ci, primary_key: :id) }
+  let(:stages_table) { table(:p_ci_stages, database: :ci, primary_key: :id) }
+
+  let(:default_attributes) { { project_id: 600, partition_id: 100 } }
+  let!(:regular_pipeline) { pipelines_table.create!(default_attributes) }
+  let!(:deleted_pipeline) { pipelines_table.create!(default_attributes) }
+  let!(:other_pipeline) { pipelines_table.create!(default_attributes) }
+
+  let!(:regular_build) do
+    stages_table.create!(pipeline_id: regular_pipeline.id, **default_attributes)
+  end
+
+  let!(:orphaned_build) do
+    stages_table.create!(pipeline_id: deleted_pipeline.id, **default_attributes)
+  end
+
+  let(:connection) { Ci::ApplicationRecord.connection }
+
+  around do |example|
+    connection.transaction do
+      connection.execute(<<~SQL)
+        ALTER TABLE ci_pipelines DISABLE TRIGGER ALL;
+      SQL
+
+      example.run
+
+      connection.execute(<<~SQL)
+        ALTER TABLE ci_pipelines ENABLE TRIGGER ALL;
+      SQL
+    end
+  end
+
+  describe '#perform' do
+    subject(:migration) do
+      described_class.new(
+        start_id: stages_table.minimum(:pipeline_id),
+        end_id: stages_table.maximum(:pipeline_id),
+        batch_table: :p_ci_stages,
+        batch_column: :pipeline_id,
+        sub_batch_size: 100,
+        pause_ms: 0,
+        connection: connection
+      )
+    end
+
+    it 'deletes from p_ci_stages where pipeline_id has no related record at p_ci_pipelines.id', :aggregate_failures do
+      expect { deleted_pipeline.delete }.to not_change { stages_table.count }
+
+      expect { migration.perform }.to change { stages_table.count }.from(2).to(1)
+
+      expect(regular_build.reload).to be_persisted
+      expect { orphaned_build.reload }.to raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+end
diff --git a/spec/migrations/20241009135743_queue_delete_orphaned_stage_records_spec.rb b/spec/migrations/20241009135743_queue_delete_orphaned_stage_records_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..176d97c55180a97a970b39b30436c36b34fa8bea
--- /dev/null
+++ b/spec/migrations/20241009135743_queue_delete_orphaned_stage_records_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueDeleteOrphanedStageRecords, migration: :gitlab_ci, feature_category: :continuous_integration do
+  let!(:batched_migration) { described_class::MIGRATION }
+
+  it 'schedules a new batched migration' do
+    reversible_migration do |migration|
+      migration.before -> {
+        expect(batched_migration).not_to have_scheduled_batched_migration
+      }
+
+      migration.after -> {
+        expect(batched_migration).to have_scheduled_batched_migration(
+          table_name: :p_ci_stages,
+          column_name: :pipeline_id,
+          interval: described_class::DELAY_INTERVAL,
+          batch_size: described_class::BATCH_SIZE,
+          sub_batch_size: described_class::SUB_BATCH_SIZE,
+          gitlab_schema: :gitlab_ci
+        )
+      }
+    end
+  end
+end