# Administrative Bulk Operations

**URL:** https://meta.discourse.org/t/administrative-bulk-operations/118349
**Category:** Self-Hosting
**Tags:** configuring, reference
**Created:** [May 22, 2019, 12:36am UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349 "2019-05-22T00:36:34Z")
**Posts on this page:** 11
**Page:** 1

<div class="post-metadata">

### Author: ![Discourse](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/discourse/32/148734_2.png) [@Discourse](https://meta.discourse.org/u/Discourse)
#### Post date: [May 22, 2019, 12:36am UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/1 "2019-05-22T00:36:34Z")

</div>

Below you will find a collection of bulk operations that can be initiated from the command line. You will need SSH access, so if you are a hosted customer, you will need to contact the Discourse team about running these commands.

> ⚠ Before working with the console it is **extremely** important that you have a recent backup. Mistakes can always happen!

First thing to do is enter your site’s container:

```plaintext
cd /var/discourse
./launcher enter app

```

**Additional Guides:**

- [Performing bulk actions as a moderator](https://meta.discourse.org/t/performing-bulk-actions-as-a-moderator/272832)
- [How do I set tag tracking level defaults historically](https://meta.discourse.org/t/how-do-i-set-tag-tracking-level-defaults-historically/111371)
- [(Obsolete) Set category tracking level defaults historically](https://meta.discourse.org/t/how-do-i-set-category-tracking-level-defaults-historically/53165)
- [Change ownership of all posts by a specific user](https://meta.discourse.org/t/change-ownership-of-all-posts-by-a-specific-user/48921)
- [Replace a string in all posts](https://meta.discourse.org/t/replace-a-string-in-all-posts/48729)
- [Edit a user preference for everyone or a subset of users](https://meta.discourse.org/t/edit-a-user-setting-for-all-discourse-users/25162)
- [Modify trust level for all users](https://meta.discourse.org/t/how-to-modify-trust-level-for-all-users/103628)
- [Apply auto-close to existing topics](https://meta.discourse.org/t/apply-auto-close-to-existing-topics/70450)
- [Logout all users through the rails console](https://meta.discourse.org/t/how-to-logout-all-users-through-the-rails-console/93239)
- [Convert all existing topics in category to wikis](https://meta.discourse.org/t/convert-all-existing-topics-in-category-to-wikis/54493)

## Change topic status

* * *

Before running the following commands, run [`rails c`](https://guides.rubyonrails.org/command_line.html#bin-rails-console) to enter the console.

- Unlist all topics within a category (excludes post action)

- Unlist all topics within a category (includes post action)

- Close all topics created before a specified date (includes post action)

## Moving topics

* * *

Move a collection of topics from one category to another

```ruby
rails c
topic_ids = [12,16,29]
cat_to = Category.find_by_slug('faq')
Topic.where(id: topic_ids).update_all(category_id: cat_to.id)
Category.update_stats

```

## Users

### Delete subset of users

* * *

Delete users that have never posted and have not visited since a specified date

```ruby
rails c
User.joins(:user_stat).where("user_stats.post_count = 0 AND previous_visit_at <= '2016-05-20'::timestamp").destroy_all

```

### Suspend a set of users based on criteria

* * *

Set who will be logged as suspending the users

```ruby
rails c
logger = StaffActionLogger.new(User.find_by(username_lower: "tshenry"))

```

Create a suspension timeframe and reason

```ruby
suspend_till = DateTime.new(2057,12,31)
reason = 'Completed Course'

```

In this example, our user criteria will be group membership.

```ruby
target_group = Group.find_by_name("summer_students")
users = User.joins(:group_users).where(group_users: {group_id: target_group.id})

```

Suspend each user based on the values established above:

```ruby
users.find_each do |u|
  u.suspended_till = suspend_till
  u.suspended_at = DateTime.now
  u.save!

  logger.log_user_suspend(u,reason)
  putc '.'
end

```

### Update user suspension reasons

* * *

Perhaps you suspended users who completed a class (see example [above](https://meta.discourse.org/t/administrative-bulk-operations/118349#heading--4)), and now you want to add the year of the class as you’ve taught multiple years.

```plaintext
UserHistory.where(action: 10, details: "Completed Course").update_all(details: "Completed 2018 Course")

```

### Unsuspend users

* * *

If you need to unsuspend users in bulk, say because they were part of a previous year’s cohort and are returning for this year, you can do so as shown below. In the example, we’re finding users by their user id.

```plaintext
user_list = [1, 3, 5, 7, 11]
users = User.where("id in (?)", user_list)

users.each do |user|
    user.suspended_till = nil
    user.suspended_at = nil
    user.save!
    StaffActionLogger.new(User.find(-1)).log_user_unsuspend(user)

    DiscourseEvent.trigger(:user_unsuspended, user: user)
end

```

## Export/Import

### Export/Import all site settings

* * *

To simply print out all of the settings that have been changed on your site, run:

```ruby
rake site_settings:export

```

If you want to export the settings to a file:

```ruby
rake site_settings:export > saved_settings.yml

```

If you want to import settings from a file:

```ruby
rake site_settings:import < saved_settings.yml

```

### Export/Import categories

* * *

There are two options for exporting and one method to handle importing.

**Export a set of complete categories**

First get a list of your category IDs:

```ruby
rake categories:list

```

Then space-separate the category IDs in the export rake task. For example:

```ruby
rake export:categories["12 6"]

```

**Export your site’s category structure**

This is essentially copying the “skeleton” of your Discourse site. It includes every category along with any groups associated with existing category permissions. It **does not include topics** :

```ruby
rake export:category_structure

```

If you want the category structure along with any groups associated with the category permissions **and** any members of those groups:

```ruby
rake export:category_structure[true]

```

**Importing a category file**

Use the exported file’s name like the example below:

```ruby
rake import:file["category-export-2019-05-16-052430.json"]

```

### Export/Import groups

* * *

**Export all of the user groups**

```ruby
rake export:groups

```

**Export all of the user groups including users**

```ruby
rake export:groups[true]

```

**Importing a group file**  
Use the exported file’s name like the example below:

```ruby
rake import:file["group-export-2019-05-16-052430.json"]

```

## Set permissions for multiple categories

* * *

⚠ Note that this will remove any existing access restrictions you have set up for the categories involved. Make sure to include all of the relevant permissions.

1. Get a list of categories along with their IDs

2. Create an array with the category IDs you wish to target.

3. Change the permissions. The `set_permissions` function can utilize the following parameters: `:full`, `:create_post`, `:readonly`

## Bulk Tag All Topics Based on a Keyword

The following script will allow you to tag topics based on the presence of a keyword in the topic title or its posts. Start by creating an array of keywords:

```ruby
rails c
keywords = ['apples','oranges']

```

Next we need to define a method:

```ruby
def tag_by_keyword(word, tag_name)
  tag = Tag.find_by_name(tag_name) || Tag.create(name: tag_name)
  keyword_topics = Topic.joins(:posts).where("topics.title ~* :keyword or posts.raw ~* :keyword", keyword: "\\y#{word}\\y").distinct

  keyword_topics.each do |topic|
    if topic.tags.exclude?(tag)
      topic.tags << tag
    end
  end
end

```

And finally run each keyword through the method. The following with tag each relevant topic with a tag called “fruit”:

```ruby
keywords.each { |word| tag_by_keyword(word, 'fruit') }

```

## Bulk Tag All Topics Within a Category

* * *

Template: `rake tags:bulk_tag_category["<tag>|<tag>",<category_id>]`  
This would be particularly useful when trying to convert a category to a tag.

First, use the following rake task to find the relevant category ID.

```ruby
rake categories:list

```

Tag all topics of the category you specify. In this example, you would be tagging all topics in the category with an ID of 6 with the “support” tag. ⚠ this will remove all other tags from each topic.

```ruby
rake tags:bulk_tag_category["support",6]

```

Append all topics of the category you specify. In this example, you would be adding the “support” tag to all topics in the category with an ID of 6, while keeping existing tags.

```ruby
rake tags:bulk_tag_category["support",6,true]

```

## Move all topics with a specific tag to a single category

When trying to restructure your Discourse site, you may find that you want to move a collection of topics without triggering any notifications. One way to do this is to create a temporary tag, apply the tag to appropriate topics, move the topics to a specific category using the code below, then finally delete the temporary tag.

Get the tag.

```ruby
rails c
tag = Tag.find_by_name("tutorial")

```

Get destination category.

- For regular categories:

```plaintext
cat_to = Category.find_by_slug('guides')

```

- For subcategories:

```plaintext
cat_to = Category.find_by_slug('child-slug','parent-slug')

```

Move the tagged topics to the destination category.

```ruby
Topic.joins(:topic_tags).where("topic_tags.tag_id = ?", tag.id).update_all(category_id: cat_to.id)

```

Update the topic counts of the affected categories.

```ruby
Category.update_stats
CategoryTagStat.update_topic_counts

```

## Move all topics from one category to another

* * *

Find the category IDs with the following rake task:

```ruby
rake categories:list

```

The first value should be the starting category ID. The second value should be the destination category ID.

```ruby
rake categories:move_topics[15,6]

```

> **Rails Console Script**
>
> ```rb
> cat_from_id = XX # Category to move topics from 
> cat_to_id = XX # Category to move topics to 
> Topic.where(category_id: cat_from_id).update_all(category_id: cat_to_id)
> Category.update_stats
> CategoryTagStat.update_topic_counts
> 
> ```

## Change owner of all topics in categories

* * *

Find the category IDs with the following rake task:

```ruby
rake categories:list

```

Specify the new owner and categories to operate on. The categories should be an array of category ids, categories `1`, `2` and `3` in the example:

```ruby
rails c
user = User.find_by(username_lower: "lowercase-username")
categories = [1, 2, 3]

```

Get all topic ids for the given categories and change the owner of the first post in all matched topics.

```ruby
topics = Topic.where(category_id: categories).pluck(:id)

topics.each do |topic|
  PostOwnerChanger.new(
    post_ids: Post.where(topic_id: topic).where(post_number: 1).pluck(:id),
    topic_id: topic,
    new_owner: user,
    acting_user: Discourse.system_user,
    skip_revision: true
  ).change_owner!
end

```

## Grant a badge to all group members

* * *

Grant a badge to all users that belong to a specific group. The first value is the group ID and the second is the badge ID.

```ruby
rails c
Group.find_by_name("event_participants").id
Badge.find_by_name("event_badge").id
exit
rake groups:grant_badge[42,102]

```

⚠ Note that the above rake task only grants a badge, it will not revoke a previously granted badge if a user is no longer part of the specified group. If you need to bulk revoke badges for all users that are no longer part of a group, you can run the following:

```ruby
rails c

badge_id = Badge.find_by_name("Some Group Member").id

group = Group.find_by_name("Some_Group")

group_user_id = group.users.pluck("id")

userBadge = UserBadge.where.not(user_id: group_user_id).where(badge_id: badge_id)

userBadge.each do |ub|
  BadgeGranter.revoke(ub, revoked_by: Discourse.system_user)
end

exit

```

## Ensure all users are at their automatic trust level

* * *

Say you set the default trust level for new or invited users to a value that isn’t working out quite the way you expected (such as TL4). Now you want to change it so your users are at the trust level they would be automatically, given their current stats. The following commands will ensure all users are at the trust level they should be according to [Understanding Discourse Trust Levels](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/). **Note** : users with locked trust-levels will not be affected.

Make sure all users are set to the correct trust level:

```ruby
rails c
User.all.find_each do |user|
  Promotion.recalculate(user)
end

```

Refresh the group stats to reflect the changes:

```ruby
Group.ensure_consistency!

```

## Topic maintenance scripts

The following ruby scripts demonstrate how to perform automated maintenance on topics based on activity dates and other criteria. These scripts combine SQL queries to identify topics with Ruby code to perform actions on them, and must be run via the rails console for your site.

Each script follows a similar pattern:

1. A SQL query that identifies relevant topics
2. Ruby code that processes each topic and applies the desired actions
3. Basic error handling and logging

These scripts can be customized by:

- Adjusting time periods (e.g., ‘6 MONTH’, ‘1 YEAR’, ‘2 YEAR’)
- Changing category selections to match your forum structure
- Modifying which actions to take (close, unlist, or move)
- Adding additional conditions like post count or view thresholds

### Close, Unlist, and Move Inactive Topics

This script identifies topics that meet the following criteria:

- In a specific category
- Open
- Unsolved (using the Discourse Solved Plugin)
- No recent activity within a specific timeframe

Then performs multiple actions:

- Closes them,
- Unlists them, and
- Moves them to a designated category for outdated content

> **SQL Query**
>
> ```sql
> WITH topic_list AS (
> SELECT ua.target_topic_id, MAX(ua.created_at) "created_at"  
> FROM user_actions ua
> INNER JOIN topics t ON t.id = ua.target_topic_id
> INNER JOIN categories c ON c.id = t.category_id
> LEFT JOIN discourse_solved_solved_topics solved ON solved.topic_id = t.id
> WHERE t.closed = false
> AND t.category_id = [CATEGORY_ID]
> AND solved.topic_id IS NULL
> AND t.deleted_at IS NULL
> GROUP BY ua.target_topic_id
> HAVING MAX(ua.created_at) <= (CURRENT_DATE - (INTERVAL '[TIME_PERIOD]'))
> ORDER BY "created_at" DESC
> )
>     
> SELECT '' AS total, target_topic_id AS topic_id, created_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY created_at DESC
> 
> ```

> **Combined SQL + Script**
>
> ```rb
> sql = "WITH topic_list AS (
> SELECT ua.target_topic_id, MAX(ua.created_at) \"created_at\"  
> FROM user_actions ua
> INNER JOIN topics t ON t.id = ua.target_topic_id
> INNER JOIN categories c ON c.id = t.category_id
> LEFT JOIN discourse_solved_solved_topics solved ON solved.topic_id = t.id
> WHERE t.closed = false
> AND t.category_id = [CATEGORY_ID]
> AND solved.topic_id IS NULL
> AND t.deleted_at IS NULL
> GROUP BY ua.target_topic_id
> HAVING MAX(ua.created_at) <= (CURRENT_DATE - (INTERVAL '[TIME_PERIOD]'))
> ORDER BY \"created_at\" DESC
> )
>     
> SELECT '' AS total, target_topic_id AS topic_id, created_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY created_at DESC"
> 
> results = ActiveRecord::Base.connection.execute(sql)
> user = Discourse.system_user
> destination_category = Category.find([DESTINATION_CATEGORY_ID])
> 
> puts "Found #{results.count} topics to process"
> 
> results.each do |row|
> begin
> topic = Topic.find(row["topic_id"])
>     
> # 1. Move to destination category
> topic.update!(category_id: destination_category.id)
> puts "#{topic.id} moved to destination category"
>     
> # 2. Close the topic
> topic.update_status('closed', true, user, until: nil)
> puts "#{topic.id} is closed"
>     
> # 3. Unlist the topic
> topic.update_status('visible', false, user, until: nil)
> puts "#{topic.id} is unlisted"
> 
> # Error Handling 
> rescue => e
> puts "Error processing topic #{row["topic_id"]}: #{e.message}"
> end
> end
> 
> puts "Process completed"
> 
> ```

### Close Solved Topics with No Recent Activity

This script closes solved topics that have been inactive for a defined period. This can help keep your forum tidy while preserving valuable solved topics.

This script identifies topics that meet the following criteria:

- In a specific category
- Open
- Solved (using the Discourse Solved Plugin)
- No recent activity within a specific timeframe

> **SQL Query**
>
> ```sql
> WITH topic_list AS (
> SELECT ua.target_topic_id, MAX(ua.created_at) "created_at"  
> FROM user_actions ua
> INNER JOIN topics t ON t.id = ua.target_topic_id
> INNER JOIN categories c ON c.id = t.category_id
> INNER JOIN discourse_solved_solved_topics solved ON solved.topic_id = t.id
> WHERE t.closed = false
> AND t.category_id IN ([CATEGORY_IDS])
> AND t.deleted_at IS NULL
> GROUP BY ua.target_topic_id
> HAVING MAX(ua.created_at) <= (CURRENT_DATE - (INTERVAL '[TIME_PERIOD]'))
> ORDER BY "created_at" DESC
> )
>     
> SELECT '' AS total, target_topic_id AS topic_id, created_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY created_at DESC
> 
> ```

> **Combined SQL + Script**
>
> ```rb
> sql = "WITH topic_list AS (
> SELECT ua.target_topic_id, MAX(ua.created_at) \"created_at\"  
> FROM user_actions ua
> INNER JOIN topics t ON t.id = ua.target_topic_id
> INNER JOIN categories c ON c.id = t.category_id
> INNER JOIN discourse_solved_solved_topics solved ON solved.topic_id = t.id
> WHERE t.closed = false
> AND t.category_id IN ([CATEGORY_IDS])
> AND t.deleted_at IS NULL
> GROUP BY ua.target_topic_id
> HAVING MAX(ua.created_at) <= (CURRENT_DATE - (INTERVAL '[TIME_PERIOD]'))
> ORDER BY \"created_at\" DESC
> )
>     
> SELECT '' AS total, target_topic_id AS topic_id, created_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY created_at DESC"
> 
> results = ActiveRecord::Base.connection.execute(sql)
> user = Discourse.system_user
> 
> puts "Found #{results.count} topics to process"
> 
> results.each do |row|
> begin
> topic = Topic.find(row["topic_id"])
>      
> # Close the topic
> topic.update_status('closed', true, user, until: nil)
> puts "#{topic.id} is closed"
> 
> # Error Handling 
> rescue => e
> puts "Error processing topic #{row["topic_id"]}: #{e.message}"
> end
> end
> 
> puts "Process completed"
> 
> ```

### Archive Previously Closed Topics

This script identifies topics that were previously closed before a specific date and moves them to an archive category while unlisting them.

> **SQL Query**
>
> ```sql
> WITH topic_list AS (
> SELECT 
> t.id AS topic_id, 
> tt.execute_at AS closed_at
> FROM topics t
> INNER JOIN categories c ON c.id = t.category_id
> LEFT JOIN topic_timers tt ON tt.topic_id = t.id AND tt.status_type IN (1, 8)
> WHERE t.closed = true
> AND t.category_id IN ([CATEGORY_IDS])
> AND t.deleted_at IS NULL
> AND tt.execute_at IS NOT NULL
> AND tt.execute_at <= (CURRENT_DATE - INTERVAL '[TIME_PERIOD]')
> ORDER BY tt.execute_at DESC
> )
>     
> SELECT '' AS total, topic_id, closed_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY closed_at DESC
> 
> ```

> **Combined SQL + Script**
>
> ```rb
> sql = "WITH topic_list AS (
> SELECT 
> t.id AS topic_id, 
> tt.execute_at AS closed_at
> FROM topics t
> INNER JOIN categories c ON c.id = t.category_id
> LEFT JOIN topic_timers tt ON tt.topic_id = t.id AND tt.status_type IN (1, 8)
> WHERE t.closed = true
> AND t.category_id IN ([CATEGORY_IDS])
> AND t.deleted_at IS NULL
> AND tt.execute_at IS NOT NULL
> AND tt.execute_at <= (CURRENT_DATE - INTERVAL '[TIME_PERIOD]')
> ORDER BY tt.execute_at DESC
> )
>     
> SELECT '' AS total, topic_id, closed_at 
> FROM topic_list
> UNION
> SELECT ''||COUNT(*), 0, CURRENT_DATE
> FROM topic_list
> ORDER BY closed_at DESC"
> 
> results = ActiveRecord::Base.connection.execute(sql)
> user = Discourse.system_user
> archive_category = Category.find([ARCHIVE_CATEGORY_ID])
> 
> puts "Found #{results.count} topics to process"
> 
> results.each do |row|
> begin
> topic = Topic.find(row["topic_id"])
>     
> # 1. Move to archive category
> topic.update!(category_id: archive_category.id)
> puts "#{topic.id} moved to archive category"
>     
> # 2. Unlist the topic
> topic.update_status('visible', false, user, until: nil)
> puts "#{topic.id} is unlisted"
> 
> # Error Handling 
> rescue => e
> puts "Error processing topic #{row["topic_id"]}: #{e.message}"
> end
> end
> 
> puts "Process completed"
> 
> ```

## Destructive rake tasks

* * *

**Delete entire categories**

The following will allow you to destroy multiple categories, along with any subcategories and topics that belong to those categories.

Print out a list of category IDs

```ruby
rake categories:list

```

Destroy a set of categories based on their ID

```ruby
rake destroy:categories[10,11,12,18,30]

```

**Delete all topics in a category**

> [@Bulk delete all topics in a category](https://meta.discourse.org/t/deleting-all-topics-in-a-category/97904):
>
> bookmark This guide provides instructions on how to bulk delete all topics within a category on a self-hosted Discourse instance. person_raising_hand Required user level: System Administrator warning SSH access to your server is required Removing all topics from a category can be necessary for various reasons, such as reorganizing content or clearing outdated discussions. This guide walks you through the steps to accomplish this task safely on a self-hosted Discourse server. …

**Remove all personal messages**

```ruby
rake destroy:private_messages

```

**Destroy all groups**

```ruby
rake destroy:groups

```

**Destroy all non-admin users**

```ruby
rake destroy:users

```

**Destroy site stats**

```ruby
rake destroy:stats

```

**Anonymize all users except staff**

```ruby
rake users:anonymize_all

```

**Permanently delete a set of posts**

The following rake task will hard-delete a list of posts based on their ID. **If a post is the first post in a topic, all posts in that topic will be hard-deleted.** Before you can successfully run the task, the `can_permanently_delete` site setting must be [enabled](https://meta.discourse.org/t/enable-setting-to-allow-admins-to-permanently-delete-data/206678).

⚠ Once a post is deleted by this task, it will no longer exist in the database and cannot be undeleted.

There are two possible approaches:

- _Option 1_ – Pass a comma-separated list of post IDs as an argument

- _Option 2_ – Specify a text file with a comma-separated list of post IDs (ideal for large sets of posts).

* * *

I’ve tried to include the most useful rake tasks in this topic, but there are many others packaged with Discourse. If you would like to see a comprehensive list, you can use the following:

All tasks that have descriptions

```plaintext
rake --tasks

```

All tasks, including those that do not have descriptions

```plaintext
rake -AT

```

> Last edited by @awesomerobot 2026-06-08T21:53:49Z
> 
> > **Check document**
> >
> > Perform check on document:

---

<div class="post-metadata">

### Author: ![Ventrilo](https://avatars.discourse-cdn.com/v4/letter/v/b487fb/32.png) [@Ventrilo](https://meta.discourse.org/u/Ventrilo)
#### Post date: [February 9, 2024, 12:52am UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/29 "2024-02-09T00:52:30Z")

</div>

@Taylor what about bulk updating all topics with a different timestamp?

Say I wanted all topics on my discourse instance to instantly be changed to show as timestamp as if they was created Today how would I do that?

---

<div class="post-metadata">

### Author: ![Firepup650](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/firepup650/32/465200_2.png) [@Firepup650](https://meta.discourse.org/u/Firepup650)
#### Post date: [February 9, 2024, 12:59am UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/30 "2024-02-09T00:59:11Z")

</div>

Probably a modification of this query:

> [@tshenry](#):
>
> ## Change owner of all topics in categories
> 
> * * *
> 
> Find the category IDs with the following rake task:
> 
> ```ruby
> rake categories:list
> 
> ```
> 
> Specify the new owner and categories to operate on. The categories should be an array of category ids, categories `1`, `2` and `3` in the example:
> 
> ```ruby
> rails c
> user = User.find_by(username_lower: "lowercase-username")
> categories = [1, 2, 3]
> 
> ```
> 
> Get all topic ids for the given categories and change the owner of the first post in all matched topics.
> 
> ```ruby
> topics = Topic.where(category_id: categories).pluck(:id)
> 
> topics.each do |topic|
> PostOwnerChanger.new(
> post_ids: Post.where(topic_id: topic).where(post_number: 1).pluck(:id),
> topic_id: topic,
> new_owner: user,
> acting_user: Discourse.system_user,
> skip_revision: true
> ).change_owner!
> end
> 
> ```

(Assuming they’re in one category)

---

<div class="post-metadata">

### Author: ![JammyDodger](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/jammydodger/32/254611_2.png) [@JammyDodger](https://meta.discourse.org/u/JammyDodger)
#### Post date: [June 7, 2024, 9:50pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/31 "2024-06-07T21:50:13Z")

</div>

4 posts were split to a new topic: [How to import category with category permissions?](https://meta.discourse.org/t/how-to-import-category-with-category-permissions/311197)

---

<div class="post-metadata">

### Author: ![wal](https://avatars.discourse-cdn.com/v4/letter/w/d6d6ee/32.png) [@wal](https://meta.discourse.org/u/wal)
#### Post date: [August 14, 2024, 3:55pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/32 "2024-08-14T15:55:01Z")

</div>

> [@Discourse](#):
>
> If you want to export the settings to a file:
> 
> `rake site_settings:export > saved_settings.yml`

Considering that this command needs to be run inside the Docker container, and the default `pwd` inside the container when running `./launcher enter app` appears to be `/var/www/discourse`, the command described here seems a little odd for a few reasons

- the `site_settings` includes the secret access keys used for S3 and other API’s, it feels like these should not be stored in a file under `/var/www` since that is traditionally the location for files that get served to the web
- since we are inside the container at this default `pwd`, I would expect the file saved here would be lost the container stops?

From inside the container, I used this command `mount | grep ^/dev/ | grep -v /etc/` to determine that the location `/shared` inside the container appears to map back to `/var/discourse/shared/standalone` on the host system. So it seems like maybe the command should be something like this?

```plaintext
cd /var/discourse
./launcher enter app
rake site_settings:export | grep -v key | grep -v secret > /shared/site_settings_$(date "+%Y-%m-%d-%H-%M-%S").yml

```

this would leave the file in a location such as `/var/discourse/shared/standalone/site_settings_2024-08-14-15-53-11.yml` on the host system

note that the extra `grep` commands piped here will remove any lines with the word “key” or “secret” in them in order to remove API keys but would also remove lines that included those words for non-sensitive reasons

does this sound about right?

---

<div class="post-metadata">

### Author: ![pfaffman](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/pfaffman/32/120154_2.png) [@pfaffman](https://meta.discourse.org/u/pfaffman)
#### Post date: [August 14, 2024, 4:55pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/33 "2024-08-14T16:55:09Z")

</div>

> [@wal](#):
>
> these should not be stored in a file under `/var/www` since that is traditionally the location for files that get served to the web

True, but I’m pretty sure that Discourse won’t serve the settings. You can run the command from any directory, and it’s assumed that if you’re doing this kind of stuff you understand what you’re doing.

So you could just

```plaintext
cd /shared/
mkdir -p my-settings
cd my-settings
rake ...

```

before you run the rake task.

Sure the dump has the keys in it, but lots of that is in plaintext in a bunch of places already (e.g., if you follow recommended procedures your S3 keys are in `app.yml`).

> [@wal](#):
>
> does this sound about right?

Yes.

---

<div class="post-metadata">

### Author: ![wal](https://avatars.discourse-cdn.com/v4/letter/w/d6d6ee/32.png) [@wal](https://meta.discourse.org/u/wal)
#### Post date: [August 19, 2024, 1:12pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/36 "2024-08-19T13:12:57Z")

</div>

oh I was not aware that the `rake` tasks would still work outside of the app’s pwd, that makes more sense then yea, thanks

---

<div class="post-metadata">

### Author: ![pfaffman](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/pfaffman/32/120154_2.png) [@pfaffman](https://meta.discourse.org/u/pfaffman)
#### Post date: [August 19, 2024, 1:15pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/37 "2024-08-19T13:15:20Z")

</div>

> [@wal](#):
>
> I was not aware that the `rake` tasks would still work outside of the app’s pwd

It still surprises me!

But also you can put the full path of the dump when you pipe out to a file.

---

<div class="post-metadata">

### Author: ![happyhappy](https://avatars.discourse-cdn.com/v4/letter/h/58f4c7/32.png) [@happyhappy](https://meta.discourse.org/u/happyhappy)
#### Post date: [February 25, 2026, 9:41pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/38 "2026-02-25T21:41:12Z")

</div>

According to these instructions, I can use

> rake export:groups

to export the set of Groups and import new Groups using the same format. However, when I create a new Group following the exported format, the –trace complains:

> ActiveRecord::RecordInvalid: Validation failed: You cannot allow membership requests for a group without any owners. (ActiveRecord::RecordInvalid)

(I get the same error if I don’t include the user\_ids.)

Here is my import file:

> {“groups”:[{“id”:352,“name”:“NewGroup1”,“created\_at”:“2026-02-18T17:56:01.807Z”,“automatic\_membership\_email\_domains”:“”,“primary\_group”:false,“title”:null,“grant\_trust\_level”:null,“incoming\_email”:null,“bio\_raw”:“This is a NewGroup.”,“allow\_membership\_requests”:true,“full\_name”:“NewGroup1”,“default\_notification\_level”:3,“visibility\_level”:2,“public\_exit”:true,“public\_admission”:false,“membership\_request\_template”:null,“messageable\_level”:3,“mentionable\_level”:3,“members\_visibility\_level”:2,“publish\_read\_state”:false,“user\_ids”:[1,2]}]}

I don’t see any way to indicate which of the users is an owner.

I even added another record of {“group\_users”:[…} to designate an owner, but continue to get the same error.

Does this work? Has anyone imported new Groups successfully and if so, what is the secret?

---

<div class="post-metadata">

### Author: ![nathank](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/nathank/32/290039_2.png) [@nathank](https://meta.discourse.org/u/nathank)
#### Post date: [June 9, 2026, 7:19am UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/39 "2026-06-09T07:19:49Z")

</div>

> [@happyhappy](#):
>
> “allow\_membership\_requests”:true

A bit late sorry, but this is your problem. If you make that `false` by either editing the group before export or hacking the .json you’ll be away.

The problem is that setting switched on demands that there be an owner to handle the requests.

You can then patch it up as you see fit once migrated.

---

<div class="post-metadata">

### Author: ![happyhappy](https://avatars.discourse-cdn.com/v4/letter/h/58f4c7/32.png) [@happyhappy](https://meta.discourse.org/u/happyhappy)
#### Post date: [June 12, 2026, 7:43pm UTC](https://meta.discourse.org/t/administrative-bulk-operations/118349/40 "2026-06-12T19:43:12Z")

</div>

That did it! Groups import without error now.

Thank you!
