CrackedRuby logo

CrackedRuby

Time Formatting (strftime)

Overview

Ruby's strftime method converts Time and Date objects into formatted string representations using format directives. The method exists on Time, Date, and DateTime classes, accepting a format string containing literal characters and percent-encoded directives that specify how temporal components appear in the output.

The format string uses directives like %Y for four-digit year, %m for zero-padded month, and %d for zero-padded day. Ruby processes these directives by replacing them with corresponding values from the time object, while preserving literal characters unchanged.

time = Time.new(2024, 3, 15, 14, 30, 45)
time.strftime("%Y-%m-%d %H:%M:%S")
# => "2024-03-15 14:30:45"

date = Date.new(2024, 12, 25)
date.strftime("%B %d, %Y")
# => "December 25, 2024"

Ruby's implementation follows POSIX strftime conventions with platform-specific extensions. The method handles locale-aware formatting for day and month names, though behavior varies across operating systems and Ruby implementations.

Time.now.strftime("%A, %B %d")
# => "Friday, March 15" (English locale)

Time.now.strftime("%c")
# => "Fri Mar 15 14:30:45 2024" (locale-specific complete format)

Basic Usage

The strftime method accepts a single format string parameter containing any combination of literal text and format directives. Each directive begins with a percent sign followed by a conversion character.

birthday = Time.new(1990, 7, 4, 16, 45)
birthday.strftime("%m/%d/%Y")
# => "07/04/1990"

birthday.strftime("%I:%M %p")
# => "04:45 PM"

birthday.strftime("Born on %A, %B %d, %Y at %I:%M %p")
# => "Born on Wednesday, July 04, 1990 at 04:45 PM"

Common directives handle different temporal components. Year directives include %Y for four-digit year and %y for two-digit year. Month directives include %m for numeric month, %B for full name, and %b for abbreviated name.

current_time = Time.now
current_time.strftime("Year: %Y (%y)")
# => "Year: 2024 (24)"

current_time.strftime("Month: %m, %B, %b")
# => "Month: 03, March, Mar"

current_time.strftime("Day: %d (%e), %A (%a)")
# => "Day: 15 ( 15), Friday (Fri)"

Time-specific directives format hours, minutes, and seconds. The %H directive produces 24-hour format while %I produces 12-hour format requiring %p for AM/PM indication. The %S directive formats seconds, and %L formats milliseconds.

timestamp = Time.new(2024, 1, 1, 9, 5, 3, 500000)
timestamp.strftime("24-hour: %H:%M:%S")
# => "24-hour: 09:05:03"

timestamp.strftime("12-hour: %I:%M:%S %p")
# => "12-hour: 09:05:03 AM"

timestamp.strftime("With milliseconds: %H:%M:%S.%L")
# => "With milliseconds: 09:05:03.500"

The method handles edge cases like midnight and noon formatting. Zero-padding behavior varies between directives, with some like %d always padding and others like %e using spaces for single digits.

midnight = Time.new(2024, 1, 1, 0, 0, 0)
midnight.strftime("%I:%M %p")
# => "12:00 AM"

noon = Time.new(2024, 1, 1, 12, 0, 0)
noon.strftime("%I:%M %p")
# => "12:00 PM"

first_day = Time.new(2024, 1, 1)
first_day.strftime("Day %d vs %e")
# => "Day 01 vs  1"

Common Pitfalls

Timezone handling represents the most significant source of strftime confusion. The method formats time values using the timezone information contained within the time object itself, not the system timezone at formatting time.

utc_time = Time.utc(2024, 1, 1, 12, 0, 0)
local_time = utc_time.getlocal

utc_time.strftime("%H:%M %Z")
# => "12:00 UTC"

local_time.strftime("%H:%M %Z") 
# => "07:00 EST" (varies by system timezone)

# Same moment, different representations
utc_time.to_i == local_time.to_i
# => true

Locale dependency creates platform-specific behavior that breaks portability. Month names, day names, and certain format directives produce different output depending on system locale settings and operating system.

# Results vary by system locale
Time.new(2024, 1, 1).strftime("%B")
# => "January" (English)
# => "janvier" (French)
# => "Januar" (German)

# Some directives behave differently across platforms
Time.new(2024, 1, 1, 14, 30).strftime("%c")
# => "Mon Jan  1 14:30:00 2024" (Linux/macOS)
# => "01/01/24 14:30:00" (Windows)

Leading zero behavior confuses developers expecting consistent padding. Some directives like %m and %d always pad with zeros, while others like %e pad with spaces, and still others like %k vary by platform.

early_time = Time.new(2024, 1, 5, 9, 5, 3)

early_time.strftime("%m/%d/%y")
# => "01/05/24" (always zero-padded)

early_time.strftime("%e/%m/%y")  
# => " 5/01/24" (space-padded day)

early_time.strftime("%k:%M")
# => " 9:05" (space-padded hour on most systems)
# => "09:05" (zero-padded on some systems)

Invalid format directives silently pass through rather than raising errors, making debugging difficult. Ruby treats unknown percent sequences as literal text, potentially hiding formatting mistakes.

time = Time.new(2024, 3, 15)

# Typo in directive - should be %Y
time.strftime("%Q-%m-%d")
# => "%Q-03-15" (invalid directive preserved)

# Missing percent sign
time.strftime("Y-m-d")
# => "Y-m-d" (literal text, no substitution)

# Double percent escapes to single percent
time.strftime("Progress: %%d complete")
# => "Progress: %d complete"

Century calculations in two-digit years follow unexpected rules. The %y directive formats years in the current century for recent dates but switches behavior for dates far in the past or future, creating ambiguous output.

# Current century assumption
Time.new(2024, 1, 1).strftime("%y")
# => "24"

Time.new(1999, 1, 1).strftime("%y")  
# => "99"

# But parsing these back creates ambiguity
# Is "99" meant to be 1999 or 2099?

Error Handling & Debugging

The strftime method rarely raises exceptions directly, but problems occur when time objects contain invalid state or when system resources affect locale-dependent formatting.

begin
  # Invalid time objects can cause issues
  broken_time = Time.at(Float::INFINITY)
  broken_time.strftime("%Y-%m-%d")
rescue RangeError => e
  puts "Time value out of range: #{e.message}"
end

begin  
  # Platform limits may cause failures
  far_future = Time.new(10000, 1, 1)
  far_future.strftime("%Y-%m-%d")
rescue ArgumentError => e
  puts "Date out of range: #{e.message}"
end

Timezone-related errors emerge when working with time zones that don't exist or when daylight saving transitions create ambiguous times. These issues manifest as unexpected output rather than exceptions.

# DST transition handling
spring_forward = Time.new(2024, 3, 10, 2, 30, 0, "-05:00")
spring_forward.strftime("%Y-%m-%d %H:%M %Z")
# => "2024-03-10 02:30 EST" (time may not exist)

# Debugging timezone issues
def debug_time_formatting(time, format)
  puts "Original: #{time.inspect}"
  puts "Timezone: #{time.zone}"
  puts "UTC offset: #{time.utc_offset}"
  puts "Formatted: #{time.strftime(format)}"
  puts "As UTC: #{time.utc.strftime(format)} UTC"
end

debug_time_formatting(Time.now, "%Y-%m-%d %H:%M %Z")

Format string validation becomes necessary when accepting user input or reading format strings from configuration files. Ruby doesn't validate format strings, so manual checking prevents silent failures.

def validate_strftime_format(format_string)
  # Check for obvious problems
  return false if format_string.nil? || format_string.empty?
  
  # Test format with known good time
  test_time = Time.new(2024, 6, 15, 12, 30, 45)
  
  begin
    result = test_time.strftime(format_string)
    # Format succeeded, but check for unchanged directives
    suspicious_patterns = format_string.scan(/%[^%\s]/)
    unchanged = suspicious_patterns.select { |pattern| result.include?(pattern) }
    
    if unchanged.any?
      puts "Warning: Unrecognized directives: #{unchanged.join(', ')}"
    end
    
    true
  rescue => e
    puts "Format validation failed: #{e.message}"
    false
  end
end

validate_strftime_format("%Y-%m-%d")    # => true
validate_strftime_format("%Y-%Q-%d")    # => true (but warns about %Q)
validate_strftime_format(nil)           # => false

Debugging locale-specific formatting requires testing across different environments. Create debugging helpers that expose locale-dependent behavior and platform differences.

def debug_locale_formatting
  time = Time.new(2024, 1, 15, 14, 30)
  
  locale_sensitive_formats = [
    "%A", "%B", "%c", "%x", "%X"
  ]
  
  puts "System locale: #{ENV['LC_ALL'] || ENV['LANG'] || 'default'}"
  puts "Ruby platform: #{RUBY_PLATFORM}"
  
  locale_sensitive_formats.each do |format|
    result = time.strftime(format)
    puts "#{format}: #{result}"
  end
end

debug_locale_formatting

Production Patterns

Web applications commonly format timestamps for display across different user contexts. Create formatting methods that handle timezone conversion and locale-appropriate display.

module TimeFormatter
  DISPLAY_FORMATS = {
    short_date: "%m/%d/%Y",
    long_date: "%B %d, %Y", 
    datetime: "%m/%d/%Y %I:%M %p",
    timestamp: "%Y-%m-%d %H:%M:%S %Z",
    iso8601: "%Y-%m-%dT%H:%M:%S%z"
  }.freeze
  
  def self.format(time, style, timezone = nil)
    return "N/A" unless time
    
    working_time = timezone ? time.in_time_zone(timezone) : time
    format_string = DISPLAY_FORMATS[style] || style
    
    working_time.strftime(format_string)
  end
  
  def self.user_friendly(time, user_timezone = "UTC")
    return "Never" unless time
    
    user_time = time.in_time_zone(user_timezone)
    now = Time.current.in_time_zone(user_timezone)
    
    if user_time.to_date == now.to_date
      "Today #{user_time.strftime('%I:%M %p')}"
    elsif user_time.to_date == now.to_date - 1.day
      "Yesterday #{user_time.strftime('%I:%M %p')}"
    elsif user_time.year == now.year
      user_time.strftime("%B %d")
    else
      user_time.strftime("%B %d, %Y")
    end
  end
end

# Usage in controllers/views
created_at = Time.current
TimeFormatter.format(created_at, :long_date)
# => "March 15, 2024"

TimeFormatter.user_friendly(created_at, "America/New_York")
# => "Today 2:30 PM"

Log formatting requires consistent, parseable timestamp formats that work across different systems and log aggregation tools. Standardize on formats that maintain precision while remaining readable.

class ApplicationLogger
  LOG_FORMAT = "%Y-%m-%d %H:%M:%S.%L %z".freeze
  
  def self.timestamp
    Time.now.strftime(LOG_FORMAT)
  end
  
  def self.log_entry(level, message, context = {})
    timestamp = self.timestamp
    context_str = context.empty? ? "" : " #{context.inspect}"
    
    "[#{timestamp}] #{level.upcase}: #{message}#{context_str}"
  end
  
  def self.structured_log(event, data = {})
    entry = {
      timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S.%L%z"),
      event: event,
      data: data
    }
    
    JSON.generate(entry)
  end
end

ApplicationLogger.log_entry("info", "User login", user_id: 123)
# => "[2024-03-15 14:30:45.123 -0500] INFO: User login {:user_id=>123}"

ApplicationLogger.structured_log("user.login", user_id: 123, ip: "192.168.1.1")
# => {"timestamp":"2024-03-15T14:30:45.123-0500","event":"user.login","data":{"user_id":123,"ip":"192.168.1.1"}}

API response formatting must handle diverse client expectations while maintaining consistency. Create standardized formatters that work across JSON, XML, and other response formats.

module ApiTimeFormatter
  # ISO 8601 format for maximum compatibility
  API_FORMAT = "%Y-%m-%dT%H:%M:%S.%L%z".freeze
  
  def self.format_for_api(time)
    return nil unless time
    time.utc.strftime(API_FORMAT.gsub('.%L', '.%3N'))
  end
  
  def self.format_collection(objects, time_fields = [:created_at, :updated_at])
    objects.map do |obj|
      formatted = obj.as_json
      
      time_fields.each do |field|
        if obj.respond_to?(field) && (time_value = obj.send(field))
          formatted[field.to_s] = format_for_api(time_value)
        end
      end
      
      formatted
    end
  end
end

# In serializers or controllers
user = User.first
api_response = {
  id: user.id,
  name: user.name,
  created_at: ApiTimeFormatter.format_for_api(user.created_at),
  last_login: ApiTimeFormatter.format_for_api(user.last_login_at)
}
# => {id: 1, name: "John", created_at: "2024-03-15T19:30:45.123Z", last_login: "2024-03-15T14:25:10.456Z"}

Caching formatted timestamps improves performance when the same times appear frequently in rendered views. Implement memoization that respects timezone changes and format variations.

class CachedTimeFormatter
  def initialize
    @format_cache = {}
    @cache_mutex = Mutex.new
  end
  
  def format(time, format_string, timezone = nil)
    cache_key = [time.to_i, format_string, timezone].hash
    
    @cache_mutex.synchronize do
      @format_cache[cache_key] ||= begin
        working_time = timezone ? time.in_time_zone(timezone) : time
        working_time.strftime(format_string)
      end
    end
  end
  
  def clear_cache!
    @cache_mutex.synchronize { @format_cache.clear }
  end
  
  def cache_stats
    @cache_mutex.synchronize { { size: @format_cache.size } }
  end
end

# Global instance for application use
CACHED_FORMATTER = CachedTimeFormatter.new

# In views or helpers
def formatted_time(time, format = "%m/%d/%Y", user_timezone = nil)
  CACHED_FORMATTER.format(time, format, user_timezone)
end

Reference

Core Methods

Method Parameters Returns Description
Time#strftime(format) format (String) String Formats time according to directives in format string
Date#strftime(format) format (String) String Formats date according to directives in format string
DateTime#strftime(format) format (String) String Formats datetime according to directives in format string

Year Directives

Directive Output Example Description
%Y 2024 2024 Four-digit year
%y 24 24 Two-digit year (00-99)
%C 20 20 Century (year/100, truncated)

Month Directives

Directive Output Example Description
%m 01-12 03 Zero-padded month number
%B January-December March Full month name
%b Jan-Dec Mar Abbreviated month name
%h Jan-Dec Mar Abbreviated month name (same as %b)

Day Directives

Directive Output Example Description
%d 01-31 15 Zero-padded day of month
%e 1-31 15 Space-padded day of month
%j 001-366 074 Day of year (Julian day)
%A Sunday-Saturday Friday Full weekday name
%a Sun-Sat Fri Abbreviated weekday name
%w 0-6 5 Weekday number (0=Sunday)
%u 1-7 5 Weekday number (1=Monday)

Week Directives

Directive Output Example Description
%U 00-53 11 Week number (Sunday start)
%W 00-53 11 Week number (Monday start)
%V 01-53 11 ISO week number
%G 2024 2024 ISO year (may differ from %Y)
%g 24 24 Two-digit ISO year

Hour Directives

Directive Output Example Description
%H 00-23 14 Zero-padded 24-hour format
%k 0-23 14 Space-padded 24-hour format
%I 01-12 02 Zero-padded 12-hour format
%l 1-12 2 Space-padded 12-hour format
%p AM/PM PM Meridian indicator
%P am/pm pm Lowercase meridian indicator

Time Component Directives

Directive Output Example Description
%M 00-59 30 Zero-padded minutes
%S 00-60 45 Zero-padded seconds
%L 000-999 123 Milliseconds
%N 000000000-999999999 123456789 Nanoseconds
%3N 000-999 123 Nanoseconds (3 digits)
%6N 000000-999999 123456 Nanoseconds (6 digits)
%9N 000000000-999999999 123456789 Nanoseconds (9 digits)

Timezone Directives

Directive Output Example Description
%z +0000 -0500 Timezone offset from UTC
%:z +00:00 -05:00 Timezone offset with colon
%::z +00:00:00 -05:00:00 Timezone offset with seconds
%Z UTC EST Timezone abbreviation

Composite Directives

Directive Equivalent Example Description
%c %a %b %e %H:%M:%S %Y Fri Mar 15 14:30:45 2024 Complete date and time
%D %m/%d/%y 03/15/24 US date format
%F %Y-%m-%d 2024-03-15 ISO date format
%r %I:%M:%S %p 02:30:45 PM 12-hour time with AM/PM
%R %H:%M 14:30 24-hour time (hour:minute)
%T %H:%M:%S 14:30:45 24-hour time (hour:minute:second)
%x varies 03/15/24 Locale date representation
%X varies 14:30:45 Locale time representation

Special Characters

Directive Output Description
%% % Literal percent sign
%n newline Newline character
%t tab Tab character

Unix Timestamp Directives

Directive Output Example Description
%s 1710518445 1710518445 Unix timestamp (seconds since epoch)