CrackedRuby logo

CrackedRuby

String Formatting

Overview

Ruby provides multiple approaches to string formatting, ranging from simple interpolation to sophisticated format specifications. The primary methods include String#%, Kernel#sprintf, Kernel#printf, and string interpolation with #{}. These tools handle numeric formatting, alignment, padding, precision control, and type conversion.

The String#% operator and sprintf method use format specifiers similar to C's printf family, supporting flags, width, precision, and conversion specifiers. Ruby extends this with additional features for object inspection and more flexible argument handling.

"Hello %s, you have %d messages" % ["Alice", 5]
# => "Hello Alice, you have 5 messages"

sprintf("%.2f%% complete", 87.459)
# => "87.46% complete"

name, count = "Bob", 12
"Welcome #{name}! (#{count} items)"
# => "Welcome Bob! (12 items)"

String interpolation with #{} evaluates Ruby expressions directly within double-quoted strings, offering the most readable syntax for simple cases. Format strings excel when precise control over output appearance is required, particularly for numeric data, tabular output, and internationalization scenarios.

Ruby's formatting system handles type conversion automatically, converting objects using to_s, to_i, to_f, or inspect as appropriate for each format specifier. The system supports positional and named arguments, making it suitable for both simple templates and complex localization systems.

Basic Usage

The String#% method applies format specifiers to arguments provided as an array or single value. Format specifiers begin with % followed by optional flags, width, precision, and a conversion character.

# Basic substitution
"Name: %s, Age: %d" % ["Carol", 28]
# => "Name: Carol, Age: 28"

# Single argument doesn't require array
"Progress: %d%%" % 75
# => "Progress: 75%"

# Multiple arguments with positioning
"Item %d: %s costs $%.2f" % [1, "Laptop", 999.99]
# => "Item 1: Laptop costs $999.99"

The sprintf method provides identical functionality with different syntax, often preferred when the format string comes from a variable or when building complex formatting functions.

template = "User %s logged in at %s"
sprintf(template, "admin", Time.now.strftime("%H:%M"))
# => "User admin logged in at 14:23"

# Equivalent using String#%
template % ["admin", Time.now.strftime("%H:%M")]

Numeric formatting controls decimal places, padding, and alignment. The precision specifier .n controls decimal places for floating-point numbers and maximum characters for strings.

# Floating-point precision
"Temperature: %.1f°C" % 23.847
# => "Temperature: 23.8°C"

# String truncation
"Comment: %.20s" % "This is a very long comment that will be truncated"
# => "Comment: This is a very long "

# Integer padding with zeros
"Order #%05d" % 42
# => "Order #00042"

Width specifiers control field width and alignment. Positive width right-aligns content, while negative width left-aligns. The alignment applies padding with spaces by default.

# Right-aligned in 10-character field
"Status: '%10s'" % "OK"
# => "Status: '        OK'"

# Left-aligned in 10-character field  
"Status: '%-10s'" % "ERROR"
# => "Status: 'ERROR     '"

# Numeric padding
"%8.2f" % 123.4
# => "  123.40"

Advanced Usage

Ruby supports positional argument references, allowing format strings to reuse arguments or present them in different orders. The syntax %n$ references the nth argument (1-indexed).

# Argument reordering
"Last: %2$s, First: %1$s" % ["John", "Doe"]
# => "Last: Doe, First: John"

# Argument reuse
"Coordinates: X=%1$d, Y=%2$d, Distance from origin: %1$d,%2$d" % [10, 20]
# => "Coordinates: X=10, Y=20, Distance from origin: 10,20"

# Complex positioning with formatting
"Date: %3$02d/%2$02d/%1$04d" % [2024, 3, 15]
# => "Date: 15/03/2024"

Hash-based formatting provides named placeholders, improving readability and maintainability for templates with many substitutions.

"Hello %(name)s, you have %(count)d messages in %(folder)s" % {
  name: "Alice",
  count: 7,
  folder: "Inbox"
}
# => "Hello Alice, you have 7 messages in Inbox"

# Combining with complex formatting
"Balance: %(currency)s%(amount)8.2f" % {
  currency: "$",
  amount: 1234.56
}
# => "Balance: $ 1234.56"

The flag characters modify formatting behavior. Common flags include + for explicit signs, # for alternate forms, 0 for zero padding, and space for positive number padding.

# Explicit sign display
"%+d, %+d" % [42, -17]
# => "+42, -17"

# Alternate form for hex and octal
"%#x, %#o" % [255, 64]
# => "0xff, 0100"

# Zero padding vs space padding
"%08d vs %8d" % [42, 42]
# => "00000042 vs       42"

# Space padding for positive numbers
"% d, % d" % [42, -17]
# => " 42, -17"

Ruby extends standard format specifiers with %p for inspect output, useful for debugging and displaying object representations that preserve type information.

values = ["string", 42, :symbol, nil, [1, 2, 3]]
values.each_with_index do |val, i|
  puts "%d: %p (%s)" % [i, val, val.class]
end
# Output:
# 0: "string" (String)
# 1: 42 (Integer)  
# 2: :symbol (Symbol)
# 3: nil (NilClass)
# 4: [1, 2, 3] (Array)

Method chaining enables building complex formatting pipelines, particularly useful when processing collections or building formatted reports.

data = [
  { name: "Product A", price: 19.99, stock: 150 },
  { name: "Product B", price: 45.00, stock: 23 },
  { name: "Product C", price: 8.50, stock: 0 }
]

formatted_report = data.map.with_index do |item, i|
  "%-3d %-12s $%7.2f %4s" % [
    i + 1,
    item[:name],
    item[:price],
    item[:stock] > 0 ? item[:stock] : "OUT"
  ]
end.join("\n")

puts "ID  Product      Price   Stock"
puts "=" * 30
puts formatted_report

Error Handling & Debugging

Invalid format specifiers raise ArgumentError exceptions with descriptive messages indicating the problematic format sequence. These errors occur at runtime when the format string is processed.

begin
  "Invalid format: %q" % "test"
rescue ArgumentError => e
  puts "Format error: #{e.message}"
end
# => Format error: invalid format character 'q'

begin
  "Missing precision: %." % 42
rescue ArgumentError => e
  puts "Format error: #{e.message}"  
end
# => Format error: incomplete format specifier; use %% to print a %

Argument count mismatches produce different error types depending on the mismatch direction. Too few arguments raise ArgumentError, while extra arguments are silently ignored.

# Too few arguments
begin
  "%s %s %s" % ["one", "two"]
rescue ArgumentError => e
  puts "Error: #{e.message}"
end
# => Error: too few arguments

# Extra arguments ignored (no error)
"%s %s" % ["one", "two", "three", "four"]
# => "one two"

Hash-based formatting raises KeyError when referenced keys don't exist in the provided hash, making missing data explicit rather than producing cryptic output.

begin
  "Hello %(name)s, %(missing)s" % { name: "Alice" }
rescue KeyError => e
  puts "Missing key: #{e.message}"
end
# => Missing key: key not found: "missing"

# Defensive programming with default values
template = "Hello %(name)s, %(status)s"
data = { name: "Alice" }
safe_data = { status: "unknown" }.merge(data)
template % safe_data
# => "Hello Alice, unknown"

Type conversion errors surface when arguments cannot be converted to the expected type for format specifiers. Ruby attempts automatic conversion but fails for incompatible types.

begin
  "%d" % "not_a_number"
rescue ArgumentError => e
  puts "Conversion error: #{e.message}"
end
# => Conversion error: invalid value for Integer(): "not_a_number"

# Safe conversion with validation
def safe_format(template, *args)
  template % args
rescue ArgumentError, TypeError => e
  "FORMAT_ERROR: #{e.message}"
end

safe_format("%d items", "abc")
# => "FORMAT_ERROR: invalid value for Integer(): \"abc\""

Debugging complex format strings benefits from systematic testing of each component. Break complex formats into smaller parts and validate arguments before formatting.

def debug_format(template, args)
  puts "Template: #{template.inspect}"
  puts "Arguments: #{args.inspect}"
  
  # Extract format specifiers
  specifiers = template.scan(/%[^%\s]*/)
  puts "Specifiers: #{specifiers}"
  
  # Check argument count
  expected = specifiers.length
  actual = args.is_a?(Array) ? args.length : 1
  puts "Expected #{expected} args, got #{actual}"
  
  result = template % args
  puts "Result: #{result.inspect}"
  result
rescue => e
  puts "Error: #{e.class} - #{e.message}"
  nil
end

debug_format("User %s has %d points", ["Alice", 100])

Production Patterns

Logging systems frequently use string formatting to create structured, readable log entries with consistent formatting across different message types and severity levels.

class Logger
  LOG_FORMAT = "[%s] %5s: %s"
  TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
  
  def log(level, message, **context)
    timestamp = Time.now.strftime(TIMESTAMP_FORMAT)
    formatted_message = LOG_FORMAT % [timestamp, level.upcase, message]
    
    unless context.empty?
      context_str = context.map { |k, v| "#{k}=#{v}" }.join(" ")
      formatted_message += " (#{context_str})"
    end
    
    puts formatted_message
  end
  
  def info(message, **context)
    log(:info, message, **context)
  end
  
  def error(message, **context)
    log(:error, message, **context)
  end
end

logger = Logger.new
logger.info("User login successful", user_id: 12345, ip: "192.168.1.1")
logger.error("Database connection failed", timeout: "30s", retry_count: 3)
# Output:
# [2024-03-15 14:30:15]  INFO: User login successful (user_id=12345 ip=192.168.1.1)
# [2024-03-15 14:30:16] ERROR: Database connection failed (timeout=30s retry_count=3)

Financial applications require precise numeric formatting with proper decimal handling, currency symbols, and thousands separators for user-facing displays.

class CurrencyFormatter
  def self.format_currency(amount, currency = "USD", precision = 2)
    # Handle negative amounts
    sign = amount < 0 ? "-" : ""
    abs_amount = amount.abs
    
    # Format with precision
    formatted = "%.#{precision}f" % abs_amount
    
    # Add thousands separators
    parts = formatted.split(".")
    parts[0] = parts[0].reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
    
    # Combine with currency symbol
    case currency.upcase
    when "USD" then "#{sign}$#{parts.join('.')}"
    when "EUR" then "#{sign}#{parts.join('.')}"
    when "GBP" then "#{sign}£#{parts.join('.')}"
    else "#{sign}#{currency} #{parts.join('.')}"
    end
  end
  
  def self.format_percentage(value, precision = 1)
    "%+.#{precision}f%%" % value
  end
end

# Usage in financial reports
portfolio = [
  { symbol: "AAPL", shares: 100, price: 150.75, change: 2.3 },
  { symbol: "GOOGL", shares: 50, price: 2750.80, change: -1.2 },
  { symbol: "TSLA", shares: 25, price: 890.45, change: 5.7 }
]

puts "%-8s %6s %12s %12s %8s" % ["Symbol", "Shares", "Price", "Value", "Change"]
puts "-" * 55

portfolio.each do |holding|
  value = holding[:shares] * holding[:price]
  puts "%-8s %6d %12s %12s %8s" % [
    holding[:symbol],
    holding[:shares],
    CurrencyFormatter.format_currency(holding[:price]),
    CurrencyFormatter.format_currency(value),
    CurrencyFormatter.format_percentage(holding[:change])
  ]
end

Report generation and data export systems use formatting to create fixed-width files, CSV exports, and human-readable reports with proper alignment and spacing.

class ReportGenerator
  COLUMN_FORMATS = {
    string: "%-*s",      # Left-aligned string
    number: "%*d",       # Right-aligned integer
    decimal: "%*.2f",    # Right-aligned float with 2 decimals
    currency: "%*s",     # Right-aligned formatted currency
    date: "%-*s"         # Left-aligned date string
  }
  
  def initialize(columns)
    @columns = columns
    @data = []
  end
  
  def add_row(row_data)
    @data << row_data
  end
  
  def generate_fixed_width
    # Calculate column widths based on data
    widths = calculate_widths
    
    # Generate header
    header = @columns.map.with_index do |col, i|
      COLUMN_FORMATS[col[:type]] % [widths[i], col[:name]]
    end.join(" ")
    
    # Generate separator
    separator = widths.map { |w| "-" * w }.join("-")
    
    # Generate data rows
    rows = @data.map do |row|
      row.map.with_index do |value, i|
        col = @columns[i]
        formatted_value = format_value(value, col[:type])
        COLUMN_FORMATS[col[:type]] % [widths[i], formatted_value]
      end.join(" ")
    end
    
    [header, separator, *rows].join("\n")
  end
  
  private
  
  def calculate_widths
    @columns.map.with_index do |col, i|
      header_width = col[:name].length
      data_width = @data.map { |row| format_value(row[i], col[:type]).length }.max || 0
      [header_width, data_width, col[:min_width] || 0].max
    end
  end
  
  def format_value(value, type)
    case type
    when :currency
      value.is_a?(Numeric) ? "$%.2f" % value : value.to_s
    when :decimal
      value.is_a?(Numeric) ? "%.2f" % value : value.to_s
    when :date
      value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : value.to_s
    else
      value.to_s
    end
  end
end

# Generate sales report
report = ReportGenerator.new([
  { name: "Product", type: :string, min_width: 12 },
  { name: "Units", type: :number, min_width: 6 },
  { name: "Price", type: :currency, min_width: 10 },
  { name: "Revenue", type: :currency, min_width: 12 },
  { name: "Date", type: :date, min_width: 10 }
])

report.add_row(["Widget Pro", 150, 29.99, 4498.50, Date.new(2024, 3, 15)])
report.add_row(["Gadget Ultra", 89, 149.95, 13345.55, Date.new(2024, 3, 15)])

puts report.generate_fixed_width

Common Pitfalls

Encoding issues arise when format strings and arguments have different encodings, particularly with non-ASCII characters and internationalization scenarios. Ruby's string formatting preserves the encoding of the format string by default.

# Encoding mismatch can cause issues
ascii_format = "Name: %s".encode("ASCII")
utf8_name = "José".encode("UTF-8")

begin
  result = ascii_format % utf8_name
rescue Encoding::CompatibilityError => e
  puts "Encoding error: #{e.message}"
end

# Solution: ensure consistent encoding
utf8_format = ascii_format.encode("UTF-8")
result = utf8_format % utf8_name
# => "Name: José"

# Or force encoding compatibility
result = ascii_format % utf8_name.encode("ASCII", undef: :replace, invalid: :replace)
# => "Name: Jos?"

Precision specifiers behave differently for strings versus numbers, leading to unexpected truncation when mixing data types in generic formatting functions.

# String truncation with precision
"%.5s" % "This is a long string"
# => "This "  (truncated to 5 characters)

# Numeric precision
"%.5f" % 3.14159265
# => "3.14159"  (5 decimal places)

# Mixing types can surprise developers
values = ["Short", "This is a very long string", 3.14159]
values.each { |v| puts "Value: %.5s" % v }
# Output:
# Value: Short
# Value: This   (truncated!)
# Value: 3.141  (converted to string first)

Width and precision interactions create confusion when both are specified. Width applies to the entire formatted result, including precision-controlled decimal places.

# Width vs precision for numbers
"'%8.2f'" % 123.456
# => "  123.46"  (8 total width, 2 decimal places)

# Common mistake: expecting width to be integer part only
"'%8.2f'" % 12345.67
# => "12345.67"  (width ignored when number is wider)

# Left-alignment with precision
"'%-8.2f'" % 123.456  
# => "123.46  "  (left-aligned in 8-character field)

Zero-padding flags interact unexpectedly with negative numbers and different numeric types, particularly with hexadecimal and octal representations.

# Zero padding with negative numbers
"%08d" % -42
# => "-0000042"  (sign preserved, padding added)

# Hex formatting with zero padding and signs
"%+08x" % 255
# => "+0000ff"  (plus sign with hex)

# Octal padding pitfalls  
"%08o" % 64
# => "00000100"  (octal representation padded)

# Mixing flags can produce unexpected results
"%+#08x" % 255
# => "+0x000ff"  (alternate form + sign + padding)

Positional argument references become error-prone in complex templates, especially when arguments are reordered or removed during template maintenance.

# Fragile positional references
template = "User %2$s (ID: %1$d) has %3$d messages from %4$s to %5$s"
args = [12345, "Alice", 7, "2024-01-01", "2024-03-15"]

# Easy to break when modifying
# If we remove argument 4, all subsequent references break
broken_template = "User %2$s (ID: %1$d) has %3$d messages until %4$s"
broken_args = [12345, "Alice", 7, "2024-03-15"]  # Missing date argument

begin
  broken_template % broken_args
rescue ArgumentError => e
  puts "Broken reference: #{e.message}"
end

# Better approach: use hash-based formatting for complex templates
safe_template = "User %(name)s (ID: %(id)d) has %(count)d messages until %(end_date)s"
safe_args = { name: "Alice", id: 12345, count: 7, end_date: "2024-03-15" }
safe_template % safe_args

Type coercion failures occur silently in some cases and loudly in others, creating inconsistent behavior patterns that are difficult to debug in production systems.

# Silent coercion that may surprise
"%d" % "123"      # => "123" (string converted to integer)
"%d" % "123.45"   # => ArgumentError (can't convert float string)
"%f" % "123"      # => "123.000000" (string converted to float)
"%s" % 123        # => "123" (number converted to string)

# Nil handling varies by format specifier  
"%s" % nil        # => "" (nil becomes empty string)
"%d" % nil        # => TypeError (nil can't be converted to integer)

# Object conversion depends on method availability
class CustomObject
  def initialize(value)
    @value = value
  end
  
  def to_s
    "Custom(#{@value})"
  end
  
  def to_i
    @value.to_i
  end
end

obj = CustomObject.new("42")
"%s" % obj        # => "Custom(42)" (uses to_s)
"%d" % obj        # => 42 (uses to_i)
"%f" % obj        # => TypeError (no to_f method)

Reference

Format Specifiers

Specifier Type Description Example
%s String String representation via to_s "Hello %s" % "World""Hello World"
%d, %i Integer Decimal integer via to_i "%d" % 42.7"42"
%o Integer Octal representation "%o" % 64"100"
%x Integer Lowercase hexadecimal "%x" % 255"ff"
%X Integer Uppercase hexadecimal "%X" % 255"FF"
%f Float Fixed-point decimal via to_f "%.2f" % 3.14159"3.14"
%e Float Scientific notation (lowercase) "%e" % 1234.5"1.234500e+03"
%E Float Scientific notation (uppercase) "%E" % 1234.5"1.234500E+03"
%g Float General format (shorter of %f or %e) "%g" % 1234.5"1234.5"
%G Float General format (shorter of %f or %E) "%G" % 0.0001"0.0001"
%c Integer Character with given codepoint "%c" % 65"A"
%p Any Object inspection via inspect "%p" % [1,2,3]"[1, 2, 3]"
%% Literal Literal percent character "100%% complete""100% complete"

Flags

Flag Description Example
+ Always show sign for numbers "%+d" % 42"+42"
- Left-align within field width "%-5s" % "hi""hi "
0 Pad with zeros instead of spaces "%05d" % 42"00042"
(space) Prefix positive numbers with space "% d" % 42" 42"
# Use alternate form "%#x" % 255"0xff"

Width and Precision

Format Description Example
%10s Right-align in 10-character field "%10s" % "hello"" hello"
%-10s Left-align in 10-character field "%-10s" % "hello""hello "
%.5s Truncate string to 5 characters "%.5s" % "hello world""hello"
%10.2f 10-character field, 2 decimal places "%10.2f" % 3.14159" 3.14"
%*s Width from next argument "%*s" % [8, "hello"]" hello"
%.*f Precision from next argument "%.*f" % [3, 3.14159]"3.142"

Positional Arguments

Format Description Example
%1$s Use first argument "%2$s %1$s" % ["World", "Hello"]"Hello World"
%2$d Use second argument "%1$s: %2$d" % ["Count", 5]"Count: 5"
%(name)s Use hash key 'name' "%(greeting)s %(name)s" % {name: "Alice", greeting: "Hi"}"Hi Alice"

Methods

Method Parameters Returns Description
String#% args (Object or Array) String Apply format string to arguments
Kernel#sprintf format (String), *args String Create formatted string
Kernel#printf format (String), *args nil Print formatted string to stdout
IO#printf format (String), *args IO Print formatted string to IO object

Common Error Types

Error Cause Example
ArgumentError Invalid format specifier "%q" % "test"
ArgumentError Too few arguments "%s %s" % ["only_one"]
ArgumentError Type conversion failure "%d" % "not_a_number"
KeyError Missing hash key "%(missing)s" % {}
TypeError Incompatible argument type "%f" % Object.new
Encoding::CompatibilityError Encoding mismatch ASCII format with UTF-8 args