diff --git a/db/docs/batched_background_migrations/populate_topics_slug_column.yml b/db/docs/batched_background_migrations/populate_topics_slug_column.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bdc927a5c7bc96f4ec9876bded671463daac82dc
--- /dev/null
+++ b/db/docs/batched_background_migrations/populate_topics_slug_column.yml
@@ -0,0 +1,8 @@
+---
+migration_job_name: PopulateTopicsSlugColumn
+description: Populates topics slug column from name column
+feature_category: groups_and_projects
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141715
+milestone: '16.9'
+queued_migration_version: 20240113071052
+finalize_after: '2024-02-13'
diff --git a/db/post_migrate/20240113071052_queue_populate_topics_slug_column.rb b/db/post_migrate/20240113071052_queue_populate_topics_slug_column.rb
new file mode 100644
index 0000000000000000000000000000000000000000..68d587c88e1fa14a7fa92721ab0fdb44a37fcad9
--- /dev/null
+++ b/db/post_migrate/20240113071052_queue_populate_topics_slug_column.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class QueuePopulateTopicsSlugColumn < Gitlab::Database::Migration[2.2]
+  milestone '16.9'
+
+  MIGRATION = "PopulateTopicsSlugColumn"
+  DELAY_INTERVAL = 2.minutes
+  BATCH_SIZE = 1000
+  SUB_BATCH_SIZE = 100
+
+  restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+  def up
+    queue_batched_background_migration(
+      MIGRATION,
+      :topics,
+      :id,
+      job_interval: DELAY_INTERVAL,
+      batch_size: BATCH_SIZE,
+      sub_batch_size: SUB_BATCH_SIZE
+    )
+  end
+
+  def down
+    delete_batched_background_migration(MIGRATION, :topics, :id, [])
+  end
+end
diff --git a/db/schema_migrations/20240113071052 b/db/schema_migrations/20240113071052
new file mode 100644
index 0000000000000000000000000000000000000000..fcd2489e43ddf1a4f4de445d05258a9cb08578b3
--- /dev/null
+++ b/db/schema_migrations/20240113071052
@@ -0,0 +1 @@
+34feec8741621a0228fb94d1737cb0f4f9aaba673f86f7fc0e03855713f9be7c
\ No newline at end of file
diff --git a/lib/gitlab/background_migration/populate_topics_slug_column.rb b/lib/gitlab/background_migration/populate_topics_slug_column.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f69237e07a550c09f75ab418c342da72165f7c8d
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_topics_slug_column.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module BackgroundMigration
+    class PopulateTopicsSlugColumn < BatchedMigrationJob
+      feature_category :groups_and_projects
+      scope_to ->(relation) { relation.where(slug: nil) }
+      operation_name :populate_topics_slug_column
+
+      def perform
+        each_sub_batch do |sub_batch|
+          sub_batch.each { |topic| topic.update! slug: clean_name(topic) }
+        end
+      end
+
+      private
+
+      def clean_name(topic)
+        cleaned_name = ::Gitlab::Slug::Path.new(topic.name).generate
+
+        Gitlab::Utils::Uniquify.new.string(cleaned_name) { |s| topic.class.find_by_slug(s) }
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_topics_slug_column_spec.rb b/spec/lib/gitlab/background_migration/populate_topics_slug_column_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3dfee7281ae9a1c146859ed23c76acbd382298cc
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_topics_slug_column_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateTopicsSlugColumn, feature_category: :groups_and_projects do
+  let(:migration) do
+    described_class.new(
+      start_id: topic1.id,
+      end_id: topic4.id,
+      batch_table: :topics,
+      batch_column: :id,
+      sub_batch_size: 100,
+      pause_ms: 2.minutes,
+      connection: ApplicationRecord.connection
+    )
+  end
+
+  let(:topics) { table(:topics) }
+
+  let!(:topic1) { topics.create!(name: 'dog 🐶') }
+  let!(:topic2) { topics.create!(name: 'some topic') }
+  let!(:topic3) { topics.create!(name: 'topic', slug: 'topic') }
+  let!(:topic4) { topics.create!(name: 'topic🐶') }
+
+  describe '#perform' do
+    subject(:perform_migration) { migration.perform }
+
+    it 'populates topics slug column' do
+      expect { perform_migration }.to change { topic1.reload.slug }.from(nil)
+        .and change { topic2.reload.slug }.from(nil)
+          .and not_change { topic3.reload.slug } # already has slug
+            .and change { topic4.reload.slug }.from(nil)
+    end
+  end
+end
diff --git a/spec/migrations/20240113071052_queue_populate_topics_slug_column_spec.rb b/spec/migrations/20240113071052_queue_populate_topics_slug_column_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1fda927b5c9797d25c19b310881c939d93b5701e
--- /dev/null
+++ b/spec/migrations/20240113071052_queue_populate_topics_slug_column_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueuePopulateTopicsSlugColumn, feature_category: :groups_and_projects 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: :topics,
+          column_name: :id,
+          interval: described_class::DELAY_INTERVAL,
+          batch_size: described_class::BATCH_SIZE,
+          sub_batch_size: described_class::SUB_BATCH_SIZE
+        )
+      }
+    end
+  end
+end