How to Work with Time Zones in Rust

Use the chrono crate to parse timestamps and convert between UTC and local time zones in Rust.

The story: your reminder app breaks reality

You're building a reminder app. A user in Tokyo sets a reminder for 9 AM. Your server runs in Frankfurt. You save the timestamp. When the alarm fires, it triggers at 2 AM Tokyo time. The user is furious. You stored the time without thinking about where "now" actually lives. Time zones aren't just formatting. They're logic. Get them wrong, and your app breaks reality.

Rust's standard library gives you std::time::SystemTime, which is great for measuring durations and monotonic clocks. It gives you almost nothing for calendar dates, time zones, or human-readable timestamps. The community standard is the chrono crate. It handles the math, the parsing, and the zone conversions so you don't have to reinvent the calendar.

Time zones are lenses, not values

The core mental model for chrono is simple: separate the instant in time from how you look at it. An instant is a point on the timeline. A time zone is just a lens.

Think of time like a movie reel. The reel has frames. Frame 42 is frame 42 everywhere. That's UTC. Time zones are just captions added to the frames. In New York, frame 42 says "10:00 AM". In London, frame 42 says "3:00 PM". The frame didn't change. The caption did.

chrono represents this with DateTime<Tz>. The DateTime holds the instant. The generic parameter Tz holds the zone. Utc is the universal reference zone. Local is the machine's current zone. FixedOffset is a zone defined by a static offset like +05:30. When you convert between zones, you keep the instant and swap the zone. The underlying moment in time stays identical.

The chrono toolkit

Add chrono to your Cargo.toml. The current stable version is 0.4.

[dependencies]
chrono = "0.4"

The main types you'll touch are:

  • DateTime<Tz>: A timestamp tied to a specific zone. This is your workhorse.
  • Utc: The UTC zone. Use this for storage and comparison.
  • Local: The system's local zone. Use this for display on the current machine.
  • NaiveDateTime: A date and time without a zone. This is dangerous. Read the section below before using it.
  • TimeZone: The trait that defines how zones work. Utc, Local, and custom zones all implement this.

Minimal example: switching lenses

Here's how you get the current time and convert it. The code shows the pattern of creating a DateTime and changing its zone.

use chrono::{DateTime, Utc, Local, TimeZone};

fn main() {
    // Get the current instant tagged with UTC.
    // This queries the OS and wraps the result in a DateTime<Utc>.
    let utc_now: DateTime<Utc> = Utc::now();
    
    // Get the current instant tagged with the system's local zone.
    // The instant is the same as utc_now, but the zone differs.
    let local_now: DateTime<Local> = Local::now();
    
    // Convert the UTC instant to local time.
    // with_timezone returns a new DateTime with the same instant but a different zone.
    let converted = utc_now.with_timezone(&Local);
    
    println!("UTC: {}", utc_now);
    println!("Local: {}", local_now);
    println!("Converted: {}", converted);
    
    // Verify the instants are identical.
    // assert_eq!(utc_now.timestamp(), converted.timestamp());
}

Utc::now() asks the operating system for the current timestamp. It wraps that in a DateTime<Utc>. Local::now() does the same but asks the OS for the local offset too. with_timezone takes the internal instant and reapplies the offset for the target zone. It returns a new DateTime. The underlying instant is identical. You're just changing the label.

Convention aside: with_timezone is the idiomatic way to convert. You'll see from_utc_datetime on the zone type in older code. with_timezone is preferred because it's a method on the DateTime, making the chain readable and explicit about what you're converting.

The naive trap: time without context

chrono has a type called NaiveDateTime. It holds a year, month, day, hour, minute, and second. It has no time zone. It's called "naive" because it assumes the time makes sense without context.

NaiveDateTime is useful for parsing strings that lack zone info, or for doing date arithmetic where the zone doesn't matter. It is dangerous for storage or comparison. If you have a NaiveDateTime of "2024-07-15T14:30:00", you have no idea if that's Tokyo time, New York time, or UTC. Comparing two naive times from different zones gives you nonsense.

If you try to use a NaiveDateTime where a DateTime is expected, the compiler rejects you with E0277 (trait bound not satisfied). The types are distinct for a reason.

use chrono::{DateTime, NaiveDateTime, Utc};

fn main() {
    // Parse a string without zone info.
    // This returns a NaiveDateTime.
    let naive: NaiveDateTime = "2024-07-15T14:30:00".parse().expect("Valid format");
    
    // You cannot compare this to a DateTime<Utc> directly.
    // This would fail with E0277 if you tried to pass naive to a function expecting DateTime<Utc>.
    
    // Attach a zone to make it safe.
    // and_utc assumes the naive time is in UTC.
    let utc_time: DateTime<Utc> = naive.and_utc();
    
    println!("Naive: {}", naive);
    println!("UTC: {}", utc_time);
}

Treat NaiveDateTime like a raw pointer. It works until it doesn't. Wrap it in a zone immediately, or drop it.

Real-world parsing and chrono-tz

Real code rarely just prints "now". You parse inputs from APIs, databases, or users. You often need specific zones like "America/New_York" rather than just "Local".

chrono supports FixedOffset for offsets like +09:00. For named zones with full DST rules, you need the companion crate chrono-tz. It provides the Tz type, which implements the TimeZone trait and includes the IANA timezone database.

Convention aside: Always use IANA names like America/New_York. Never use abbreviations like EST or PST. Abbreviations are ambiguous. EST could be Eastern Standard Time or Eastern Summer Time (Australia). IANA names are the gold standard and resolve ambiguity.

Add chrono-tz to your dependencies.

[dependencies]
chrono = "0.4"
chrono-tz = "0.9"

Here's how you parse a timestamp and work with a named zone.

use chrono::{DateTime, TimeZone, Utc};
use chrono_tz::Tz;

fn main() {
    // Parse an ISO 8601 string with an offset.
    // This returns a DateTime<FixedOffset>.
    let input = "2024-07-15T14:30:00+09:00";
    let parsed: DateTime<chrono::offset::FixedOffset> = input.parse().expect("Valid ISO 8601");
    
    // Convert to UTC for storage or comparison.
    let utc_time = parsed.with_timezone(&Utc);
    
    // Work with a named timezone.
    // Parse the IANA name into a Tz value.
    let ny_tz: Tz = "America/New_York".parse().expect("Valid IANA name");
    
    // Convert the UTC time to New York time.
    let ny_time = utc_time.with_timezone(&ny_tz);
    
    println!("Parsed: {}", parsed);
    println!("UTC: {}", utc_time);
    println!("New York: {}", ny_time);
}

chrono-tz builds a static map of zones at compile time. Lookups are fast. The Tz type is cheap to copy. You can parse the string once and reuse the zone.

Daylight Saving Time and the gaps

Time zones break when clocks jump. DST creates two problems: ambiguous times and non-existent times.

When clocks fall back, 1 AM happens twice. An ambiguous time. When clocks spring forward, 2 AM vanishes. A non-existent time. chrono handles this, but you need to know how.

with_timezone is safe. It just changes the view. The instant exists, so the zone can always calculate the offset. The danger is constructing a local time manually. If you use from_local_datetime on a zone, you might hit the gap.

use chrono::TimeZone;
use chrono_tz::Tz;

fn main() {
    let ny_tz: Tz = "America/New_York".parse().unwrap();
    
    // Try to construct a time during the spring-forward gap.
    // 2024-03-10 02:30:00 does not exist in New York.
    let result = ny_tz.with_ymd_and_hms(2024, 3, 10, 2, 30, 0);
    
    // result is a LocalResult.
    // It can be None, Single, or Ambiguous.
    match result {
        chrono::LocalResult::None => println!("Time does not exist"),
        chrono::LocalResult::Single(dt) => println!("Single: {}", dt),
        chrono::LocalResult::Ambiguous(early, late) => println!("Ambiguous: {} or {}", early, late),
    }
}

LocalResult forces you to handle the edge cases. If you ignore it, you get a compiler error. This is a feature. DST breaks linear time. Use LocalResult to handle the ambiguity, or your app will crash on the second Sunday in November.

Serialization and storage rules

When you store time in a database or send it over an API, you need a format. chrono implements serde's Serialize and Deserialize traits.

The rule is simple: store UTC. Display Local. Everything else is a bug waiting to happen.

If you store local time, you lose the offset info. Is "10:00" EST or EDT? You don't know. If the user changes their zone, or DST rules change, your data becomes corrupted. UTC is unambiguous. It survives zone changes, DST shifts, and user migrations.

use chrono::Utc;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Event {
    // Store as UTC. serde will serialize this as an ISO 8601 string with Z suffix.
    timestamp: chrono::DateTime<Utc>,
}

fn main() {
    let event = Event {
        timestamp: Utc::now(),
    };
    
    let json = serde_json::to_string(&event).unwrap();
    println!("{}", json);
    // Output: {"timestamp":"2024-07-15T14:30:00.123456789Z"}
}

Serialize UTC. Your future self will thank you when the schema changes.

Decision matrix: picking the right type

Time zone types are tools. Pick the one that matches the operation.

Use Utc when you need a universal reference point for storage, comparison, or serialization. Use Local when you are displaying time to a user on the current machine and trust the system clock configuration. Use FixedOffset when you parse timestamps that carry an explicit offset like +05:30 but don't need full DST rules. Use chrono-tz with IANA names when you need to work with specific regions like America/New_York and require correct historical and future DST transitions. Use DateTime<Utc> for database columns and API payloads. Use DateTime<Local> only for the final render step in a CLI or desktop app. Use NaiveDateTime only for intermediate parsing or arithmetic where the zone is irrelevant, and attach a zone before storing or comparing. Use LocalResult when constructing times from components in a zone that observes DST.

Pick the type that matches the intent. Storage gets UTC. Display gets Local. Logic gets IANA.

Where to go next