CrackedRuby logo

CrackedRuby

Hash Compaction

A guide to removing nil values from Ruby hash objects using compact methods and related techniques.

Core Built-in Classes Hash Class
2.5.7

Overview

Hash compaction removes nil values from hash objects, creating cleaner data structures and eliminating unwanted empty entries. Ruby provides the compact and compact! methods on Hash objects to perform this operation, with compact returning a new hash and compact! modifying the existing hash in place.

The compaction process iterates through key-value pairs and excludes any pair where the value is nil. Keys associated with nil values disappear entirely from the resulting hash. Other falsy values like false, 0, or empty strings remain unchanged since compaction targets only nil specifically.

hash = { a: 1, b: nil, c: 3, d: nil, e: 5 }
compacted = hash.compact
# => { a: 1, c: 3, e: 5 }

# Original hash unchanged
puts hash
# => { a: 1, b: nil, c: 3, d: nil, e: 5 }

Hash compaction commonly occurs in data processing pipelines, API response cleaning, form parameter handling, and configuration management. Web applications frequently use compaction to remove empty form fields or filter out unset configuration values.

user_params = {
  name: "John",
  email: "john@example.com", 
  phone: nil,
  address: nil,
  age: 30
}

clean_params = user_params.compact
# => { name: "John", email: "john@example.com", age: 30 }

The compaction operation preserves the original key-value relationships for non-nil values and maintains the hash's internal structure and ordering. Ruby implements compaction efficiently by creating a new hash with only the qualifying entries rather than removing entries one by one.

Basic Usage

The compact method creates a new hash containing only the key-value pairs where the value is not nil. The original hash remains unchanged, making this approach safe for use with shared or referenced hash objects.

data = { 
  name: "Alice", 
  email: nil, 
  age: 25, 
  phone: nil, 
  city: "Portland" 
}

filtered_data = data.compact
# => { name: "Alice", age: 25, city: "Portland" }

# Original data unmodified  
puts data.length
# => 5

The compact! method modifies the hash in place, removing nil-valued entries from the existing object. This method returns the hash itself if changes occurred, or nil if no nil values existed.

settings = { 
  theme: "dark", 
  notifications: true, 
  beta_features: nil, 
  auto_save: nil 
}

result = settings.compact!
# => { theme: "dark", notifications: true }

puts settings
# => { theme: "dark", notifications: true }

puts result.equal?(settings)
# => true

When no nil values exist in the hash, compact returns a new hash with identical contents, while compact! returns nil to indicate no modifications occurred.

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

new_hash = clean_hash.compact
puts new_hash == clean_hash
# => true
puts new_hash.equal?(clean_hash) 
# => false

modification_result = clean_hash.compact!
puts modification_result
# => nil
puts clean_hash
# => { a: 1, b: 2, c: 3 }

Compaction works with any hash structure, including nested hashes, but only removes nil values at the top level. Nested nil values require separate handling or recursive compaction approaches.

nested_data = {
  user: { name: "Bob", email: nil },
  settings: nil,
  preferences: { theme: nil, language: "en" }
}

compacted = nested_data.compact
# => { user: { name: "Bob", email: nil }, preferences: { theme: nil, language: "en" } }

Hash compaction preserves all other falsy values including false, 0, empty strings, and empty arrays. Only nil values trigger removal during compaction.

mixed_values = {
  flag: false,
  count: 0,
  text: "",
  list: [],
  nothing: nil,
  value: 42
}

result = mixed_values.compact
# => { flag: false, count: 0, text: "", list: [], value: 42 }

Error Handling & Debugging

Hash compaction methods generally operate without raising exceptions, but certain scenarios require careful handling. Frozen hashes prevent the use of compact! since this method attempts to modify the hash in place.

frozen_hash = { a: 1, b: nil, c: 3 }.freeze

# This works fine - creates new hash
safe_result = frozen_hash.compact
# => { a: 1, c: 3 }

# This raises FrozenError
begin
  frozen_hash.compact!
rescue FrozenError => e
  puts "Cannot modify frozen hash: #{e.message}"
end

When debugging compaction operations, verify that target values are actually nil and not other falsy values. Use nil? checks and explicit comparisons to identify the exact nature of values.

problematic_hash = { a: "", b: false, c: 0, d: nil, e: "nil" }

puts "Before compaction: #{problematic_hash.length}"
result = problematic_hash.compact
puts "After compaction: #{result.length}"
# Only 'd: nil' gets removed

# Debug each value
problematic_hash.each do |key, value|
  puts "#{key}: #{value.inspect} (nil? #{value.nil?})"
end

For deep compaction of nested structures, implement recursive compaction with proper error handling for different object types.

def deep_compact(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(key, value), hash|
      compacted_value = deep_compact(value)
      hash[key] = compacted_value unless compacted_value.nil?
    end
  when Array
    obj.map { |item| deep_compact(item) }.compact
  else
    obj
  end
end

complex_data = {
  users: [
    { name: "Alice", email: nil, active: true },
    { name: "Bob", email: "bob@example.com", active: nil }
  ],
  settings: { theme: nil, lang: "en" },
  metadata: nil
}

cleaned_data = deep_compact(complex_data)
# => { users: [{ name: "Alice", active: true }, { name: "Bob", email: "bob@example.com" }], settings: { lang: "en" } }

When compaction produces unexpected results, examine the data types and values present. String representations of nil like "nil" or "null" remain in the hash since they are not the nil object.

# Common debugging scenario
api_response = {
  id: 123,
  name: "Product",
  description: "null",  # String, not nil
  price: nil,           # Actual nil
  category: ""          # Empty string, not nil
}

# Only removes the price key
cleaned = api_response.compact
# => { id: 123, name: "Product", description: "null", category: "" }

# To handle string representations of null
cleaned_further = cleaned.reject { |k, v| v == "null" || v == "" }
# => { id: 123, name: "Product" }

Production Patterns

Rails applications frequently use hash compaction for parameter filtering, configuration management, and API response cleaning. Integration with strong parameters and controller actions creates cleaner data flow.

# In a Rails controller
class UsersController < ApplicationController
  def update
    # Remove nil values from permitted parameters
    clean_params = user_params.compact
    
    if @user.update(clean_params)
      render json: @user.as_json.compact
    else
      render json: { errors: @user.errors }, status: 422
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :phone, :address)
  end
end

API serialization often benefits from compaction to reduce payload size and eliminate unnecessary nil fields. Combined with conditional attribute inclusion, this creates flexible response structures.

class ApiSerializer
  def self.serialize(object, options = {})
    base_attributes = {
      id: object.id,
      name: object.name,
      email: object.email,
      phone: object.phone,
      created_at: object.created_at
    }
    
    # Add optional attributes based on permissions
    base_attributes[:admin_notes] = object.admin_notes if options[:include_admin]
    base_attributes[:internal_id] = object.internal_id if options[:include_internal]
    
    # Remove nil values for cleaner API responses
    base_attributes.compact
  end
end

# Usage in API endpoint
user_data = ApiSerializer.serialize(user, include_admin: current_user.admin?)

Configuration management systems use compaction to handle optional settings and environment-specific overrides. This pattern allows flexible configuration inheritance with explicit nil handling.

class ConfigManager
  def self.build_config(base_config, environment_overrides = {}, user_overrides = {})
    # Merge configurations with precedence
    merged_config = base_config
      .merge(environment_overrides.compact)
      .merge(user_overrides.compact)
    
    # Remove any explicitly set nil values to use defaults
    merged_config.compact
  end
end

base = { 
  timeout: 30, 
  retries: 3, 
  debug: false, 
  cache_size: 1000 
}

production_overrides = { 
  debug: nil,      # Explicitly disable debug in production
  timeout: 60, 
  cache_size: nil  # Use base value
}

config = ConfigManager.build_config(base, production_overrides)
# => { timeout: 60, retries: 3, debug: false, cache_size: 1000 }

Background job processing benefits from compaction when handling variable argument sets and optional parameters. This prevents jobs from attempting to process meaningless nil values.

class EmailNotificationJob
  def self.perform_later(user_id, template, options = {})
    # Clean options to avoid passing nil values to email service
    clean_options = {
      subject: options[:subject],
      from_name: options[:from_name], 
      reply_to: options[:reply_to],
      tracking_enabled: options[:tracking_enabled]
    }.compact
    
    perform_now(user_id, template, clean_options)
  end
  
  def self.perform_now(user_id, template, options)
    user = User.find(user_id)
    # Email service receives only meaningful parameters
    EmailService.send_template(user.email, template, options)
  end
end

Common Pitfalls

The distinction between compact and compact! creates a common source of errors. Developers sometimes expect compact to modify the original hash or assume compact! always returns the hash.

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

# Mistake: Expecting compact to modify original
data.compact  
puts data
# => { a: 1, b: nil, c: 3 }  (unchanged!)

# Correct: Assign the result
data = data.compact
puts data
# => { a: 1, c: 3 }

# Mistake: Expecting compact! to always return the hash
clean_data = { x: 1, y: 2, z: 3 }
result = clean_data.compact!
puts result.inspect
# => nil (no changes made)

# Correct: Check return value or use the original hash
if clean_data.compact!
  puts "Hash was modified"
else
  puts "No nil values found"
end

Nested hash structures require special attention since standard compaction only operates on the top level. Developers often expect deep compaction behavior without implementing it explicitly.

# Pitfall: Expecting deep compaction
nested = {
  user: { 
    name: "John", 
    email: nil, 
    preferences: { theme: nil, notifications: true } 
  },
  settings: nil
}

result = nested.compact
# Only removes top-level nil values
puts result
# => { user: { name: "John", email: nil, preferences: { theme: nil, notifications: true } } }

# The email and theme keys still have nil values

Type confusion occurs when string representations of null values remain in the hash after compaction. API responses and form data often contain string "null" or "nil" values that survive the compaction process.

# Common API response scenario  
parsed_json = {
  id: 123,
  name: "Item",
  description: "null",  # JSON null became string
  category_id: nil,     # Actual Ruby nil
  tags: "[]"            # Empty array became string
}

compacted = parsed_json.compact
# => { id: 123, name: "Item", description: "null", tags: "[]" }

# Additional filtering needed for string nulls
fully_cleaned = compacted.reject { |k, v| 
  v.nil? || v == "null" || v == "undefined" || (v.is_a?(String) && v.strip.empty?)
}

Frozen hash modification attempts cause runtime errors that can crash applications if not handled properly. This often occurs in configuration objects or cached data structures.

# Configuration loaded once and frozen
CONFIG = { api_key: ENV['API_KEY'], debug: nil, timeout: 30 }.freeze

# Later in the application - this will crash
begin
  CONFIG.compact!
rescue FrozenError
  # Handle frozen hash properly
  CLEAN_CONFIG = CONFIG.compact.freeze
end

Performance assumptions about compaction can lead to problems with large hashes. While generally efficient, compaction creates new hash objects and iterates through all entries.

# Performance pitfall with frequent compaction
large_hash = 100_000.times.each_with_object({}) do |i, hash|
  hash["key_#{i}"] = rand < 0.1 ? nil : rand
end

# Inefficient: Repeated compaction in loop
10.times do
  large_hash = large_hash.merge("new_key" => nil).compact
end

# Better: Compact once after all modifications
modified_hash = large_hash.dup
10.times { |i| modified_hash["new_key_#{i}"] = nil }
final_hash = modified_hash.compact

Reference

Core Methods

Method Parameters Returns Description
#compact None Hash Returns new hash with nil values removed
#compact! None Hash or nil Removes nil values in place, returns hash if modified or nil if unchanged

Method Behavior Details

Scenario compact Result compact! Result Original Hash
Hash with nil values New hash without nils Same hash, nils removed, returns self Modified (compact! only)
Hash without nil values New identical hash Returns nil Unchanged
Empty hash New empty hash Returns nil Unchanged
Frozen hash New hash (works) Raises FrozenError Unchanged

Compaction Rules

Value Type Removed by Compact Example
nil Yes { a: nil }{}
false No { a: false }{ a: false }
0 No { a: 0 }{ a: 0 }
Empty string "" No { a: "" }{ a: "" }
Empty array [] No { a: [] }{ a: [] }
String "nil" No { a: "nil" }{ a: "nil" }

Common Patterns

# Safe parameter cleaning
clean_params = params.compact

# In-place modification with error handling
begin
  hash.compact!
rescue FrozenError
  hash = hash.compact
end

# Conditional compaction
result = should_compact? ? data.compact : data

# Chaining with other hash methods
filtered = hash.compact.reject { |k, v| v.empty? }.transform_keys(&:to_sym)

# Deep compaction helper
def deep_compact(hash)
  hash.compact.transform_values do |value|
    value.is_a?(Hash) ? deep_compact(value) : value
  end
end

Performance Characteristics

Operation Time Complexity Space Complexity Notes
compact O(n) O(k) Where k = non-nil entries
compact! O(n) O(1) Modifies in place
Deep compaction O(n*m) O(k*m) Where m = nesting depth

Related Methods

Method Purpose Relationship to Compact
#reject Remove entries by condition More general than compact
#select Keep entries by condition Inverse of reject
#delete_if Remove entries in place Similar to compact! but with block
#keep_if Keep entries in place Inverse of delete_if