rubyguides

Working with hashes in Ruby: lookups, merges, and filtering

Working hashes in Ruby is a skill you will use every day, because working hashes model named data better than any other built-in type. Hashes are one of Ruby’s most useful data structures. If you need to work with hashes in Ruby, you are usually trying to model named data: a user record, a configuration block, a response payload, or a lookup table. Those problems all benefit from keyed access, because the value you want is identified by name instead of position.

TL;DR

  • Use hashes when you want keyed lookup instead of positional access.
  • Reach for fetch when a missing key should be handled explicitly.
  • Use dig when nested data may or may not be present.
  • Prefer merge for a new hash and merge! when you want to mutate the original.
  • Use select, reject, and the transform_* methods to reshape data without manual loops.

Intro context

Hashes feel natural when the label matters more than the order. An array tells you what comes next, but a hash tells you what each value means. That makes hashes a strong fit for configuration data, API responses, counters, and small bits of application state that would be awkward to store in parallel arrays.

This guide focuses on the hash operations you will use most often in everyday Ruby code: creating hashes, reading values safely, iterating over entries, merging results, filtering data, and converting hashes into other formats. If you understand those core patterns, you can usually read and write hash-heavy Ruby code without much ceremony.

Creating hashes

Ruby gives you a few ways to create hashes, and the best choice depends on where the data already lives. A literal is usually the clearest option because it shows the structure directly. Constructors become more useful when the data is already in another form, such as an array of pairs or a list of arguments.

The literal form is easy to read and easy to scan in code review:

person = { name: "Alice", age: 30 }
# => {:name=>"Alice", :age=>30}

If you want a hash that can grow on demand, Hash.new with a default value is often a better fit. It is especially handy when you are counting, grouping, or accumulating values because new keys can start with a known baseline instead of nil. The default value also saves you from writing nil-check guards every time you access a key that might not exist yet.

counts = Hash.new(0)
counts[:apple]  # => 0 (not nil!)

The Hash[] constructor works well when your data already exists as pairs or as a flat list of alternating keys and values. In that case, you can convert the structure directly instead of rebuilding it by hand. Both the symbol-key and string-key forms accept the same alternating pattern, so you can pick whichever one matches your existing data.

Hash["a", 1, "b", 2]
# => {"a"=>1, "b"=>2}

# Or with symbols
Hash[city: "NYC", state: "NY"]
# => {:city=>"NYC", :state=>"NY"}

Accessing values

The square bracket syntax returns nil for missing keys:

That behavior is convenient when a missing key is acceptable, but it can also hide mistakes. If the key should always be present, use a method that makes the failure explicit so the bug does not show up later in your code path. The compact hash[:key] form saves typing, but the trade-off is that typos in key names return nil silently instead of raising an error.

config = { host: "localhost", port: 3000 }
config[:host]    # => "localhost"
config[:missing] # => nil

Use fetch when a missing key should trigger a deliberate branch in your logic. It gives you a clear place to provide a fallback value or raise a KeyError, which makes the intent obvious to the reader. The second argument to fetch is only evaluated when the key is absent, so providing a computed default is both safe and efficient. Unlike the bracket syntax, fetch forces you to decide upfront what should happen when a key is missing, which turns a potential nil bug into an explicit and deliberate choice.

config.fetch(:host)           # => "localhost"
config.fetch(:missing, "N/A") # => "N/A"

# Raise an error for missing keys
config.fetch(:missing) # raises KeyError

The dig method is useful for nested hashes because it removes a long chain of nil checks. API responses and configuration blobs often contain optional fields, so dig keeps the lookup concise without hiding errors in the parts of the structure that do exist. Think of dig as a safe navigation operator for hashes: each key you pass is tried in sequence, and the chain stops at the first nil instead of raising NoMethodError.

data = { user: { address: { city: "Boston" } } }
data.dig(:user, :address, :city)  # => "Boston"
data.dig(:user, :address, :zip)   # => nil (no error!)

Iterating over hashes

Hashes give you several iteration methods, and each one serves a different purpose. Use the method that matches the shape of the work you need to do, because that keeps the block small and the intent obvious. Choosing the right iterator also makes the code self-documenting: seeing each_key tells a reader immediately that only the names matter here, not the values.

config = { host: "localhost", port: 3000, ssl: true }

# Iterate over keys
config.each_key { |key| puts key }
# host
# port
# ssl

# Iterate over values
config.each_value { |value| puts value }
# localhost
# 3000
# true

# Iterate over key-value pairs
config.each { |key, value| puts "#{key}: #{value}" }
# host: localhost
# port: 3000
# ssl: true

When you are only interested in the names, each_key is clearer than unpacking values you will never use. The same idea applies to each_value, which is a nice fit when you are aggregating or printing the values without caring about the keys.

Merging hashes

The merge method combines hashes without modifying the original. That makes it a good choice when you want to layer defaults with user settings or combine two small data sets for display.

defaults = { theme: "dark", lang: "en" }
user_prefs = { theme: "light" }

merged = defaults.merge(user_prefs)
# => {:theme=>"light", :lang=>"en"}
# defaults unchanged

Use merge! when you want to mutate the existing object in place. That approach is useful in setup code or short scripts, but it deserves a little caution in shared code because callers may not expect the original hash to change.

options = { debug: false }
options.merge!(timeout: 30)
# => {:debug=>false, :timeout=>30}

For deep merging, you need a custom approach because Ruby’s built-in merge only handles the top level. The recursive pattern below is a small helper that preserves nested hashes while still letting the right-hand side win when values are not both hashes.

def deep_merge(h1, h2)
  h1.merge(h2) { |key, v1, v2|
    v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1, v2) : v2
  }
end

Filtering and transforming

Filtering is where hashes become especially flexible, because you can reshape a data set without writing manual loops. The key idea is that the block describes what to keep or transform, while the method handles the iteration for you.

The select and reject methods filter by condition:

scores = { alice: 95, bob: 72, carol: 88, dave: 55 }

passing = scores.select { |_, score| score >= 70 }
# => {:alice=>95, :bob=>72, :carol=>88}

failing = scores.reject { |_, score| score >= 70 }
# => {:dave=>55}

Transform keys and values with dedicated methods when the shape of the data should change, not just the membership. This is often cleaner than mapping to arrays and rebuilding the hash by hand. Both transform_keys and transform_values return a new hash, leaving the original untouched, which is a safe default when upstream code still needs the old keys.

data = { name: "Alice", age: 30 }

# Transform keys to uppercase
data.transform_keys(&:upcase)
# => {"NAME"=>"Alice", "AGE"=>30}

# Transform values
data.transform_values(&:to_s)
# => {:name=>"Alice", :age=>"30"}

Common patterns

Count occurrences

Counting values is one of the most common hash patterns in Ruby. It shows up in text processing, reporting, and deduplication tasks whenever you need a running total for each distinct item. Starting with Hash.new(0) gives every new key a numeric starting point, so the first increment reads naturally: counts[word] += 1 works even on the very first encounter with a word.

words = %w[apple banana apple cherry banana apple]
counts = Hash.new(0)
words.each { |word| counts[word] += 1 }
# => {"apple"=>3, "banana"=>2, "cherry"=>1}

Reverse a hash

Inversion is useful when the values are unique and you want to turn them into lookup keys. It is a simple trick, but it is easy to misuse if you do not check for duplicate values first. When two or more keys share the same value, invert silently keeps only the last one, so verify uniqueness before relying on the inverted result.

phonebook = { alice: "123", bob: "456" }
reversed = phonebook.invert
# => {"123"=>:alice, "456"=>:bob}

Default values for missing keys

Sometimes a hash needs to create its own nested structure as it grows. That pattern is common in grouping and aggregation code, where you want to append values to an array for each key without pre-seeding every possible entry.

inventory = Hash.new { |hash, key| hash[key] = [] }
inventory[:apples] << "red"
inventory[:apples] << "green"
# => {:apples=>["red", "green"]}

Summary

Hashes in Ruby are flexible, predictable, and easy to reason about once you know the core methods. Literal syntax works for most everyday cases, Hash.new helps when keys appear dynamically, and fetch or dig give you safe access patterns for missing or nested values.

If you remember the basic distinction between merge, select, reject, and transform_*, you can handle most hash-heavy tasks with confidence. That is usually enough for configuration data, API payloads, and small in-memory lookups.

Converting between formats

Hashes often need to move between Ruby-native structures and serialization formats. Converting to arrays is a quick way to inspect the entries, and converting to JSON is the usual step when you need to send the data somewhere else.

data = { a: 1, b: 2, c: 3 }

# Keys as array
data.keys    # => [:a, :b, :c]

# Values as array
data.values  # => [1, 2, 3]

# Key-value pairs as nested arrays
data.to_a    # => [[:a, 1], [:b, 2], [:c, 3]]

# Back to hash
Hash[data.to_a]  # => {:a=>1, :b=>2, :c=>3}

Working with JSON is equally straightforward, especially when hashes come from an API or need to be sent over the wire. The built-in JSON parser will usually give you string keys, which is worth remembering if the rest of your code expects symbols.

require "json"

# Hash to JSON string
data = { name: "Alice", skills: ["ruby", "rails"] }
json_string = data.to_json
# => "{\"name\":\"Alice\",\"skills\":[\"ruby\",\"rails\"]}"

# JSON string to hash
parsed = JSON.parse(json_string)
# => {"name"=>"Alice", "skills"=>["ruby", "rails"]}

# Symbolize keys during parsing
JSON.parse(json_string, symbolize_names: true)
# => {:name=>:Alice, :skills=>[:ruby, :rails]}

Hash vs array: when to use which

Use hashes when you need key-value associations and fast lookup by key. The choice between a hash and an array can shape how the rest of your code reads, so it is worth making it explicit from the start. Hashes win when you need to find a specific entry by name, while arrays are better when the position itself carries meaning.

# Fast lookup by name
users = { alice: { id: 1, email: "alice@example.com" } }
users[:alice][:email]  # O(1) lookup

Use arrays when you need ordered collections or when the position is part of the meaning. In cases where both order and keyed access matter, you can store the order in an array and the values in a hash, keeping each concern in its own data structure.

# Ordered collection
queue = [:first, :second, :third]
queue[0]  # => :first

A hash lookup is O(1) on average, while searching an array is O(n). That difference matters when you need repeated keyed access over a large data set. Arrays still win when the order itself is meaningful, so the right choice depends on what the code needs to communicate.

Performance tips

When you are creating many hashes, consider using Hash.new with a block for lazy initialization. The block only runs when a key is first accessed, so you avoid paying for work that may never be needed.

# Instead of
cache = {}
def get_data(key)
  cache[key] ||= expensive_operation(key)
end

# Use
cache = Hash.new { |h, k| h[k] = expensive_operation(k) }

That pattern is especially helpful in grouping code, cache builders, and aggregation tasks. It keeps the call site small while still making the initialization behavior explicit.

If you want to work with hashes in Ruby more effectively, the next step is usually to combine them with arrays, JSON, and blocks. Those pieces are where hashes become more than a storage container, because they let you reshape real application data without a lot of boilerplate.

Frequently asked questions

How do I handle missing keys?

Use fetch when a missing key should be deliberate, and use dig when you are walking through nested hashes that may not be fully populated. Those two methods cover most cases where [] would otherwise hide a bug.

When should I use merge!?

Use merge! only when mutating the original hash is the right choice for the code around it. If you are preparing a new value for display, serialization, or return from a method, merge is usually easier to reason about.

How do I count repeated values?

Start with Hash.new(0) and increment the count for each item. That pattern is short, fast, and easy to read, which is why it shows up so often in text processing and reporting code.

See Also