Handling timezones in Rails applications
In the Rails application, we may have two different timezones.
- Application timezone
- Database timezone
In most of the cases database timezone is set to UTC
, but application timezone could be different from database timezone.
How to set application timezone?
- Set timezone at the application level
We can set config.time_zone
configuration in config/application.rb
file.
ActiveRecord fetches UTC from the database and converts it to the timezone specified in config.time_zone
.
Refer this article in case you need help with setting up timezone at application level.
2. Set the timezone based on the one specified by the user
Take timezone from users in a form and save it in the users' table in the database.
create_table :users do |t|
t.string :time_zone, default: "UTC"
...
end
We can set timezone by using around_action
in ApplicationController
for logged-in users.
# app/controllers/application_controller.rb
around_action :set_time_zone, if: :current_user
private
def set_time_zone(&block)
Time.use_zone(current_user.time_zone, &block)
end
We pass the timezone of the current user to the use_zone
method and this sets the timezone for that block.
When the request gets completed, the original timezone is set back.
Check out this article for more on setting user specific timezone.
Let's take a look at the example below to understand how ActiveRecord
converts time into database timezone(assume UTC timezone) while querying on the database.
[1] pry(main)> Time.zone.name
=> "Eastern Time (US & Canada)"
[2] pry(main)> start_date = DateTime.new(2019, 10, 07).to_time_in_current_zone.beginning_of_day
=> Mon, 07 Oct 2019 00:00:00 EDT -04:00
[3] pry(main)> end_date = DateTime.new(2019, 10, 13).to_time_in_current_zone.end_of_day
=> Sun, 13 Oct 2019 23:59:59 EDT -04:00
[4] pry(main)> User.where(created_at: start_date..end_date).count
(0.7ms) SELECT COUNT(*) FROM `users` WHERE (`users`.`created_at` BETWEEN '2019-10-07 04:00:00' AND '2019-10-14 03:59:59')
=> 806
In the above example, the application timezone is set to EST
.
If you observe the Select
query generated by ActiveRecord
, you see that it converted the start_date
and end_date
into UTC format for EST timezone.
Here, in EST
timezone, the day starts at UTC 4 am on October 7th and ends at UTC 3:59 am on October 8th.
Timezone issue at the database level
Now, let's take an example where we want to group data by week(i.e. where the week starts on Monday and ends on Sunday). For the grouping of data, we can make use of the WEEK
function of MySQL.
[5] pry(main)> start_date
=> Mon, 07 Oct 2019 00:00:00 EDT -04:00
[6] pry(main)> end_date
=> Sun, 13 Oct 2019 23:59:59 EDT -04:00
[7] pry(main)> User.where(created_at: start_date..end_date).group("WEEK(created_at, 5)").count
(0.9ms) SELECT COUNT(*) AS count_all, WEEK(created_at, 5) AS week_created_at_5 FROM `users` WHERE (`users`.`created_at` BETWEEN '2019-10-07 04:00:00' AND '2019-10-14 03:59:59') GROUP BY WEEK(created_at, 5)
=> {40=>788, 41=>18}
Here, the date range that we passed is of one week where the week started on Mon, 07 Oct 2019
and ended on Sun, 13 Oct 2019
. We are fetching user records created in a week and grouping them by week.
As you can see in the result, we have got week_number => # users created in that week
. We were fetching results for one week only, but still, we receive count for 2 weeks i.e. week numbers 40 and 41.
Why did this happen?
The database is saving dates in UTC format. When WEEK
function gets applied, it considers dates in UTC format. In UTC timezone, week #40 ended on Sun, 13 Oct 2019 at 23:59:59 pm
, but as per EST timezone, week #40 ends on Mon, 14 Oct 2019 at 03:59:59 am
.
The WEEK
functions get applied at database level on dates in UTC format and it considers the record created between Sun, 13 Oct 2019 at 24:00:00 pm
to 14 Oct 2019 at 03:59:59 am
in week #41 instead of week #40.
Due to this, we get a count for 2 weeks even though we fetched data for a week only.
How to solve this issue?
To fix this issue, we need to consider timezone at the database level as well. MySQL provides CONVERT_TZ
function for converting dates from one timezone into another. CONVERT_TZ (date, from_tz, to_tz)
takes 3 arguments i.e. date
as 1st argument, from timezone
as 2nd argument and to timezone
as 3rd one.
In the rails application, we can create the below method for converting timezone at the database level.
UTC_OFFSET = '+00:00'
def mysql_datetime_in_current_tz(field: nil)
return '' if field.blank?
"CONVERT_TZ(#{field}, '#{UTC_OFFSET}', '#{Time.zone.formatted_offset}')"
end
mysql_datetime_in_current_tz(field: 'users.created_at') => "CONVERT_TZ(users.created_at, '+00:00', '-04:00')"
It takes field
name as an argument. It also considers the timezone set in the application and fetches formatted_offset
value. In case of an EST timezone, it will return -04:00
.
We can use this method for converting timezone as below:
[8] pry(main)> users_created_at = mysql_datetime_in_current_tz(field: 'users.created_at')
=> "CONVERT_TZ(users.created_at, '+00:00', '-04:00')"
[9] pry(main)> User.where(created_at: start_date..end_date).group("WEEK(#{users_created_at}, 5)").count
(1.4ms) SELECT COUNT(*) AS count_all, WEEK(CONVERT_TZ(created_at,'+00:00','-4:00'), 5) AS week_convert_tz_created_at_00_00_4_00_5 FROM `users` WHERE (`users`.`created_at` BETWEEN '2019-10-07 04:00:00' AND '2019-10-14 03:59:59') GROUP BY WEEK(CONVERT_TZ(created_at,'+00:00','-4:00'), 5)
=> {40=>806}
As you can see above, it has returned data only for the first week by considering the timezone of the logged-in user.
Hope this article will help you identify and deal with timezone issues encountered while using MySQL functions. Please do share it with your peers.
Thanks for reading!