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

flowchart TD Q{"What do you need?"} Q -->|"How long did this take?"| M["Process.clock_gettime
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

Time

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.

Date

date library (a default gem, 3.5.1 in Ruby 4.0). A day in the proleptic Gregorian or the Julian calendar. No time of day, no zone.

Birthdays, due dates, reporting periods, any value that is a day and not an instant.

DateTime

date library. A Date subclass with a time and an offset. Documented as deprecated: "Use Time class."

Nothing new. See Why DateTime is deprecated.

Process.clock_gettime

Core. Reads a named operating system clock and returns a Float or an Integer in the unit you ask for.

Measuring elapsed time, timeouts, rate limiting, benchmarks. Never for dates shown to people.

Integer or Float seconds

Epoch seconds, as returned by Time#to_i and Time#to_f.

Storage and interchange when both sides agree on the epoch. Prefer Integer or Rational to Float for precision.

Three libraries are involved, and which of them you need to require decides whether a method exists:

  • Time itself is core: Time.now, Time.new, Time.at, Time.utc, Time.local, strftime, arithmetic and Time#iso8601 need no require. Since Ruby 3.4 Time#xmlschema and its alias Time#iso8601 are part of the core class; before 3.4 they were added by the time library.

  • require "time" (the time default 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 methods Time#rfc2822 and Time#httpdate. Calling Time.iso8601 without the require raises NoMethodError, even on 4.0.

  • require "date" (the date default gem) defines Date and DateTime, and adds Time#to_date, Time#to_datetime and Date#to_time. The time library requires date itself.

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)

The points worth remembering from this file:

  • A Time created from fields with no offset takes the local zone, which comes from the TZ environment variable (see below). Such a Time has a zone name ("CET"), while a Time created with an explicit offset such as "+09:00" has zone equal to nil: an offset is not a zone, it carries no rules and no name.

  • Time.utc produces a Time whose utc? is true and whose zone is "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 -0000 with %-z).

  • Two Time objects are == when they are the same instant, whatever their offsets: Time.utc(2026, 3, 5, 9, 30, 15) equals Time.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, :usec or :microsecond and :nsec or :nanosecond as the unit of the second number. Time.at(1_772_703_015.5) stores the fraction exactly as the Float represents it (see the pitfalls below).

  • A Time holds an exact number of seconds (an Integer or a Rational), not a Float, so it has no fixed resolution. usec, nsec and subsec read the fraction; Time.now itself 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

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)

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 an Integer number of seconds. The offset never changes. Time.zone_offset (from the time library) 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 TZ environment variable or the operating system. Time.now, Time.new without an offset, Time.local, getlocal and localtime without arguments use it. Its zone is 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

local_to_utc(local_time)

Receives a Time whose fields are the wall-clock reading in the zone, expressed as if they were UTC, and returns the UTC Time for it. Called by Time.new(fields, zone).

utc_to_local(utc_time)

Receives a UTC Time and returns the wall-clock fields in the zone, again as a Time that is read as if it were UTC. Called by Time.at, Time.now and getlocal.

abbr(time)

Optional. Time#zone returns the object itself and abbr supplies the text for %Z.

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

Process::CLOCK_MONOTONIC

Time since an unspecified point, never adjusted and never going backwards. Use it for elapsed time.

Process::CLOCK_REALTIME

The wall clock, as Time.now reads it, as a number.

Process::CLOCK_PROCESS_CPUTIME_ID

CPU time consumed by this process, not elapsed time. A sleep does not advance it.

Process::CLOCK_THREAD_CPUTIME_ID

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

%Y, %C, %y

Year, century, year within the century

2026, 20, 26

%G, %g, %V

ISO 8601 week-based year, its two-digit form and the ISO week number (01-53)

2026, 26, 10

%m, %B, %b / %h

Month as 01-12, full name, abbreviated name

03, March, Mar

%d, %e, %j

Day of the month (01-31), the same blank-padded, day of the year (001-366)

05, " 5", 064

%A, %a, %u, %w

Day name, abbreviated name, weekday 1-7 starting Monday, weekday 0-6 starting Sunday

Thursday, Thu, 4, 4

%U, %W

Week of the year starting on Sunday, starting on Monday

09, 09

%H, %I, %k, %l

Hour 00-23, 01-12, and the blank-padded 0-23 and 1-12 forms

09, 09, " 9", " 9"

%M, %S

Minute, second

04, 07

%L, %N

Fraction of the second: milliseconds, and nanoseconds. A width after the % sets the digits: %3N, %6N, %9N, %12N.

123, 123456789

%p, %P

AM or PM, and the lower-case am or pm

AM, am

%z, %:z, %::z, %:::z

UTC offset as +hhmm, +hh:mm, +hh:mm:ss, and the shortest of hours or hours and minutes

+0100, +01:00, +01:00:00, +01

%Z

Zone abbreviation. Empty for a Time that has only an offset.

empty, or CET for a local Time

%s

Seconds since the epoch

1772697847

%F, %T, %D, %R, %r

Shortcuts: %Y-%m-%d, %H:%M:%S, %m/%d/%y, %H:%M, %I:%M:%S %p

2026-03-05, 09:04:07, 03/05/26, 09:04, 09:04:07 AM

%c, %x, %X, %v

Locale-free date-and-time, date, time, and the form %e-%^b-%4Y

"Thu Mar 5 09:04:07 2026", 03/05/26, 09:04:07, " 5-MAR-2026"

%n, %t, %%

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: %-d is 5, %-m is 3, %-I is 9. This is how to write days and months without a leading zero (the C library spells it differently, and it is not portable to other languages' strftime).

_

Pad with spaces: %_d is " 5".

0

Pad with zeros, the default for numbers: %03d is 005, %010Y is 0000002026.

^

Upper-case the result: %^a is THU, %^B is MARCH.

#

Change the case: for names the result is upper-case (%#b is MAR), for %p and %Z it is lower-case (cet).

:

Only for %z: insert colons, %:z, %::z, %:::z.

width

A number sets the minimum width: %10A right-aligns in 10 columns, %-10A removes the padding, %^10a combines flags.

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

Time.iso8601(s), Time.xmlschema(s)

2026-03-05T09:04:07+01:00

Strict date-time. A bare date is rejected. A string with no offset is read in the local zone.

Time.rfc2822(s), Time.httpdate(s)

Mail and HTTP date formats

Strict. httpdate always yields UTC. Use these for headers.

Time.strptime(s, format)

Any strftime directives

Strict about the format. Uses %s for epoch seconds. Raises when a field is out of range.

Time.parse(s, now = nil)

Anything Date._parse understands

Forgiving. Fields missing from the string come from now (default: the current time), so "09:04" means "today at 09:04". Raises ArgumentError when nothing time-like is found.

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
  • The day of the month is only checked against 31. Time.utc(2026, 2, 30) is 2 March, not an error, and Time.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 Time counts every day as 86,400 seconds, like POSIX time.

  • Time + Time is a TypeError, as is subtracting a String; there is no automatic conversion.

  • There is no month arithmetic on Time. Add days, or go through Date#>> (below), for "the same day next month".

  • Epoch values are not limited to 32 bits: Time.at accepts any Integer or Rational, 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)>"
  • Date.new with no arguments is the first day of the Julian calendar epoch, -4712-01-01 (Julian day 0), not today. Date.today is today in the local zone, so it follows TZ.

  • An invalid date raises Date::Error (a subclass of ArgumentError, so rescue ArgumentError catches it). Rescue it where a user-supplied date enters the program, and use Date.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#cwday is the ISO weekday (Monday is 1, Sunday is 7), wday counts from Sunday as 0, and cweek and cwyear are the ISO week and week-based year; the last days of December can belong to week 1 of the next cwyear, so use cwyear and not year with cweek.

  • Date is Comparable and is also a Range endpoint with succ, so (d1..d2).each, step, upto, downto, count and cover? all work. Ranges of Time cannot be iterated with each, which is one more reason to use Date for days.

  • Date#to_time is the start of the day in the local zone. Time#to_date keeps 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#inspect shows the internals (#<Date: 2026-03-19 ((2461119j,0s,0n),+0s,2299161j)>: Julian day, offset and calendar reform day), while to_s and iso8601 give 2026-03-19.

  • Date::MONTHNAMES, Date::ABBR_MONTHNAMES, Date::DAYNAMES and Date::ABBR_DAYNAMES are frozen English arrays; MONTHNAMES starts with nil so that MONTHNAMES[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

DateTime.now

Time.now

DateTime.new(2026, 3, 5, 10, 30, 0, "+01:00")

Time.new(2026, 3, 5, 10, 30, 0, "+01:00")

DateTime.parse(string)

Time.iso8601(string), Time.strptime(string, format) or Time.parse(string) (require "time")

datetime.to_date

time.to_date

date.to_datetime

date.to_time (local midnight) or Time.utc(date.year, date.month, date.day)

datetime + Rational(1, 24) (add an hour)

time + 3600

datetime.strftime(format)

time.strftime(format)

DateTime#iso8601

Time#iso8601

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.

  • 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 Date for values that are days, Time for values that are instants, and Process.clock_gettime(Process::CLOCK_MONOTONIC) for durations. Do not use DateTime.

  • 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.iso8601 or Date.strptime at boundaries, and let Time.parse and Date.parse stay 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 Rational or Integer for sub-second values that must be exact, and compare floats of seconds with a tolerance.

  • Prefer a Date range or Date#step to loops with + 1 on a Time, which drift across daylight saving transitions.

  • RuboCop’s Style/TimeNow (pending, enabled with NewCops: enable) prefers Time.now to Time.new with no arguments, Lint/Void flags an operator expression whose result is ignored, and Style/DateTime can be enabled to ban DateTime.

Common mistakes

Mistake What to do instead

Timing code with Time.now - start

Use Process.clock_gettime(Process::CLOCK_MONOTONIC), or Benchmark.realtime. The wall clock can jump.

Using Time.now + 86400 for "tomorrow"

The result is 25 or 23 hours later in wall-clock terms across a daylight saving change. Add days to a Date or compute in UTC.

Calling Time.iso8601 or Time.parse without require "time"

require "time". The instance method Time#iso8601 needs no require since Ruby 3.4, but the parsers always do.

Parsing Time.parse("05/03/2026") or Date.parse("05/03/2026")

They read day/month, silently. Use strptime with an explicit format.

Relying on TZ of the host

Write the offset in the data, or pass an explicit zone. TZ differs between laptops, CI and containers.

Treating Time.new(2026, 2, 30) as an error

It rolls over to 2 March. Check with Date.valid_date? first, or use Date.new, which raises Date::Error.

Storing a Float of epoch seconds

Use Integer seconds (or Rational/nanoseconds). A Float has about 15-16 significant digits and loses nanoseconds; Time.at(1_772_703_015.123456789).nsec is 123456716.

Assuming a Time is a calendar day

Compare days with Date (or call to_date on both sides). A Time is never == to a Date.

Changing ENV["TZ"] to format one value

Use getlocal(offset) or a zone object. ENV["TZ"] is process-wide and not thread-safe in effect.

Using DateTime

Use Time. See the replacement table above.

Version notes

Ruby Change

3.1

Time.new, Time.at and Time.now accept in: for the zone. Time.new converts string components to integers more strictly. %-z renders -0000 for unknown offsets.

3.2

Time.new(string) parses Time#inspect and ISO 8601 style strings. Time#deconstruct_keys, Date#deconstruct_keys and DateTime#deconstruct_keys support pattern matching.

3.3

Time.new(string) is stricter: a string without a time part, like "2023-12-20", raises ArgumentError ("no time information").

3.4

Time#xmlschema and Time#iso8601 are in core (they were provided by the time library). Range#step accepts non-numeric begins such as Time, using +. On Windows Time#zone encodes the system zone name in UTF-8.

4.0

date 3.5.1 and time 0.4.2 are the default gems. The 4.0 NEWS lists no change to the core Time class.

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

DateTime in examples and models

Time (and Date for days). DateTime is documented as deprecated.

Time.parse and Time.iso8601 shown without the require

They need require "time". Time#iso8601 does not need it from Ruby 3.4.

Time.new("2023-12-20") to parse a date

ArgumentError since Ruby 3.3. Use Date.iso8601, Time.strptime, or give a time part.

Timing with Time.now - start

Process.clock_gettime(Process::CLOCK_MONOTONIC).

Stubbing the clock with global stubs in every test

Inject a clock; use a stubbing gem only for legacy code.

Switching zones by assigning ENV["TZ"]

Pass the zone with in: (Ruby 3.1) or a timezone object, without touching the process environment.

Date.parse on untrusted input

It limits input to 128 characters since date 3.2.1 and still guesses; prefer strptime or iso8601.