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
    end

Iteration 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 map and select on a Set return an Array, not a new Set. This differs from Array behavior where map returns an Array and select returns an Array. If you need the result as a Set, use to_set or use Set-specific methods like keep_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.

AspectArraySet
DuplicatesAllowedNot allowed
OrderPreservedNot guaranteed
include? complexityO(n)O(1)
Set operationsManual / gemNative
MemoryMore efficient for small dataLarger (hash backing)
Indexing (arr[0])
sortIn-place (sort!)Returns an Array
Best forOrdered data, sequential accessUnique 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 checkssubset?, 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 freeze for 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 conversionarray.to_set and set.to_a for back-and-forth conversion; duplicates automatically disappear when converting to a Set.

← Previous: Enumerable   Next: Comparable →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact