skip to content

In Ruby, how do Time#utc and Time#localtime differ from getutc and getlocal, and what decides the local zone?

level: middleimportance: should knowfreq 38%

answer

  1. same instant, different display
  2. one mutates, one copies
  3. FrozenError on a frozen Time
  4. TZ environment variable, process-wide
  5. in: keyword since 3.1

basics

~20 s

Time#utc and Time#localtime convert the receiver in place and return self; getutc and getlocal return a new Time and leave the original alone. The local zone comes from the operating system or the TZ environment variable, for the whole process.

solid answer

~40 s

All four keep the same instant and only change how it is displayed. `utc` (alias `gmtime`) and `localtime` **mutate the receiver** and return `self`, so every other reference to that object now shows the new zone, and on a frozen `Time` that needs converting they raise `FrozenError`. `getutc` (alias `getgm`) and `getlocal` return a **new** `Time`. `localtime("+09:00")` and `getlocal("+09:00")` accept a zone argument: an offset string, `"UTC"`, a military letter, an integer of seconds, or a timezone object. "Local" means the process's zone: the system setting, overridden by `ENV["TZ"]`, which is global to all threads. Since Ruby 3.1 you can skip the conversion step with `Time.now(in: "UTC")` or `Time.at(x, in: "+09:00")`. Prefer the non-mutating forms on shared objects.

code

ruby · 13 lines
ruby
t = Time.new(2026, 9, 30, 12, 0, 0, "+02:00")
copy = t.getutc          # new object
copy.equal?(t)           # => false
t.utc_offset             # => 7200, t unchanged

same = t.utc             # converts t itself
same.equal?(t)           # => true
t.utc_offset             # => 0
t == copy                # => true, same instant

f = Time.now.freeze
f.getutc                 # fine, returns a new Time
f.utc                    # raises FrozenError

go deeper

for a junior

Recall that utc and localtime change the object while getutc and getlocal return a new one, and that converting never changes the instant.

for a middle

Explain the zone modes, the accepted zone specifiers, the in: keyword from 3.1, and why frozen or shared Time objects need the get forms.

for a senior

Show the production view: TZ is process-global, named zones need a timezone object, fixed offsets ignore daylight saving, so normalise to UTC at the boundary.

for a principal

Argue for a team rule: no in-place zone conversion on shared values, one boundary normalisation, and a vetted timezone library instead of ad hoc offsets or TZ swaps.

## An instant and its display zone A Ruby **`Time`** holds an instant (seconds since the Unix epoch) and a **zone mode**: *local*, *UTC*, or a *fixed offset*. Converting between zones never changes the instant: `t.to_i`, `==` and `<=>` give the same results before and after. What changes is the wall-clock fields (`hour`, `day`, `zone`, `utc_offset`) and how `to_s`, `strftime` and `iso8601` render it. Ruby offers two families of conversion methods, and the difference between them is **mutation**. | Method | Receiver changed? | Returns | |---|---|---| | `utc` / `gmtime` | yes | `self`, now in UTC mode | | `localtime` | yes | `self`, now local or at the given offset | | `getutc` / `getgm` | no | a new `Time` in UTC | | `getlocal` | no | a new local or offset `Time` | ## Why mutation matters `utc` and `localtime` call an internal modify check and then rewrite the object's zone fields. Consequences: 1. **Aliasing surprises.** If a `Time` is stored in a hash, an instance variable or a constant, `record[:starts_at].utc` changes what every other holder of that object sees. 2. **Frozen objects fail.** On a frozen `Time`, `utc` raises `FrozenError` (unless it is already in that mode), while `getutc` works because it builds a new object. 3. **Return value.** Both return `self`, so `t.utc.equal?(t)` is `true`; chaining hides the mutation. In shared code, prefer `getutc` and `getlocal`. Use `utc` on a `Time` you just created and own, such as `Time.now.utc`. ## Zone arguments `localtime`, `getlocal`, `Time.now(in:)`, `Time.at(..., in:)` and `Time.new(..., in:)` accept the same **timezone specifiers**: - an hours/minutes offset string such as `"+09:00"` or `"-05:00"`; - `"UTC"`, or the single military letters `"A"`..`"I"` and `"K"`..`"Z"` (`"Z"` is UTC); - an integer number of seconds between `-86399` and `86399`; - a **timezone object** that responds to `local_to_utc` and `utc_to_local`, such as those from a timezone gem. A name like `"Europe/Berlin"` is **not** understood by core `Time` on its own: without a `Time.find_timezone` hook it raises `ArgumentError` (`"+HH:MM", "-HH:MM", "UTC" or "A".."I","K".."Z" expected for utc_offset`). A fixed offset such as `"+01:00"` also never follows daylight-saving changes, which matters for anything scheduled in the future. ## What decides "local" "Local" is the zone of the **process**, not of a user or a request: - By default the C library reads the system zone setting. - The `TZ` environment variable overrides it. Assigning `ENV["TZ"] = "Asia/Tokyo"` makes Ruby re-read the zone before the next local conversion. - Because `ENV` is process-global, changing `TZ` affects every thread, which is why you never swap it per request. - A `Time` created with `Time.now` is local even when `TZ` is `"UTC"`: `utc?` is `true` only for times made with `Time.utc`, `Time#utc` or `Time#getutc`. ## Reading what a Time displays Three readers tell you which zone a `Time` is in: - `utc?` (alias `gmt?`) is `true` only in UTC mode. - `utc_offset` (aliases `gmt_offset`, `gmtoff`) is the offset in seconds, such as `7200` for `+02:00`. - `zone` returns the zone name for local and UTC times, such as `"UTC"` or a platform-dependent local name, or the timezone object when one was used. They are useful in tests and logs: asserting `utc_offset` catches a value that silently stayed local. They are *display* properties, so two `Time` objects with different offsets can still be `==`. ## Picking the zone up front Since **Ruby 3.1**, `Time.now`, `Time.at` and `Time.new` take `in:`, so you rarely need a conversion step: ```ruby Time.now(in: "UTC") # current instant, UTC mode Time.at(1_780_000_000, in: "+09:00") Time.new(2026, 12, 25, in: "+07:00") ``` In `Time.new` the `in:` value is only a default: if a string argument already carries an offset, `in:` is ignored. ## Rules of thumb - Compare and store instants; the display zone is presentation. - Use `getutc` or `getlocal` on objects you do not own. - Normalise to UTC at the boundary with `in: "UTC"` or `getutc`. - Never change `ENV["TZ"]` to serve one user.

  • In Ruby, does t.utc == t.getlocal ever return false for the same Time t?
    No. `==` compares instants, not display zones, so a UTC view and a local view of one instant are equal. Only the wall-clock fields (`hour`, `zone`, `utc_offset`) and the rendered strings differ. Note that `t.utc` has already converted `t` itself by the time the comparison runs.
  • In Ruby, is Time.now a UTC-mode time when the TZ environment variable is "UTC"?
    No. Its fields show UTC wall-clock values with offset 0, but it is still a local-mode time: `utc?` returns `false`, because only `Time.utc`, `Time#utc` and `Time#getutc` produce UTC mode. The difference is visible in `iso8601`, which prints `+00:00` for the local-mode value and `Z` after `getutc`.

saying these in an interview costs you the question

  • Time#utc returns a converted copy and leaves the original alone.
  • Converting a Time to UTC changes its to_i value.
  • Time.now(in: "Europe/Berlin") works in plain Ruby.
  • Setting ENV["TZ"] inside a request only affects that request.
  • A fixed offset like "+01:00" follows daylight saving automatically.