Constantin De La Roche
/

Ruby cheatsheet

Press / to search · Esc to clear · raw markdown: basics, idioms

Basics

The building blocks — strings, arrays, hashes, classes, files.

#Strings

s = "hello"s.length                    # 5s.upcase, s.downcase, s.capitalize, s.swapcases.strip, s.lstrip, s.rstrip # trim whitespaces.chomp                     # drop a trailing newline onlys.chars                     # ["h", "e", "l", "l", "o"]s.bytes, s.lines            # bytes / lines as arrays"a-b-c".split("-")          # ["a", "b", "c"]%w[a b c].join("-")         # "a-b-c""ab" * 3                    # "ababab""ab" + "cd"                 # "abcd"  (both sides must be strings)s[0], s[-1], s[1..3], s[1, 2]   # index, from the end, range, offset+lengths.start_with?("he"), s.end_with?("lo")s.include?("ell")s.index("l")                # 2  (nil if absent)s.sub("l", "L")             # first matchs.gsub("l", "L")            # every matchs.tr("el", "ip")            # char-by-char translations.delete("l"), s.squeeze    # "heo", "helo"# Interpolation only works in double quotesname = "world""hello #{name}"             # "hello world"'hello #{name}'             # literal, no interpolation# Multi-line: heredoc. `<<~` strips the common indentation.text = <<~SQL  select *  from usersSQLs.to_sym, :hello.to_s"42".to_i, "3.14".to_f, 42.to_s, 255.to_s(2)   # "11111111""abc".frozen?               # true — string literals are frozen in 3.x with the magic comment

#Symbols

:name                       # an interned, immutable name — cheap to compare:name.to_s                  # "name""name".to_sym               # :name# Symbols are the default for hash keys, method names, and enum-ish values{ status: :active }         # => {status: :active}[1, 2, 3].map(&:to_s)       # symbol-to-proc: same as { |n| n.to_s }user.respond_to?(:save)user.send(:save)            # call by name (public_send skips private methods)

#Numbers

7 / 2                       # 3   — integer division7.0 / 2                     # 3.57.fdiv(2)                   # 3.57 % 3, -7 % 3               # 1, 2  (sign follows the divisor)7.divmod(3)                 # [2, 1]2 ** 10                     # 10243.7.round, 3.7.floor, 3.7.ceil        # 4, 3, 43.14159.round(2)            # 3.141234.5678.truncate(2)       # 1234.5610.times { |i| ... }        # 0..91.upto(5) { |i| ... }5.downto(1) { |i| ... }1.step(10, 3).to_a          # [1, 4, 7, 10]rand(100)                   # 0..99rand(1.0..2.0)Integer("42"), Float("1.5") # strict: raises on garbage, unlike to_i1_000_000                   # underscores are ignored0xff, 0b1010, 0o755         # 255, 10, 493require "bigdecimal/util""0.1".to_d + "0.2".to_d     # exact money maths — never use Float for money

#Arrays

a = [3, 1, 2]a = Array.new(3, 0)         # [0, 0, 0]a = Array.new(3) { |i| i * i }  # [0, 1, 4]%w[a b c]                   # ["a", "b", "c"]   — %i[a b c] gives symbolsa.first, a.last, a[-2]a.first(2), a.last(2)       # slices, not just one elementa[1..], a[0, 2], a.dig(0, 1)a << 4                      # push (returns the array, so it chains)a.push(4, 5); a.pop         # enda.unshift(0); a.shift       # fronta.insert(1, :x)a.delete(3)                 # by valuea.delete_at(0)              # by indexa.delete_if { |x| x.odd? }  # in placea.sort, a.sort!             # ! mutates and returns selfa.reverse, a.uniq, a.flatten, a.compact   # compact drops nilsa.sum, a.min, a.max, a.minmaxa.include?(2), a.index(2), a.count(2)a.sample, a.shufflea.each_slice(2).to_a        # [[3, 1], [2]]a.each_cons(2).to_a         # sliding window pairs[1, 2] + [3]                # concat[1, 2, 3] - [2]             # difference     => [1, 3][1, 2] & [2, 3]             # intersection   => [2][1, 2] | [2, 3]             # union, uniq    => [1, 2, 3][1, 2].product([3, 4])      # [[1, 3], [1, 4], [2, 3], [2, 4]][[1, 2], [3, 4]].transpose  # [[1, 3], [2, 4]][1, 2].zip([3, 4])          # [[1, 3], [2, 4]]a.empty?, a.any?, a.all?, a.none?, a.one?

#Hashes

h = { name: "Ada", age: 36 }            # symbol keys (the usual)h = { "name" => "Ada" }                 # any object can be a keyh = Hash.new(0)                         # default value for missing keysh = Hash.new { |hash, k| hash[k] = [] } # default *and* store ith[:name]                    # "Ada"      (nil if missing)h.fetch(:name)              # raises KeyError if missingh.fetch(:city, "n/a")       # with a defaulth.dig(:user, :address, :city)           # nil-safe nestingh[:city] = "London"h.store(:city, "London")h.delete(:age)h.key?(:name), h.value?(36)h.keys, h.values, h.sizeh.to_a                      # [[:name, "Ada"], ...]h.each { |key, value| ... }h.map { |k, v| [k, v.to_s] }.to_hh.transform_values(&:to_s)h.transform_keys(&:to_s)h.select { |k, v| v.is_a?(String) }     # filter is an aliash.reject { |k, _| k == :age }h.sum { |_, v| v.to_i }h.min_by { |_, v| v }h.merge(other)              # new hash, other wins on conflicth.merge(other) { |key, mine, theirs| mine + theirs }h.merge!(other)             # in placeh.slice(:name, :age)        # keep only theseh.except(:age)              # drop theseh.any? { |k, v| v.nil? }h.group_by { |k, v| v.class }

#Ranges

(1..5).to_a                 # [1, 2, 3, 4, 5]   — inclusive(1...5).to_a                # [1, 2, 3, 4]      — exclusive end("a".."e").to_a             # ["a", "b", "c", "d", "e"](1..10).include?(5)         # cover? is faster for numeric ranges(1..10).step(2).to_a        # [1, 3, 5, 7, 9](1..Float::INFINITY).lazy.map { |n| n * 2 }.first(3)   # [2, 4, 6](1..)                       # beginless/endless ranges: (..10), (1..)array[2..]                  # from index 2 to the endcase agewhen 0..12  then :childwhen 13..17 then :teenelse             :adultend

#Conditionals

if x > 0  :positiveelsif x < 0  :negativeelse  :zeroendputs "ok" if valid?         # modifier form — reads best on one short lineputs "no" unless valid?     # unless == if not (never use unless/else)# Everything except nil and false is truthy: 0, "" and [] are all true.value = maybe_nil || "default"value ||= "default"         # assign only if nil/falsecount = h[:n] ||= 0x = condition ? "yes" : "no"case statuswhen :active, :trial then charge!when String          then puts "got a string"when /^adm/          then :adminelse                      :unknownend# case with no subject reads like a condlabel =  case  when score > 90 then "A"  when score > 80 then "B"  else                 "C"  end

#Loops

5.times { |i| puts i }[1, 2, 3].each { |n| puts n }[1, 2, 3].each_with_index { |n, i| puts "#{i}: #{n}" }h.each_pair { |k, v| puts "#{k}=#{v}" }# for/while exist but blocks are the idiom; reach for while only for# genuinely unbounded loops.while queue.any?  item = queue.shift  next if item.nil?         # skip  break if item == :stop    # exit  redo                      # rerun this iteration (rare)enduntil done? do work endloop do                     # infinite; StopIteration breaks it cleanly  break unless more?end3.times.map { |i| i * 2 }   # [0, 2, 4] — times returns an enumerator

#Methods & arguments

def greet(name)  "hello #{name}"           # the last expression is the return valueenddef greet(name = "world")   # defaultdef sum(*nums)              # splat: any number of positional argsdef config(**opts)          # double splat: keyword args as a hashdef each_row(&block)        # capture the block as a procdef create(name:, age: 0)   # required and optional keywordscreate(name: "Ada")args = ["a", "b"]opts = { age: 3 }greet(*args)                # splat on the way increate(**opts)def find(id) = Record.all[id]     # endless method (3.0+), for one-linersdef valid?(x) = !x.nil?     # `?` → returns a booleandef save!                   # `!` → the dangerous/mutating variantdef name=(value)            # `=` → assignment: obj.name = "x"  @name = valueend# Multiple return values are just an arraydef min_max(a) = [a.min, a.max]lo, hi = min_max([3, 1, 2])

#Classes

class User  attr_reader :name         # defines name  attr_writer :email        # defines email=  attr_accessor :age        # both  ADMIN_ROLES = %i[owner admin].freeze   # constant  def initialize(name, age: 0)    @name = name            # instance variable    @age = age  end  def self.build(attrs)     # class method    new(attrs[:name])  end  def to_s = "#{@name} (#{@age})"        # used by string interpolation  def <=>(other) = age <=> other.age     # gives you sort, min, max...  include Comparable                     # ...and < > == between?  private  def secret = "hidden"     # everything below `private` is privateenduser = User.new("Ada", age: 36)user.nameuser.instance_variable_get(:@name)       # escape hatch, avoid in real codeclass Admin < User          # inheritance  def initialize(name)    super(name, age: 0)     # bare `super` forwards the same args  endenduser.is_a?(User), user.instance_of?(User), User.ancestorsuser.class, user.respond_to?(:name)

#Modules & mixins

module Greetable  def greet = "hi, #{name}"       # instance methods for the includerendmodule Countable  def self.included(base)         # hook    base.extend(ClassMethods)  end  module ClassMethods    def count = all.size  endendclass User  include Greetable               # adds instance methods  extend  Countable               # adds class methods  prepend Auditing                # inserts *before* the class in the chainend# Modules are also namespacesmodule Billing  class Invoice; end  RATE = 0.2endBilling::Invoice.newBilling::RATEmodule_function                   # in a module: make the methods callable as Billing.foo

#Exceptions

begin  risky!rescue ArgumentError, TypeError => e  warn e.messagerescue StandardError => e         # never rescue Exception: it catches ^C and NoMemoryError  report(e)  raise                           # re-raise the same error, backtrace intactelse  puts "no error"ensure  file&.close                     # always runsend# Method-level rescue — no begin neededdef fetch  http_getrescue Timeout::Error  nilendraise ArgumentError, "id required"raise MyError.new("boom")class MyError < StandardError  def initialize(msg = "something broke") = superendvalue = Integer(input) rescue 0   # inline rescue: terse, but swallows everything# Retriesattempts = 0begin  call_apirescue Net::OpenTimeout  retry if (attempts += 1) < 3  raiseend

#Files & paths

require "pathname"File.read("a.txt")                   # whole file as a stringFile.readlines("a.txt", chomp: true) # array of lines, no newlinesFile.write("a.txt", "content")       # truncates; mode: "a" to appendFile.open("a.txt", "w") do |f|       # block form closes the file for you  f.puts "line"endFile.foreach("big.log") { |line| ... }   # streams, constant memoryFile.exist?("a.txt"), File.directory?("dir"), File.size("a.txt")File.basename("/a/b.txt", ".txt")    # "b"File.extname("/a/b.txt")             # ".txt"File.dirname("/a/b.txt")             # "/a"File.join("a", "b", "c.txt")         # "a/b/c.txt"File.expand_path("../c", __dir__)Dir.glob("**/*.rb")                  # recursiveDir.children("dir")                  # names, without . and ..require "fileutils"FileUtils.mkdir_p("a/b/c")FileUtils.cp("a", "b"); FileUtils.mv("a", "b"); FileUtils.rm_f("a")path = Pathname("config") / "app.yml"    # Pathname composes with /path.exist?, path.read, path.each_linerequire "json"data = JSON.parse(File.read("a.json"), symbolize_names: true)File.write("a.json", JSON.pretty_generate(data))require "csv"CSV.foreach("a.csv", headers: true) { |row| row["name"] }

Idioms

The Ruby way — blocks, Enumerable, pattern matching, safe navigation.

#Blocks

[1, 2, 3].each { |n| puts n }        # one line: braces[1, 2, 3].each do |n|                # multi-line: do...end  puts nend# Braces bind tighter than do...end — this bites with paren-less calls:puts [1, 2].map { |n| n }            # block goes to map    ✅puts [1, 2].map do |n| n end         # block goes to puts   ❌sum = 0[1, 2, 3].each { |n| sum += n }      # blocks close over the enclosing scope[1, 2].each { |n; tmp| tmp = n }     # `;` declares block-local varsdef with_timing                      # a method that takes a block  start = Time.now  result = yield                     # runs the block  puts Time.now - start  resultendwith_timing { slow_work }def maybe  return :no_block unless block_given?  yield 1, 2                         # blocks can take several argsend# &block turns the block into an object you can pass ondef each_row(&block)  rows.each(&block)end[[1, 2], [3, 4]].each { |(a, b)| a + b }   # destructuring in the params{ a: 1 }.each { |k, v| }                   # hash yields [key, value] pairs[1, 2, 3].each { |_| :ignored }            # _ for "don't care"

#Enumerable — the workhorse

nums = [1, 2, 3, 4, 5]nums.map { |n| n * 2 }               # [2, 4, 6, 8, 10]nums.select(&:even?)                 # [2, 4]        (filter is an alias)nums.reject(&:even?)                 # [1, 3, 5]nums.find { |n| n > 3 }              # 4             (detect is an alias)nums.filter_map { |n| n * 2 if n.even? }   # map + compact in one passnums.flat_map { |n| [n, n] }         # map then flatten one levelnums.each_with_object([]) { |n, acc| acc << n * 2 }nums.sum, nums.count, nums.count(&:even?)nums.reduce(:+)                      # 15            (inject is an alias)nums.reduce(0) { |acc, n| acc + n }nums.min_by(&:abs), nums.max_by(&:abs)nums.sum { |n| n * 2 }               # sum takes a block, no map needednums.partition(&:even?)              # [[2, 4], [1, 3, 5]]nums.group_by { |n| n % 3 }          # {1=>[1, 4], 2=>[2, 5], 0=>[3]}nums.tally                           # {1=>1, 2=>1, ...} — counts occurrencesnums.each_slice(2).to_a              # fixed-size chunksnums.each_cons(2).to_a               # sliding pairsnums.chunk_while { |a, b| b == a + 1 }.to_a    # runs of consecutive valuesnums.zip(nums.map(&:to_s))nums.take(2), nums.drop(2)nums.take_while { |n| n < 3 }, nums.drop_while { |n| n < 3 }nums.any?(&:even?), nums.all?(&:positive?), nums.none?(&:zero?), nums.one?(&:odd?)nums.first(2), nums.each_entry, nums.to_a# `each_with_index` gives (item, i); `with_index` chains onto any enumeratornums.map.with_index { |n, i| "#{i}:#{n}" }nums.each.with_index(1) { |n, i| ... }     # start counting at 1# `lazy` for big or infinite sources: nothing runs until `first`/`to_a`(1..Float::INFINITY).lazy.select(&:even?).first(5)

#Sorting & grouping

users.sort_by(&:age)                       # by one keyusers.sort_by { |u| [u.dept, -u.age] }     # multi-key, `-` for descendingusers.sort_by { |u| [u.dept, u.name] }.reverseusers.max_by(3, &:age)                     # top 3 without a full sortusers.min_by(&:age)words.sort_by(&:downcase)                  # case-insensitivewords.sort                                 # uses <=> — works on any Comparableusers.sort { |a, b| a.name <=> b.name }    # explicit comparator (slower)users.group_by(&:dept)                            # {"eng" => [...], ...}users.group_by(&:dept).transform_values(&:size)   # counts per groupusers.each_with_object(Hash.new(0)) { |u, h| h[u.dept] += 1 }users.sum(&:salary)users.uniq(&:email)                        # uniq by a keyusers.index_by(&:id)                       # Rails; plain Ruby: to_h { [_1.id, _1] }users.to_h { |u| [u.id, u] }

#Procs, lambdas & method objects

square = ->(n) { n * n }              # lambda literalsquare.call(3); square.(3); square[3] # three ways to calladd = lambda { |a, b| a + b }blk = proc { |a, b| a.to_i + b.to_i } # proc: lenient about arity, returns from the caller[1, 2].map(&square)                   # & converts a proc to a block[1, 2].map(&:to_s)                    # &:sym → { |x| x.to_s }[1, 2].map(&method(:puts))            # & on a Method object# lambda vs proc, the two differences that matter:#   arity   — a lambda raises on the wrong number of args, a proc pads with nil#   return  — `return` in a lambda returns from the lambda, in a proc from the methodadder = ->(a) { ->(b) { a + b } }     # closures composeadd_two = adder.call(2)add_two.call(3)                       # 5double = ->(n) { n * 2 }inc    = ->(n) { n + 1 }(double >> inc).call(3)               # 7  — double, then inc(double << inc).call(3)               # 8  — inc, then double[1, 2, 3].map { _1 * 2 }              # _1.._9 numbered params (2.7+)[1, 2, 3].map { it * 2 }              # `it` for a single param (3.4+)

#Nil, safe navigation & defaults

user&.address&.city         # nil instead of NoMethodErroruser&.save!name = user&.name || "anon"list = maybe_nil.to_a       # nil.to_a == [], nil.to_s == "", nil.to_i == 0h.fetch(:key, "default")h.fetch(:key) { expensive } # block form: only evaluated when missingvalue.nil?                  # the only reliable nil test — 0 and "" are truthyArray(nil), Array([1]), Array(1)      # [], [1], [1] — normalise to an array@cache ||= expensive        # memoise (careful: re-runs if the result is nil/false)@cache = defined?(@cache) ? @cache : expensive   # memoise nil/false correctlyx = h.dig(:a, :b, :c)       # nil-safe nested lookupitems.compact               # drop nilsitems.compact_blank         # Rails: drops "", [], {} toodef name = @name.presence || "anon"   # Rails: presence → nil if blank

#Pattern matching (case/in)

config = { db: { host: "localhost", port: 5432 } }case configin { db: { host: String => host, port: Integer => port } }  "#{host}:#{port}"in { db: { url: String => url } }  urlelse  raise ArgumentErrorendcase responsein { status: 200..299, body: }        # binds `body`, checks a range  parse(body)in { status: 404 }  nilin { status: Integer => code } if code >= 500  retry_laterendcase pointin [x, y]           then "2D"in [x, y, z]        then "3D"in []               then "empty"in [Integer => first, *rest] then restend# One-line formsconfig => { db: { host: } }           # destructure or raise (rightward assignment)if config in { db: { host: String } } # boolean test  ...end

#String formatting

format("%05.2f", 3.14159)             # "03.14"   (sprintf is the same method)format("%-10s|", "left")              # "left      |"format("%s scored %d%%", name, 90)"%s is %d" % ["Ada", 36]              # % operator form12345.to_s.reverse.scan(/\d{1,3}/).join(",").reverse   # "12,345""%.2f" % 1234.5                       # "1234.50""a,b".split(",")                      # ["a", "b"]["a", "b"].join(", ")"hello".center(11, "*")               # "***hello***""7".rjust(3, "0")                     # "007""abc".ljust(5)s.each_char, s.scan(/\w+/)            # tokenise"CamelCase".gsub(/([a-z])([A-Z])/, '\1_\2').downcase   # camel → snake<<~TEXT                               # squiggly heredoc strips indentation  Dear #{name},    indented lineTEXT

#Struct & Data

Point = Struct.new(:x, :y) do         # mutable value object, free ==, to_a, each  def distance = Math.sqrt(x**2 + y**2)endp1 = Point.new(3, 4)p1.x; p1.x = 5; p1.to_a; p1 == Point.new(5, 4)Named = Struct.new(:x, :y, keyword_init: true)Named.new(x: 1, y: 2)Coord = Data.define(:lat, :lng)       # 3.2+: immutable, keyword or positionalc = Coord.new(lat: 48.8, lng: 2.3)c.with(lat: 45.0)                     # a new copy, one field changedc.to_h                                # {lat: 48.8, lng: 2.3}# Reach for Data over a bare Hash the moment a shape has a name.

#Mutation, dup & freeze

# Convention: `!` marks the mutating/dangerous twin of a safe method.a.sort   # new array          a.sort!   # sorts in place, nil if nothing changeds.upcase # new string         s.upcase! # in placeh.merge  # new hash           h.merge!  # in placeb = a.dup                  # shallow copy — nested objects are sharedb = a.clone                # dup + keeps frozen state and singleton methodsdeep = Marshal.load(Marshal.dump(a))   # crude deep copyCONFIG = { host: "x" }.freeze          # freeze constants: mutation now raisesCONFIG.frozen?s = +"mutable"                         # unary + gives an unfrozen strings = -"interned"                        # unary - gives a frozen, deduped one# `each` over a collection you are mutating is a classic bug — build a new one:kept = items.reject { |i| i.expired? }

#Time & dates

require "time"require "date"Time.now                              # localTime.now.utc                          # always store and compare in UTCTime.now.to_i                         # unix epochTime.at(1_700_000_000)Time.now.strftime("%Y-%m-%d %H:%M:%S")    # formatTime.parse("2026-08-26 14:00")            # lenient parseTime.iso8601("2026-08-26T14:00:00Z")      # strictTime.now.iso8601                          # serialiseDate.today, Date.today - 7, (Date.today >> 1)   # a week ago, a month aheadDate.new(2026, 8, 26).strftime("%d/%m/%Y")(Date.new(2026, 1, 1)..Date.new(2026, 1, 5)).to_aelapsed = Time.now - start            # a Float, in secondsProcess.clock_gettime(Process::CLOCK_MONOTONIC)   # for measuring durations

#Regex quickies

"a1b2" =~ /\d/                        # 1 — index of the first match, else nil"a1b2".match?(/\d/)                   # true — fastest, allocates nothing"a1b2"[/\d+/]                         # "1" — the matched text, or nil"a1b2".scan(/\d/)                     # ["1", "2"] — every match"a1b2".gsub(/\d/) { |d| d.to_i + 1 }  # block gets each matchm = "2026-08-26".match(/(?<y>\d{4})-(?<m>\d{2})/)m[:y], m[:m], m[0], m.pre_match, m.post_matchif "id=42" =~ /id=(\d+)/  $1                                  # "42" — also $~, $` and $'end"a-b".split(/[-_]/)text.gsub(/\s+/, " ").strip           # squeeze whitespace/foo/i     # case-insensitive/^x/       # start of *line*   — \A is start of string/x$/       # end of *line*     — \z is end of string (\Z allows a trailing \n)/a.+?b/    # lazy quantifierRegexp.escape(user_input)             # never interpolate raw input into a regex

#Misc that saves time

x.tap { |v| puts v }                  # peek inside a chain, returns xx.then { |v| v * 2 }                  # pipe x into a block, returns the resultrequire "pp"; pp object               # pretty-print a nested structurep object                              # inspect + newline, returns the objectputs object                           # to_s, returns nildefined?(foo)                         # "local-variable", "method", nil...__method__                            # the current method's namecaller                                # the backtrace, as stringsbinding.irb                           # drop into a REPL right hereObjectSpace.count_objects             # allocation countsrequire "benchmark"Benchmark.realtime { work }Comparable, Enumerable                # include these + <=> or each and get the rest freeobj.frozen?, obj.object_id, obj.hashobj.instance_variablesUser.instance_methods(false)          # methods defined on User itselfENV.fetch("DATABASE_URL")             # fetch, not [] — fail loudly on a missing varARGV                                  # command-line args$stdout.sync = true                   # unbuffered output in a containerabort "message"                       # print to stderr and exit 1at_exit { cleanup }

#Gems, bundler & tooling

ruby -vruby -e 'puts 1 + 1'              # one-linerruby -rjson -e 'puts JSON.parse(STDIN.read)["a"]'   # -r requires a libirb                               # REPLbundle init                       # create a Gemfilebundle add rspec --group developmentbundle installbundle update rails               # one gem onlybundle exec rspec                 # run inside the bundlebundle outdatedgem install rubocoprubocop -a                        # autocorrect the safe copsrubocop -A                        # ...and the unsafe onesrspec                             # run the suiterspec spec/models/user_spec.rb:42 # one example, by lineruby -Ilib -e 'require "mygem"'

#Naming & style conventions

ConventionMeans
snake_casemethods, variables, files
CamelCaseclasses and modules
SCREAMING_SNAKEconstants (freeze them)
name?returns a boolean
name!the dangerous twin: mutates, raises, or both
name=a setter, called as obj.name = x
@ivar / @@cvar / $globalinstance / class / global variable
_unuseda parameter you must accept but won't use
  • Two-space indent, no tabs. do...end for multi-line blocks, { } for one-liners.
  • Prefer each/map over for; prefer guard clauses over nesting.
  • unless for a simple negative, never with else and never with &&/||.
  • Methods return their last expression: an explicit return is for early exits only.
  • Bang methods often return nil when nothing changed — never chain off sort!.