Set #
Arrays store elements in order and allow duplicates. Sometimes you need neither — you just need a collection where every element is guaranteed unique and you frequently check whether a value is in it. This is where Set comes in. Set is a data structure implementing the mathematical set concept: no duplicates, operations like union and intersection available natively, and element existence checks far faster than Array. Set is part of the Ruby standard library — you need require "set" before using it, but no gem installation needed. This article covers how Set works, when to choose it over Array, all available set operations, and Set’s integration with Enumerable.
Why Set, Not Array? #
Before discussing its API, it’s important to understand the problems Set solves — because without this context, Set feels like a more limited Array.
The first problem: unwanted duplicates. When building a collection from various sources, ensuring no duplicates in an Array requires manual checks or calling .uniq every time.
The second problem: slow existence checks. Array#include? must scan elements one by one from the start — O(n). For large arrays frequently checked, this becomes a bottleneck.
require "set"
# The duplicate problem in Array
tag_array = []
tag_array << "ruby"
tag_array << "programming"
tag_array << "ruby" # duplicates enter freely
tag_array << "ruby"
# => ["ruby", "programming", "ruby", "ruby"]
tag_array.uniq # must call uniq every time
# => ["ruby", "programming"]
# Set: duplicates are automatically ignored
tag_set = Set.new
tag_set << "ruby"
tag_set << "programming"
tag_set << "ruby" # silently ignored
tag_set << "ruby"
# => #<Set: {"ruby", "programming"}>
# No uniq needed — Set is always unique
# Existence checks
large_data = (1..100_000).to_a
data_set = Set.new(large_data)
# Array include? — O(n), scans one by one
large_data.include?(99_999) # slow for large arrays
# Set include? — O(1), direct hash lookup
data_set.include?(99_999) # very fast
flowchart TD
A{Need a unique collection?} -- No --> B[Use Array]
A -- Yes --> C{Frequently check include?}
C -- No --> D[Array + uniq]
C -- Yes --> E{Need order?}
E -- Yes --> F[Array + uniq + sort]
E -- No --> G[Use Set ✓]
G --> H[include? O-1]
G --> I[Automatically unique]
G --> J[Set operations]Internally, Set uses a Hash as its backing store. Every Set element becomes a Hash key, providing O(1) lookup — just like Hash. This means Set elements must be hashable, i.e., have consistent hash and eql? methods.
Creating Sets #
There are several ways to create a Set, each for a different context.
require "set"
# An empty Set
empty = Set.new
# => #<Set: {}>
# From an array
from_array = Set.new([1, 2, 3, 2, 1])
# => #<Set: {1, 2, 3}> -- duplicates automatically removed
# With a transformation block — like map before entering the Set
from_block = Set.new([1, 2, 3, 4, 5]) { |n| n ** 2 }
# => #<Set: {1, 4, 9, 16, 25}>
# Shorthand with Kernel#Set (Ruby 2.5+)
# Same as Set.new but more concise
letters = Set["a", "b", "c", "b", "a"]
# => #<Set: {"a", "b", "c"}>
# From a Range
Set.new(1..5)
# => #<Set: {1, 2, 3, 4, 5}>
# Converting from an Array
["apple", "mango", "apple", "orange"].to_set
# => #<Set: {"apple", "mango", "orange"}>
# Nested Sets (Set of Sets)
Set.new([Set.new([1, 2]), Set.new([3, 4])])
# => #<Set: {#<Set: {1, 2}>, #<Set: {3, 4}>}>
Adding and Removing Elements #
Set provides a familiar interface for adding and removing elements, with consistent behavior — addition operations never produce duplicates.
s = Set.new
# Add one element — << or add
s << "ruby"
s.add("python")
s << "ruby" # no effect, "ruby" already exists
# => #<Set: {"ruby", "python"}>
# add? — returns nil if the element already exists (useful for conditionals)
s.add?("golang") # => #<Set: {"ruby", "python", "golang"}>
s.add?("ruby") # => nil (already exists)
# Add many at once — merge
s.merge(["rust", "kotlin", "ruby"]) # "ruby" is ignored
# => #<Set: {"ruby", "python", "golang", "rust", "kotlin"}>
# Remove an element — delete
s.delete("kotlin")
# => #<Set: {"ruby", "python", "golang", "rust"}>
# delete? — returns nil if the element doesn't exist
s.delete?("haskell") # => nil (doesn't exist)
s.delete?("rust") # => #<Set: {"ruby", "python", "golang"}>
# Remove with a condition — delete_if
s.delete_if { |language| language.start_with?("p") }
# => #<Set: {"ruby", "golang"}>
# Empty the Set
s.clear
# => #<Set: {}>
Set Operations #
This is Set’s main advantage over Array — mathematical set operations are available directly as methods. You can do union, intersection, difference, and subset checks without manual loops.
Union — Combining Two Sets #
Union produces a new Set containing all elements from both Sets, without duplicates.
backend = Set.new(["ruby", "python", "golang", "rust"])
frontend = Set.new(["javascript", "typescript", "ruby"])
# Union — | or union
all = backend | frontend
# => #<Set: {"ruby", "python", "golang", "rust", "javascript", "typescript"}>
# Equivalent methods
backend.union(frontend) # identical to |
# merge for in-place union (modifies the original Set)
backend_copy = backend.dup
backend_copy.merge(frontend)
# backend_copy now contains the union of both
Intersection — The Overlap of Two Sets #
Intersection produces a Set containing only the elements present in both Sets.
backend = Set.new(["ruby", "python", "golang", "rust"])
fullstack = Set.new(["ruby", "javascript", "python", "sql"])
# Intersection — & or intersection
both = backend & fullstack
# => #<Set: {"ruby", "python"}>
backend.intersection(fullstack) # identical to &
# Practical example: find common tags between two articles
article_a = Set.new(["ruby", "programming", "web", "api"])
article_b = Set.new(["python", "programming", "api", "data"])
shared_tags = article_a & article_b
# => #<Set: {"programming", "api"}>
Difference — Subtracting Sets #
Difference produces a Set containing elements present in the first Set but not the second.
all_languages = Set.new(["ruby", "python", "golang", "rust", "javascript"])
known = Set.new(["ruby", "python"])
# Difference — - or difference
need_to_learn = all_languages - known
# => #<Set: {"golang", "rust", "javascript"}>
all_languages.difference(known) # identical to -
# Symmetric difference — elements in one but not both
a = Set.new([1, 2, 3, 4])
b = Set.new([3, 4, 5, 6])
# Symmetric difference: (a | b) - (a & b)
(a | b) - (a & b)
# => #<Set: {1, 2, 5, 6}>
# Or with XOR (^)
a ^ b
# => #<Set: {1, 2, 5, 6}>
Subset and Superset #
Checking whether one set is part of another.
ruby_features = Set.new(["oop", "functional", "dynamic", "scripting"])
oop_features = Set.new(["oop", "dynamic"])
web_features = Set.new(["oop", "web", "framework"])
# subset? — are all elements of this Set in another Set?
oop_features.subset?(ruby_features) # => true
web_features.subset?(ruby_features) # => false ("web" isn't in ruby_features)
# superset? — does this Set contain all elements of another Set?
ruby_features.superset?(oop_features) # => true
ruby_features.superset?(web_features) # => false
# proper_subset? — a subset but not exactly equal
oop_features.proper_subset?(ruby_features) # => true
ruby_features.proper_subset?(ruby_features) # => false (exactly equal, not a proper subset)
# Checks with operators
oop_features < ruby_features # proper subset
oop_features <= ruby_features # subset (can be equal)
ruby_features > oop_features # proper superset
ruby_features >= oop_features # superset (can be equal)
flowchart LR
subgraph Union["A | B — all elements"]
U1["A: {1,2,3}"]
U2["B: {3,4,5}"]
U3["Result: {1,2,3,4,5}"]
U1 --> U3
U2 --> U3
end
subgraph Intersection["A & B — overlap"]
I1["A: {1,2,3}"]
I2["B: {3,4,5}"]
I3["Result: {3}"]
I1 --> I3
I2 --> I3
end
subgraph Difference["A - B — subtraction"]
D1["A: {1,2,3}"]
D2["B: {3,4,5}"]
D3["Result: {1,2}"]
D1 --> D3
D2 --> D3
endIteration and Enumerable #
Set includes the Enumerable module, so all the methods you learned in the Enumerable article are directly available on Set.
languages = Set.new(["ruby", "python", "golang", "rust", "kotlin"])
# All Enumerable methods are available
languages.map(&:upcase)
# => ["RUBY", "PYTHON", "GOLANG", "RUST", "KOTLIN"] -- returns Array!
languages.select { |l| l.length > 4 }
# => ["python", "golang", "kotlin"] -- returns Array!
languages.sort
# => ["golang", "kotlin", "python", "ruby", "rust"]
languages.min_by(&:length)
# => "ruby"
languages.any? { |l| l.start_with?("r") }
# => true
languages.count { |l| l.include?("o") }
# => 2
Note that Enumerable methods like
mapandselecton a Set return an Array, not a new Set. This differs from Array behavior wheremapreturns an Array andselectreturns an Array. If you need the result as a Set, useto_setor use Set-specific methods likekeep_if.languages = Set.new(["ruby", "python", "golang"]) # map returns an Array languages.map(&:upcase).class # => Array # If you need a Set, add to_set languages.map(&:upcase).to_set # => #<Set: {"RUBY", "PYTHON", "GOLANG"}> # keep_if — in-place filtering, returns a Set languages.keep_if { |l| l.length > 4 } # => #<Set: {"python", "golang"}>
# each — iterate as usual
languages.each { |l| puts l }
# each_with_object — building a data structure from a Set
languages = Set.new(["ruby", "python", "golang"])
lengths = languages.each_with_object({}) do |l, hash|
hash[l] = l.length
end
# => {"ruby"=>4, "python"=>6, "golang"=>6}
# group_by on a Set
languages.group_by(&:length)
# => {4=>["ruby"], 6=>["python", "golang"]}
# flat_map on a Set
Set.new(["a b", "c d", "e f"]).flat_map { |s| s.split }
# => ["a", "b", "c", "d", "e", "f"]
Checks and Comparisons #
Set provides expressive, intuitive check methods.
s = Set.new([1, 2, 3, 4, 5])
# include? / member? — O(1), far faster than Array
s.include?(3) # => true
s.include?(9) # => false
s.member?(3) # => true (alias of include?)
# empty? and size / length
s.empty? # => false
Set.new.empty? # => true
s.size # => 5
s.length # => 5 (alias)
# Comparing two Sets — == checks element equality, not order
Set.new([1, 2, 3]) == Set.new([3, 1, 2]) # => true
Set.new([1, 2, 3]) == Set.new([1, 2]) # => false
# disjoint? — do two Sets share no elements?
a = Set.new([1, 2, 3])
b = Set.new([4, 5, 6])
c = Set.new([3, 4, 5])
a.disjoint?(b) # => true (no overlap)
a.disjoint?(c) # => false (3 is shared)
# intersect? — the opposite of disjoint?
a.intersect?(b) # => false
a.intersect?(c) # => true
Common Usage Patterns #
Some real-world patterns where Set is significantly more appropriate than Array.
Efficient Deduplication #
# ANTI-PATTERN: using Array + uniq repeatedly
def collect_permissions(user_list)
all = []
user_list.each do |user|
all += user.permissions
end
all.uniq # uniq at the end, but the array can be huge before that
end
# CORRECT: use a Set from the start
def collect_permissions(user_list)
user_list.each_with_object(Set.new) do |user, set|
user.permissions.each { |p| set << p }
end
end
# Or more concisely
def collect_permissions(user_list)
user_list.flat_map(&:permissions).to_set
end
Tracking Already-Processed Items #
# Common scenario: process items, don't process already-processed ones
def process_queue(queue)
already_processed = Set.new
queue.each do |item|
# include? on a Set is O(1) — safe for large loops
next if already_processed.include?(item.id)
process(item)
already_processed << item.id
end
end
# Web crawling: don't visit the same URL twice
def crawl(start_url)
visited = Set.new
queue = [start_url]
until queue.empty?
url = queue.pop
next if visited.include?(url)
visited << url
new_links = fetch_links(url)
queue.concat(new_links - visited.to_a)
end
end
Validating Allowed Values #
# ANTI-PATTERN: Array for validation — include? is O(n)
VALID_STATUSES = ["pending", "active", "suspended", "deleted"]
def valid_status?(status)
VALID_STATUSES.include?(status) # scans the array every time
end
# CORRECT: Set for validation — include? is O(1)
VALID_STATUSES = Set.new(["pending", "active", "suspended", "deleted"]).freeze
def valid_status?(status)
VALID_STATUSES.include?(status) # direct lookup
end
# Similar patterns for permissions, roles, etc.
ADMIN_ROLE = Set.new([:create, :read, :update, :delete]).freeze
EDITOR_ROLE = Set.new([:create, :read, :update]).freeze
VIEWER_ROLE = Set.new([:read]).freeze
def allowed?(role, action)
case role
when :admin then ADMIN_ROLE.include?(action)
when :editor then EDITOR_ROLE.include?(action)
when :viewer then VIEWER_ROLE.include?(action)
else false
end
end
Data Comparison Analysis #
# Comparing two configuration versions
old_config = Set.new(["feature_a", "feature_b", "feature_c", "feature_d"])
new_config = Set.new(["feature_b", "feature_c", "feature_e", "feature_f"])
added = new_config - old_config
# => #<Set: {"feature_e", "feature_f"}>
removed = old_config - new_config
# => #<Set: {"feature_a", "feature_d"}>
unchanged = old_config & new_config
# => #<Set: {"feature_b", "feature_c"}>
puts "Added: #{added.to_a.join(', ')}"
puts "Removed: #{removed.to_a.join(', ')}"
puts "Unchanged: #{unchanged.to_a.join(', ')}"
Set vs Array Comparison #
Understanding when Set is more appropriate than Array (and vice versa) is key to using it effectively.
| Aspect | Array | Set |
|---|---|---|
| Duplicates | Allowed | Not allowed |
| Order | Preserved | Not guaranteed |
| include? complexity | O(n) | O(1) |
| Set operations | Manual / gem | Native |
| Memory | More efficient for small data | Larger (hash backing) |
| Indexing (arr[0]) | ✓ | ✗ |
| sort | In-place (sort!) | Returns an Array |
| Best for | Ordered data, sequential access | Unique values, membership tests |
# When Array is more appropriate
shopping_list = ["milk", "bread", "milk", "eggs"]
# ✓ the "milk" duplicate is intentional (buy 2)
# ✓ order may matter
# ✓ need index access: shopping_list[0]
# When Set is more appropriate
languages_mastered = Set.new(["ruby", "python", "golang"])
# ✓ no duplicates needed — mastering Ruby twice makes no sense
# ✓ frequent checks: languages_mastered.include?("rust")
# ✓ operations: languages_needed - languages_mastered (what to learn)
# Converting back and forth
array = [1, 2, 3, 2, 1]
set = array.to_set # => #<Set: {1, 2, 3}>
back = set.to_a # => [1, 2, 3] (order not guaranteed)
sorted_back = set.sort # => [1, 2, 3] (if order is needed)
Set and Thread Safety #
Set is not thread-safe by default. If multiple threads access the same Set concurrently, you can get race conditions.
require "set"
require "monitor"
# ANTI-PATTERN: Set accessed from many threads without protection
cache = Set.new
threads = 10.times.map do |i|
Thread.new { cache << i } # race condition!
end
threads.each(&:join)
# CORRECT: use a Mutex or Monitor for protection
cache = Set.new
mutex = Mutex.new
threads = 10.times.map do |i|
Thread.new do
mutex.synchronize { cache << i }
end
end
threads.each(&:join)
# the cache is now safe to access from many threads
# Alternative: use SizedQueue or other thread-safe data structures
# for more complex cases
If you use Ruby 3.x with Ractors, note that Set can’t be shared between Ractors directly because it isn’t immutable. For Ractors, use a frozen Set or send a copy of it.
CONSTANT = Set.new(["a", "b", "c"]).freeze # A frozen Set is safe to read from anywhere # but can't be modified CONSTANT << "d" # => FrozenError!
Summary #
- Set for unique collections with fast lookup — if you frequently call
include?on large collections, Set is far more efficient than Array due to O(1) vs O(n) lookup.require "set"first — Set isn’t built-in like Array and Hash; you need to require it before using.- Set operations are available natively — union (
|), intersection (&), difference (-), and symmetric difference (^) are available directly without manual loops.- Subset/superset checks —
subset?,superset?,proper_subset?, and the<,<=,>,>=operators for comparing set relations.- Enumerable is available — because Set includes Enumerable, all methods like
map,select,group_by, and others can be used, but the results are Arrays, not new Sets.- Use
freezefor constants — Sets used as validation lists or permissions should be frozen so they can’t be accidentally modified.- Set isn’t thread-safe — use a Mutex when accessing a Set from many threads simultaneously.
- Easy conversion —
array.to_setandset.to_afor back-and-forth conversion; duplicates automatically disappear when converting to a Set.