Arrays
An array is Ruby’s built-in, ordered list — the workhorse collection you’ll reach for constantly, whether you’re holding a handful of numbers, a list of user records, or the results of a database query. Unlike arrays in many other languages, a Ruby array has no fixed size or fixed element type: it grows and shrinks as you go, and a single array can freely mix integers, strings, hashes, or any other object. This lesson covers how arrays really work under the hood, the full syntax for creating and accessing them, a deep tour of the methods you’ll use every day, and the mutation pitfalls that trip up even experienced Rubyists.
Overview: How Arrays Work in Ruby
An array in Ruby is an ordered, integer-indexed collection that can hold any mix of objects. Under the hood, a Ruby array is a genuine object — an instance of the Array class — not a raw memory block like arrays in C. That means every array responds to methods, can be duplicated, inspected with p or .inspect, and passed around like any other value, and array.class always returns Array.
Ruby arrays are dynamic: you never declare a fixed size up front, and the array grows or shrinks automatically as you push, pop, insert, or delete elements. Ruby manages the underlying storage for you, over-allocating a little extra capacity so repeated pushes don’t reallocate memory constantly — but from the language’s perspective you only ever interact with the public API.
A huge part of what makes arrays so powerful is that Array includes the Enumerable module. Enumerable supplies dozens of methods — map, select, reduce, sort_by, group_by, each_with_index, and many more — all built on top of a single method your class must define: each. Array defines each and inherits the rest for free through Ruby’s method lookup: when you call numbers.select { ... }, Ruby doesn’t find select directly on Array; it walks the ancestor chain (the object’s singleton class, then Array, then its included modules like Enumerable, then Object) until it finds a matching definition. This ancestor-chain lookup is the same mechanism that powers mixins and super throughout Ruby.
One more detail worth internalizing early: array elements are stored by reference for anything that isn’t a simple immutable value. If you put the same string or array object into a collection twice, or store an array inside another array, mutating that inner object through one reference changes what you see through every reference to it. This is a common source of subtle bugs, covered in Common Mistakes below.
Syntax
There are several ways to create an array, each suited to a different situation:
numbers = [1, 2, 3]
words = %w
zeros = Array.new(4, 0)
squares = Array.new(5) { |i| i * i }
puts numbers.inspect
puts words.inspect
puts zeros.inspect
puts squares.inspect
Output:
[1, 2, 3]
["ruby", "is", "fun"]
[0, 0, 0, 0]
[0, 1, 4, 9, 16]
- [el1, el2, …] — the array literal, the most common way to write a fixed set of values.
- %w[…] — a shorthand literal for an array of strings, split on whitespace, without quotes or commas.
- Array.new(size, default) — creates an array of the given size filled with
default. Careful: every slot holds the same object reference, which is dangerous ifdefaultis mutable (like an array or hash). - Array.new(size) { |i| … } — the block form calls the block once per index and uses its return value for that slot, producing independent objects. Prefer this over the two-argument form whenever the default isn’t a plain immutable value.
- arr[index], arr[start, length], arr[range] — read one element or a slice; negative indices count from the end.
Common Array & Enumerable Methods at a Glance
| Method | Returns | Description |
|---|---|---|
push / << |
self (mutated) | Append one or more elements to the end |
pop |
Object or nil | Remove and return the last element |
shift |
Object or nil | Remove and return the first element |
unshift |
self (mutated) | Add one or more elements to the front |
map / collect |
new Array | Transform every element, keep the same length |
select / filter |
new Array | Keep elements where the block is truthy |
reject |
new Array | Drop elements where the block is truthy |
reduce / inject |
a single Object | Fold the array down to one accumulated value |
sort / sort_by |
new Array | Return a sorted copy, original unchanged |
uniq |
new Array | Return a copy with duplicates removed |
flatten |
new Array | Collapse nested arrays into one flat array |
compact |
new Array | Return a copy with all nil elements removed |
include? |
true / false | Check whether a value is present |
join |
String | Combine elements into one string with a separator |
Examples
Example 1: basic creation, indexing, and mutation.
fruits = ["apple", "banana", "cherry"]
puts fruits[0]
puts fruits[-1]
puts fruits.length
fruits.push("date")
puts fruits.inspect
Output:
apple
cherry
3
["apple", "banana", "cherry", "date"]
fruits[0] reads the first element, and fruits[-1] reads the last element by counting backward from the end — there’s no need for fruits[fruits.length - 1] as in some other languages. push mutates the array in place, adding "date" to the end, which is why the final inspect shows four elements even though length was checked earlier.
Example 2: transforming a collection with Enumerable.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = numbers.select { |n| n.even? }
squared = numbers.map { |n| n * n }
total = numbers.reduce(0) { |sum, n| sum + n }
puts "Evens: #{evens}"
puts "Squared: #{squared}"
puts "Total: #{total}"
Output:
Evens: [2, 4, 6, 8, 10]
Squared: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Total: 55
select keeps only elements where the block returns something truthy, producing a brand-new array. map transforms every element and returns a same-length array of the results. reduce(0) { |sum, n| sum + n } starts an accumulator at 0 and folds the array down to a single number by repeatedly applying the block. None of these three calls modify numbers itself.
Example 3: a more realistic scenario — an array of structured records.
Product = Struct.new(:name, :price, :quantity)
inventory = [
Product.new("Widget", 4.5, 10),
Product.new("Gadget", 12.0, 3),
Product.new("Gizmo", 7.25, 5)
]
total_value = inventory.sum { |item| item.price * item.quantity }
cheapest = inventory.min_by(&:price)
sorted_names = inventory.sort_by(&:price).map(&:name)
puts "Total inventory value: $#{total_value}"
puts "Cheapest item: #{cheapest.name}"
puts "Sorted by price: #{sorted_names}"
Output:
Total inventory value: $117.25
Cheapest item: Widget
Sorted by price: ["Widget", "Gizmo", "Gadget"]
This example stores Struct objects (a lightweight way to define a simple record class) inside an array and then treats that array like a tiny in-memory database: sum aggregates a computed value across every element, min_by finds the element with the smallest value for a block expression, and sort_by(&:price).map(&:name) chains two Enumerable calls together — sort the records by price, then pull just the names out of the sorted result. Chaining like this is idiomatic Ruby: each method returns a new array, ready for the next call.
How Indexing and Slicing Work
Ruby arrays are always zero-indexed: the first element is at index 0. Negative indices count backward from the end, so -1 is always the last element. Beyond single-element access, Ruby supports two slicing forms: array[start, length] (a starting index and a count) and array[range] (a Range object), both of which return a new array rather than a single element. Reading past the end of the array never raises an error — it simply returns nil.
letters = ["a", "b", "c", "d", "e"]
puts letters[0]
puts letters[-1]
puts letters[1, 3].inspect
puts letters[1..3].inspect
puts letters[10].inspect
Output:
a
e
["b", "c", "d"]
["b", "c", "d"]
nil
Step by step: letters[0] is the element at position zero, "a". letters[-1] walks backward one step from the end to reach "e". letters[1, 3] starts at index 1 and takes 3 elements, giving ["b", "c", "d"]. letters[1..3] uses an inclusive range covering indices 1 through 3, landing on the identical slice. letters[10] asks for an index that doesn’t exist, and rather than raising an exception Ruby simply returns nil, which is why the last line prints the literal text nil.
Destructuring Arrays with Pattern Matching
Modern Ruby’s case/in pattern matching can destructure an array directly by shape, binding variables to positions in one step — use it when you want to branch on the structure of an array, and stick with plain indexing or case/when for simple value checks.
point = [3, 4]
case point
in [0, 0]
puts "Origin"
in [x, 0]
puts "On the x-axis at #{x}"
in [x, y]
puts "Point at (#{x}, #{y})"
end
Output:
Point at (3, 4)
Ruby tries each in pattern top to bottom. [0, 0] doesn’t match because the second element is 4, not 0. [x, 0] doesn’t match for the same reason. [x, y] matches any two-element array and binds x and y to 3 and 4, so that branch runs.
Common Mistakes
Mistake 1: assuming a copy when you actually copied a reference. Assigning one variable to another doesn’t duplicate the array — both names point at the exact same object in memory.
original = [1, 2, 3]
copy = original
copy << 4
puts original.inspect
Output:
[1, 2, 3, 4]
Even though the code only ever mutates copy, original changes too, because copy and original are two names for the same array object. Use dup (or clone) to get an independent array before mutating it.
original = [1, 2, 3]
copy = original.dup
copy << 4
puts original.inspect
puts copy.inspect
Output:
[1, 2, 3]
[1, 2, 3, 4]
Mistake 2: mutating an array while iterating over it. Calling a method like delete inside an each block shifts every later element down by one index, which can cause each‘s internal cursor to skip an element entirely.
numbers = [2, 4, 1, 3]
numbers.each do |n|
numbers.delete(n) if n.even?
end
puts numbers.inspect
Output:
[4, 1, 3]
The intent was to remove every even number, but 4 survives: after 2 is deleted from index 0, the array becomes [4, 1, 3] and each moves on to index 1 (now 1), completely skipping the 4 that slid into index 0. The safe fix is to build a new array instead of mutating the one you’re iterating over.
numbers = [2, 4, 1, 3]
numbers = numbers.reject { |n| n.even? }
puts numbers.inspect
Output:
[1, 3]
Mistake 3: confusing == with equal?. == compares contents (value equality), while equal? compares object identity — whether two references point at the literal same object in memory. Two arrays with identical elements are almost always == but not equal?.
a = [1, 2, 3]
b = [1, 2, 3]
if a.equal?(b)
puts "Same array"
else
puts "Different objects, even though the contents look identical"
end
Output:
Different objects, even though the contents look identical
a and b are two separate array objects that happen to hold the same values, so equal? returns false. To check whether two arrays contain the same elements in the same order, use == instead.
a = [1, 2, 3]
b = [1, 2, 3]
if a == b
puts "Same contents"
else
puts "Different contents"
end
Output:
Same contents
Best Practices
- Prefer non-mutating methods (
map,select,sort) over their bang equivalents unless you specifically need to mutate in place and understand who else holds a reference to that array. - Call
dupbefore mutating an array you received as a method argument or don’t own outright — callers rarely expect their array to change underneath them. - Never mutate a collection you’re iterating with
each; build the result withmap,select, orrejectinstead. - Check for emptiness with
array.empty?, not by relying on the array’s truthiness — in Ruby, onlynilandfalseare falsy, so even[]is truthy. - Use
Array.new(size) { |i| ... }instead ofArray.new(size, some_mutable_default)whenever the default is an object like an array or hash, to avoid every slot sharing one reference. - Reach for
each_with_index,map.with_index, oreach_with_objectinstead of hand-managing a separate counter variable. - Use
freezeon arrays meant to act as constants, so an accidental mutation raises immediately instead of causing a hard-to-trace bug elsewhere.
Practice Exercises
- Write a method
odd_doubled(numbers)that takes an array of integers and returns a new array containing only the odd numbers, each multiplied by two, without mutating the original array. For[1, 2, 3, 4, 5, 6], the result should be[2, 6, 10]. - Write a method
word_lengths(words)that takes an array of strings and returns a hash mapping each word, as a symbol, to its length. For["ruby", "is", "fun"], the result should be{ ruby: 4, is: 2, fun: 3 }. Hint: look intoeach_with_objector building an array of pairs and callingto_h. - Write a method
safe_sort(numbers)that returns a new, sorted array without mutating the array passed in. Test it on[5, 3, 1, 4, 2]and confirm the original array is unchanged afterward.
Summary
- Arrays are ordered, dynamically-sized, real objects that can hold any mix of element types.
- Access elements with
arr[i]; negative indices count from the end;arr[start, length]andarr[range]return slices; out-of-range access returnsnilinstead of raising an error. ArrayincludesEnumerable, so nearly every transformation method (map,select,reduce,sort_by, and more) is built on top of one sharedeach-based interface, found via Ruby’s ancestor-chain method lookup.- Non-bang methods like
sortandmapreturn a new array; bang methods likesort!andmap!mutate the receiver in place — know which one you’re calling. - Two variables can reference the same array object; mutating through one mutates both. Use
dupor a non-mutating method when you need an independent copy. - Never mutate an array while iterating over it with
each— userejectorselectto build a new array instead. ==compares contents;equal?compares object identity — they usually disagree for two separately-created arrays with the same elements.- Pattern matching with
case/incan destructure an array by shape and bind its elements to variables in one step.
