LaunchKit
← All posts
· 19 min read · by The LaunchKit team · 0 views

Rails time zones

Every Rails process runs on three clocks and they do not have to agree. Most applications never notice, because the default configuration quietly points two of them at UTC and nobody in the team lives in a zone where the third one matters. The trouble arrives on the day somebody renders a date, or schedules a nightly job, or parses a timestamp that came in over HTTP, and the answer is off by one hour or one calendar day in a way no test reproduces.

Everything below was run against activesupport 8.1.3.1, tzinfo 2.0.6, Ruby 4.0.5 and PostgreSQL 17.7, on a laptop whose system zone is Europe/Paris. The outputs are copied out of bin/rails runner and psql.

Three clocks, and only one of them is configured in Ruby

Ask a Rails console which time it is and three different subsystems have an opinion. Here are all three, printed by one bin/rails runner script:

c = ActiveRecord::Base.lease_connection
puts "ActiveRecord.default_timezone = #{ActiveRecord.default_timezone.inspect}"
puts "Rails config.time_zone = #{Rails.application.config.time_zone.inspect}"
puts "Time.zone = #{Time.zone.name}"
puts "ENV['TZ'] = #{ENV['TZ'].inspect}  Time.now.zone = #{Time.now.zone}"
puts "pg session timezone = #{c.select_value('show timezone').inspect}"
puts "SolidQueue.time_zone = #{SolidQueue.time_zone.inspect}"
ActiveRecord.default_timezone = :utc
Rails config.time_zone = "UTC"
Time.zone = UTC
ENV['TZ'] = nil  Time.now.zone = CEST
pg session timezone = "UTC"
SolidQueue.time_zone = "Etc/UTC"

The first clock is the operating system's. Time.now, Date.today and anything built on Time.local read it, and in a Docker image it is whatever the base layer shipped rather than anything in your repository.

The second is Time.zone, set from config.time_zone, which the Rails guides describe as setting "the default time zone for the application and enables time zone awareness for Active Record". In a generated config/application.rb the line is commented out, which is the case in both repositories behind this site, so the value is the framework default of "UTC".

The third is the database session. Active Record sets it on every connection, in postgresql_adapter.rb:

if default_timezone == :utc
  raw_execute("SET SESSION timezone TO 'UTC'", "SCHEMA")
else
  raw_execute("SET SESSION timezone TO DEFAULT", "SCHEMA")
end

Note that default_timezone is a separate setting from config.time_zone. The guides define it as "whether to use Time.local (if set to :local) or Time.utc (if set to :utc) when pulling dates and times from the database. The default is :utc." Change config.time_zone to Paris and the database session stays on UTC, which is the arrangement you want and not the one the name suggests.

What Time.now is actually wrong about

Time.now gets blamed for a bug it does not have. Writing one through Active Record is safe. In a process with Time.zone = UTC and a system clock on CEST, both of these wrote the same UTC instant:

Time.now      = 2026-09-24 16:24:13.61114 +0200
Time.zone.now = 2026-09-24 14:24:13.611142000 UTC +00:00

raw SQL view:
  {"name" => "via Time.now",      "at" => 2026-09-24 14:24:13.61114 UTC}
  {"name" => "via Time.zone.now", "at" => 2026-09-24 14:24:13.611142 UTC}

The instant is right either way, because a Ruby Time carries its offset and the adapter converts. What Time.now is wrong about is everything you compute from it. Every derived value is computed in the operating system's zone, not the application's:

Time.now.hour                  = 16
Time.zone.now.hour             = 14
Time.now.beginning_of_day      = 2026-09-24 00:00:00 +0200
Time.zone.now.beginning_of_day = 2026-09-24 00:00:00.000000000 UTC +00:00
Time.now.strftime('%H:%M %Z')      = 16:24 CEST
Time.zone.now.strftime('%H:%M %Z') = 14:24 UTC

Date.today has the same property, and the day it bites is the day the two clocks are on different dates. With Time.zone = "Pacific/Kiritimati" and the same CEST machine:

Date.today      = 2026-09-24
Time.zone.today = 2026-09-25

So the rule is narrower than "never call Time.now" and easier to follow. Reach for Time.zone.now, Time.current, Time.zone.today and Date.current whenever the value will be formatted, truncated to a day, compared against a calendar date, or shown to a human. Time.now is fine for measuring how long something took, which is the one job where the zone is irrelevant, and Process.clock_gettime is better at that anyway.

Time.zone.parse and Time.parse read the same string differently

Parsing is where the two APIs actually diverge, and the divergence is in two places at once. Given a naive string with no offset in it, Time.parse assumes the system zone and Time.zone.parse assumes the application zone:

input string: "2026-09-24 09:30:00"
Time.parse       -> 2026-09-24 09:30:00 +0200  utc=2026-09-24 07:30:00 UTC
Time.zone.parse  -> 2026-09-24 09:30:00.000000000 UTC +00:00  utc=2026-09-24 09:30:00 UTC

Two hours apart from one string, decided by an environment variable. Given a string that carries its own offset both agree on the instant, and only the zone they display it in differs.

The second difference is failure. Time.parse raises on junk; Time.zone.parse hands back nil:

Time.zone.parse("hello") -> nil
Time.parse("hello")      -> ArgumentError: no time information in "hello"
Time.zone.parse("")      -> nil
Time.zone.parse(nil)     -> TypeError: no implicit conversion of nil into String
Time.zone.parse("2026-13-45") -> ArgumentError: argument out of range

The nil is deliberate and lives in parts_to_time, the private method every TimeZone parser funnels through:

def parts_to_time(parts, now)
  raise ArgumentError, "invalid date" if parts.nil?
  return if parts.empty?

An unparseable string produces empty parts, empty parts return early, and the caller gets nil with nothing logged. The LaunchKit boilerplate hits this in its admin template history, where prompt snapshots are stored as JSON with an iso8601 string inside, the same pattern jsonb columns in Rails is about, and the view reads (time_ago_in_words(Time.zone.parse(snapshot["saved_at"])) + " ago" rescue ""). The rescue catches the NoMethodError that the nil causes one call later, which works, and which is also why a corrupted snapshot renders as an empty span instead of an error anyone notices.

Storing UTC, and the columns that opt out of it

Rails stores UTC and converts on the way out. A datetime column in a Rails 8.1 PostgreSQL schema is timestamp without time zone, holding an instant already normalised to UTC, and reading the attribute back gives an ActiveSupport::TimeWithZone in the current Time.zone whatever the row contains.

Which types get that treatment is a class attribute, and in a booted PostgreSQL application it reads:

time_zone_aware_attributes = true
time_zone_aware_types      = [:datetime, :time, :timestamptz, :timestamptz]

:date is absent from that list, and the absence is the interesting part. Assigning the same string to a date and a datetime column with Time.zone = "Pacific/Auckland":

  renews_on -> Thu, 24 Sep 2026 (Date)
  renews_at -> 2026-09-24 00:00:00.000000000 NZST +12:00 (ActiveSupport::TimeWithZone)
  stored: {"renews_on" => Thu, 24 Sep 2026, "renews_at" => 2026-09-23 12:00:00 UTC}

A date is a calendar day and means the same thing to everyone, which is correct for a birthday and wrong for a renewal deadline that expires at a particular moment. Pick the column type by asking whether the value has an instant behind it.

The duplicated :timestamptz is a real thing rather than a typo, from active_record.postgresql_time_zone_aware_types in railtie.rb, which appends with << inside two nested on_load hooks and therefore runs twice. Harmless, and a good reminder that the attribute is a plain array you can inspect.

One more property of the write path, which is quiet: casting an unparseable string to a datetime attribute rescues the ArgumentError and stores nil. TzSub.new(renews_at: "not a time") left renews_at as nil and the record still answered valid? true. A presence validation catches it; a format validation on the raw params catches it earlier.

The query that quietly returns yesterday evening

Comparing a datetime column against a bare Date is the time zone bug that survives review, because the line reads correctly in English and the SQL it builds does not. Two orders, one placed at 21:00 New York time yesterday and one at 09:00 New York time today, with Time.zone set to America/New_York:

  stored: yesterday 21:00 New York -> 2026-09-24 01:00:00 UTC
  stored: today 09:00 New York     -> 2026-09-24 13:00:00 UTC

TzOrder.where("created_at >= ?", Time.zone.today).count = 2
    returned: yesterday 21:00 New York
    returned: today 09:00 New York

TzOrder.where(created_at: Time.zone.today.all_day).count = 1
    returned: today 09:00 New York

The first query is wrong by four hours of orders. Time.zone.today is a Date, a Date binds as '2026-09-24', and PostgreSQL compares that against a timestamp column as midnight UTC, which is 20:00 the previous evening in New York. Every order between 20:00 and midnight yesterday counts as today's.

Date#all_day is the fix, and it is zone aware in both spellings. ActiveSupport patches it to build its bounds through Time.zone, so Date.today.all_day and Time.zone.today.all_day both produced 2026-09-24 00:00:00 EDT -04:00..2026-09-24 23:59:59.999999999 EDT -04:00 and the same BETWEEN '2026-09-24 04:00:00' AND '2026-09-25 03:59:59.999999'. Date.today is still the wrong starting point, for the reason in the section above, but the range it builds is correct for whatever day it names.

The habit worth forming is that a date never goes into a where against a datetime column. Convert it to a range first, and let the range carry the zone.

Two broken hours a year

Daylight saving does two structurally different things to a local clock, and a zone that observes it gets both every year. On 2026-03-08 in America/New_York the local times from 02:00:00 to 02:59:59 do not exist. On 2026-11-01 the local times from 01:00:00 to 01:59:59 happen twice, once at -04:00 and once at -05:00.

ActiveSupport resolves both without telling you:

zone.parse(2026-03-08 01:59:59) -> 2026-03-08 01:59:59 EST -05:00   utc=2026-03-08 06:59:59 UTC
zone.parse(2026-03-08 02:00:00) -> 2026-03-08 03:00:00 EDT -04:00   utc=2026-03-08 07:00:00 UTC
zone.parse(2026-03-08 02:30:00) -> 2026-03-08 03:30:00 EDT -04:00   utc=2026-03-08 07:30:00 UTC
zone.parse(2026-03-08 03:00:00) -> 2026-03-08 03:00:00 EDT -04:00   utc=2026-03-08 07:00:00 UTC

Two different input strings, 02:00:00 and 03:00:00, produced the same instant. The mechanism is a rescue and a retry in TimeWithZone, with the comment left in:

rescue ::TZInfo::PeriodNotFound
  # time is in the "spring forward" hour gap, so we're moving the time forward one hour and trying again
  @time += 1.hour
  retry
end

The layer underneath does raise. TZInfo::Timezone#period_for_local answers the gap with TZInfo::PeriodNotFound: 2026-03-08 02:30:00 is an invalid local time. and the ambiguous hour with TZInfo::AmbiguousTime: 2026-11-01 01:30:00 is an ambiguous local time. ActiveSupport calls it as period_for_local(time, dst) { |periods| periods.last }, so ambiguity silently resolves to the daylight saving reading and the gap silently shifts forward.

Both choices are defensible for rendering a form, and neither is defensible for a cron expression that has to fire exactly once. If your application accepts a wall clock time from a user in their own zone, 2:30 AM on one Sunday a year is a value you cannot honour, and saying so in a validation beats storing 03:30 and hoping nobody compares it to what they typed.

The recurring job that skips a day

Solid Queue schedules recurring work in a time zone, and the zone comes from your application config. SolidQueue::Engine sets SolidQueue.time_zone = app.config.time_zone, RecurringTask appends it to the parsed cron when the schedule does not name one, and Fugit does the arithmetic. Set that zone to somewhere with daylight saving and a daily 2:30am task looks like this:

SolidQueue.time_zone = "America/New_York"
t = SolidQueue::RecurringTask.new(key: "prune", command: "1", schedule: "every day at 2:30am", static: true)
sched = t.send(:parsed_schedule_with_time_zone)
cron: "30 2 * * * America/New_York" zone="America/New_York"
  run_at (UTC, what solid_queue stores) 2026-03-06 07:30:00   local 2026-03-06 02:30:00 EST
  run_at (UTC, what solid_queue stores) 2026-03-07 07:30:00   local 2026-03-07 02:30:00 EST
  run_at (UTC, what solid_queue stores) 2026-03-09 06:30:00   local 2026-03-09 02:30:00 EDT
  run_at (UTC, what solid_queue stores) 2026-03-10 06:30:00   local 2026-03-10 02:30:00 EDT

2026-03-08 is missing. The daily job runs at 07:30 UTC on the seventh and at 06:30 UTC on the ninth, 47 hours apart, and nothing raises, nothing logs, and the dashboard shows two successful runs on either side of a date with none. A nightly report is simply absent for one day a year, and the person who notices is a customer.

Fugit handles the other direction better than folklore suggests. Walking 30 1 * * * America/New_York across 2026-11-01 produced exactly one run, at 01:30 EDT, not two. The duplicated hour does not duplicate the job.

The defence costs one line. Leave config.time_zone at UTC, or name UTC in the schedule, and the cron arithmetic never meets a transition. This site does that by accident rather than by decision: config/recurring.yml declares prune_analytics with schedule: every day at 4am, config.time_zone is commented out, and SolidQueue.time_zone therefore reads "Etc/UTC". The job runs at 04:00 UTC every day of the year, which is 05:00 or 06:00 in Paris depending on the season, and for pruning analytics rows nobody cares. For a job whose whole point is to fire before the local business day, UTC is the wrong answer and you have to take the transition seriously instead. The broader tradeoffs of that scheduler are in Solid Queue vs Sidekiq.

One day is not twenty four hours

Duration arithmetic on a TimeWithZone is calendar arithmetic, and calendar arithmetic is not addition. Starting from noon on 2026-03-07 in America/New_York:

start           2026-03-07 12:00:00 EST -05:00  (utc 2026-03-07 17:00:00 UTC)
t + 1.day       2026-03-08 12:00:00 EDT -04:00  (utc 2026-03-08 16:00:00 UTC)
t + 24.hours    2026-03-08 13:00:00 EDT -04:00  (utc 2026-03-08 17:00:00 UTC)
1.day == 24.hours ? true
(t+1.day) - (t+24.hours) = -3600.0 seconds

1.day == 24.hours is true and the two land an hour apart. 1.day is a variable length duration that preserves the wall clock reading; 24.hours is 86400 seconds. Which one you want depends on the sentence you are writing. "The trial expires tomorrow at the same time" is 1.day. "The token is good for 24 hours" is 24.hours. Writing one and meaning the other is a bug that only appears twice a year and only for customers in a DST zone.

The same arithmetic makes days unequal:

midnight to midnight on the spring-forward day = 82800.0 seconds (23.0 h)
midnight to midnight on the fall-back day      = 90000.0 seconds (25.0 h)

Any code that divides an elapsed span by 86400 to get a number of days is wrong on those two dates. Any code that adds n * 86400 to build a series of daily timestamps drifts by an hour after the first transition it crosses. Build the series with 1.day and the wall clock stays put, which is almost always what a series of daily timestamps is for.

time_zone_select hands you a name Postgres has never heard of

time_zone_select is the reason so many applications have a time_zone column full of strings that nothing outside Ruby can read. The helper renders ActiveSupport::TimeZone.all by default:

<option value="International Date Line West">(GMT-12:00) International Date Line West</option>
<option value="American Samoa">(GMT-11:00) American Samoa</option>
<option value="Midway Island">(GMT-11:00) Midway Island</option>
... 152 options

The value submitted is the Rails name, not the IANA identifier. ActiveSupport::TimeZone::MAPPING has 152 entries, translating "Paris" to "Europe/Paris", "Bern" to "Europe/Zurich" and "Kyiv" to "Europe/Kiev", which is the retired spelling of that identifier. TZInfo knows 598 identifiers and pg_timezone_names has the same 598 rows, so the select covers about a quarter of the world's zones and names them in a dialect only ActiveSupport speaks.

Hand one of those strings to the database and you get this:

select timestamp '2026-09-24 14:00:00' at time zone 'Europe/Paris';
       with_iana
------------------------
 2026-09-24 12:00:00+00

select timestamp '2026-09-24 14:00:00' at time zone 'Paris';
ERROR:  time zone "Paris" not recognized

Same for a JavaScript client calling Intl.DateTimeFormat, and same for any other service reading that column. Store the IANA identifier. ActiveSupport::TimeZone[] accepts one directly, including zones outside the mapping, so nothing in Ruby breaks:

Time.zone = 'Europe/Sofia' -> Europe/Sofia, now 2026-09-24 17:24:51 +0300
Time.zone = 'Mars/Olympus' -> ArgumentError: Invalid Timezone: Mars/Olympus

time_zone_select takes a :model option, documented as defaulting to ActiveSupport::TimeZone, and passing TZInfo::Timezone instead gives you the whole database with identifiers as values:

<option value="Africa/Abidjan">Africa - Abidjan</option>
<option value="Africa/Accra">Africa - Accra</option>
... 598 options

The prettier labels are worth something, and they are not worth a column your own infrastructure cannot interpret.

Giving one reader their own clock

A per-user zone is set for the duration of a request and never assigned globally, because Time.zone= writes to ActiveSupport::IsolatedExecutionState and a Puma worker serves many users on the same threads. The documentation on Time.use_zone shows the shape, and the shape is an around_action:

around_action :set_time_zone

private
  def set_time_zone(&block)
    Time.use_zone(Current.user&.time_zone || "UTC", &block)
  end

Active Job carries that across the enqueue boundary, which is easy to miss. ActiveJob::Core#initialize captures @timezone = Time.zone&.name and serialises it into the job payload, and ActiveJob::ExecutionState#perform_now wraps the work in Time.use_zone(timezone) { super }. So a job enqueued from inside the block above runs in the user's zone hours later:

in a request with Time.use_zone('America/Los_Angeles'):
  serialize['timezone'] = "America/Los_Angeles"
  inside perform: Time.zone = America/Los_Angeles, Time.zone.now = 07:27 PDT

a job enqueued with no request around it:
  inside perform: Time.zone = UTC, Time.zone.now = 14:27 UTC

Useful for a mailer rendering "sent at", which is the case Action Mailer in production covers, and a trap for a job that computes a date boundary and was enqueued from a request. A nightly aggregation job enqueued by a recurring task gets the application default; the same job enqueued by a customer clicking a button gets that customer's zone and aggregates a different set of rows.

The alternative to storing a zone at all is rendering in the browser. Basecamp's local_time gem emits a <time> element and formats it client side, which keeps the HTML cacheable because every reader gets the same markup. Version 3.0.3 is from 2025-03-13, MIT, and the repository is active with commits through July 2026. If you have no other use for a user's zone, that is less machinery than a column, a select and an around_action.

My own tz database is four days out of date

Zone rules are not code, they are data, and the data is a dependency that goes stale without a version bump in your lockfile. TZInfo 2.0.6 on a Mac or a Linux container reads the operating system's files by default:

data source = #<TZInfo::DataSources::ZoneinfoDataSource: /usr/share/zoneinfo>
system zoneinfo version file: 2026b

Morocco moved to permanent UTC at 02:00 on 2026-09-20, four days before this post. The IANA release notes for tzdata 2026c say "Morocco plans to move back to permanent UTC, without daylight saving time transitions, on 2026-09-20 at 02:00. This also affects Western Sahara." My system files are 2026b. Same process, same Ruby, the two data sources side by side:

# /usr/share/zoneinfo, tzdb 2026b
Africa/Casablanca: offset now = 3600 (+01) dst=true

# tzinfo-data 1.2026.4, tzdb 2026d
Africa/Casablanca: offset now = 0 (+00) dst=false

An application on this laptop is one hour wrong about Casablanca right now, and would be one hour wrong about Edmonton from 2026-11-01, since 2026b still schedules a transition that Alberta abolished in 2026c. Nothing raises. The zone exists, the lookup succeeds, the answer is stale.

The generated Rails Gemfile line is gem "tzinfo-data", platforms: %i[ windows jruby ], which both repositories behind this site carry unchanged. On Linux and macOS that gem is not installed, so the answer comes from the operating system: /usr/share/zoneinfo on the laptop above, and the base image's copy in production. A Docker deploy from ruby:slim gets whatever tzdata Debian shipped when the layer was built and keeps it until the image is rebuilt, which for a stable application can be months. Removing the platforms: restriction pins the data in Gemfile.lock instead, at the cost of owning another dependency to bump. IANA shipped four releases in the first nine months of 2026, so the bump is not annual.

The call, and what would change it

Leave config.time_zone at UTC and store IANA identifiers per user. UTC everywhere is the only configuration where a recurring job cannot skip a day, where a log line means the same thing to every engineer reading it, and where the database session and the Ruby process cannot drift apart. Convert at the edges: Time.use_zone in an around_action for rendering, and the user's own zone when you build a day boundary for a report.

Never call Time.now, Date.today or Time.parse on a value a human will see. The three of them read an environment variable your application does not control, and the difference between a laptop and a container is exactly the difference between a passing test and a wrong date in production. This site's own Atom feed proves the point against me: app/views/yield/feed.atom.erb renders article.published_on.to_time.utc.iso8601, and Date#to_time defaults to the system zone, so an article published on 2026-09-24 serialises as 2026-09-23T22:00:00Z when the process runs in CEST and 2026-09-24T00:00:00Z in a UTC container. Same template, same row, different day. published_on.in_time_zone.utc.iso8601 is the spelling that does not care.

What would change the recommendation: a config.time_zone set to a real zone is right when the business genuinely has one clock, a single-country payroll or booking system where "the end of the day" is a legal deadline in one place. Take the daylight saving transitions seriously in that case rather than hoping, because the scheduler will not.

The cost of the position is honest to state: UTC everywhere means every timestamp a developer reads in a log or a console is in a zone nobody lives in, and every support conversation starts with mental arithmetic. That is a real tax, paid daily, to avoid a bug that arrives twice a year.

What this post does not cover

The LaunchKit boilerplate has no per-user time zone. The users table carries no time_zone column, config.time_zone is commented out, and every timestamp in it goes through Time.current, with Time.zone.at(unix) converting Stripe's epoch seconds in the subscription webhook. Nothing in it calls Time.now or Date.today. So the product is the UTC-everywhere configuration recommended above, which is the honest provenance for a post that would otherwise be selling you a feature.

Also absent: timestamptz as a column type, which PostgreSQL stores identically to timestamp and differs from it only in how literals are interpreted on the way in and out; tstzrange and the exclusion constraints that make booking systems work; leap seconds, which live in the "right" TZif files that tzdata 2026a stopped installing by default and that Ruby has no way to represent anyway; Time.zone.rfc3339, which is stricter than parse and raises ArgumentError: invalid date rather than guessing at a missing component; and MySQL, whose zone tables have to be loaded separately and whose CONVERT_TZ returns NULL when they are not.

#rails #active-support

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.