Strings

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). A String is a mutable sequence of bytes that carries an encoding, so that those bytes can be read as characters. It is the most used core class in a typical program, and it has more than a hundred methods, which is why this page is organised by what you want to do with a string: create it, change it, ask it questions, cut it up, search it, substitute parts of it, split it, change its case, pad it, format values into it, convert it to other types and scan it from left to right.

Three neighbouring topics have their own pages and are only touched here. The literal syntax (quotes, escapes, %q, heredocs, ?a) is on Literals. Encodings, force_encoding versus encode, invalid bytes and Unicode normalisation are on Encodings and Unicode. Regular expressions, which most of the searching and substituting methods accept, are on Regular Expressions. Conversion protocols (to_s versus to_str, String()) are on Conversions and Coercion, and the freezing rules are on Object Lifecycle, Freezing and Copying. For the symbol type, which is often confused with a string, see Symbols.

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off, and Style/OneClassPerFile off). Lines that end in # => show the value of the expression on that line; they were produced by running the code, in the Ruby 3.4+ inspect format. Output written by puts or by an error is shown separately. Two examples carry a RuboCop directive on purpose: the format page below uses literal format strings, and the error table builds mistakes on purpose.

A String in one page

Property What it means

Bytes plus encoding

A string stores bytes and an Encoding. length counts characters as the encoding defines them; bytesize counts the bytes. A literal in a UTF-8 source file is UTF-8, so "Café".length is 4 and "Café".bytesize is 5.

Mutable by default

Most methods ending in ! and << change the receiver. Literals are chilled since 3.4 (they warn when mutated under -W:deprecated) and frozen under the frozen_string_literal magic comment.

Comparable

String includes Comparable; <=> compares byte by byte (after encoding checks), so "B" < "a".

Not a collection

A string is not Enumerable. Use chars, each_char, bytes, lines and friends to iterate.

Not a symbol

"dune" and :dune are different objects with different methods; a symbol is immutable and unique. See Symbols.

Implicit conversion

to_str is the implicit conversion used by +, File.join and others; a class that defines it can be used where a string is required.

The class documentation groups its methods by purpose, and so does this page. The methods that exist in several forms follow one convention: a method with ! modifies the receiver and returns it, or returns nil when nothing changed; the same name without ! returns a new string. The nil result is the usual surprise, because it makes a bang method unusable in a chain.

flowchart LR lit["literal in a file without a magic comment"] --> chilled["chilled string: mutable, warns once under -W:deprecated"] mag["literal with frozen_string_literal: true"] --> frozen["frozen string: FrozenError on change"] fresh["String.new, +text, dup, interpolation"] --> mutable["mutable string"] chilled -- "first change" --> mutable mutable -- "freeze, -text" --> frozen frozen -- "dup, +text" --> mutable chilled -- "freeze" --> frozen

Creating strings

Most strings come from literals, interpolation, format or a conversion method such as to_s. The class method String.new is for the cases a literal cannot express: a string with a given encoding, a pre-sized buffer, or a mutable copy of another string.

# frozen_string_literal: true

String.new.encoding                            # => #<Encoding:BINARY (ASCII-8BIT)>
"".encoding                                    # => #<Encoding:UTF-8>
String.new(encoding: Encoding::UTF_8).encoding # => #<Encoding:UTF-8>
buffer = String.new(capacity: 4096)            # => ""
buffer.encoding                                # => #<Encoding:BINARY (ASCII-8BIT)>
copy = String.new("Dune")                      # => "Dune"
copy.frozen?                                   # => false
"Dune".frozen?                                 # => true
String.try_convert("Dune")                     # => "Dune"
String.try_convert(:dune)                      # => nil
String(42)                                     # => "42"
String(:dune)                                  # => "dune"
String(nil)                                    # => ""
String([1, "two"])                             # => "[1, \"two\"]"
tripled = "ab" * 3                             # => "ababab"
tripled.length                                 # => 6
String.new("Dune").b.encoding                  # => #<Encoding:BINARY (ASCII-8BIT)>

Source: String.new

The details worth remembering:

  • String.new with no argument returns an empty binary string (BINARY, also called ASCII-8BIT), not a UTF-8 one, unlike the empty literal "". Appending UTF-8 text to it still works (the encoding is adjusted on the first append of a non-ASCII string), but a buffer that starts as String.new and receives only ASCII keeps the binary encoding. Pass encoding: Encoding::UTF_8 or start from +"" when the buffer is meant to hold text.

  • capacity: only preallocates memory; the string is still empty. It is a hint for building large strings and is rarely needed.

  • String.new("Dune") copies the argument (and its encoding), and the copy is not frozen, even if the literal was.

  • String.try_convert(obj) calls to_str and returns nil when the object does not respond to it. It is the checked form of the implicit conversion, used by libraries that accept "anything string-like".

  • String(obj) (the Kernel method) tries to_str, then to_s, so it accepts everything, including nil.

  • "ab" * 3 repeats a string; a negative count raises ArgumentError (see Errors and warnings).

Mutating and non-mutating operations

The two ways to add text to a string differ in a way that matters for both correctness and speed. + builds a new string and leaves both operands alone. << appends to the receiver and returns the receiver, so it can be chained. += is not an operator of its own: a += b is a = a + b, so it creates a new string every time and rebinds the variable, while << keeps the same object.

# frozen_string_literal: true

title = +"Dune"
id = title.object_id
title << " Messiah"        # => "Dune Messiah"
title.object_id.equal?(id) # => true
title += "!"               # => "Dune Messiah!"
title.object_id.equal?(id) # => false

log = String.new
log << "a" << "b" << 99 # => "abc"
log.concat("d", "e")    # => "abcde"
log.prepend(">> ")      # => ">> abcde"
log.insert(3, "[")      # => ">> [abcde"
log.insert(-1, "]")     # => ">> [abcde]"
log.replace("fresh")    # => "fresh"
log.clear               # => ""

begin
  title + 2
rescue TypeError => e
  puts "#{e.class}: #{e.message}"
end
count = 2
puts "#{title} #{count}"
puts title + count.to_s

Output:

TypeError: no implicit conversion of Integer into String
Dune Messiah! 2
Dune Messiah!2

Source: String#<<

Points to take from the example:

  • << returns self, which is why log << "a" << "b" works. It accepts an Integer and appends the character with that code point (99 becomes "c"), which surprises people who expect "abc" << 1 to append the digit.

  • concat takes several arguments at once; prepend adds at the front; insert(index, text) inserts before the given index, and a negative index counts from the end and inserts after that position, so insert(-1, "]") appends.

  • replace swaps the whole content and clear empties the string; both keep the object’s identity.

  • There is no implicit conversion in +: "Dune" + 2 raises TypeError: no implicit conversion of Integer into String. Use interpolation ("Dune #{count}") or call to_s yourself. Interpolation calls to_s and never raises for a missing conversion, which is why it is the preferred way to build a message.

  • Because a += b creates a new object, code that holds another reference to the old string does not see the change; << is visible through every reference. This is both the point of << and its main hazard (see Pitfalls).

Source: String#+

Frozen strings, chilled strings and mutability

A frozen string raises FrozenError on any change. Literals become frozen when the file starts with the magic comment # frozen_string_literal: true; that is the recommended default for every file (RuboCop’s Style/FrozenStringLiteralComment enforces it). The comment makes the literal itself frozen and de-duplicated: two occurrences of the same literal in a file are the same object. It does not freeze interpolated strings (since Ruby 3.0 a "#{x} text" is a new, unfrozen string each time) or strings built by String.new.

# frozen_string_literal: true

literal = "Dune"
literal.frozen? # => true
begin
  literal << " Messiah"
rescue FrozenError => e
  puts "#{e.class}: #{e.message}"
  puts e.receiver.equal?(literal)
end

mutable = +literal
mutable.frozen?                      # => false
mutable.equal?(literal)              # => false
mutable << " Messiah"                # => "Dune Messiah"
(+mutable).equal?(mutable)           # => true
literal.dup.frozen?                  # => false
literal.clone.frozen?                # => true
literal.clone(freeze: false).frozen? # => false
"#{literal} Messiah".frozen?         # => false
String.new("Dune").frozen?           # => false
"Dune".equal?("Dune")                # => true
suffix = "ne"
(-"Dune").equal?(-"Du#{suffix}") # => true
"Dune".dedup.equal?(-"Dune")     # => true

Output:

FrozenError: can't modify frozen String: "Dune"
true

Source: String#+@

The operators and methods that move between the two states:

Expression Result

+text (String#+@)

The receiver itself when it is not frozen, otherwise an unfrozen copy. For a chilled string (3.4 and later) it also returns a copy, so +"literal" is the portable way to ask for a mutable string.

-text (-@), dedup

A frozen, de-duplicated string: equal frozen strings share one object. dedup is the alias (3.2).

freeze

Freezes the receiver in place and returns it.

dup

An unfrozen copy, without the singleton methods.

clone

A copy that keeps the frozen state and the singleton methods; clone(freeze: false) returns an unfrozen copy.

frozen?

Tells whether the string is frozen. A chilled string reports false.

FrozenError#receiver gives the string that could not be changed. The message is can't modify frozen String: followed by the inspect of the string.

Chilled strings (3.4 and later)

Without a magic comment, a string literal is chilled in Ruby 3.4 and later (including 4.0): frozen? returns false and the string can be changed, but Ruby remembers that the literal is supposed to be frozen one day. The first change emits a warning of the deprecated category, which is off by default; ruby -w, -W:deprecated, RUBYOPT=-W:deprecated or Warning[:deprecated] = true turn it on. After the first change the string behaves as a plain mutable one, so each string warns at most once. The same applies to the string returned by Symbol#to_s in 3.4.

# rubocop:disable Style/FrozenStringLiteralComment

# No magic comment: literals are chilled in Ruby 3.4 and later.
Warning[:deprecated] = true
title = "Dune"
title.frozen?       # => false
title << " Messiah" # => "Dune Messiah"
title << "!"        # => "Dune Messiah!"

fresh = +"Dune"
fresh << " Messiah" # => "Dune Messiah"

safe = "Dune".dup
safe << " Messiah" # => "Dune Messiah"
# rubocop:enable Style/FrozenStringLiteralComment

Standard error:

final/e04_chilled.rb:7: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)

Only the first mutation of the literal warns (line 7); the second append to the same string does not. +"Dune" and "Dune".dup give a plain mutable string from the start, so they never warn. The command-line switches --enable-frozen-string-literal (freeze every literal in files without an explicit comment, which finds code that mutates literals), --disable-frozen-string-literal (the old behaviour, no warning) and --debug-frozen-string-literal (report where the literal was created) are described on Literals and Ruby Command Line and Environment. An explicit # frozen_string_literal: false also keeps literals plain and silent.

Strings are not frozen by default in Ruby 4.0. Feature #20205 (enable frozen_string_literal by default) is still open, and chilled strings are the migration path. The practical advice is to put the magic comment in every file, to build strings you intend to change with +"" or String.new, and to run the tests once with -W:deprecated.

Querying a string

The query methods never change the receiver. Most of them have a byte-based twin (byteindex, bytesize, byteslice) for code that works on binary data or needs offsets to hand to I/O functions.

# frozen_string_literal: true

s = "Café Müller"
s.length                                                       # => 11
s.bytesize                                                     # => 13
s.empty?                                                       # => false
s.ascii_only?                                                  # => false
s.include?("Mül")                                              # => true
s.start_with?("Caf")                                           # => true
s.start_with?(/c/i)                                            # => true
s.end_with?("er", "ler")                                       # => true
s.index("é")                                                   # => 3
s.byteindex("M")                                               # => 6
s.rindex("l")                                                  # => 8
s.index("l", 8)                                                # => 8
s.count("a-z")                                                 # => 6
s.count("lm", "^m")                                            # => 2
s =~ /ü/                                                       # => 6
s.match?(/mü/i)                                                # => true
s.match(/(?<first>\p{L}+) (?<last>\p{L}+)/)[:last]             # => "Müller"
[("a" <=> "b"), ("b" <=> "a"), ("a" <=> "a".dup), ("a" <=> 1)] # => [-1, 1, 0, nil]
"dune".casecmp("DUNE")                                         # => 0
"dune".casecmp?("DUNE")                                        # => true
"ä".casecmp?("Ä")                                              # => true
"ä".casecmp("Ä")                                               # => 1
"abc".between?("abb", "abd")                                   # => true
"abc".eql?("abc")                                              # => true
"abc".equal?("abc".dup)                                        # => false
"abc".hash.eql?("abc".dup.hash)                                # => true
s.valid_encoding?                                              # => true
s.unicode_normalized?                                          # => true

Source: String#include?

Method Answers

length, size

Number of characters. bytesize is the number of bytes. empty? is true for a zero-length string.

ascii_only?, valid_encoding?, encoding

Encoding facts: valid_encoding? is false for byte sequences the encoding forbids. See Encodings and Unicode.

include?(str), start_with?(*prefixes), end_with?(*suffixes)

Substring tests. start_with? also accepts a Regexp (anchored at the start); end_with? accepts only strings.

index, rindex, byteindex, byterindex

Position of the first or last match as a character or byte offset, with an optional start offset; nil when absent. byteindex and byterindex are 3.2 additions.

count(*selectors)

Number of characters matched by one or more character selectors (see Character selectors).

match?(pattern), match, =~

Regexp tests. match? returns a boolean, allocates no MatchData and does not set $~; match returns MatchData; =~ returns the index or nil.

<=>, ==, eql?, equal?

Ordering, content equality, content equality without conversion, identity. <=> returns nil for a non-string.

casecmp, casecmp?

Case-insensitive comparison: casecmp folds ASCII only and returns -1, 0, 1 or nil; casecmp? uses Unicode case folding and returns a boolean.

between?, clamp

From Comparable.

hash

A hash code consistent with eql?, used for hash keys.

start_with? and end_with? take several arguments and return true when any of them matches, which replaces a chain of ||. index with a string argument does a plain substring search; with a Regexp it searches for a match. Counting characters instead of bytes is the reason "Café Müller".index("é") and "Café Müller".byteindex("M") differ from what a byte-oriented language would print: "M" is the character at index 5, but byte index 6, because é takes two bytes. Equality has its own page: Equality, Hashing and Comparison.

Slicing: [], slice and []=

[] (alias slice) is the single method behind most of what other languages call substring, charAt, left and right. It accepts an index, a start and length, a range (including beginless and endless ranges), a string, or a regular expression with an optional capture.

# frozen_string_literal: true

s = "The Dispossessed"
s[0]                       # => "T"
s[-1]                      # => "d"
s[100]                     # => nil
s[4, 3]                    # => "Dis"
s[4, 100]                  # => "Dispossessed"
s[16, 1]                   # => ""
s[17, 1]                   # => nil
s[4..8]                    # => "Dispo"
s[4...8]                   # => "Disp"
s[-5..]                    # => "essed"
s[..2]                     # => "The"
s["Dis"]                   # => "Dis"
s["xyz"]                   # => nil
s[/D\w+/]                  # => "Dispossessed"
s[/(D)(\w+)/, 2]           # => "ispossessed"
s[/(?<word>p\w+)/, "word"] # => "possessed"
s.slice(0, 3)              # => "The"
s.byteslice(4, 3)          # => "Dis"
"é".byteslice(0)           # => "\xC3"
s.chr                      # => "T"

t = +"The Dispossessed"
t.slice!(0, 4)          # => "The "
t.to_s                  # => "Dispossessed"
t.slice!(/s+/)          # => "s"
t.to_s                  # => "Dipossessed"
t[0, 3] = "Lat"         # => "Lat"
t.to_s                  # => "Latossessed"
t[/\w+\z/] = "Hand"     # => "Hand"
t.to_s                  # => "Hand"
t["Hand"] = "Left Hand" # => "Left Hand"
t.to_s                  # => "Left Hand"
begin
  t["zzz"] = "x"
rescue IndexError => e
  puts "#{e.class}: #{e.message}"
end
begin
  t[100] = "x"
rescue IndexError => e
  puts "#{e.class}: #{e.message}"
end
t.bytesplice(0, 4, "Right") # => "Right Hand"
t.to_s                      # => "Right Hand"

Output:

IndexError: string not matched
IndexError: index 100 out of string

Source: String#[]

The rules that decide whether a slice is nil, an empty string or a short string:

  • An out-of-range single index (s[100]) is nil, and a negative index counts from the end (s[-1] is the last character).

  • With a start and a length, a start equal to the length returns "" (an empty string at the very end), while a start past the length returns nil. A length past the end is cut: s[4, 100] returns what is left.

  • Ranges follow the same rule for the start, and s[-5..] or s[..2] are the clear way to write "the last five" and "up to index 2".

  • A string argument returns a new copy of itself if it occurs, nil otherwise. A regular expression returns the whole match, or one capture when a second argument (a number or a name) is given.

  • slice! removes the selected part from the receiver and returns it; slice and [] leave the receiver alone. []= replaces the part selected by any of the same selectors and returns the assigned value.

  • []= raises IndexError when the selector finds nothing: string not matched for a string or regexp, index 100 out of string for an index.

  • byteslice cuts at byte offsets and can return an invalid string ("é".byteslice(0) is the single byte "\xC3"); bytesplice (3.2) replaces a byte range. Use them for binary data, not for text.

  • Slices are copies: changing the result does not change the original, and s[0, 3] allocates a new String (see Performance notes).

Source: String#slice!

Searching and extracting

The search methods return positions, parts or MatchData. partition and rpartition always return three strings, which makes them safe to destructure even when the separator is missing (the missing parts are empty strings).

# frozen_string_literal: true

line = "978-0-306-40615-7 | The Dispossessed | Ursula K. Le Guin"
line.split(" | ")      # => ["978-0-306-40615-7", "The Dispossessed", "Ursula K. Le Guin"]
line.partition(" | ")  # => ["978-0-306-40615-7", " | ", "The Dispossessed | Ursula K. Le Guin"]
line.rpartition(" | ") # => ["978-0-306-40615-7 | The Dispossessed", " | ", "Ursula K. Le Guin"]
line.partition("zzz")  # => ["978-0-306-40615-7 | The Dispossessed | Ursula K. Le Guin", "", ""]
line.scan(/\d+/)       # => ["978", "0", "306", "40615", "7"]
line.scan(/(\d)-(\d)/) # => [["8", "0"], ["6", "4"], ["5", "7"]]
line.scan("s").size    # => 6
initials = +""
line.scan(/[A-Z]\w+/) { |word| initials << word[0] }
initials.to_s                                        # => "TDULG"
m = line.match(/(?<group>\d+)-(?<publisher>\d+)-/)   # => #<MatchData "978-0-" group:"978" publisher:"0">
m[:group]                                            # => "978"
m.post_match[0, 8]                                   # => "306-4061"
line.match?(/\bGuin\z/)                              # => true
line =~ /Dis/                                        # => 24
Regexp.last_match(0)                                 # => "Dis"
Regexp.last_match.pre_match.size                     # => 24
line.index(/\|/)                                     # => 18
line.rindex(/\|/)                                    # => 37
line.delete_prefix("978-").delete_suffix(" Le Guin") # => "0-306-40615-7 | The Dispossessed | Ursula K."
line.start_with?("978", "979")                       # => true
line.end_with?("Guin")                               # => true
  • split is covered in Splitting and iterating; here it breaks the line into its three fields.

  • scan(pattern) returns every non-overlapping match. With no capture groups it returns an array of strings; with groups it returns an array of arrays, one per match, which is the shape needed for to_h-style parsing. With a block it yields each match instead and returns the receiver.

  • match and =~ set the thread-local special variables $~, $1 and $&; Regexp.last_match is the readable way to reach them (RuboCop’s Style/SpecialGlobalVars prefers it). match? sets nothing and is faster.

  • A named capture is read with m[:group], and MatchData#pre_match and post_match return what came before and after.

  • delete_prefix and delete_suffix (2.5) remove a literal prefix or suffix and are the safe alternatives to a sub(/\Afoo/, "") that is easy to get wrong; the ! forms return nil when nothing was removed.

Substituting with sub and gsub

sub replaces the first match, gsub all matches. Both take a pattern (a String or a Regexp) and either a replacement or a block. A string pattern is matched literally: its characters are never special, so "THX1138".gsub('\d+', "00") changes nothing.

# frozen_string_literal: true

s = "the dispossessed, the left hand of darkness"
s.sub("the", "The")                                    # => "The dispossessed, the left hand of darkness"
s.gsub("the", "The")                                   # => "The dispossessed, The left hand of darkness"
s.gsub(/\bthe\b/, "a")                                 # => "a dispossessed, a left hand of darkness"
s.gsub(/(\w+) (\w+)/, '\2 \1')                         # => "dispossessed the, left the of hand darkness"
s.gsub(/(?<a>\w+) (?<b>\w+)/, '\k<b> \k<a>')           # => "dispossessed the, left the of hand darkness"
s.gsub(/t(h)e/, '<\0|\1>')                             # => "<the|h> dispossessed, <the|h> left hand of darkness"
s.sub(/dis(?=p)/, '[\&]')                              # => "the [dis]possessed, the left hand of darkness"
"ab-cd".sub("-", '(\`)')                               # => "ab(ab)cd"
s.gsub(/\bthe\b/, "the" => "THE")                      # => "THE dispossessed, THE left hand of darkness"
s.gsub(/[ae]/, "a" => "4", "e" => "3")                 # => "th3 disposs3ss3d, th3 l3ft h4nd of d4rkn3ss"
s.gsub(/\b\w/, &:upcase)                               # => "The Dispossessed, The Left Hand Of Darkness"
s.gsub(/\w+/) { |w| w.length > 3 ? w.capitalize : w }  # => "the Dispossessed, the Left Hand of Darkness"
s.gsub(/(?<w>\b\w)/) { Regexp.last_match[:w].upcase }  # => "The Dispossessed, The Left Hand Of Darkness"
s.gsub("the") { "\\" }                                 # => "\\ dispossessed, \\ left hand of darkness"
s.gsub("the", "\\\\")                                  # => "\\ dispossessed, \\ left hand of darkness"
s.gsub("the", "$&")                                    # => "$& dispossessed, $& left hand of darkness"
s.gsub(/\bthe\b/).with_index.map { |m, i| "#{m}#{i}" } # => ["the0", "the1"]

u = +"hello"
u.sub!(/z+/, "y")                               # => nil
u.gsub!("l", "L")                               # => "heLLo"
u.sub!("hello", "x")                            # => nil
u.sub!("heLLo", "x")                            # => "x"
"price: 5".sub(/\d+/) { |n| (n.to_i * 2).to_s } # => "price: 10"
"a.b.c".gsub(".", "-")                          # => "a-b-c"
"a.b.c".gsub(/./, "-")                          # => "-----"
"THX1138".gsub('\d+', "00")                     # => "THX1138"
"THX1138".gsub(/\d+/, "00")                     # => "THX00"

Source: String#gsub

The replacement can be:

  • a string, in which \1, \2 (numbered captures), \k<name> (a named capture), \0 or \& (the whole match), a backslash followed by a backtick (the text before) and \' (the text after) are back-references, and \\ is one backslash. Because the string literal consumes backslashes first, a back-reference is usually written in single quotes ('\2 \1') or with a doubled backslash in double quotes ("\\2 \\1"). $& and $1 are not interpreted in a replacement string.

  • a hash, in which the matched text is looked up and its value is the replacement. A match with no key is replaced by the empty string, and symbol keys never match. This is a compact way to translate characters or words.

  • a block, which receives the matched text and returns the replacement. Inside the block $~, $1 and Regexp.last_match describe the current match, and the block’s value is converted with to_s. The block form avoids the escaping problems of the string form: s.gsub("the") { "\\" } inserts one backslash, whereas the string "\\\\" is needed for the same result in the string form.

  • nothing, in which case gsub returns an Enumerator over the matches. with_index then gives numbered replacements, and the same trick exists for scan.

sub! and gsub! return nil when nothing changed, as the example shows (u.sub!(/z+/, "y")). That makes them unsafe at the end of a method whose result is used, and it makes x = s.gsub!(...) wrong whenever no replacement happens; prefer the non-bang methods unless you need to change a shared string. RuboCop’s Style/RedundantRegexpArgument suggests a plain string when the pattern is a literal with no special characters, which also avoids the regular expression engine.

For translating single characters, tr (next section) is faster and clearer than gsub.

Character selectors: tr, delete, squeeze and strip

Several methods take character selectors, a small language of their own that is similar to a regular-expression character class but is not one. A selector is a string: the characters in it are the set; a leading ^ negates the set; a hyphen between two characters is a range ("a-d"); and a backslash escapes ^, - and \. These methods accept selectors: tr, tr_s, count, delete, squeeze, and, since Ruby 4.0, strip, lstrip and rstrip (Feature #21552).

With more than one selector (in count, delete, squeeze and the 4.0 strip family), the selected characters are those common to all the selectors, so delete("a-c", "b-d") removes only b and c.

# frozen_string_literal: true

"hello".tr("el", "ip")             # => "hippo"
"hello".tr("a-y", "b-z")           # => "ifmmp"
"hello".tr("^l", "*")              # => "**ll*"
"hello".tr("el", "x")              # => "hxxxo"
"hello".tr("l", "")                # => "heo"
"hello".tr_s("l", "r")             # => "hero"
"aabbcc".tr_s("ab", "x")           # => "xcc"
"abracadabra".delete("a")          # => "brcdbr"
"abracadabra".delete("^bc")        # => "bcb"
"abracadabra".delete("a-c", "b-d") # => "araadara"
"aaabbb  ccc".squeeze              # => "ab c"
"aaabbb  ccc".squeeze("a-b")       # => "ab  ccc"
"abracadabra".count("a-c")         # => 8
"abracadabra".count("^a")          # => 6
"a-b^c\\d".delete("\\-")           # => "ab^c\\d"
"a-b^c\\d".delete("\\^")           # => "a-bc\\d"
"a-b^c\\d".delete("\\\\")          # => "a-b^cd"
"  Dune \n".strip                  # => "Dune"
"  Dune \n".lstrip                 # => "Dune \n"
"  Dune \n".rstrip                 # => "  Dune"
"xxDunexx".strip("x")              # => "Dune"
"xxDunexx".lstrip("x")             # => "Dunexx"
"xxDunexx".rstrip("x")             # => "xxDune"
"--[Dune]--".strip("-", "^[")      # => "[Dune]"
"--[Dune]--".strip("^a-zA-Z")      # => "Dune"
"  Dune\t".strip(" ")              # => "Dune\t"
"\0 Dune \0".strip                 # => "Dune"
"Dune\r\n".chomp                   # => "Dune"
"Dune\n\n".chomp                   # => "Dune\n"
"Dune\r\n\r\n".chomp("")           # => "Dune"
"Dune".chomp("ne")                 # => "Du"
"Dune".chop                        # => "Dun"
"Dune\r\n".chop                    # => "Dune"
file = +"dune.rb"
file.delete_suffix!(".rb") # => "dune"
file.delete_suffix!(".rb") # => nil
file.to_s                  # => "dune"
Method Behaviour

tr(from, to)

Translates each character in from to the character at the same position in to. A shorter to is padded with its last character ("hello".tr("el", "x") is "hxxxo"); an empty to deletes. ^ negates from.

tr_s(from, to)

Like tr, then squeezes runs of the translated characters into one ("hello".tr_s("l", "r") is "hero").

delete(*selectors)

Removes every selected character.

squeeze(*selectors)

Collapses runs of the same character into one; without arguments, every run; with selectors, only the selected characters.

count(*selectors)

Counts the selected characters.

strip, lstrip, rstrip

Remove leading and trailing whitespace. Whitespace is any mix of NUL, tab, line feed, vertical tab, form feed, carriage return and space. With selectors (4.0) they remove the selected characters from the ends instead, so "xxDunexx".strip("x") is "Dune" and strip("^a-zA-Z") keeps only the letters between the outermost letters.

chomp(sep = $/)

Removes one trailing record separator: "\r\n", "\n" or "\r" by default; a given suffix; and with "", all trailing newlines. Returns a copy.

chop

Removes the last character, or a trailing "\r\n" as a unit.

delete_prefix, delete_suffix

Remove a literal prefix or suffix, once.

Two traps. First, with selectors, strip(" ") removes only spaces, so the tab at the end of " Dune\t" stays; use plain strip for "any whitespace". Second, strip with selectors changes what a call with arguments means on older Ruby: on 3.4 and earlier strip accepts no argument and raises ArgumentError, so code that has to run on several versions should use delete_prefix/delete_suffix or a regular expression (sub(/\A-+|-+\z/, "")).

Source: String#strip

Splitting and iterating

split cuts a string into an array; lines and each_line cut at line boundaries and keep the separators unless told otherwise; chars, bytes, codepoints and grapheme_clusters break a string into its units. The each_* forms return an Enumerator without a block, which avoids building the whole array.

# frozen_string_literal: true

csv = "dune,,messiah,,"
csv.split(",")      # => ["dune", "", "messiah"]
csv.split(",", -1)  # => ["dune", "", "messiah", "", ""]
csv.split(",", 2)   # => ["dune", ",messiah,,"]
"  a  b\tc\n".split # => ["a", "b", "c"]
awk_mode = " "
"  a  b".split(awk_mode)            # => ["a", "b"]
"  a  b".split(/ /)                 # => ["", "", "a", "", "b"]
"a1b22c".split(/\d+/)               # => ["a", "b", "c"]
"a1b22c".split(/(\d+)/)             # => ["a", "1", "b", "22", "c"]
"2026-10-18".split("-").map(&:to_i) # => [2026, 10, 18]

text = "one\ntwo\r\nthree\n"
text.lines                                                    # => ["one\n", "two\r\n", "three\n"]
text.lines(chomp: true)                                       # => ["one", "two", "three"]
text.each_line("o").to_a                                      # => ["o", "ne\ntwo", "\r\nthree\n"]
text.each_line.with_index(1).map { |l, i| "#{i}:#{l.chomp}" } # => ["1:one", "2:two", "3:three"]
"a\n\nb\n\n\nc".each_line("").to_a                            # => ["a\n\n", "b\n\n", "c"]

"héllo".chars                      # => ["h", "é", "l", "l", "o"]
"héllo".bytes.first(3)             # => [104, 195, 169]
"héllo".codepoints                 # => [104, 233, 108, 108, 111]
"héllo".each_char.to_a.size        # => 5
"🇪🇸".length                        # => 2
"🇪🇸".grapheme_clusters.length      # => 1
"e\u0301".grapheme_clusters.length # => 1
"abc".each_char.map(&:ord)         # => [97, 98, 99]
"abc".sum                          # => 294

Source: String#split

Rules for split:

  • Trailing empty fields are dropped unless a negative limit is given: "dune,,messiah,,".split(",") has three elements, split(",", -1) keeps all five. A positive limit stops after that many fields and leaves the rest unsplit in the last.

  • A single space as separator (or no argument) is "awk mode": leading whitespace is ignored and runs of any whitespace separate fields. split(/ /) is a plain regular expression and keeps the empty fields.

  • A regular expression with a capture group adds the separators to the result.

  • With a block, split yields each field and returns the receiver (use it to avoid the array).

  • split("") and split(//) return the characters, but chars says it better; "🇪🇸".chars has two code points while grapheme_clusters has one, which is what a user sees as a single character.

lines and each_line accept a separator string (each_line("o") splits after each "o"), the empty string for paragraph mode (splits at blank lines), and chomp: true to drop the separators. Use each_line on a large input read into memory, or better, iterate an IO directly (see IO, Files and Directories).

sum is a trivial byte checksum (the sum of the bytes modulo 65536 by default); it is not suitable for integrity checks, for which the Digest library is the tool. unpack and unpack1 decode binary strings (see the Array#pack page of the reference); they belong with binary data and encodings.

Case mapping

upcase, downcase, capitalize and swapcase (and their ! forms) use full Unicode case mapping by default for UTF-8, UTF-16, UTF-32 and ISO-8859 strings. That means the result can have a different length ("straße".upcase is "STRASSE"), that the title-case digraph is respected ("dž".capitalize is "Dž"), and that the mapping is reversible only for plain cases ("Straße".downcase.upcase does not return the original). Context-dependent mapping, such as the Greek final sigma rule, is not supported.

# frozen_string_literal: true

title = "the left hand of darkness"
title.upcase                 # => "THE LEFT HAND OF DARKNESS"
title.capitalize             # => "The left hand of darkness"
"Dune MESSIAH".swapcase      # => "dUNE messiah"
"DUNE".downcase.capitalize   # => "Dune"
"dž".capitalize               # => "Dž"
"straße".upcase              # => "STRASSE"
"ÀÉÎ".downcase               # => "àéî"
"ÀÉÎ".downcase(:ascii)       # => "ÀÉÎ"
"Türkiye".upcase(:turkic)    # => "TÜRKİYE"
"I".downcase(:turkic)        # => "ı"
"Straße".downcase(:fold)     # => "strasse"
"Straße".casecmp?("STRASSE") # => true
"Straße".casecmp("STRASSE")  # => 1
(+"Dune").upcase!            # => "DUNE"
(+"DUNE").upcase!            # => nil
shout = +"dune"
shout.upcase!                           # => "DUNE"
shout.upcase!                           # => nil
shout.to_s                              # => "DUNE"
title.split.map(&:capitalize).join(" ") # => "The Left Hand Of Darkness"

Source: Case Mapping

The optional options are symbols that can be combined:

Option Effect

:ascii

Only A to Z and a to z change; everything else is left alone.

:turkic

Turkish and Azeri rules for the dotted and dotless I ("I".downcase(:turkic) is "ı").

:fold

Only for downcase: Unicode case folding, which maps ß to ss; this is what comparing strings caselessly needs.

casecmp? uses folding, so "Straße".casecmp?("STRASSE") is true, whereas casecmp handles ASCII only and returns a number. A title like "the left hand of darkness".split.map(&:capitalize).join(" ") is the common word-by-word capitalisation; the shelf gem wraps the same idea in the title_case refinement (see The strings of the shelf gem). The bang forms return nil when nothing changed, as (+"DUNE").upcase! shows.

Padding and alignment

ljust, rjust and center pad to a width counted in characters, with a space by default or with a given pad string that is repeated and cut as needed. They return a new string and never truncate; a width smaller than the length returns the string unchanged. An empty pad string raises ArgumentError (zero width padding).

# frozen_string_literal: true

"Dune".ljust(10, ".")  # => "Dune......"
"Dune".rjust(10)       # => "      Dune"
"Dune".center(11, "*") # => "***Dune****"
"Dune".center(3)       # => "Dune"
"7".rjust(3, "0")      # => "007"
"Dune".ljust(10, "<>") # => "Dune<><><>"
"日本".ljust(6, ".")     # => "日本...."
"日本".bytesize          # => 6

rows = [["Dune", 1965], ["Emma", 1815], ["Hyperion", 1989]]
width = rows.map { |title, _| title.length }.max
rows.each { |title, year| puts "#{title.ljust(width)} | #{year.to_s.rjust(6)}" }
puts "Shelf".center(20, "=")

Output:

Dune     |   1965
Emma     |   1815
Hyperion |   1989
=======Shelf========

Source: String#ljust

Width counts characters, not terminal cells: "日本".ljust(6, ".") pads with four dots even though each of those two characters is two cells wide in a monospaced terminal, and combining marks count as characters too. For aligned tables with wide text, compute widths with a library or pad using format and accept the limit. The next section covers format, which is the better tool for tables of numbers.

Formatting with format, sprintf and String#%

Kernel#format (alias sprintf) builds a string from a template and arguments; Kernel#printf and IO#printf write the result instead of returning it; String#% is the operator form ("%s is %d" % ["Dune", 3]), which takes a single argument or an array of arguments. A format specification has the form %[flags][width][.precision]type, where the type is the only required part.

# frozen_string_literal: true

# Deliberate illustration: literal format strings and arguments.
# rubocop:disable Style/FormatStringToken, Style/FormatString, Style/RedundantFormat, Lint/FormatParameterMismatch

format("%s has %d copies", "Dune", 3)                                # => "Dune has 3 copies"
format("%5d|%-5d|%05d", 42, 42, 42)                                  # => "   42|42   |00042"
format("%+d % d %+d", 5, 5, -5)                                      # => "+5  5 -5"
format("%.2f %8.3f %-8.1f|", 3.14159, 2.5, 2.5)                      # => "3.14    2.500 2.5     |"
format("%e %g %g", 12_345.678, 12_345.678, 0.00001234)               # => "1.234568e+04 12345.7 1.234e-05"
format("%x %X %#x %o %#o %b %#b %08b", 255, 255, 255, 8, 8, 5, 5, 5) # => "ff FF 0xff 10 010 101 0b101 00000101"
format("%c%c %%", 65, "z")                                           # => "Az %"
format("%10s|%-10s|%.3s", "Dune", "Dune", "Messiah")                 # => "      Dune|Dune      |Mes"
format("%p %s", "Dune", "Dune")                                      # => "\"Dune\" Dune"
format("%p %s", nil, nil)                                            # => "nil "
format("%2$s %1$s", "world", "hello")                                # => "hello world"
format("%<title>s (%<year>d)", title: "Dune", year: 1965)            # => "Dune (1965)"
format("%{title} (%{year})", title: "Dune", year: 1965)              # => "Dune (1965)"
format("%-12<title>s|%6.2<price>f", title: "Dune", price: 9.5)       # => "Dune        |  9.50"
format("%*d|%-*d|", 6, 42, 6, 42)                                    # => "    42|42    |"
format("%.*f", 1, 2.25)                                              # => "2.2"
format("%d", 3.99)                                                   # => "3"
format("%d", "0x1f")                                                 # => "31"
format("%s", [1, "a"])                                               # => "[1, \"a\"]"
format("%f", 1r / 3)                                                 # => "0.333333"
format("%.20f", 0.1)                                                 # => "0.10000000000000000555"
format("%.0f %.0f %.0f", 0.5, 1.5, 2.5)                              # => "0 2 2"
[0.5, 1.5, 2.5].map(&:round)                                         # => [1, 2, 3]
format("%.2f %.2f %.2f", 2.675, 8.345, 1.005)                        # => "2.68 8.34 1.00"
[2.675, 8.345, 1.005].map { |f| f.round(2) }                         # => [2.68, 8.35, 1.01]
format("%.2f", 0.1 + 0.2)                                            # => "0.30"
puts "%s is %d" % ["Dune", 3]
puts "%05.1f%%" % 99.5
puts "%s" % [[1, 2]]
puts "%s and %s" % %w[a b]
puts "%{a}-%{b}" % { a: 1, b: 2 }
$stdout.printf("%-6s%3d\n", "Emma", 2)

[
  -> { format("%d", "abc") },
  -> { format("%d", nil) },
  -> { format("%s %s", "only one") },
  -> { format("%z", 1) },
  -> { format("%<a>s", { b: 1 }) },
  -> { format("%d %<a>d", 1, a: 2) }
].each do |attempt|
  attempt.call
rescue ArgumentError, TypeError, KeyError => e
  puts "#{e.class}: #{e.message}"
end
# rubocop:enable Style/FormatStringToken, Style/FormatString, Style/RedundantFormat, Lint/FormatParameterMismatch

Output:

Dune is 3
099.5%
[1, 2]
a and b
1-2
Emma    2
ArgumentError: invalid value for Integer(): "abc"
TypeError: can't convert nil into Integer
ArgumentError: too few arguments
ArgumentError: malformed format string - %z
KeyError: key<a> not found
ArgumentError: named<a> after unnumbered(1)

Types

Type Meaning

d, i, u

Decimal integer. A float is truncated toward zero (format("%d", 3.99) is "3"); a string goes through Integer() (so "0x1f" works and "abc" raises ArgumentError); nil raises TypeError.

b, B, o, x, X

Binary, octal, hexadecimal integer. A negative number prints as a two’s complement prefixed with .. unless a + or space flag is used.

f

Decimal floating point, default precision 6; accepts Rational and Integer.

e, E

Scientific notation.

g, G

Shorter of e and f for the precision given (default 6 significant digits).

a, A

Hexadecimal floating point.

c

A character: an Integer code point or a one-character string.

s

to_s of the argument. The precision truncates ("%.3s" of "Messiah" is "Mes").

p

inspect of the argument.

%%

A literal percent sign.

Flags, width and precision

Flag or part Effect

space

Reserve a space for the sign of a non-negative number.

#

Alternate form: a 0x, 0b or 0 prefix; always a decimal point for floats.

+

Always show the sign.

-

Left-justify within the width.

0

Pad with zeros instead of spaces.

n$

Use the n-th argument (%2$s %1$s), which allows reordering for translation.

width

Minimum number of characters. * takes the width from the argument list; a negative width left-justifies.

.precision

For integers the minimum number of digits, for f, e and a the digits after the point, for g the significant digits, for s and p the maximum length. .* takes it from the arguments.

Named references

%<name>s takes the next specification’s type and reads the value from a hash argument by key; %{name} inserts the value with to_s and accepts no type, flags or width. They read much better than a row of %s, and they are safe against argument reordering, but they cannot be mixed with * or n$: format("%d %<a>d", 1, a: 2) raises ArgumentError (named<a> after unnumbered(1)). A missing key raises KeyError.

The errors a bad format string can raise are in the example: ArgumentError: too few arguments, malformed format string - %z, invalid value for Integer(): "abc", TypeError: can't convert nil into Integer, and KeyError: key<a> not found. With $DEBUG set, too many arguments raise ArgumentError as well; otherwise extra arguments are ignored.

Money and rounding

format("%.2f", amount) is for display. Binary floats cannot hold 0.1 exactly (format("%.20f", 0.1) prints 0.10000000000000000555), and the rounding of decimal-looking values is not what a C or Python programmer expects: format("%.2f", 2.675) is "2.68" and format("%.2f", 8.345) is "8.34", while Float#round(2) gives 2.68 and 8.35, and the exact binary values would give 2.67 and 8.35. Halves of exactly representable numbers go to the even digit in format ("%.0f" of 0.5, 1.5 and 2.5 is 0 2 2) and away from zero in Float#round (1 2 3). The rule is that the two methods disagree and neither is a money rounding rule: keep money in integer cents or Rational/BigDecimal (see Numbers), and format only at the edge.

RuboCop has opinions about all of this: Style/FormatString prefers format to String#%, Style/FormatStringToken prefers annotated tokens (%<name>s) over a run of %s, and Style/RedundantFormat rejects a format call whose arguments are all literals. The example turns those three off, plus Lint/FormatParameterMismatch for the deliberate mistakes, with a comment, because showing the specifications requires literal calls.

Source: Kernel#format

Conversions to and from strings

The conversions from a string are lenient (to_i, to_f, to_r, to_c: read what looks like a number from the start and ignore the rest, returning zero when nothing matches) or strict (Integer(), Float(), Rational(): raise unless the whole string is a valid number). Prefer the strict form for input that must be valid; exception: false returns nil instead of raising.

# frozen_string_literal: true

"42".to_i                        # => 42
"42abc".to_i                     # => 42
"abc".to_i                       # => 0
" 42 ".to_i                      # => 42
"1_000".to_i                     # => 1000
"ff".to_i(16)                    # => 255
"0b101".to_i(0)                  # => 5
"0x1f".hex                       # => 31
"777".oct                        # => 511
"3.7abc".to_f                    # => 3.7
"1e3".to_f                       # => 1000.0
".5".to_f                        # => 0.5
"1/3".to_r                       # => (1/3)
"0.75".to_r                      # => (3/4)
"1+2i".to_c                      # => (1+2i)
Integer("42")                    # => 42
Integer("0x1f", 16)              # => 31
Integer("abc", exception: false) # => nil
Float("3.5")                     # => 3.5
Float("3.5x", exception: false)  # => nil
name = "hello world"
name.to_sym                                           # => :"hello world"
"A".ord                                               # => 65
65.chr                                                # => "A"
233.chr(Encoding::UTF_8)                              # => "é"
name.to_s.equal?(name)                                # => true
:dune.to_s                                            # => "dune"
nil.to_s                                              # => ""
[1, "a"].to_s                                         # => "[1, \"a\"]"
12_345.to_s.reverse.scan(/\d{1,3}/).join(",").reverse # => "12,345"
255.to_s(2)                                           # => "11111111"

Source: String#to_i

  • to_i(base) accepts bases 2 to 36 and, with base 0, honours the 0b, 0o and 0x prefixes. Underscores between digits are allowed ("1_000".to_i is 1000); surrounding spaces are ignored.

  • hex and oct are to_i(16) and to_i(8) with prefix handling. They exist for historical reasons; to_i(16) is clearer.

  • to_sym (alias intern) creates or finds the symbol; any string works (:"hello world").

  • ord returns the code point of the first character and Integer#chr is its inverse; chr without an encoding returns a byte for values below 256 and raises RangeError for larger ones unless an encoding is given.

  • to_s returns the receiver itself for a String (and a copy for a subclass); to_str is the implicit conversion. The protocols are explained on Conversions and Coercion.

  • inspect returns a printable literal form (used by p and by IRB), dump additionally escapes non-ASCII as \u sequences, and undump reverses dump. Kernel#puts calls to_s.

Successors, ranges and ordering

succ (alias next) returns the "next" string the way an odometer does: it increments the rightmost alphanumeric and carries to the left. upto and string ranges use it. Strings compare with <=> byte by byte, which gives surprising orders for mixed case and for numbers inside text.

# frozen_string_literal: true

"a".succ                                                       # => "b"
"az".succ                                                      # => "ba"
"zz".succ                                                      # => "aaa"
"a9".succ                                                      # => "b0"
"Zz".succ                                                      # => "AAa"
"1.9".succ                                                     # => "2.0"
"-9".succ                                                      # => "-10"
"".succ                                                        # => ""
"a".upto("e").to_a                                             # => ["a", "b", "c", "d", "e"]
"08".upto("11").to_a                                           # => ["08", "09", "10", "11"]
("a".."e").step(2).to_a                                        # => ["a", "c", "e"]
("aa".."ad").to_a                                              # => ["aa", "ab", "ac", "ad"]
("a".."e").cover?("cc")                                        # => true
("a".."e").include?("cc")                                      # => false
%w[b10 a2 a10 B1].sort                                         # => ["B1", "a10", "a2", "b10"]
%w[b10 a2 a10 B1].sort_by(&:downcase)                          # => ["a10", "a2", "B1", "b10"]
%w[b10 a2 a10 B1].sort_by { |v| [v[0].downcase, v[1..].to_i] } # => ["a2", "a10", "B1", "b10"]
%w[pear Apple fig].max_by(&:length)                            # => "Apple"
"abc".eql?("abc")                                              # => true
"a".ord                                                        # => 97

Source: String#succ

  • "az".succ is "ba" and "zz".succ is "aaa"; with no alphanumerics, the rightmost character code is incremented.

  • "a".upto("e") and ("a".."e") iterate with succ until the end value is reached or the length would exceed it. "08".upto("11") keeps the zero padding, which is useful for numbered file names.

  • Range#include? on a string range iterates (it is false for "cc" in "a".."e" because "cc" is not generated by succ), whereas cover? compares with <=> and is true. Pick cover? for "is it between" tests. See Ranges.

  • The default order is byte order, so uppercase sorts before lowercase and "a10" before "a2". For human sorting use sort_by with a key that splits the text and the number, as in the example, or casecmp.

StringScanner

StringScanner (the strscan library, a default gem, loaded with require "strscan") reads a string from left to right, matching a pattern at the current position and moving on. It is the tool for lexers and small parsers where a single regular expression becomes unreadable, and it is faster than repeatedly slicing the string because it never copies the unread part.

# frozen_string_literal: true

require "strscan"

ss = StringScanner.new("978-0-306-40615-7 | The Dispossessed | 1974")
ss.scan(/\d{3}/)             # => "978"
ss.pos                       # => 3
ss.scan("-")                 # => "-"
ss.scan(/x+/)                # => nil
ss.check(/\d+/)              # => "0"
ss.pos                       # => 4
ss.peek(5)                   # => "0-306"
ss.skip(/[\d-]+/)            # => 13
ss.scan_until(/\|/)          # => " |"
ss.pre_match                 # => "978-0-306-40615-7 "
ss.skip(/\s+/)               # => 1
ss.scan(/[^|]+/)             # => "The Dispossessed "
ss.unscan.pos                # => 20
ss.scan(/[^|]+/)             # => "The Dispossessed "
ss.scan(/\|\s*(?<year>\d+)/) # => "| 1974"
ss[:year]                    # => "1974"
ss[0]                        # => "| 1974"
ss.eos?                      # => true
ss.reset.pos                 # => 0
ss.scan_integer              # => 978
ss.getch                     # => "-"
ss.exist?(/\|/)              # => 15
ss.terminate.eos?            # => true

# A small lexer for a search query such as: title:"Left Hand" author:Guin -draft
class QueryLexer
  Token = Data.define(:type, :text)

  def initialize(input)
    @scanner = StringScanner.new(input)
  end

  def tokens
    list = []
    while (token = next_token)
      list << token
    end
    list
  end

  private

  def next_token
    @scanner.skip(/\s+/)
    return if @scanner.eos?

    phrase || term || exclusion || unexpected
  end

  def phrase
    return unless @scanner.scan(/(?<field>\w+):"(?<value>[^"]*)"/)

    Token.new(:phrase, "#{@scanner[:field]}=#{@scanner[:value]}")
  end

  def term
    return unless @scanner.scan(/(?<field>\w+):(?<value>\S+)/)

    Token.new(:term, "#{@scanner[:field]}=#{@scanner[:value]}")
  end

  def exclusion
    Token.new(:exclude, @scanner[:word]) if @scanner.scan(/-(?<word>\S+)/)
  end

  def unexpected
    raise ArgumentError, "unexpected #{@scanner.getch.inspect} at #{@scanner.charpos - 1}"
  end
end

QueryLexer.new('title:"Left Hand" author:Guin -draft').tokens.each { |token| p token }
begin
  QueryLexer.new("title:Dune ?").tokens
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end

Output:

#<data QueryLexer::Token type=:phrase, text="title=Left Hand">
#<data QueryLexer::Token type=:term, text="author=Guin">
#<data QueryLexer::Token type=:exclude, text="draft">
ArgumentError: unexpected "?" at 11

Source: StringScanner

The scanner has a stored string, a byte position (pos) and the match data of the last operation:

Method Behaviour

scan(pattern)

Matches the pattern at the position, advances and returns the match; nil if it does not match there.

scan_until(pattern)

Matches anywhere ahead, advances past the match and returns everything from the position to its end.

skip(pattern), skip_until(pattern)

Like scan and scan_until but return the length that was skipped, and allocate no string.

check(pattern), check_until(pattern), match?(pattern), exist?(pattern)

Look without advancing.

peek(n), getch, get_byte, scan_byte, peek_byte

Look at or consume a number of characters or bytes.

scan_integer

Matches and converts an integer at the position (a recent addition to strscan).

eos?, rest, rest_size, pos, charpos, pos=, reset, terminate, unscan

Position control and inspection. pos is a byte offset and charpos a character offset.

matched, pre_match, post_match, [], captures, named_captures, values_at

Data of the last match, including named captures (ss[:year]).

string, string=, concat

The stored string, replacement and append.

The lexer in the example is a standard shape: skip whitespace, try each token pattern in turn, and raise at the first character no pattern accepts. Two details matter. A pattern passed to scan is anchored at the current position (it behaves like \G), which is what makes the scanner a tokenizer and not a search. And pos is a byte position: on multibyte text charpos gives the character offset to show in an error message.

The strings of the shelf gem

The running shelf gem builds strings in several places: Book#to_s interpolates a title and an author, Isbn#initialize normalises its input with delete("-") and match?, Loan#describe builds messages, and the title_case refinement adds a method to String without a global monkey patch. The example below adds a small helper module with the string work a catalogue tool needs: parsing a pipe-separated line, building a slug, hyphenating an ISBN-13 and printing aligned rows.

# frozen_string_literal: true

require "shelf"

using Shelf::Refinements::TitleCase

# Helpers that build strings for the shelf catalogue.
module Labels
  module_function

  # "The  Left Hand of Darkness!" -> "the-left-hand-of-darkness"
  def slug(title)
    title.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
  end

  # "9780306406157" -> "978-0-306-40615-7"
  def hyphenate(digits)
    digits.sub(/\A(\d{3})(\d)(\d{3})(\d{5})(\d)\z/, '\1-\2-\3-\4-\5')
  end

  def row(book, width)
    format("%<title>s  %-22<author>s  %<isbn>s",
           title: book.title.ljust(width), author: book.author.name, isbn: hyphenate(book.isbn.to_s))
  end

  def parse(line)
    isbn, title, author = line.split("|", 3).map(&:strip)
    Shelf::Book.new(isbn: Shelf::Isbn.new(isbn), title: title.title_case, author: Shelf::Author.new(author))
  end
end

lines = <<~CATALOG.lines(chomp: true)
  978-0-306-40615-7 | the dispossessed | Ursula K. Le Guin
  9780140449136     | the left HAND of darkness | Ursula K. Le Guin
CATALOG

catalog = Shelf::Catalog.new
lines.each { |line| catalog.add(Labels.parse(line)) }

width = catalog.map { |book| book.title.length }.max
catalog.each { |book| puts Labels.row(book, width) }
catalog.map { |book| Labels.slug(book.title) }           # => ["the-dispossessed", "the-left-hand-of-darkness"]
Labels.slug("  The Dispossessed: An Ambiguous Utopia! ") # => "the-dispossessed-an-ambiguous-utopia"
Labels.hyphenate("9780306406157")                        # => "978-0-306-40615-7"
catalog.first.to_s                                       # => "The Dispossessed by Ursula K. Le Guin"
catalog.first.isbn.inspect                               # => "#<data Shelf::Isbn digits=\"9780306406157\">"

Output:

The Dispossessed           Ursula K. Le Guin       978-0-306-40615-7
The Left Hand Of Darkness  Ursula K. Le Guin       978-0-140-44913-6

Source: String#gsub

A few things the example shows in practice:

  • A squiggly heredoc (<<~) strips the common indentation, and lines(chomp: true) turns the block into an array of lines.

  • split("|", 3).map(&:strip) limits the number of fields, so a title containing a pipe would still produce three fields, and strips each of them.

  • The slug pipeline is a chain of non-bang methods: downcase, gsub of every non-alphanumeric run, then delete_prefix/delete_suffix for the ends. A chain of bang methods would break at the first nil.

  • hyphenate uses a fixed 3-1-3-5-1 layout for illustration. Real ISBN hyphenation depends on registrant ranges and needs a table, which is why Isbn stores the plain digits and formats only on output.

  • using Shelf::Refinements::TitleCase activates the refinement for the rest of the file only; the second title was typed in mixed case and came out as The Left Hand Of Darkness. See Refinements and Monkey Patching.

Performance notes

The costs in string code are almost always allocations: every new String is an object the garbage collector must track and eventually free. The example counts the objects a block allocates with GC.stat(:total_allocated_objects), which is a deterministic measure (timings vary by machine). The counts are approximate: a few objects of overhead and small differences between versions are normal.

# frozen_string_literal: true

# Counts the objects a block allocates (an approximation: the numbers vary by a few between runs and versions).
def allocations
  before = GC.stat(:total_allocated_objects)
  yield
  GC.stat(:total_allocated_objects) - before
end

def with_plus_equals(count)
  text = +""
  count.times { text += "x" }
  text
end

def with_append(count)
  text = +""
  count.times { text << "x" }
  text
end

def with_interpolation(count)
  text = +""
  count.times { |i| text << "n#{i}," }
  text
end

def with_join(count)
  Array.new(count) { |i| "n#{i}," }.join
end

big = "x" * 1000
allocations { 1 }                                    # => 1
allocations { with_plus_equals(1000) }               # => 1004
allocations { with_append(1000) }                    # => 2
allocations { with_interpolation(1000) }             # => 1992
allocations { with_join(1000) }                      # => 1997
allocations { big.chars }                            # => 1003
allocations { big.each_char { |c| c } }              # => 1001
allocations { big.bytes }                            # => 2
allocations { big.each_byte { |b| b } }              # => 1
allocations { 1000.times { big.start_with?("xx") } } # => 0
allocations { 1000.times { big[0, 2] == "xx" } }     # => 1001
allocations { 1000.times { -"a literal" } }          # => 0
allocations { 1000.times { "a literal".dup } }       # => 1000

Source: GC.stat

What the numbers say:

  • a = a + b and += allocate one string per iteration (about 1000), whereas << appends to one buffer (about two objects in total). In a loop that builds a long string, << (or Array#join, or StringIO) is the right choice; a one-off a + b is fine.

  • Interpolation inside << costs about two objects per iteration, because the interpolated text and the converted number are built before they are appended. Building the pieces in an array and calling join is about equal. For a handful of values, interpolation is the clearest and is as fast as any alternative.

  • chars and each_char allocate one string per character, while bytes allocates one array of integers (small integers are not heap objects) and each_byte allocates nothing. If you only need to test bytes or code points, prefer each_byte or each_codepoint.

  • start_with? allocates nothing, while s[0, 2] == "xx" allocates the slice; prefer the predicate methods. Likewise match? over match or =~ when you only need a boolean.

  • -"literal" and "literal".freeze of an already frozen literal allocate nothing, whereas dup allocates a string every time. With the magic comment, a literal inside a loop is already a shared object.

  • Pre-sizing a buffer (String.new(capacity: n)) can save reallocations when the final size is known, but measure before using it.

The measurement tools (Benchmark, benchmark-ips, ObjectSpace, memory profilers) and the garbage collector are covered on Performance, Benchmarking and Profiling and Garbage Collection and Memory.

Errors and warnings

# frozen_string_literal: true

# Deliberate illustration: each lambda is a mistake that raises.
# rubocop:disable Style/StringConcatenation, Style/FormatStringToken, Lint/FormatParameterMismatch

attempts = {
  "no implicit to_s in +" => -> { "Dune " + 2 },
  "nil receiver" => -> { nil + "Dune" },
  "wrong method" => -> { "Dune".shout },
  "negative repeat" => -> { "Dune" * -1 },
  "empty pad" => -> { "Dune".ljust(10, "") },
  "bad arity" => -> { "Dune".center },
  "Integer() is strict" => -> { Integer("12abc") },
  "frozen literal" => -> { "Dune".upcase! },
  "invalid bytes" => -> { "caf\xE9".split("a") },
  "too few format args" => -> { format("%s and %s", "Dune") },
  "missing hash key" => -> { format("%<name>s", title: "Dune") }
}

attempts.each do |label, attempt|
  attempt.call
rescue StandardError => e
  puts "#{label}: #{e.class}: #{e.message}"
end
# rubocop:enable Style/StringConcatenation, Style/FormatStringToken, Lint/FormatParameterMismatch

Output:

no implicit to_s in +: TypeError: no implicit conversion of Integer into String
nil receiver: NoMethodError: undefined method '+' for nil
wrong method: NoMethodError: undefined method 'shout' for an instance of String
negative repeat: ArgumentError: negative argument
empty pad: ArgumentError: zero width padding
bad arity: ArgumentError: wrong number of arguments (given 0, expected 1..2)
Integer() is strict: ArgumentError: invalid value for Integer(): "12abc"
frozen literal: FrozenError: can't modify frozen String: "Dune"
invalid bytes: ArgumentError: invalid byte sequence in UTF-8
too few format args: ArgumentError: too few arguments
missing hash key: KeyError: key<name> not found

Source: String#center

Exception or warning When Remedy

TypeError (no implicit conversion of Integer into String)

+ or another method that needs a string received a non-string.

Interpolate or call to_s.

NoMethodError (undefined method 'shout' for an instance of String)

A method that does not exist; the message names the class, and nil shows as for nil.

Check the receiver; in 3.4 and later the method name is quoted with ', not a backtick.

ArgumentError (negative argument, zero width padding, wrong number of arguments)

Invalid argument values or arity.

Validate the argument before the call.

ArgumentError (invalid value for Integer(): "12abc")

A strict conversion of text that is not a number.

Rescue it, or pass exception: false.

ArgumentError (invalid byte sequence in UTF-8)

A pattern-matching method such as split or =~ met bytes that are not valid in the string’s encoding.

scrub, or fix the encoding. See Encodings and Unicode.

FrozenError (can't modify frozen String: "Dune")

A change to a frozen string (including a literal under the magic comment).

Work on +text or dup.

IndexError (string not matched, index 100 out of string)

[]= or slice! selected something that does not exist.

Check with include? or index first.

KeyError (key<name> not found)

format with a named reference whose key is missing.

Provide the key.

Encoding::UndefinedConversionError, Encoding::CompatibilityError

Transcoding or combining incompatible encodings.

See the encodings page.

Warning literal string will be frozen in the future

A chilled literal was mutated with the deprecated category on.

Use +"" or dup, or freeze deliberately.

Pitfalls

# frozen_string_literal: true

a = b = +"Dune"
a << " Messiah"
p b

shared = Array.new(2, +"x")
shared.first << "y"
p shared

separate = Array.new(2) { +"x" }
separate.first << "y"
p separate

p "abc"[3], "abc"[3, 1], "abc"[4, 1]
puts "an index of 0 is truthy" if "Dune".index("D")
title = "Dune"
puts title == :Dune, title == :Dune.to_s
p "é".match?(/\w/), "é".match?(/\p{Word}/), "é".match?(/[[:alpha:]]/)

Output:

"Dune Messiah"
["xy", "xy"]
["xy", "x"]
nil
""
nil
an index of 0 is truthy
false
true
false
true
true

Source: String#[]

  • Aliasing. a = b = +"Dune" makes both names point to the same string, and << through one name is visible through the other. Array.new(2, +"x") shares one string between both slots, whereas the block form creates two. This is the usual cause of "my list of strings all changed" bugs.

  • Boundary indexes. "abc"[3] is nil but "abc"[3, 1] is "", and "abc"[4, 1] is nil; code that treats both as "empty" misses the difference.

  • Zero is truthy. "Dune" =~ /D/ returns the index 0, which is a true value in Ruby; code ported from languages where 0 is false misbehaves. Use match? for a boolean.

  • \w is ASCII only. In Ruby a regular expression \w does not match é. Use \p{Word}, \p{L} or for Unicode text (see Regular Expressions).

  • Strings and symbols. "Dune" == :Dune is false; convert one side deliberately. Hash keys are not interchangeable either.

  • Bang methods return nil. name.strip!.upcase raises NoMethodError when name had no whitespace.

  • Encoding. String.new is binary; building text from it and from user input mixes encodings. Create buffers with +"".

  • gsub backslashes. A replacement "\\1" is a back-reference, and a literal backslash needs four characters in the string form; use the block form.

  • Mutating arguments. A method that calls << or upcase! on its argument changes the caller’s string and raises FrozenError for a frozen one. Return a new string instead.

Where older material is outdated

Books and articles about Ruby strings were written for versions before 3.4, and several statements changed. The differences that touch strings:

Statement in older material Ruby 4.0 Notes

"String literals are frozen by default since 3.4."

Not frozen. They are chilled: mutable, with a warning only under -W:deprecated.

The magic comment or --enable-frozen-string-literal is still needed. Feature #20205 is open.

undefined method `x' for nil:NilClass

undefined method 'x' for nil

3.3 changed the receiver text (for an instance of String), 3.4 changed the quote characters.

{:name=>"Ico"} as the printed hash

{name: "Ico"}

3.4 Hash#inspect; affects strings printed with p and interpolated hashes.

strip accepts no arguments

strip, lstrip and rstrip accept character selectors

New in 4.0 (Feature #21552).

Symbol#to_s returns a new mutable string

The result is chilled since 3.4

Making that result frozen is under discussion for a later release.

str.freeze on every constant literal

Redundant with the magic comment

RuboCop’s Style/RedundantFreeze flags it.

str.sub! returns the string

Returns nil when nothing changed

Unchanged since 1.8, but still a frequent bug.

The version notes for the String API itself:

Version Change

2.3

The frozen_string_literal magic comment and the squiggly heredoc <<~.

2.4

Full Unicode case mapping and casecmp?.

2.5

delete_prefix, delete_suffix, undump and each_grapheme_cluster.

3.0

Interpolated strings are no longer frozen by the magic comment. Most String methods (*, center, slice, split, tr and the like) return String instances, not instances of a subclass.

3.1

unpack and unpack1 accept an offset: keyword.

3.2

byteindex, byterindex, bytesplice and dedup.

3.3

bytesplice accepts an index and length or a range of the source string.

3.4

Chilled strings (+@ returns a copy of them), append_as_bytes, the new error-message quote and Hash#inspect formats.

4.0

strip, lstrip and rstrip accept character selectors.

Summary

  • A String is mutable bytes with an encoding; length counts characters and bytesize counts bytes.

  • Put # frozen_string_literal: true in every file, build mutable strings with +"" or String.new, and expect a chilled-string warning under -W:deprecated where you do not.

  • << appends in place, + and += create new strings; interpolation avoids the TypeError of +.

  • [], slice, slice! and []= share one selector language: index, start and length, range, string, regexp.

  • sub/gsub take a pattern and a string, hash or block; prefer the block form to avoid backslash traps, and remember that bang methods return nil when nothing changed.

  • Character selectors power tr, delete, squeeze, count and, in 4.0, strip.

  • format handles padding, precision and named references; it rounds the exact binary value, so keep money out of floats.

  • StringScanner is the right tool for tokenizing; count allocations, not just time, when tuning string code.