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) |