Skip to content
Joey Wang
Menu

Search

Full Stack Development

Timezone Bugs in Ruby: BST vs UTC in CI and Cron

How British Summer Time breaks Ruby tests, cron schedules, and cloud functions that assume UTC, and how to write timezone-aware specs instead.

· 4 min read

railsengineering #ruby#rails#testing#ci

Audio summary

Twice a year, engineers across the UK hit mysterious failing tests, flaky job schedules, or missed cron jobs. The cause is British Summer Time: the daylight saving switch that breaks systems assuming UTC, or relying on local time inconsistently.


The problem

Say you have a job scheduled for 2:00am BST. The clock moves forward an hour in March, back in October. Now consider:

# schedule_job.rb
require 'time'

def schedule_job
  now = Time.now
  puts "Scheduling job at: #{now}"
end

schedule_job

On a machine set to UK local time, this returns BST in summer and GMT in winter. But CI runners are often set to UTC, cloud functions trigger in UTC unless configured otherwise, and comparing Time.now against Time.utc or a parsed literal like Time.parse("2024-04-01 02:00:00") can produce surprises in any of those contexts.


CI timezone pitfall

A test like this:

describe "ScheduledJob" do
  it "schedules at 2 AM" do
    scheduled_time = Time.parse("2024-04-01 02:00:00")
    expect(scheduled_time.hour).to eq(2)
  end
end

passes locally and fails in CI, because the test implicitly assumes local time while the runner is on UTC. Use an explicit zone instead:

Time.use_zone("London") do
  scheduled_time = Time.zone.parse("2024-04-01 02:00")
  expect(scheduled_time.hour).to eq(2)
end

Specify a time zone in the test, not just in production code.


Cloud schedulers default to UTC

Google Cloud Scheduler and AWS EventBridge run on UTC by default. A cron string like "0 2 * * *" runs at 2am UTC, which is 3am BST in summer, not the 2am BST you meant. Define schedules in UTC deliberately, and convert explicitly when the business logic is in local time:

Time.use_zone("London") do
  local_time = Time.zone.local(2024, 4, 1, 2)
  utc_time = local_time.utc
  puts "2am BST is #{utc_time} UTC"
end

Business logic vs. system logic

The harder question: does “11 PM” mean the wall clock in London, or a fixed instant in absolute time? You need to decide before you write the code, because the two cases store different things.

Local time that should survive DST shifts (“the deadline is 11 PM London time, whatever the clock does”):

Time.use_zone("London") do
  deadline = Time.zone.local(2024, 4, 1, 23, 0) # 11 PM London time
  puts "Deadline in UTC: #{deadline.utc}"
end

A fixed instant (“this deadline is always 22:00 UTC, full stop”):

deadline = Time.utc(2024, 4, 1, 22, 0)

For users outside the UK, store everything in UTC, present it in the user’s local timezone, and say which one you mean in the message itself (“Deadline is 11 PM London time (BST)”).


Testing DST edge cases in Ruby

DST creates two transitions a year that don’t behave like normal time arithmetic:

  • Spring forward (March): 2:00–2:59am doesn’t exist.
  • Fall back (October): 1:00–1:59am happens twice.

Example 1: spring forward, a nonexistent time

Time.use_zone("London") do
  nonexistent = Time.zone.parse("2024-03-31 02:30")
  puts nonexistent # => Sun, 31 Mar 2024 01:30:00 GMT +00:00 (silently rolled back)
end

Fail loudly instead of silently rolling back:

Time.use_zone("London") do
  begin
    ts = Time.zone.local(2024, 3, 31, 2, 30)
    puts ts
  rescue => e
    puts "Invalid time: #{e.message}"
  end
end

Example 2: fall back, an ambiguous time

Time.use_zone("London") do
  ambiguous = Time.zone.parse("2024-10-27 01:30")
  puts ambiguous # => which one, pre-DST or post-DST?
end

Disambiguate explicitly:

zone = ActiveSupport::TimeZone["London"]
first_1_30 = zone.local(2024, 10, 27, 1, 30, 0, true)  # BST
second_1_30 = zone.local(2024, 10, 27, 1, 30, 0, false) # GMT

How to test for DST in your app

  • Use Timecop or travel_to for time-sensitive behavior:
travel_to Time.find_zone("London").local(2024, 3, 31, 2, 30) do
  # test code here
end
  • Validate user input during DST transitions instead of trusting it.
  • Log DST-transition hours specifically so anomalies surface.
  • Confirm cron, CI, and test runners agree on which timezone they’re operating in.

Hard-coded vs. dynamic datetimes in specs

Specs that hard-code a value like this are common:

it "sets the deadline correctly" do
  expect(my_deadline).to eq(Time.parse("2024-04-01 23:00:00"))
end

Pros of hard-coded datetimes

Deterministic, easy to reason about, stable across runs.

Cons of hard-coded datetimes

Ambiguous without an explicit timezone, doesn’t exercise DST transitions, can shift meaning silently depending on system timezone, and goes stale as years pass.

Pros of dynamic datetimes (Time.zone.today, 1.day.from_now)

Closer to real usage, easier to express relative behavior (“next Sunday at 1:30 AM”), no stale hard-coded dates.

Cons of dynamic datetimes

Tests can fail as time passes, harder to debug, and DST shifts get missed unless you test for them deliberately.

Combining both intentionally

Use timezone-aware hard-coded values for stable, deterministic tests:

Time.use_zone("London") do
  time = Time.zone.parse("2024-10-27 01:30")
  expect(job.run_at).to eq(time)
end

Use dynamic time only for relative behavior:

it "sends reminder 1 day before deadline" do
  deadline = 3.days.from_now
  reminder = 1.day.before(deadline)
  expect(send_reminder_at).to eq(reminder)
end

And write explicit DST edge case tests with fixed problem dates:

describe "DST transition" do
  it "handles missing hour during spring forward" do
    time = Time.find_zone("London").local(2024, 3, 31, 2, 30)
    expect(time).not_to eq(nil) # or test fallback behavior
  end
end

The takeaway

BST breaks code, tests, and jobs in ways that are subtle precisely because they only show up twice a year. The real fix isn’t a library, it’s deciding upfront whether your business logic wants a wall-clock time or a fixed instant, storing accordingly, and writing at least one test that covers the DST transition itself instead of assuming the calendar will behave.