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
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
Timecoportravel_tofor 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.