Date & Time #
Dates and times are one of those domains that look simple but hide surprising complexity — timezones, daylight saving time, leap years, different regional formats, and inconsistent representations across systems. Ruby provides three main classes for working with time: Time (built-in, for time with up to nanosecond precision), Date (from the standard library, for dates only), and DateTime (a combination of both, but largely superseded by modern Time). Understanding when to use each class, how to handle timezones correctly, and how to format time for various contexts is an essential skill you can’t afford to skip.
The Three Time Classes in Ruby #
Before diving into examples, it’s important to understand the differences between these three classes:
flowchart TD
A[Needs] --> B{"Need time info\n(hour, minute, second)?"}
B -- No --> C["Date\nrequire 'date'\nDate only: 2024-08-15"]
B -- Yes --> D{"Need timezone\nand high precision?"}
D -- Yes --> E["Time\nBuilt into Ruby\nMost recommended"]
D -- Light only --> F["DateTime\nrequire 'date'\nLegacy — slower than Time"]
E --> G["Time.now\nTime.new\nTime.at\nTime.parse"]
C --> H["Date.today\nDate.new\nDate.parse"]
F --> I["Avoid for new code\nUse Time"]require 'date'
# Time — built into Ruby, most common and most recommended
t = Time.now
puts t.class # => Time
# Date — date only, without hour/minute/second
d = Date.today
puts d.class # => Date
# DateTime — legacy, prefer Time for new code
dt = DateTime.now
puts dt.class # => DateTime
# Capability comparison
puts Time.now.nsec # => nanosecond — highest precision
puts Date.today.wday # => 0=Sunday, 1=Monday, ..., 6=Saturday
Creating Time Objects #
Time #
# Current time
now = Time.now # system local time
utc_now = Time.now.utc # current time in UTC
# Specific time — Time.new(year, month, day, hour, minute, second, timezone)
independence = Time.new(1945, 8, 17, 10, 0, 0, "+07:00")
puts independence # => 1945-08-17 10:00:00 +0700
# Time.mktime — uses the system's local timezone
local_time = Time.mktime(2024, 12, 25, 8, 30, 0)
# Time.gm / Time.utc — always UTC
utc_time = Time.gm(2024, 12, 25, 1, 30, 0) # 08:30 WIB = 01:30 UTC
# Time.at — from a Unix timestamp (seconds since January 1, 1970 UTC)
from_unix = Time.at(0) # => 1970-01-01 07:00:00 +0700
from_unix = Time.at(1_700_000_000) # around November 2023
puts from_unix.to_i # back to a Unix timestamp
# Time.parse — from a string (requires require 'time')
require 'time'
from_string = Time.parse("2024-08-15 08:30:00 +0700")
from_iso = Time.parse("2024-08-15T08:30:00+07:00") # ISO 8601 format
puts from_string
Date #
require 'date'
# Today
today = Date.today
puts today # => 2024-08-15
# Specific date
independence = Date.new(1945, 8, 17)
christmas = Date.new(2024, 12, 25)
# Date.parse — from a string
from_string = Date.parse("2024-08-15")
from_string = Date.parse("15 August 2024") # more flexible format
# Date.strptime — parsing with an explicit format (safer than parse)
from_format = Date.strptime("15/08/2024", "%d/%m/%Y")
puts from_format # => 2024-08-15
# Ordinal date — the nth day of the year
day_of_year = Date.ordinal(2024, 100) # the 100th day of 2024
puts day_of_year # => 2024-04-09
Accessing Time Components #
t = Time.new(2024, 8, 15, 14, 30, 45, "+07:00")
# Date components
puts t.year # => 2024
puts t.month # => 8 (1=January, 12=December)
puts t.day # => 15
puts t.yday # => 228 (the 228th day of the year)
# Time components
puts t.hour # => 14
puts t.min # => 30
puts t.sec # => 45
puts t.nsec # => 0 (nanosecond)
puts t.usec # => 0 (microsecond)
# Day of the week
puts t.wday # => 4 (0=Sunday, 1=Monday, ..., 6=Saturday)
puts t.monday? # => false
puts t.thursday? # => true
# Timezone info
puts t.zone # => "+07:00" or "WIB"
puts t.utc_offset # => 25200 (seconds = 7 hours)
puts t.utc? # => false
puts t.gmt? # => false
# Unix timestamp
puts t.to_i # => seconds since the epoch (Jan 1, 1970 UTC)
puts t.to_f # => with decimals for sub-seconds
# Date components
d = Date.new(2024, 8, 15)
puts d.year # => 2024
puts d.month # => 8
puts d.day # => 15
puts d.wday # => 4 (Thursday)
puts d.yday # => 228
puts d.cweek # => 33 (the 33rd ISO week of the year)
puts d.cwday # => 4 (1=Monday, 7=Sunday in the ISO standard)
puts d.leap? # => true (2024 is a leap year)
Formatting with strftime #
strftime is the main method for turning a time object into a string with a specific format. The name comes from the C standard library, and the format is consistent across almost every programming language.
t = Time.new(2024, 8, 15, 14, 30, 5, "+07:00")
# Date formats
puts t.strftime("%Y") # => "2024" (4-digit year)
puts t.strftime("%y") # => "24" (2-digit year)
puts t.strftime("%m") # => "08" (2-digit month)
puts t.strftime("%-m") # => "8" (month without leading zero)
puts t.strftime("%d") # => "15" (2-digit day)
puts t.strftime("%-d") # => "15" (day without leading zero)
puts t.strftime("%j") # => "228" (day of the year)
# Time formats
puts t.strftime("%H") # => "14" (24-hour clock)
puts t.strftime("%I") # => "02" (12-hour clock)
puts t.strftime("%M") # => "30" (minute)
puts t.strftime("%S") # => "05" (second)
puts t.strftime("%p") # => "PM"
puts t.strftime("%P") # => "pm"
# Day and month names
puts t.strftime("%A") # => "Thursday" (full day name)
puts t.strftime("%a") # => "Thu" (short day name)
puts t.strftime("%B") # => "August" (full month name)
puts t.strftime("%b") # => "Aug" (short month name)
# Timezone
puts t.strftime("%Z") # => "+07:00" or "WIB"
puts t.strftime("%z") # => "+0700"
# Common combined formats
puts t.strftime("%Y-%m-%d") # => "2024-08-15"
puts t.strftime("%d/%m/%Y") # => "15/08/2024"
puts t.strftime("%H:%M:%S") # => "14:30:05"
puts t.strftime("%Y-%m-%d %H:%M:%S") # => "2024-08-15 14:30:05"
puts t.strftime("%Y-%m-%dT%H:%M:%S%z") # => ISO 8601: "2024-08-15T14:30:05+0700"
puts t.strftime("%d %B %Y, at %H.%M") # => "15 August 2024, at 14.30"
Indonesian Date Formatting #
Because strftime day and month names are in English, you need to map them yourself for Indonesian display:
DAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
MONTH_NAMES = %w[
January February March April May June
July August September October November December
].freeze
def format_indonesian(time)
day = DAY_NAMES[time.wday]
month = MONTH_NAMES[time.month - 1]
"#{day}, #{time.day} #{month} #{time.year}"
end
t = Time.new(2024, 8, 15)
puts format_indonesian(t) # => "Thursday, 15 August 2024"
Time Arithmetic #
Time stores time as the number of seconds since the epoch, so arithmetic is done in seconds:
now = Time.now
# Adding and subtracting time (in seconds)
one_hour_later = now + 3_600 # + 60*60 seconds
one_day_later = now + 86_400 # + 24*60*60 seconds
one_week_later = now + 7 * 86_400
one_hour_ago = now - 3_600
yesterday = now - 86_400
# Constants for readability
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE # 3_600
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR # 86_400
SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY # 604_800
tomorrow = now + SECONDS_PER_DAY
next_week = now + SECONDS_PER_WEEK
To add months or years, don’t multiply by 30 or 365 days because the result will be inaccurate (months have 28-31 days, leap years have 366 days). Use theDateclass which has the>>method (advance N months) and<<method (go back N months), or use ActiveSupport in Rails which provides1.month.from_nowand similar.
require 'date'
# Date supports accurate day arithmetic
today = Date.today
tomorrow = today + 1
yesterday = today - 1
last_week = today - 7
# Advance/go back months with >> and << (accurate!)
next_month = Date.today >> 1 # advance 1 month
two_months_ago = Date.today << 2 # go back 2 months
next_year = Date.today >> 12
# Compare with inaccurate manual calculations:
# Date.today + 30 ← not always "next month"
# Date.today + 365 ← not always "next year" (leap years!)
# Iterating dates in a range
(Date.new(2024, 1, 1)..Date.new(2024, 1, 7)).each do |date|
puts date.strftime("%A, %d %B %Y")
end
# => Monday, 01 January 2024
# => Tuesday, 02 January 2024
# => ...
Calculating Time Differences #
start = Time.new(2024, 1, 1, 0, 0, 0)
finish = Time.new(2024, 8, 15, 14, 30, 0)
# Time difference — the result is in SECONDS (Float)
difference_seconds = finish - start
puts difference_seconds # => 19940200.0 (seconds)
# Manual conversion to more meaningful units
def format_duration(seconds)
days = (seconds / 86_400).floor
rest = seconds % 86_400
hours = (rest / 3_600).floor
rest = rest % 3_600
minutes = (rest / 60).floor
seconds = (rest % 60).floor
"#{days} days, #{hours} hours, #{minutes} minutes, #{seconds} seconds"
end
puts format_duration(difference_seconds)
# => "227 days, 14 hours, 30 minutes, 0 seconds"
# Date difference — the result is in DAYS (Rational)
d1 = Date.new(2024, 1, 1)
d2 = Date.new(2024, 8, 15)
puts (d2 - d1).to_i # => 227 days
# Accurate year/month/day difference
def detailed_difference(start_date, end_date)
years = end_date.year - start_date.year
months = end_date.month - start_date.month
days = end_date.day - start_date.day
if days < 0
months -= 1
days += Date.new(end_date.year, end_date.month, 1).prev_month.next_month.prev_day.day
end
if months < 0
years -= 1
months += 12
end
"#{years} years, #{months} months, #{days} days"
end
born = Date.new(1990, 3, 15)
today = Date.new(2024, 8, 15)
puts detailed_difference(born, today) # => "34 years, 5 months, 0 days"
Timezones #
Timezone handling is one of the hardest parts of working with time. Ruby itself (without Rails) has limited timezone support:
# UTC — Coordinated Universal Time
utc = Time.now.utc
puts utc # => 2024-08-15 07:30:00 UTC
puts utc.utc? # => true
# Converting between UTC and local and back
local = Time.now
puts local.utc_offset # => 25200 (seconds = +07:00)
puts local.utc # convert to UTC
puts local.localtime # convert to the system's local timezone
# getlocal — change to a specific offset without changing the absolute time
t = Time.now
puts t.getlocal("+07:00") # WIB (Western Indonesia Time)
puts t.getlocal("+08:00") # WITA (Central Indonesia Time)
puts t.getlocal("+09:00") # WIT (Eastern Indonesia Time)
# Time.now vs Time.now.utc — an important difference!
local_now = Time.now # uses the system timezone
utc_now = Time.now.utc # always UTC
puts local_now.to_i == utc_now.to_i # => true (same Unix timestamp)
# Creating Time with an explicit timezone
# Always include the offset when creating time from external data
from_db = Time.new(2024, 8, 15, 14, 30, 0, "+07:00") # WIB
from_api = Time.parse("2024-08-15T07:30:00Z") # Z = UTC
Timezone best practices:
✓ Store times in the database always in UTC
✓ Convert to the local timezone only when displaying to the user
✓ Use Time.now.utc rather than Time.now for server timestamps
✓ Always include the offset/timezone when parsing strings from external sources
✓ Use Time.parse (require 'time') rather than DateTime.parse for new code
✗ Don't assume the system timezone is the same across environments
✗ Don't store times as strings without timezone info
Comparing Times #
Time and Date support all the standard comparison operators:
t1 = Time.new(2024, 1, 1)
t2 = Time.new(2024, 8, 15)
t3 = Time.new(2024, 1, 1)
puts t1 < t2 # => true
puts t1 > t2 # => false
puts t1 == t3 # => true
puts t1 != t2 # => true
puts t1 <= t3 # => true
# between? — check whether a time falls within a range
now = Time.now
start = Time.new(2024, 1, 1)
finish = Time.new(2024, 12, 31)
puts now.between?(start, finish) # => true/false depending on when it's run
# Sort an array of times
times = [Time.new(2024, 3, 1), Time.new(2024, 1, 15), Time.new(2024, 6, 30)]
puts times.sort.map { |t| t.strftime("%d %b") }.inspect
# => ["15 Jan", "01 Mar", "30 Jun"]
times.min.strftime("%d %B %Y") # => "15 January 2024"
times.max.strftime("%d %B %Y") # => "30 June 2024"
# Time ranges — check whether a date is within a range
promo_period = Date.new(2024, 8, 1)..Date.new(2024, 8, 31)
puts promo_period.include?(Date.new(2024, 8, 15)) # => true
puts promo_period.include?(Date.new(2024, 9, 1)) # => false
# Iterating dates in a range
promo_period.each do |date|
puts date if date.wday == 0 # only Sundays
end
Measuring Execution Duration #
To measure code performance, use Process.clock_gettime (more precise than Time.now):
# The right way — Process.clock_gettime for benchmarking
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# Code being measured
1_000_000.times { Math.sqrt(rand) }
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
puts "Duration: #{((finish - start) * 1000).round(2)}ms"
# A reusable abstraction
def time_it(label = "Operation")
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
puts "#{label}: #{(duration * 1000).round(3)}ms"
result
end
time_it("Sorting 100k items") do
Array.new(100_000) { rand }.sort
end
# Benchmark from the standard library — more complete
require 'benchmark'
Benchmark.bm(20) do |x|
x.report("Array#sort:") { Array.new(10_000) { rand }.sort }
x.report("Array#sort_by:") { Array.new(10_000) { rand }.sort_by { |n| n } }
end
Safe Parsing #
require 'time'
require 'date'
# Time.parse — tolerant but can produce surprising results
Time.parse("15 August") # => might use the current year
Time.parse("08:30") # => uses today's date
# Date.strptime — stricter, the format must match exactly
def safe_parse_date(str, format = "%Y-%m-%d")
Date.strptime(str, format)
rescue ArgumentError, TypeError => e
puts "Invalid date format: #{e.message}"
nil
end
puts safe_parse_date("2024-08-15") # => 2024-08-15
puts safe_parse_date("15/08/2024", "%d/%m/%Y") # => 2024-08-15
puts safe_parse_date("not a date").inspect # => nil (safe)
# More robust date validation
def valid_date?(year, month, day)
Date.valid_date?(year, month, day)
end
puts valid_date?(2024, 2, 29) # => true (2024 is a leap year)
puts valid_date?(2023, 2, 29) # => false (2023 is not a leap year)
puts valid_date?(2024, 13, 1) # => false (there's no month 13)
Common Application Patterns #
Audit Timestamps #
module Timestampable
def self.included(base)
base.instance_eval do
attr_reader :created_at, :updated_at
end
end
def touch_created
@created_at = Time.now.utc
@updated_at = Time.now.utc
end
def touch_updated
@updated_at = Time.now.utc
end
def age_in_days
((Time.now.utc - @created_at) / 86_400).floor
end
end
class Article
include Timestampable
attr_reader :title
def initialize(title)
@title = title
touch_created
end
def update(new_title)
@title = new_title
touch_updated
end
end
article = Article.new("Ruby Basics")
puts article.created_at.strftime("%Y-%m-%d %H:%M:%S UTC")
sleep 0.01
article.update("Complete Ruby Guide")
puts article.updated_at > article.created_at # => true
Frequently Needed Time Checks #
def workday?(date = Date.today)
!date.saturday? && !date.sunday?
end
def end_of_month?(date = Date.today)
date.next_day.month != date.month
end
def start_of_month?(date = Date.today)
date.day == 1
end
def next_workday(from = Date.today)
date = from + 1
date += 1 while !workday?(date)
date
end
def workdays_in_month(year, month)
(Date.new(year, month, 1)..Date.new(year, month, -1))
.count { |d| workday?(d) }
end
puts workday?(Date.today)
puts next_workday.strftime("%A, %d %B %Y")
puts "Workdays in August 2024: #{workdays_in_month(2024, 8)}"
# => "Workdays in August 2024: 22"
Summary #
- Use
Timefor new code — faster thanDateTime, supports timezones, and has nanosecond precision.DateTimestill exists but is considered legacy.- Use
Datefor date-only data — when hours are irrelevant (birthdays, deadlines, holidays),Dateis more appropriate and prevents timezone bugs.- Always store times in UTC — convert to the local timezone only when displaying to the user. This prevents Daylight Saving Time bugs and server timezone differences.
strftimefor formatting to a string,strptime/parsefor parsing from a string —strftimeis flexible,strptimeis safer because its format is explicit.- Use
>>and<<onDateto advance/go back months — don’t multiply by 30 because months have 28-31 days.Timearithmetic produces seconds (Float) — divide by 3_600 for hours, 86_400 for days.Datearithmetic produces days (Rational) — call.to_ito get an Integer.Process.clock_gettimefor benchmarking — more accurate thanTime.nowbecause it’s unaffected by system clock changes.Date.valid_date?for date validation — far more reliable than tryingDate.newand rescuing ArgumentError.strftimeday and month names are in English — for Indonesian display, create your own array mapping.