acts-as-taggable-on is a commonly used gem in Rails for handling tagging functionality. However, similar features can also be implemented using PostgreSQL’s special Array type. For example, the acts-as-taggable-array-on gem utilizes PostgreSQL Arrays to achieve this. This article will compare the following solutions:

  1. acts-as-taggable-on
  2. acts-as-taggable-array-on
  3. pg_taggable

Scenario

Suppose there are two models: Post and User. A Post has a tags attribute and belongs to a User. Common functionalities might include:

  1. Searching for posts by tags.
  2. Retrieving the tags of a specific user.
  3. Counting the occurrences of a user’s tags (e.g., for a tag cloud).

Features

acts-as-taggable-on

Setup

acts-as-taggable-on uses two additional tables to store tags. Once the database tables are set up, you only need to configure it in the model.

1
2
3
4
5
class Post < ApplicationRecord
belongs_to :user

acts_as_taggable_on :tags
end

To enable retrieving tags by users, the User model needs to be additionally configured as a tagger.

1
2
3
4
5
class User < ApplicationRecord
has_many :posts

acts_as_tagger
end

Note that if the configuration is added later, the existing data will not have any tagger records.

Working with Tags

If there’s no need to record the tagger, you can use it directly like this:

1
2
3
4
post.tag_list = 'food, travel, technology'
post.save
post.tag_list
# => ["food", "travel", "technology"]

However, after using acts_as_tagger, you must use it like this:

1
2
3
4
5
user.tag(post, with: 'food, travel, technology', on: :tags)
post.owner_tags_on(user, :tags)

# Or retrieve all of them.
post.all_tags_list

The logic is a bit complex, since the tagger feature is actually intended for a different purpose. Here, it’s being used simply to distinguish tags by user.

Query

Use tagged_with to perform searches with various conditions.

1
2
3
4
5
6
7
8
9
Post.tagged_with(%w[food travel technology], on: :tags)
Post.tagged_with(%w[food travel technology], on: :tags, match_all: true)
Post.tagged_with(%w[food travel technology], on: :tags, any: true)
Post.tagged_with(%w[food travel technology], on: :tags, exclude: true)
Post.tagged_with(%w[food travel], on: :tags).tagged_with(%w[technology], on: :tags, exclude: true)

# pattern matching
Post.tagged_with(%w[food], on: :tags, wild: :suffix)
Post.tagged_with(%w[food], on: :tags, wild: :prefix)

The official Ransack documentation explains how to search records using acts-as-taggable-on.
However, in the latest version of Ransack, there’s a bug that we can’t use it directly.
Some additional setup is required, in the Post model, add:

1
2
3
4
5
6
7
8
9
10
11
def self.ransackable_attributes(auth_object = nil)
%w[tags_name]
end

def self.ransackable_associations(auth_object = nil)
%w[tags]
end

ransacker :tags_name do |parent|
Arel::Nodes::SqlLiteral.new("tags.name")
end

Then we can query it just like a regular field.

1
2
Post.joins(:tags).ransack(tags_name_eq: 'tag').result(distinct: true)
Post.joins(:tags).ransack(tags_name_in: %w[tag1 tag2]).result(distinct: true)

Tag List

1
2
3
4
5
6
7
8
9
10
# All tags of the user
user.owned_tags

# Tags in the user's posts
user.owned_tags.where(taggings: { taggable_type: 'Post' })

# Return something like
# => [#<ActsAsTaggableOn::Tag:0x00000001210b5000 id: 66, name: "tag65", created_at: "2025-04-11 09:10:24.285143000 +0000", updated_at: "2025-04-11 09:10:24.285143000 +0000", taggings_count: 100>, ...
# You can use pluck to extract the tags.
user.owned_tags.pluck(:name)

Counting

1
2
3
4
5
6
7
8
9
10
11
# all tags
Post.all.tag_counts_on(:tags)

# To count the number of tags for user_id 1
Post.where(user_id: 1).tag_counts_on(:tags)

# Return something like
# => [#<ActsAsTaggableOn::Tag:0x00000001210bc6c0 id: 34, name: "tag33", created_at: "2025-04-11 09:10:22.523571000 +0000", updated_at: "2025-04-11 09:10:22.523571000 +0000", taggings_count: 100, count: 10>
Post.where(user_id: 1).tag_counts_on(:tags).each do |record|
# record.name, record.count
end

acts-as-taggable-array-on

Setup

Since it’s using PostgreSQL Array, you need to create the relevant columns and indexes.

1
2
3
4
5
6
7
8
9
create_table :posts do |t|
t.bigint :user_id
t.string :tags, array: true, default: []

t.timestamps

t.index :user_id
t.index :tags, using: "gin"
end

Next, add the following in the model:

1
2
3
4
5
class Post < ApplicationRecord
belongs_to :user

taggable_array :tags
end

When using the string type, it is case-sensitive. If you want it to be case-insensitive, you should use citext instead.

Working with Tags

You can treat it like a regular array.

1
2
3
4
5
post.tags = %w[food travel technology]
post.save

post.tags
# => ["food", "travel", "technology"]

It will not remove duplicate items.

1
2
3
4
5
post.tags = %w[food food]
post.save

post.tags
# => ["food", "food"]

Query

It will generate the corresponding class methods.

  • with_any_#{tag_name}, equal to tagged_with(any: true)
  • with_all_#{tag_name}, equal to tagged_with with default options
  • without_any_#{tag_name}, equal to tagged_with(exclude: true)
  • without_all_#{tag_name}, acts-as-taggable-on has no corresponding functionality
1
2
3
4
5
Post.with_any_tags(%w[food travel technology])
Post.with_all_tags(%w[food travel technology])
Post.without_any_tags(%w[food travel technology])
Post.without_all_tags(%w[food travel technology])
Post.with_all_tags(%w[food travel]).without_any_tags(%w[technology])

There is no match_all or pattern matching.

Ransack Search

Scopes can be used directly for searching. Add the following to the Post model:

1
2
3
def self.ransackable_scopes(auth_object = nil)
%w[with_all_tags]
end

You can then search directly.

1
2
Post.ransack(with_all_tags: 'tag').result
Post.ransack(with_all_tags: 'tag1,tag2').result

Tag List

It will generate the corresponding class methods.

  • all_#{tag_name}
1
2
3
4
5
6
7
8
# all tags
Post.all_tags

# Tags in the user's posts
Post.all_tags { where(user_id: 1) }

# It will directly output an array of tag strings.
# => ["food", "travel", "technology"]

You can see that the usage for condition filtering is not easy to use. Using this, you can manually implement pattern matching:

1
2
tags = Post.all_tags.select { |t| t.start_with?('food') }
Post.without_any_tags(tags)

Counting

It will generate the corresponding class methods.

  • #{tag_name}_cloud
1
2
3
4
5
6
7
8
# all tags
Post.tags_cloud

# To count the number of tags for user_id 1
Post.tags_cloud { where(user_id: 1) }

# It will directly output an hash with counts.
# => {"tag0"=>100,

Similarly, the usage for condition filtering is not easy to use.

pg_taggable

Setup

Similar to acts-as-taggable-array-on, the migration needs to add the columns, as mentioned above. In the model, you should use taggable instead.

1
2
3
4
5
class Post < ApplicationRecord
belongs_to :user

taggable :tags
end

Working with Tags

Similar to acts-as-taggable-array-on, you can treat it like a regular array. Refer to the above.

But by default, it will remove duplicate items.

1
2
3
4
5
post.tags = %w[food food]
post.save

post.tags
# => ["food"]

If you want it to allow duplicate items, you can change the settings:

1
taggable :tags, unique: false

Query

Using the where method directly for queries, it will generate the following query:

  • any_#{tag_name}, equal to tagged_with(any: true)
  • all_#{tag_name}, equal to tagged_with with default options
  • #{tag_name}_in, acts-as-taggable-on has no corresponding functionality
  • #{tag_name}_eq, equal to tagged_with(match_all: true)
1
2
3
4
5
Post.where(any_tags: %w[food travel technology])
Post.where(all_tags: %w[food travel technology])
Post.where(tags_in: %w[food travel technology])
Post.where(tags_eq: %w[food travel technology])
Post.where(all_tags: %w[food travel]).where.not(any_tags: %w[travel])

You can also use not directly.

1
2
# equal to tagged_with(exclude: true)
Post.where.not(any_tags: %w[food travel technology])

It doesn’t support pattern matching, but it offers some different search methods.

Ransack Search

Scopes can be used directly for searching. Add the following to the Post model:

1
2
3
def self.ransackable_scopes(auth_object = nil)
%w[all_tags]
end

You can then search directly.

1
2
Post.ransack(all_tags: 'tag').result
Post.ransack(all_tags: 'tag1,tag2').result
1
2
3
4
5
6
7
8
9
10
11
Post.tags
# => #<ActiveRecord::Relation [#<Post tag: "food", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "technology", id: nil>]>

Post.tags.distinct.pluck(:tag)
# => ["food", "travel", "technology"]

# equal to Post.tags.distinct.pluck(:tag)
Post.uniq_tags

# Tags in the user's posts
Post.where(user_id: 1).uniq_tags

Using this, you can manually implement pattern matching:

1
2
tags = Post.tags.where("tag LIKE ?", "food%").distinct.pluck(:tag)
Post.where(any_tags: tags)

Counting

It will generate the corresponding class methods.

  • count_#{tag_name}
1
2
3
4
5
6
# equal to Post.tags.group(:tag).count
Post.count_tags
# => {"food"=>1, "travel"=>2, "technology"=>1}

# It will directly output an hash with counts.
Post.where(user_id: 1).count_tags

Benchmark

Next, let’s test the performance. I created a benchmark project, and the results are approximately as follows (reorganized and formatted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
---------------------------------
benchmark:create
acts-as-taggable-on 0.021 (± 0.0%) i/s (47.50 s/i) - 1.000 in 47.499407s
acts-as-taggable-array-on 0.487 (± 0.0%) i/s (2.05 s/i) - 3.000 in 6.161091s
pg_taggable 0.484 (± 0.0%) i/s (2.07 s/i) - 3.000 in 6.197196s
---------------------------------
benchmark:as_json
acts-as-taggable-on 0.055 (± 0.0%) i/s (18.21 s/i) - 1.000 in 18.208944s
acts-as-taggable-array-on 0.983 (± 0.0%) i/s (1.02 s/i) - 6.000 in 6.152531s
pg_taggable 1.034 (± 0.0%) i/s (967.47 ms/i) - 6.000 in 5.809804s
---------------------------------
benchmark:owned_tags
acts-as-taggable-on 2.471 (±40.5%) i/s (404.74 ms/i) - 12.000 in 5.234341s
acts-as-taggable-array-on 3.249 (± 0.0%) i/s (307.82 ms/i) - 16.000 in 5.031208s
pg_taggable 3.323 (± 0.0%) i/s (300.89 ms/i) - 17.000 in 5.165599s
---------------------------------
benchmark:count
acts-as-taggable-on 1.070 (± 0.0%) i/s (934.87 ms/i) - 6.000 in 5.621106s
acts-as-taggable-array-on 3.061 (± 0.0%) i/s (326.66 ms/i) - 16.000 in 5.250155s
pg_taggable 3.273 (± 0.0%) i/s (305.55 ms/i) - 17.000 in 5.215984s
---------------------------------
benchmark:like
acts-as-taggable-on 6.360 (±78.6%) i/s (157.22 ms/i) - 26.000 in 5.050598s
acts-as-taggable-array-on 0.807 (± 0.0%) i/s (1.24 s/i) - 5.000 in 6.217276s
pg_taggable 0.928 (± 0.0%) i/s (1.08 s/i) - 5.000 in 5.392569s
---------------------------------
benchmark:all_tags[1]
acts-as-taggable-on 4.025 (±24.8%) i/s (248.46 ms/i) - 20.000 in 5.054010s
acts-as-taggable-array-on 10.377 (±19.3%) i/s (96.37 ms/i) - 50.000 in 5.070872s
pg_taggable 10.658 (±18.8%) i/s (93.83 ms/i) - 51.000 in 5.045711s
---------------------------------
benchmark:all_tags[5]
acts-as-taggable-on 0.463 (± 0.0%) i/s (2.16 s/i) - 3.000 in 6.480544s
acts-as-taggable-array-on 15.917 (±50.3%) i/s (62.83 ms/i) - 66.000 in 5.082511s
pg_taggable 11.104 (±27.0%) i/s (90.06 ms/i) - 52.000 in 5.009450s
---------------------------------
benchmark:all_tags[10]
acts-as-taggable-on 0.117 (± 0.0%) i/s (8.53 s/i) - 1.000 in 8.533582s
acts-as-taggable-array-on 17.848 (±50.4%) i/s (56.03 ms/i) - 76.000 in 5.207719s
pg_taggable 11.504 (±17.4%) i/s (86.93 ms/i) - 56.000 in 5.007423s
---------------------------------
benchmark:any_tags[1]
acts-as-taggable-on 3.362 (± 0.0%) i/s (297.47 ms/i) - 17.000 in 5.135870s
acts-as-taggable-array-on 5.745 (±17.4%) i/s (174.05 ms/i) - 28.000 in 5.024439s
pg_taggable 6.228 (±32.1%) i/s (160.56 ms/i) - 30.000 in 5.149333s
---------------------------------
benchmark:any_tags[5]
acts-as-taggable-on 2.637 (±37.9%) i/s (379.24 ms/i) - 13.000 in 5.161145s
acts-as-taggable-array-on 1.807 (± 0.0%) i/s (553.50 ms/i) - 10.000 in 5.558059s
pg_taggable 1.851 (± 0.0%) i/s (540.14 ms/i) - 10.000 in 5.467386s
---------------------------------
benchmark:any_tags[10]
acts-as-taggable-on 2.171 (±46.1%) i/s (460.58 ms/i) - 11.000 in 5.311140s
acts-as-taggable-array-on 1.490 (± 0.0%) i/s (671.32 ms/i) - 8.000 in 5.378806s
pg_taggable 1.509 (± 0.0%) i/s (662.76 ms/i) - 8.000 in 5.308514s
---------------------------------
benchmark:exclude_tags[1]
acts-as-taggable-on 2.572 (± 0.0%) i/s (388.73 ms/i) - 13.000 in 5.167899s
acts-as-taggable-array-on 1.269 (± 0.0%) i/s (787.84 ms/i) - 7.000 in 5.544178s
pg_taggable 1.323 (± 0.0%) i/s (755.66 ms/i) - 7.000 in 5.306183s
---------------------------------
benchmark:exclude_tags[5]
acts-as-taggable-on 2.077 (± 0.0%) i/s (481.49 ms/i) - 11.000 in 5.325972s
acts-as-taggable-array-on 1.112 (± 0.0%) i/s (899.34 ms/i) - 6.000 in 5.440142s
pg_taggable 1.171 (± 0.0%) i/s (854.11 ms/i) - 6.000 in 5.129889s
---------------------------------
benchmark:exclude_tags[10]
acts-as-taggable-on 1.956 (±51.1%) i/s (511.35 ms/i) - 10.000 in 5.371704s
acts-as-taggable-array-on 0.998 (± 0.0%) i/s (1.00 s/i) - 5.000 in 5.023394s
pg_taggable 1.014 (± 0.0%) i/s (985.74 ms/i) - 6.000 in 5.918865s
---------------------------------
benchmark:match_all_tags[1]
acts-as-taggable-on 1.495 (± 0.0%) i/s (668.81 ms/i) - 8.000 in 5.379761s
pg_taggable 7.993 (±37.5%) i/s (125.11 ms/i) - 37.000 in 5.061658s
---------------------------------
benchmark:match_all_tags[5]
acts-as-taggable-on 0.396 (± 0.0%) i/s (2.53 s/i) - 2.000 in 5.051440s
pg_taggable 17.332 (±69.2%) i/s (57.70 ms/i) - 54.000 in 5.137717s
---------------------------------
benchmark:match_all_tags[10]
acts-as-taggable-on 0.112 (± 0.0%) i/s (8.90 s/i) - 1.000 in 8.897128s
pg_taggable 14.906 (±73.8%) i/s (67.09 ms/i) - 48.000 in 5.091181s
---------------------------------

It can be seen that PostgreSQL Array performs better in most cases, while acts-as-taggable-on has lower performance in the following areas:

  • Adding new tags
  • Reading tags
  • Default search mode (all_tags)
  • match_all
  • Counting

The first three are common use cases.

acts-as-taggable-on performs better in less commonly used pattern matching scenarios.

Conclusions

We compare in a table:

Itemacts-as-taggable-onacts-as-taggable-array-onpg_taggable
FeaturesOXO
Easy to useO
PerformanceXOO
General-purposeOXX
Ransack SupportOO
  • The latter two are only available when using PostgreSQL. If you’re certain about using PostgreSQL, pg_taggable could be a good option.
  • At the beginning, I intended to use acts-as-taggable-array-on, but its functionality was not very user-friendly, so I ended up writing pg_taggable by myself.