Arrays

Published by Programming Line in Ruby. Last updated .

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 if default is 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 dup before 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 with map, select, or reject instead.
  • Check for emptiness with array.empty?, not by relying on the array’s truthiness — in Ruby, only nil and false are falsy, so even [] is truthy.
  • Use Array.new(size) { |i| ... } instead of Array.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, or each_with_object instead of hand-managing a separate counter variable.
  • Use freeze on arrays meant to act as constants, so an accidental mutation raises immediately instead of causing a hard-to-trace bug elsewhere.

Practice Exercises

  1. 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].
  2. 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 into each_with_object or building an array of pairs and calling to_h.
  3. 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] and arr[range] return slices; out-of-range access returns nil instead of raising an error.
  • Array includes Enumerable, so nearly every transformation method (map, select, reduce, sort_by, and more) is built on top of one shared each-based interface, found via Ruby’s ancestor-chain method lookup.
  • Non-bang methods like sort and map return a new array; bang methods like sort! and map! 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 dup or a non-mutating method when you need an independent copy.
  • Never mutate an array while iterating over it with each — use reject or select to 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/in can destructure an array by shape and bind its elements to variables in one step.