Time and Dates
|
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); features newer than Ruby 3.0 name the version that introduced them.
Ruby has three tools for time, and most mistakes come from using the wrong one. Time is a core class that represents an instant on
the time line together with the UTC offset it is displayed in. Date is a calendar day with no time of day and no zone, and lives in
the date library. Process.clock_gettime reads the operating system clocks, notably the monotonic clock that is the right way to
measure how long something took. This page covers each of them in full: how to create, convert, compare and round a Time, what a
time zone is in Ruby (an offset, a name from the TZ environment variable, or a zone object you write), what happens across
daylight saving transitions, every strftime directive and flag, the strict and the forgiving parsers, the Date API and the
calendar reform it models, why DateTime should not be used, and how to keep time out of your tests by injecting a clock.
Related pages: the comparison protocol behind Time and Date ordering is in
Equality, Hashing and Comparison, stepping through time ranges is
in Ranges, matching on time fields is in
Pattern Matching, writing times to JSON, YAML and Marshal is deepened in
Serialization: JSON, YAML, CSV and Marshal, testing code
that depends on the clock is in Testing with Minitest and
RSpec and Test Doubles, measuring code is in
Performance, Benchmarking and Profiling, and the traps
around floating point and wall clocks are collected in
Common Mistakes and Gotchas. For how other languages approach the
same problem, see Java dates and times,
Go time and timers and
PHP date and time.
Every example on this page 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 (target Ruby 4.0, double-quoted strings). The output shown in # => comments is the real
output. The examples that print local times first set ENV["TZ"] = "Europe/Madrid", so the output does not depend on the machine
that ran them; the effect of that line is explained in The TZ environment variable. The examples
reuse the shelf library catalogue used throughout this section (Shelf::LendingPolicy is new on this page); each file is
self-contained so it can be run on its own. Lines written as p expression are only there because RuboCop rejects an operator
expression whose value is ignored (Lint/Void); the comment shows what the expression returns.
Choosing the right type
CLOCK_MONOTONIC"] Q -->|"A moment that happened"| T["Time
store as UTC or with an offset"] Q -->|"A calendar day"| D["Date
no time of day, no zone"] Q -->|"A day and a time in a zone that has rules"| Z["Time plus the zone name
kept next to it"] Q -->|"DateTime"| X["Do not use it
deprecated, use Time"] T --> S["Serialize with iso8601
or epoch seconds"] D --> S2["Serialize with iso8601
2026-03-19"]
| Type | What it is | Use it for |
|---|---|---|
|
Core class. An instant (exact seconds since the Unix epoch, with sub-second precision) plus a UTC offset used to display it. |
Timestamps, deadlines, log lines, durations between two wall-clock instants, anything with a time of day. |
|
|
Birthdays, due dates, reporting periods, any value that is a day and not an instant. |
|
|
Nothing new. See Why DateTime is deprecated. |
|
Core. Reads a named operating system clock and returns a |
Measuring elapsed time, timeouts, rate limiting, benchmarks. Never for dates shown to people. |
Integer or Float seconds |
Epoch seconds, as returned by |
Storage and interchange when both sides agree on the epoch. Prefer |
Three libraries are involved, and which of them you need to require decides whether a method exists:
-
Timeitself is core:Time.now,Time.new,Time.at,Time.utc,Time.local,strftime, arithmetic andTime#iso8601need norequire. Since Ruby 3.4Time#xmlschemaand its aliasTime#iso8601are part of the core class; before 3.4 they were added by thetimelibrary. -
require "time"(thetimedefault gem, 0.4.2 in Ruby 4.0) adds the class methods that parse and the formats other than ISO 8601:Time.parse,Time.strptime,Time.iso8601,Time.xmlschema,Time.rfc2822,Time.httpdate, and the instance methodsTime#rfc2822andTime#httpdate. CallingTime.iso8601without therequireraisesNoMethodError, even on 4.0. -
require "date"(thedatedefault gem) definesDateandDateTime, and addsTime#to_date,Time#to_datetimeandDate#to_time. Thetimelibrary requiresdateitself.
Time
Creating a Time
Time.now returns the current instant in the local zone. Time.new with fields builds a Time from a calendar reading, Time.utc
(alias Time.gm) reads the fields as UTC, Time.local (alias Time.mktime) reads them in the local zone, and Time.at builds a Time
from epoch seconds. The fields are, in order, year, month, day, hour, minute, second; everything after the year is optional and
defaults to the start of the period (January, the 1st, midnight). The month can also be a three-letter name, and the second can be
fractional (a Rational is exact, a Float is not). A seventh argument or the in: keyword (Ruby 3.1) chooses the offset, and
Time.now(in:) and Time.at(in:) accept it as well. Passing both the seventh argument and in: raises
ArgumentError ("timezone argument given as positional and keyword arguments").
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
# Local time (uses the TZ environment variable), UTC and an explicit offset.
local = Time.new(2026, 3, 5, 10, 30, 15)
local.inspect # => "2026-03-05 10:30:15 +0100"
local.utc_offset # => 3600
local.zone # => "CET"
local.utc? # => false
utc = Time.utc(2026, 3, 5, 9, 30, 15)
utc.inspect # => "2026-03-05 09:30:15 UTC"
utc.zone # => "UTC"
utc.utc? # => true
p utc == local # => true
tokyo = Time.new(2026, 3, 5, 18, 30, 15, "+09:00")
tokyo.inspect # => "2026-03-05 18:30:15 +0900"
tokyo.zone # => nil
p tokyo == utc # => true
# The in: keyword accepts the same offsets, and "Z" means UTC.
Time.new(2026, 3, 5, 9, 30, 15, in: "Z") # => 2026-03-05 09:30:15 UTC
Time.new(2026, 3, 5, in: "-05:00") # => 2026-03-05 00:00:00 -0500
Time.at(0, in: "UTC") # => 1970-01-01 00:00:00 UTC
Time.at(1_772_703_015, in: "+01:00") # => 2026-03-05 10:30:15 +0100
# Missing fields default to the start of the period.
Time.new(2026) # => 2026-01-01 00:00:00 +0100
Time.local(2026, "mar", 5) # => 2026-03-05 00:00:00 +0100
Time.utc(2026, 12, 31, 23, 59, 59, 500_000) # => 2026-12-31 23:59:59.5 UTC
# Epoch seconds and fractions.
utc.to_i # => 1772703015
utc.to_f # => 1772703015.0
Time.at(1_772_703_015, 250, :millisecond, in: "UTC") # => 2026-03-05 09:30:15.25 UTC
Time.at(1_772_703_015.5, in: "UTC").subsec # => (1/2)
Source: Ruby 4.0 documentation: Time.new
The points worth remembering from this file:
-
A
Timecreated from fields with no offset takes the local zone, which comes from theTZenvironment variable (see below). Such aTimehas a zone name ("CET"), while aTimecreated with an explicit offset such as"+09:00"haszoneequal tonil: an offset is not a zone, it carries no rules and no name. -
Time.utcproduces aTimewhoseutc?istrueand whosezoneis"UTC". The offsets"Z"and"UTC"give the same result, and"-00:00"is parsed too (it means "UTC, offset unknown" in RFC 3339 and prints as-0000with%-z). -
Two
Timeobjects are==when they are the same instant, whatever their offsets:Time.utc(2026, 3, 5, 9, 30, 15)equalsTime.new(2026, 3, 5, 18, 30, 15, "+09:00"). The offset only changes how the instant is read. -
Time.at(seconds, subsecond, unit)accepts:millisecond,:usecor:microsecondand:nsecor:nanosecondas the unit of the second number.Time.at(1_772_703_015.5)stores the fraction exactly as theFloatrepresents it (see the pitfalls below). -
A
Timeholds an exact number of seconds (anIntegeror aRational), not aFloat, so it has no fixed resolution.usec,nsecandsubsecread the fraction;Time.nowitself is as precise as the operating system clock.
Time.new with a string (Ruby 3.2 and 3.3)
Time.new with a single string argument parses the format that Time#inspect and ISO 8601 print. Ruby 3.2 added the feature
(Feature #18033), and Ruby 3.3 made it strict (Bug #19293): a string with no time part is an error instead of silently being read as
a year. The in: keyword supplies an offset when the string has none, and precision: (default 9) limits the stored fraction digits.
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
# Time.new with a string parses what Time#inspect and ISO 8601 print.
Time.new("2026-03-05 10:30:15") # => 2026-03-05 10:30:15 +0100
Time.new("2026-03-05 10:30:15 +09:00") # => 2026-03-05 10:30:15 +0900
Time.new("2026-03-05T10:30:15Z") # => 2026-03-05 10:30:15 UTC
Time.new("2026-03-05 10:30:15.125 UTC") # => 2026-03-05 10:30:15.125 UTC
Time.new("2026-03-05 10:30:15", in: "-03:00") # => 2026-03-05 10:30:15 -0300
Time.new("2026-03-05 10:30:15.123456789", precision: 3) # => 2026-03-05 10:30:15.123 +0100
# Since Ruby 3.3 the string form is strict.
begin
Time.new("2026-03-05")
rescue ArgumentError => e
e.message # => "no time information"
end
begin
Time.new("2026-13-05 10:30:15")
rescue ArgumentError => e
e.message # => "mon out of range"
end
# A string holding only a year is still the year argument.
Time.new("2026") # => 2026-01-01 00:00:00 +0100
Source: Ruby 4.0 documentation: Time.new
Two rules explain the last line: a string that is only digits is still the year argument, as it was before 3.2, while a string
that contains a date separator must also contain a time. The strict form is the only parser in core. For dates without a time, or for
formats that are not ISO 8601, use Time.strptime, Time.parse or Date (below).
Fields, arithmetic, comparison and rounding
A Time answers year, month (alias mon), day (alias mday), hour, min, sec, wday (0 is Sunday), yday, zone,
utc_offset (alias gmt_offset and gmtoff), dst? (alias isdst) and the day-of-week predicates sunday? to saturday?.
to_a returns the ten-element array [sec, min, hour, day, month, year, wday, yday, isdst, zone].
Arithmetic is in seconds. Time#+ adds a number of seconds and returns a Time with the receiver’s offset; Time#- with a number subtracts
seconds, and with another Time returns the difference as a Float of seconds. Adding two times is a TypeError. Time includes
Comparable, so <, >, between? and clamp work, and <=> returns nil against a non-Time. round, floor and ceil
take the number of fraction digits.
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
borrowed = Time.new(2026, 3, 5, 10, 30, 15.75, "+01:00")
returned = Time.new(2026, 3, 19, 18, 0, 0, "+01:00")
# Adding a number adds seconds; subtracting two Times gives a Float.
p borrowed + 60 # => 2026-03-05 10:31:15.75 +0100
p borrowed - 3600 # => 2026-03-05 09:30:15.75 +0100
p borrowed + 0.5 # => 2026-03-05 10:30:16.25 +0100
p borrowed + Rational(1, 3) # => 2026-03-05 10:30:16 1/12 +0100
p returned - borrowed # => 1236584.25
((returned - borrowed) / 86_400).floor # => 14
p borrowed - returned # => -1236584.25
# Comparable: ordering, between? and clamp.
p borrowed < returned # => true
borrowed.between?(borrowed, returned) # => true
p borrowed <=> returned # => -1
borrowed.clamp(returned, returned) # => 2026-03-19 18:00:00 +0100
[returned, borrowed].min # => 2026-03-05 10:30:15.75 +0100
# Fields.
borrowed.year # => 2026
borrowed.month # => 3
borrowed.day # => 5
borrowed.hour # => 10
borrowed.min # => 30
borrowed.sec # => 15
borrowed.wday # => 4
borrowed.yday # => 64
borrowed.thursday? # => true
borrowed.to_a # => [15, 30, 10, 5, 3, 2026, 4, 64, false, nil]
# Sub-second precision.
borrowed.usec # => 750000
borrowed.nsec # => 750000000
borrowed.subsec # => (3/4)
borrowed.to_r # => (7090812063/4)
borrowed.inspect # => "2026-03-05 10:30:15.75 +0100"
borrowed.to_s # => "2026-03-05 10:30:15 +0100"
# Rounding.
borrowed.round # => 2026-03-05 10:30:16 +0100
borrowed.floor # => 2026-03-05 10:30:15 +0100
borrowed.ceil # => 2026-03-05 10:30:16 +0100
borrowed.round(1) # => 2026-03-05 10:30:15.8 +0100
# Equality compares the instant and the exact fraction, never the offset.
p Time.at(0, in: "UTC") == Time.at(0, in: "+01:00") # => true
Time.at(0, in: "UTC").eql?(Time.at(0, in: "+01:00")) # => true
p Time.at(0.1) == Time.at(Rational(1, 10)) # => false
Time.at(0.1).subsec # => (3602879701896397/36028797018963968)
Time.at(Rational(1, 10)).subsec # => (1/10)
Source: Ruby 4.0 documentation: Time#-
Equality needs care. == and eql? compare the instant and the exact fraction and ignore the offset, so a Time can be used as a
Hash key without normalizing its zone first. A Float such as 0.1 is not the rational one tenth, so Time.at(0.1) and
Time.at(Rational(1, 10)) are different instants (their subsec is shown above). Prefer Integer or Rational when a fraction
matters, and compare with a tolerance when the values came from floating point.
Time zones and offsets
Ruby’s core has two kinds of zone and one extension point.
-
An offset: a fixed number of seconds from UTC, written
"+HH:MM","-HH:MM","+HHMM","+HH","UTC","Z", a military letter"A"to"I"and"K"to"Z"(J is excluded), or anIntegernumber of seconds. The offset never changes.Time.zone_offset(from thetimelibrary) converts abbreviations such as"EST"to seconds, but a bare"EST"is not accepted as an offset argument. -
The local zone: the zone the process runs in, with its daylight saving rules, taken from the
TZenvironment variable or the operating system.Time.now,Time.newwithout an offset,Time.local,getlocalandlocaltimewithout arguments use it. Itszoneis an abbreviation such as"CET"or"CEST". -
A timezone object: any object that follows the protocol described below. This is how an IANA database library plugs in.
Names such as "Europe/Madrid" are not accepted as an offset: Time.new(2026, 7, 1, 12, 0, 0, "Europe/Madrid") raises
ArgumentError, and so does an offset out of range or a malformed one. getlocal and localtime return the same instant read in
another offset; getutc / getgm and utc / gmtime read it in UTC. The methods without a get prefix change the receiver and the
ones with it return a new Time, which matters for an object you share.
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
due = Time.utc(2026, 7, 1, 12, 0, 0)
# Converting never changes the instant, only how it is shown.
due.getlocal # => 2026-07-01 14:00:00 +0200
due.getlocal("-05:00") # => 2026-07-01 07:00:00 -0500
due.getlocal(3600 * 9) # => 2026-07-01 21:00:00 +0900
due.getutc # => 2026-07-01 12:00:00 UTC
due.zone # => "UTC"
due.getlocal.zone # => "CEST"
due.getlocal.dst? # => true
Time.utc(2026, 1, 1).getlocal.dst? # => false
due.getlocal.utc_offset # => 7200
Time.utc(2026, 1, 1).getlocal.utc_offset # => 3600
# Single-letter military zones are accepted too.
Time.new(2026, 7, 1, 12, 0, 0, "A") # => 2026-07-01 12:00:00 +0100
Time.new(2026, 7, 1, 12, 0, 0, "Z") # => 2026-07-01 12:00:00 UTC
# A bad offset is an ArgumentError.
begin
Time.new(2026, 7, 1, 12, 0, 0, "+25:00")
rescue ArgumentError => e
e.message # => "utc_offset out of range"
end
begin
Time.new(2026, 7, 1, 12, 0, 0, "Europe/Madrid")
rescue ArgumentError => e
e.message # => "\"+HH:MM\", \"-HH:MM\", \"UTC\" or \"A\"..\"I\",\"K\"..\"Z\" expected for utc_offset: Europe/Madrid"
end
# utc and localtime change the receiver, getutc and getlocal return a copy.
stamp = Time.new(2026, 7, 1, 12, 0, 0, "+02:00")
stamp.utc # => 2026-07-01 10:00:00 UTC
stamp.utc? # => true
stamp.localtime # => 2026-07-01 12:00:00 +0200
stamp.utc? # => false
The TZ environment variable
The local zone comes from the TZ environment variable, using the names of the operating system’s time zone database
(Europe/Madrid, America/New_York, Asia/Tokyo, UTC), or from the system setting when TZ is unset. Assigning ENV["TZ"] inside
a program takes effect immediately for the whole process, every thread included, which is why the examples on this page can set it,
and why library code must not: change the zone for one computation with an offset or a zone object instead. In a minimal container the
database is often missing, and local times then fall back to UTC; install tzdata in the image or pass an explicit zone.
Daylight saving transitions
Local time is not continuous. When the clocks spring forward, the wall-clock readings in the skipped hour do not exist, and when they
fall back the readings in the repeated hour exist twice. Ruby resolves both without raising: a skipped reading is moved forward by the
size of the gap, and an ambiguous reading is resolved to one of the two instants (here the later one, with offset +01:00). Because
Time arithmetic is in absolute seconds, adding 86_400 to noon of the day before a transition does not give noon of the next day.
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
# Spring forward: 02:30 on 29 March 2026 does not exist in Madrid.
Time.new(2026, 3, 29, 1, 59, 59) # => 2026-03-29 01:59:59 +0100
Time.new(2026, 3, 29, 2, 30, 0) # => 2026-03-29 03:30:00 +0200
Time.new(2026, 3, 29, 3, 0, 0) # => 2026-03-29 03:00:00 +0200
p Time.new(2026, 3, 29, 3, 0, 0) - Time.new(2026, 3, 29, 1, 59, 59) # => 1.0
# Fall back: 02:30 on 25 October 2026 happens twice. Ruby picks one reading.
ambiguous = Time.new(2026, 10, 25, 2, 30, 0)
p ambiguous # => 2026-10-25 02:30:00 +0100
ambiguous.dst? # => false
Time.new(2026, 10, 25, 2, 30, 0, in: "+02:00") # => 2026-10-25 02:30:00 +0200
p Time.new(2026, 10, 25, 2, 30, 0, in: "+01:00").to_i - Time.new(2026, 10, 25, 2, 30, 0, in: "+02:00").to_i # => 3600
# "Same wall clock tomorrow" is not "plus 86,400 seconds".
p Time.new(2026, 3, 28, 12, 0, 0) + 86_400 # => 2026-03-29 13:00:00 +0200
Time.new(2026, 3, 29, 12, 0, 0) # => 2026-03-29 12:00:00 +0200
p (Time.new(2026, 3, 29, 12, 0, 0) - Time.new(2026, 3, 28, 12, 0, 0)) / 3600 # => 23.0
To be explicit about an ambiguous reading, give the offset (in: "+02:00" or in: "+01:00"); to add "a day" in the wall-clock sense,
do the arithmetic on the calendar fields (convert to a Date, add, and build a new Time) or run the whole computation in UTC and
convert at the edge.
Timezone objects
The seventh argument of Time.new, the in: keyword of Time.new, Time.at and Time.now, and the argument of getlocal and
localtime also accept an object that implements the timezone protocol. Ruby asks the object to convert in both directions:
| Method | Contract |
|---|---|
|
Receives a |
|
Receives a UTC |
|
Optional. |
The tzinfo gem is the usual implementation of this protocol for IANA zones (TZInfo::Timezone.get("Europe/Madrid") works as
the in: argument). The next example writes the smallest useful one, a fixed-offset zone with a name.
# frozen_string_literal: true
module Shelf
# A fixed-offset zone object following the protocol Time expects of the
# objects given as the zone argument or the in: keyword.
class FixedZone
attr_reader :name
def initialize(name, offset_seconds)
@name = name
@offset = offset_seconds
end
# Receives a Time whose wall-clock fields are in this zone and returns the
# same fields read as UTC.
def local_to_utc(local)
local - @offset
end
# Receives a Time in UTC and returns the wall-clock fields in this zone.
def utc_to_local(utc)
utc + @offset
end
# Optional: used by Time#zone.
def abbr(_time) = name
def dst?(_time) = false
end
end
zone = Shelf::FixedZone.new("LIB", 3 * 3600)
at_desk = Time.new(2026, 3, 5, 10, 30, 0, zone)
at_desk.inspect # => "2026-03-05 10:30:00 +0300"
at_desk.zone.equal?(zone) # => true
at_desk.utc_offset # => 10800
at_desk.utc.inspect # => "2026-03-05 07:30:00 UTC"
Time.at(1_772_703_015, in: zone).inspect # => "2026-03-05 12:30:15 +0300"
Time.now(in: zone).utc_offset # => 10800
Reading the clock: wall clock versus monotonic clock
Time.now reads the wall clock: the clock that the operating system keeps in step with real-world time. It can jump forwards or
backwards at any moment (NTP corrections, an administrator, a virtual machine resuming), so the difference between two Time.now
readings can be negative or wrong. Process.clock_gettime(Process::CLOCK_MONOTONIC) reads a clock that only moves forward and is
never adjusted, but whose starting point is arbitrary: the number means nothing alone and is only useful subtracted from another
reading of the same clock. Use the monotonic clock for durations, timeouts and benchmarks, and Time.now for "what time is it".
Process.clock_gettime(clock_id, unit = :float_second) takes the clock constant and a unit: :float_second (the default),
:float_millisecond, :float_microsecond, :second, :millisecond, :microsecond and :nanosecond. The common clocks are:
| Constant | Meaning |
|---|---|
|
Time since an unspecified point, never adjusted and never going backwards. Use it for elapsed time. |
|
The wall clock, as |
|
CPU time consumed by this process, not elapsed time. A |
|
CPU time consumed by the calling thread. |
Which constants exist depends on the platform; a missing one raises Errno::EINVAL or is not defined, so keep to the first three
unless you target a single operating system. Process.clock_getres(clock_id) returns the resolution of a clock.
# frozen_string_literal: true
# Wall clock (can jump) versus monotonic clock (only moves forward).
wall = Time.now
mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
wall.class # => Time
mono.class # => Float
# Units: the default is a Float of seconds.
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond).class # => Integer
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_microsecond).class # => Float
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).class # => Integer
# Other clocks.
Process.clock_gettime(Process::CLOCK_REALTIME).class # => Float
Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID).class # => Float
Process.clock_getres(Process::CLOCK_MONOTONIC).class # => Float
# Two readings of the monotonic clock never go backwards.
first = Process.clock_gettime(Process::CLOCK_MONOTONIC)
second = Process.clock_gettime(Process::CLOCK_MONOTONIC)
p second >= first # => true
module Shelf
# Measures how long a block takes with the monotonic clock.
module Stopwatch
def self.measure
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
[result, elapsed]
end
end
end
result, elapsed = Shelf::Stopwatch.measure do
sleep 0.05
:done
end
p result # => :done
p elapsed >= 0.05 # => true
p elapsed < 1 # => true
The Benchmark library measures with the monotonic clock for the same reason; see
Performance, Benchmarking and Profiling. Subtracting two
Time.now readings to time a block is the most common time bug in Ruby code.
Formatting with strftime
Time#strftime(format) builds a string from a format in which each % sequence is a directive, and every other character is copied.
Ruby’s implementation is documented on its own page, and it is a superset of the C library’s strftime. A directive is
%, optional flags, an optional width, an optional colon prefix for the offset directives, and the conversion letter.
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
t = Time.new(2026, 3, 5, 9, 4, 7.123456789r, "+01:00")
# Date and time.
t.strftime("%Y-%m-%d") # => "2026-03-05"
t.strftime("%F %T") # => "2026-03-05 09:04:07"
t.strftime("%d/%m/%y") # => "05/03/26"
t.strftime("%D") # => "03/05/26"
t.strftime("%e|%-d|%_d|%03d") # => " 5|5| 5|005"
t.strftime("%B %b %h") # => "March Mar Mar"
t.strftime("%A %a %^a %^A") # => "Thursday Thu THU THURSDAY"
t.strftime("%j %U %W %u %w") # => "064 09 09 4 4"
t.strftime("%G-W%V-%u") # => "2026-W10-4"
t.strftime("%C %y %Y") # => "20 26 2026"
# Clock.
t.strftime("%H:%M:%S") # => "09:04:07"
t.strftime("%I:%M %p|%l:%M %P") # => "09:04 AM| 9:04 am"
t.strftime("%R|%r") # => "09:04|09:04:07 AM"
t.strftime("%L|%N|%3N|%6N|%9N|%12N") # => "123|123456789|123|123456|123456789|123456789000"
# Zone and epoch.
t.strftime("%z|%:z|%::z|%:::z") # => "+0100|+01:00|+01:00:00|+01"
t.strftime("%Z") # => ""
t.getlocal.strftime("%Z") # => "CET"
t.getutc.strftime("%Z|%z") # => "UTC|+0000"
t.strftime("%s") # => "1772697847"
# Whole-value conversions, flags and width.
t.strftime("%c") # => "Thu Mar 5 09:04:07 2026"
t.strftime("%x %X") # => "03/05/26 09:04:07"
t.strftime("%+") # => "%+"
t.strftime("%v") # => " 5-MAR-2026"
t.strftime("%10A|%-10A|%^10a|%#b|%010Y") # => " Thursday|Thursday| THU|MAR|0000002026"
t.strftime("100%% at %H%n%t.") # => "100% at 09\n\t."
t.strftime("%Q|%i") # => "%Q|%i"
The input for this file was 2026-03-05 09:04:07.123456789 +01:00, a Thursday. The conversions, grouped:
| Directive | Meaning | Output for the instant above |
|---|---|---|
|
Year, century, year within the century |
|
|
ISO 8601 week-based year, its two-digit form and the ISO week number (01-53) |
|
|
Month as 01-12, full name, abbreviated name |
|
|
Day of the month (01-31), the same blank-padded, day of the year (001-366) |
|
|
Day name, abbreviated name, weekday 1-7 starting Monday, weekday 0-6 starting Sunday |
|
|
Week of the year starting on Sunday, starting on Monday |
|
|
Hour 00-23, 01-12, and the blank-padded 0-23 and 1-12 forms |
|
|
Minute, second |
|
|
Fraction of the second: milliseconds, and nanoseconds. A width after the |
|
|
|
|
|
UTC offset as |
|
|
Zone abbreviation. Empty for a |
empty, or |
|
Seconds since the epoch |
|
|
Shortcuts: |
|
|
Locale-free date-and-time, date, time, and the form |
|
|
Newline, tab, a literal percent sign |
Flags go between the % and the letter and change the padding or the case:
| Flag | Effect |
|---|---|
|
Do not pad: |
|
Pad with spaces: |
|
Pad with zeros, the default for numbers: |
|
Upper-case the result: |
|
Change the case: for names the result is upper-case ( |
|
Only for |
width |
A number sets the minimum width: |
Locale is not used: month and day names are always English. For localized output use the i18n gem or build the string from your
own tables. %+ (the date(1) format) and %Q (milliseconds since the epoch, which Date#strftime does support) are not
supported by Time#strftime: an unknown conversion is copied through unchanged, as the last two lines of the example show, so a
typo in a format never raises. Since Ruby 3.1 %-z renders a UTC time of unknown local offset as -0000 (RFC 3339).
ISO 8601, RFC 2822 and the time library
Store and exchange instants as ISO 8601 / RFC 3339 strings with an offset (2026-03-05T09:04:07.500+01:00) or as UTC (Z).
Time#iso8601(digits = 0) and its alias Time#xmlschema are in core since Ruby 3.4; the digits argument adds fraction digits.
The parsers are in the time library, so require "time". They fall into two families: the strict ones that accept exactly one format and
raise ArgumentError otherwise (iso8601 / xmlschema, rfc2822, httpdate, strptime), and Time.parse, which guesses.
# frozen_string_literal: true
require "time"
ENV["TZ"] = "Europe/Madrid"
t = Time.new(2026, 3, 5, 9, 4, 7.5r, "+01:00")
# Formatting.
t.iso8601 # => "2026-03-05T09:04:07+01:00"
t.xmlschema(3) # => "2026-03-05T09:04:07.500+01:00"
t.getutc.iso8601(6) # => "2026-03-05T08:04:07.500000Z"
t.getutc.xmlschema # => "2026-03-05T08:04:07Z"
t.rfc2822 # => "Thu, 05 Mar 2026 09:04:07 +0100"
t.httpdate # => "Thu, 05 Mar 2026 08:04:07 GMT"
t.to_s # => "2026-03-05 09:04:07 +0100"
t.inspect # => "2026-03-05 09:04:07.5 +0100"
# Strict parsing of ISO 8601 / RFC 3339.
Time.iso8601("2026-03-05T09:04:07+01:00") # => 2026-03-05 09:04:07 +0100
Time.iso8601("2026-03-05T09:04:07.25Z") # => 2026-03-05 09:04:07.25 UTC
Time.rfc2822("Thu, 05 Mar 2026 09:04:07 +0100") # => 2026-03-05 09:04:07 +0100
Time.httpdate("Thu, 05 Mar 2026 08:04:07 GMT") # => 2026-03-05 08:04:07 UTC
Time.strptime("05/03/2026 09:04", "%d/%m/%Y %H:%M") # => 2026-03-05 09:04:00 +0100
Time.strptime("1772697847", "%s") # => 2026-03-05 09:04:07 +0100
# Forgiving parsing.
Time.parse("2026-03-05 09:04:07") # => 2026-03-05 09:04:07 +0100
Time.parse("Mar 5 2026 9:04 PM UTC") # => 2026-03-05 21:04:00 UTC
Time.parse("09:04", Time.new(2030, 1, 2)) # => 2030-01-02 09:04:00 +0100
begin
Time.iso8601("2026-03-05")
rescue ArgumentError => e
e.message # => "invalid xmlschema format: \"2026-03-05\""
end
begin
Time.parse("not a time")
rescue ArgumentError => e
e.message # => "no time information in \"not a time\""
end
begin
Time.strptime("2026-13-40", "%Y-%m-%d")
rescue ArgumentError => e
e.message # => "invalid date or strptime format - '2026-13-40' '%Y-%m-%d'"
end
| Method | Format | Behaviour |
|---|---|---|
|
|
Strict date-time. A bare date is rejected. A string with no offset is read in the local zone. |
|
Mail and HTTP date formats |
Strict. |
|
Any |
Strict about the format. Uses |
|
Anything |
Forgiving. Fields missing from the string come from |
Time.parse is for interactive input where leniency is welcome and for log scraping. For data crossing a system boundary use
Time.iso8601 or Time.strptime, whose failures are errors instead of surprises. A string without an offset is read in the local zone
by all of them, so a server whose TZ changes parses the same text into a different instant: always write the offset.
Out-of-range values, leap seconds and other edges
# frozen_string_literal: true
ENV["TZ"] = "Europe/Madrid"
# Out-of-range fields.
Time.utc(2026, 2, 30) # => 2026-03-02 00:00:00 UTC
Time.utc(2026, 2, 28, 24) # => 2026-03-01 00:00:00 UTC
Time.utc(2016, 12, 31, 23, 59, 60) # => 2017-01-01 00:00:00 UTC
Time.utc(2026, 4, 31) # => 2026-05-01 00:00:00 UTC
begin
Time.utc(2026, 13, 1)
rescue ArgumentError => e
e.message # => "mon out of range"
end
Time.new(2026, 2, 30, 0, 0, 0, "+01:00") # => 2026-03-02 00:00:00 +0100
begin
Time.utc(2026, 1, 1, 25)
rescue ArgumentError => e
e.message # => "hour out of range"
end
begin
Time.now + Time.now
rescue TypeError => e
e.message # => "time + time?"
end
begin
Time.now - "5"
rescue TypeError => e
e.message # => "can't convert String into an exact number"
end
# Months are not a Time unit: add days or convert through Date.
p Time.new(2026, 1, 31, 12, 0, 0, "+01:00") + (30 * 86_400) # => 2026-03-02 12:00:00 +0100
# A float epoch loses precision that a Rational keeps.
Time.at(1_772_703_015.123456789).nsec # => 123456716
Time.at(Rational(1_772_703_015_123_456_789, 1_000_000_000)).nsec # => 123456789
Time.at(1_772_703_015, 123_456_789, :nsec).nsec # => 123456789
Source: Ruby 4.0 documentation: Time.utc
-
The day of the month is only checked against 31.
Time.utc(2026, 2, 30)is 2 March, not an error, andTime.utc(2026, 4, 31)is 1 May. The month (1-12), hour (0-24), minute and second (0-60) are range checked.Date.new(2026, 2, 30)does raise. -
Leap seconds are not modelled. A second of 60 is accepted and rolls over into the next minute, and
Timecounts every day as 86,400 seconds, like POSIX time. -
Time + Timeis aTypeError, as is subtracting aString; there is no automatic conversion. -
There is no month arithmetic on
Time. Add days, or go throughDate#>>(below), for "the same day next month". -
Epoch values are not limited to 32 bits:
Time.ataccepts anyIntegerorRational, so there is no year 2038 problem in Ruby itself.
Date
require "date" defines Date, a calendar day identified by its Julian Day Number. A Date has no time of day, no zone and no
daylight saving, which is exactly why it is the right type for a due date: "the 19th" is the 19th in every zone. Arithmetic is in days:
Date#+ and Date#- with a number move by days, and Date#- with another Date returns the difference as a Rational
number of days (call to_i for a whole number). The month and year operators are >> and << (add and subtract months, clamping to
the last day of a shorter month), next_day, prev_day, next_month, prev_month, next_year and prev_year, each taking an optional count.
Constructors accept negative day and month numbers that count from the end, so Date.new(2026, 3, -1) is the last day of March and
Date.new(2026, -1, -1) the last day of the year. The validity checks are Date.valid_date?, valid_ordinal?, valid_commercial? and
valid_jd?, and the constructors Date.civil (alias of new), Date.ordinal, Date.commercial (ISO week date) and Date.jd.
# frozen_string_literal: true
require "date"
ENV["TZ"] = "Europe/Madrid"
# Construction.
due = Date.new(2026, 3, 19)
due.to_s # => "2026-03-19"
Date.new(2026, 3, -1).to_s # => "2026-03-31"
Date.new(2026, -1, -1).to_s # => "2026-12-31"
Date.new.to_s # => "-4712-01-01"
Date.valid_date?(2026, 2, 29) # => false
Date.valid_date?(2028, 2, 29) # => true
Date.leap?(2100) # => false
Date.ordinal(2026, 64).to_s # => "2026-03-05"
Date.commercial(2026, 10, 4).to_s # => "2026-03-05"
Date.jd(2_461_000).to_s # => "2025-11-20"
Date.today.class # => Date
begin
Date.new(2026, 2, 29)
rescue Date::Error => e
[e.class, e.class.superclass, e.message] # => [Date::Error, ArgumentError, "invalid date"]
end
# Arithmetic is in days.
(due + 14).to_s # => "2026-04-02"
(due - 19).to_s # => "2026-02-28"
(due >> 1).to_s # => "2026-04-19"
(due << 3).to_s # => "2025-12-19"
due.next_day(10).to_s # => "2026-03-29"
due.prev_month.to_s # => "2026-02-19"
due.next_year.to_s # => "2027-03-19"
(Date.new(2026, 1, 31) >> 1).to_s # => "2026-02-28"
Date.new(2028, 2, 29).next_year.to_s # => "2029-02-28"
p due - Date.new(2026, 3, 5) # => (14/1)
(due - Date.new(2026, 3, 5)).to_i # => 14
# Fields.
due.year # => 2026
due.month # => 3
due.mday # => 19
due.wday # => 4
due.cwday # => 4
due.cweek # => 12
due.cwyear # => 2026
due.yday # => 78
due.thursday? # => true
due.leap? # => false
due.jd # => 2461119
due.mjd # => 61118
Date::MONTHNAMES[3] # => "March"
p Date::ABBR_DAYNAMES # => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
# Ranges, step and upto.
(Date.new(2026, 3, 1)..Date.new(2026, 3, 4)).map(&:day) # => [1, 2, 3, 4]
Date.new(2026, 3, 1).step(Date.new(2026, 3, 29), 7).map(&:day) # => [1, 8, 15, 22, 29]
Date.new(2026, 3, 30).upto(Date.new(2026, 4, 2)).map(&:day) # => [30, 31, 1, 2]
(Date.new(2026, 3, 1)...Date.new(2026, 3, 8)).count(&:sunday?) # => 1
(Date.new(2026, 3, 1)..Date.new(2026, 3, 31)).cover?(due) # => true
# Conversion and formatting.
due.to_s # => "2026-03-19"
due.iso8601 # => "2026-03-19"
due.strftime("%A, %-d %B %Y") # => "Thursday, 19 March 2026"
due.to_time # => 2026-03-19 00:00:00 +0100
Time.new(2026, 3, 19, 23, 59, 59).to_date.to_s # => "2026-03-19"
Time.utc(2026, 3, 19, 23, 59, 59).getlocal.to_date.to_s # => "2026-03-20"
due.inspect # => "#<Date: 2026-03-19 ((2461119j,0s,0n),+0s,2299161j)>"
Source: Ruby 4.0 documentation: Date
-
Date.newwith no arguments is the first day of the Julian calendar epoch,-4712-01-01(Julian day 0), not today.Date.todayis today in the local zone, so it followsTZ. -
An invalid date raises
Date::Error(a subclass ofArgumentError, sorescue ArgumentErrorcatches it). Rescue it where a user-supplied date enters the program, and useDate.valid_date?when invalid input is expected and not exceptional. -
Date#>>clamps: 31 January plus one month is 28 February, and 29 February plus one year is 28 February. Adding months twice is not the same as adding two months at once. -
Date#cwdayis the ISO weekday (Monday is 1, Sunday is 7),wdaycounts from Sunday as 0, andcweekandcwyearare the ISO week and week-based year; the last days of December can belong to week 1 of the nextcwyear, so usecwyearand notyearwithcweek. -
DateisComparableand is also aRangeendpoint withsucc, so(d1..d2).each,step,upto,downto,countandcover?all work. Ranges ofTimecannot be iterated witheach, which is one more reason to useDatefor days. -
Date#to_timeis the start of the day in the local zone.Time#to_datekeeps the date in the `Time’s own offset: the same instant can be 19 March in Madrid and 20 March somewhere east, as the last two conversions show. Convert to the zone that owns the date first. -
Date#inspectshows the internals (#<Date: 2026-03-19 ((2461119j,0s,0n),+0s,2299161j)>: Julian day, offset and calendar reform day), whileto_sandiso8601give2026-03-19. -
Date::MONTHNAMES,Date::ABBR_MONTHNAMES,Date::DAYNAMESandDate::ABBR_DAYNAMESare frozen English arrays;MONTHNAMESstarts withnilso thatMONTHNAMES[3]is March.
Parsing dates
Date.parse guesses the format; Date.strptime and Date.iso8601 (also rfc3339, rfc2822, httpdate, xmlschema and jisx0301) are exact. A
guessed parse of an ambiguous numeric date is the classic data bug: 05/03/2026 is read as day/month, 03/05/2026 as 3 May, and
nothing in the result tells you that. Since the date library 3.2.1 a string longer than 128 characters is refused before it is parsed, which
closes a regular-expression denial-of-service hole; pass limit: nil for trusted long input (as the last line shows) or a larger
integer. Date._parse returns the raw hash of fields the parser found, which is useful for finding out why a string parses oddly.
# frozen_string_literal: true
require "date"
# Date.parse guesses; Date.strptime and Date.iso8601 are exact.
Date.parse("2026-03-19").to_s # => "2026-03-19"
Date.parse("19 Mar 2026").to_s # => "2026-03-19"
Date.parse("March 19, 2026").to_s # => "2026-03-19"
Date.parse("05/03/2026").to_s # => "2026-03-05"
Date.parse("03/05/2026").to_s # => "2026-05-03"
Date.strptime("03/05/2026", "%m/%d/%Y").to_s # => "2026-03-05"
Date.strptime("2026-078", "%Y-%j").to_s # => "2026-03-19"
Date.iso8601("2026-W12-4").to_s # => "2026-03-19"
Date.iso8601("20260319").to_s # => "2026-03-19"
Date._parse("19 Mar 2026 10:30") # => {hour: 10, min: 30, year: 2026, mon: 3, mday: 19}
begin
Date.parse("not a date")
rescue Date::Error => e
e.message # => "invalid date"
end
begin
Date.iso8601("19/03/2026")
rescue Date::Error => e
e.message # => "invalid date"
end
begin
Date.strptime("2026-02-30", "%F")
rescue Date::Error => e
e.message # => "invalid date"
end
# A long string is refused before it is parsed (limit: 128 characters).
begin
Date.parse("1" * 200)
rescue ArgumentError => e
e.message # => "string length (200) exceeds the limit 128"
end
Date.parse("2026-03-19", limit: nil).to_s # => "2026-03-19"
Rules of thumb: parse user-facing input with strptime and an explicit format (or a locale-aware library), machine input with iso8601, and use Date.parse
only for convenience scripts.
The calendar reform
Date models two calendars. The proleptic Julian calendar has a leap year every four years, and the Gregorian calendar skips the years
divisible by 100 unless they are divisible by 400. The Gregorian reform of 1582 dropped ten days, and every Date has a start, the Julian Day Number
of the first Gregorian day. Before it dates are read in the Julian calendar, from it on in the Gregorian one. The default start is Date::ITALY (15 October 1582), and the
constants are Date::ENGLAND (14 September 1752, the British Empire and its colonies), Date::JULIAN (infinity: always Julian) and Date::GREGORIAN
(minus infinity: always Gregorian). Choose the start only when you handle historical dates; for everything after 1752 the default is already correct.
# frozen_string_literal: true
require "date"
# The Gregorian reform: ten days vanished in Italy in October 1582.
Date.valid_date?(1582, 10, 4) # => true
Date.valid_date?(1582, 10, 10) # => false
Date.valid_date?(1582, 10, 15) # => true
(Date.new(1582, 10, 15) - Date.new(1582, 10, 4)).to_i # => 1
Date.new(1582, 10, 4).julian? # => true
Date.new(1582, 10, 15).gregorian? # => true
Date.new(1582, 10, 4).start # => 2299161.0
# The start argument chooses the reform date (ITALY is the default).
p Date::ITALY # => 2299161
p Date::ENGLAND # => 2361222
p Date::JULIAN # => Infinity
p Date::GREGORIAN # => -Infinity
Date.new(1752, 9, 14, Date::ENGLAND).to_s # => "1752-09-14"
Date.valid_date?(1752, 9, 10, Date::ENGLAND) # => false
Date.valid_date?(1752, 9, 10) # => true
# The proleptic calendars extend one rule over all of history.
Date.new(1500, 2, 29, Date::JULIAN).to_s # => "1500-02-29"
Date.valid_date?(1500, 2, 29, Date::GREGORIAN) # => false
Date.new(1500, 3, 1, Date::JULIAN).jd # => 2268993
Date.new(1500, 3, 1, Date::GREGORIAN).jd # => 2268983
Date.new(1500, 3, 1, Date::GREGORIAN).new_start(Date::JULIAN).to_s # => "1500-02-20"
Dates before the reform are therefore not what a library that applies the Gregorian rules to all of history (the ISO calendar of java.time, for example) gives for the same year, month and day,
and Date.valid_date?(1582, 10, 10) is false although that day exists in the proleptic Gregorian calendar. Date#new_start, Date#italy, Date#england, Date#julian
and Date#gregorian return the same day read under a different reform setting, and Date#julian? and Date#gregorian? report which reading applies.
Why DateTime is deprecated
DateTime is a Date subclass that adds a time of day and a fixed offset, and the official documentation states: "DateTime class is considered
deprecated. Use Time class." Its zone support is a fixed offset with no daylight saving rules, its arithmetic mixes days and fractions of a day as Rational numbers, and
its parsing and strftime duplicate those of Time. The date library keeps it for backward compatibility, and RuboCop has a Style/DateTime
cop for it (disabled by default). New code, and examples older than Ruby 2.x that use it, should use Time instead:
| Old DateTime code | Replacement |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The only legitimate use left is reading old data: Time#to_datetime and Date#to_datetime exist to hand a value to an API that still demands a DateTime.
Time values in pattern matching and ranges
Since Ruby 3.2, Time#deconstruct_keys and Date#deconstruct_keys (Feature #19071) let time values appear in case/in patterns. Time
offers year, month, day, yday, wday, hour, min, sec, subsec, dst and zone; Date offers year, month, day, yday, wday
(and DateTime adds the time fields). Passing a key array asks for only those keys. Neither class implements deconstruct, so there is no array pattern. Since
Ruby 3.4 a range whose begin is a Time can be stepped with a number of seconds, and an endless range steps forever (take a bounded number).
# frozen_string_literal: true
require "date"
ENV["TZ"] = "Europe/Madrid"
t = Time.new(2026, 3, 19, 18, 0, 0, "+01:00")
# Time#deconstruct_keys and Date#deconstruct_keys (Ruby 3.2).
t.deconstruct_keys(nil).keys # => [:year, :month, :day, :yday, :wday, :hour, :min, :sec, :subsec, :dst, :zone]
t.deconstruct_keys(%i[year month]) # => {year: 2026, month: 3}
Date.new(2026, 3, 19).deconstruct_keys(%i[year wday yday]) # => {year: 2026, wday: 4, yday: 78}
def office_hours(time)
case time
in { wday: 6 | 0 } then "closed at the weekend"
in { hour: 9...18 } then "open"
in { hour: } then "closed at #{hour}:00"
end
end
office_hours(Time.new(2026, 3, 21, 10)) # => "closed at the weekend"
office_hours(Time.new(2026, 3, 19, 10)) # => "open"
office_hours(t) # => "closed at 18:00"
# A Time range steps by seconds (Ruby 3.4).
(Time.utc(2026, 3, 19)..).step(86_400).take(3).map(&:day) # => [19, 20, 21]
(Time.utc(2026, 3, 19)..Time.utc(2026, 3, 19, 0, 0, 2)).step(1).map(&:sec) # => [0, 1, 2]
# A Time range can still be tested but not iterated with each.
(Time.utc(2026, 3, 19)..Time.utc(2026, 3, 20)).cover?(Time.utc(2026, 3, 19, 12)) # => true
begin
(Time.utc(2026, 3, 19)..Time.utc(2026, 3, 20)).each { it }
rescue TypeError => e
e.message # => "can't iterate from Time"
end
A range of Time values is a legitimate way to express "between these two instants": cover? and === (so case and when) work, and step works since 3.4. each does not,
because Time has no succ, and the error reads "can’t iterate from Time". See
Ranges and Pattern Matching.
Injecting a clock
Code that calls Time.now or Date.today directly cannot be tested without waiting or stubbing a global. The clean alternative is the
dependency-injection idiom: take a callable (a lambda, a Proc, or any object that answers call) as a keyword argument whose default is the real clock, and
let tests pass a frozen one. Shelf::LendingPolicy below uses it to compute the due date, whether a loan is overdue, and the fine, and it shows
the main design rule for dates: a due date is a Date, derived from the instant of the loan with Time#to_date, and the comparison with today is made between dates, so the time of day does not matter.
# frozen_string_literal: true
require "date"
ENV["TZ"] = "Europe/Madrid"
module Shelf
# Lending rules. The clock is injected, so tests never need to stub Time.now.
class LendingPolicy
LOAN_DAYS = 14
FINE_CENTS_PER_DAY = 25
def initialize(clock: -> { Time.now })
@clock = clock
end
# A due date is a calendar date: the library is open until closing time.
def due_on(borrowed_at) = borrowed_at.to_date + LOAN_DAYS
def overdue?(borrowed_at) = today > due_on(borrowed_at)
def fine_cents(borrowed_at)
days_late = (today - due_on(borrowed_at)).to_i
days_late.positive? ? days_late * FINE_CENTS_PER_DAY : 0
end
private
def today = @clock.call.to_date
end
end
borrowed_at = Time.new(2026, 3, 5, 10, 30, 0)
frozen_clock = ->(time) { -> { time } }
on_time = Shelf::LendingPolicy.new(clock: frozen_clock.call(Time.new(2026, 3, 19, 23, 59, 59)))
on_time.due_on(borrowed_at).to_s # => "2026-03-19"
on_time.overdue?(borrowed_at) # => false
on_time.fine_cents(borrowed_at) # => 0
late = Shelf::LendingPolicy.new(clock: frozen_clock.call(Time.new(2026, 3, 24, 8, 0, 0)))
late.overdue?(borrowed_at) # => true
late.fine_cents(borrowed_at) # => 125
# The default clock is the real one.
Shelf::LendingPolicy.new.overdue?(Time.now) # => false
Stubbing Time.now with a test double is the alternative, and gems such as Timecop and ActiveSupport::Testing::TimeHelpers (travel_to) do it for you. They work by replacing the
global, so they leak between threads and break tests that run in parallel, and they cannot see calls to Process.clock_gettime. Stubbing in each framework is covered in
Testing with Minitest and
RSpec and Test Doubles. Never make an assertion on elapsed wall-clock time in CI; a loaded machine
fails it intermittently. sleep in a test is a smell for the same reason.
Serialization
JSON has no time type. With the json library a Time is written through its to_s (not ISO 8601), and a Date through to_s, which happens to be ISO 8601. Neither is read back as a Time or a Date: JSON.parse returns strings, so the schema (or your own code) has to convert them. Write
iso8601 explicitly, with fraction digits if you need them, and parse with Time.iso8601. Marshal is the one format that round-trips every
property of a Time (instant, offset and fraction), but it is Ruby-only and must never read untrusted input. YAML stores both classes as timestamps,
but YAML.safe_load refuses them until the classes are permitted, which is the right default.
# frozen_string_literal: true
require "date"
require "json"
require "time"
require "yaml"
ENV["TZ"] = "Europe/Madrid"
stamp = Time.new(2026, 3, 5, 9, 4, 7.5r, "+01:00")
# JSON has no time type: Time and Date are written as strings.
JSON.generate({ at: stamp }) # => "{\"at\":\"2026-03-05 09:04:07 +0100\"}"
JSON.generate({ due_on: Date.new(2026, 3, 19) }) # => "{\"due_on\":\"2026-03-19\"}"
JSON.generate({ borrowed_at: stamp.iso8601(3) }) # => "{\"borrowed_at\":\"2026-03-05T09:04:07.500+01:00\"}"
Time.iso8601(JSON.parse(JSON.generate({ at: stamp.iso8601(3) }))["at"]).inspect # => "2026-03-05 09:04:07.5 +0100"
# Marshal keeps the instant, the offset and the fraction.
copy = Marshal.load(Marshal.dump(stamp))
copy.inspect # => "2026-03-05 09:04:07.5 +0100"
p copy == stamp # => true
p copy.utc_offset # => 3600
# YAML needs the classes allowed when loading safely.
yaml = YAML.dump({ "due_on" => Date.new(2026, 3, 19), "at" => stamp.getutc })
p yaml # => "---\ndue_on: 2026-03-19\nat: 2026-03-05 08:04:07.500000000 Z\n"
begin
YAML.safe_load(yaml)
rescue Psych::DisallowedClass => e
e.message # => "Tried to load unspecified class: Date"
end
YAML.safe_load(yaml, permitted_classes: [Date, Time]).values.map(&:to_s) # => ["2026-03-19", "2026-03-05 08:04:07 UTC"]
More formats (CSV, MessagePack, and the security aspects of Marshal and YAML) are in
Serialization: JSON, YAML, CSV and Marshal.
Recommended practice
-
Store and transmit instants in UTC (or with an explicit offset) as ISO 8601 strings or epoch integers; convert to a local zone only to display. Store the IANA zone name (
"Europe/Madrid") next to the instant when the user’s wall-clock intent matters, for example for a recurring meeting at "10:00 local", because an offset alone cannot tell you what the clock will read after the next transition. -
Use
Datefor values that are days,Timefor values that are instants, andProcess.clock_gettime(Process::CLOCK_MONOTONIC)for durations. Do not useDateTime. -
Do not change
ENV["TZ"]in library code, and do not rely on the machine’s zone for data that crosses machines; write the offset. -
Parse with
Time.iso8601,Time.strptime,Date.iso8601orDate.strptimeat boundaries, and letTime.parseandDate.parsestay in scripts. -
Inject the clock (a callable that returns a
Time) and take the zone as a parameter instead of reading globals inside business logic. -
Keep
RationalorIntegerfor sub-second values that must be exact, and compare floats of seconds with a tolerance. -
Prefer a
Daterange orDate#stepto loops with+ 1on aTime, which drift across daylight saving transitions. -
RuboCop’s
Style/TimeNow(pending, enabled withNewCops: enable) prefersTime.nowtoTime.newwith no arguments,Lint/Voidflags an operator expression whose result is ignored, andStyle/DateTimecan be enabled to banDateTime.
Common mistakes
| Mistake | What to do instead |
|---|---|
Timing code with |
Use |
Using |
The result is 25 or 23 hours later in wall-clock terms across a daylight saving change. Add days to a |
Calling |
|
Parsing |
They read day/month, silently. Use |
Relying on |
Write the offset in the data, or pass an explicit zone. |
Treating |
It rolls over to 2 March. Check with |
Storing a |
Use |
Assuming a |
Compare days with |
Changing |
Use |
Using |
Use |
Version notes
| Ruby | Change |
|---|---|
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
|
4.0 |
|
Where older material is outdated
Tutorials and books written for Ruby 2.x still show patterns that are wrong or discouraged now:
| Older pattern | Current practice |
|---|---|
|
|
|
They need |
|
|
Timing with |
|
Stubbing the clock with global stubs in every test |
Inject a clock; use a stubbing gem only for legacy code. |
Switching zones by assigning |
Pass the zone with |
|
It limits input to 128 characters since |
References
-
Ruby 4.0 documentation: Time (creating, timezone specifiers, timezone objects, methods for rounding and converting)
-
Comparable and Range
-
Ruby 3.1.0 NEWS, 3.2.0, 3.3.0, 3.4.0 and 4.0.0
-
The Ruby Style Guide: no DateTime and RuboCop Style/DateTime