How does chrono::TimeZone::from_local_datetime handle ambiguous local times during daylight saving transitions?
from_local_datetime returns a LocalResult enum that captures the three possible outcomes when converting a local date-time to a timezone-aware datetime: None when the local time doesn't exist (during spring forward), Ambiguous when the local time occurs twice (during fall back), and Single when the local time has a unique mapping. During daylight saving transitions, some local times either don't exist at all or exist twiceâfrom_local_datetime exposes these edge cases rather than silently picking one interpretation, forcing the caller to decide how to handle ambiguity. This design prevents silent bugs where a timestamp could be off by an hour without the developer realizing it.
The LocalResult Enum
use chrono::{TimeZone, LocalResult, Utc, FixedOffset, NaiveDateTime, NaiveDate, NaiveTime};
// from_local_datetime returns LocalResult<DateTime<Tz>>
fn local_result_variants() {
let offset = FixedOffset::east_opt(5 * 3600).unwrap(); // UTC+5
// Single: unique mapping
let result = offset.from_local_datetime(&NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
));
match result {
LocalResult::None => println!("No such local time"),
LocalResult::Single(dt) => println!("Unique time: {}", dt),
LocalResult::Ambiguous(earliest, latest) => {
println!("Ambiguous: {} or {}", earliest, latest);
}
}
}LocalResult<T> encodes the three possible outcomes of local-to-timezone conversion.
Single: Unambiguous Times
use chrono::{TimeZone, LocalResult, FixedOffset, NaiveDateTime, NaiveDate, NaiveTime};
fn unambiguous_times() {
// Most times have a unique mapping
let offset = FixedOffset::east_opt(8 * 3600).unwrap(); // UTC+8
let result = offset.from_local_datetime(&NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
));
// Fixed offsets don't have DST, so times are always unambiguous
assert!(matches!(result, LocalResult::Single(_)));
if let LocalResult::Single(dt) = result {
println!("Unambiguous time: {}", dt);
println!("Unix timestamp: {}", dt.timestamp());
}
}Most times are unambiguousâSingle contains the unique mapping.
None: Non-Existent Times During Spring Forward
use chrono::{TimeZone, LocalResult, NaiveDateTime, NaiveDate, NaiveTime};
// During spring forward, the clock jumps from 01:59:59 to 03:00:00
// Times like 02:30:00 simply don't exist
fn non_existent_times() {
// This example uses a timezone with DST transitions
// In US Eastern time, March 10, 2024 at 2:00 AM doesn't exist
// The clock jumps from 1:59:59 AM EST to 3:00:00 AM EDT
// With chrono-tz:
// use chrono_tz::US::Eastern;
// let result = Eastern.from_local_datetime(&NaiveDateTime::new(
// NaiveDate::from_ymd_opt(2024, 3, 10).unwrap(),
// NaiveTime::from_hms_opt(2, 30, 0).unwrap(),
// ));
// assert!(matches!(result, LocalResult::None));
// LocalResult::None means: "this local time never occurred"
// You cannot create a valid DateTime from a non-existent local time
println!("During spring forward, some local times don't exist");
}LocalResult::None indicates a local time that was skipped during spring forward.
Ambiguous: Times During Fall Back
use chrono::{TimeZone, LocalResult, NaiveDateTime, NaiveDate, NaiveTime};
// During fall back, the clock repeats an hour
// Times like 01:30:00 occur twice (once in EDT, once in EST)
fn ambiguous_times() {
// In US Eastern time, November 3, 2024 at 1:30 AM occurs twice:
// 1:30 AM EDT (UTC-4) then 1:30 AM EST (UTC-5) an hour later
// With chrono-tz:
// use chrono_tz::US::Eastern;
// let result = Eastern.from_local_datetime(&NaiveDateTime::new(
// NaiveDate::from_ymd_opt(2024, 11, 3).unwrap(),
// NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
// ));
//
// match result {
// LocalResult::Ambiguous(earliest, latest) => {
// // earliest is 1:30 AM EDT (UTC-4)
// // latest is 1:30 AM EST (UTC-5)
// // Same local time, different UTC offsets
// }
// _ => panic!("Expected ambiguous"),
// }
println!("Ambiguous times have two valid interpretations");
}LocalResult::Ambiguous contains both interpretations: earliest (before transition) and latest (after).
The Ambiguous Tuple
use chrono::{TimeZone, LocalResult, DateTime, FixedOffset};
fn ambiguous_tuple() {
// The Ambiguous variant contains (earliest, latest)
// earliest: First occurrence (higher offset during DST, e.g., UTC-4)
// latest: Second occurrence (lower offset standard time, e.g., UTC-5)
// If we have an ambiguous result:
// let LocalResult::Ambiguous(earliest, latest) = result;
//
// Both have the same local time representation
// assert_eq!(earliest.naive_local(), latest.naive_local());
//
// But different UTC offsets
// assert!(earliest.offset() != latest.offset());
//
// And different Unix timestamps (1 hour apart for 1-hour DST shift)
// assert_eq!(latest.timestamp() - earliest.timestamp(), 3600);
// For a 1-hour DST shift:
// earliest: 2024-11-03 01:30:00 EDT (UTC-4) -> timestamp X
// latest: 2024-11-03 01:30:00 EST (UTC-5) -> timestamp X + 3600
}The Ambiguous tuple contains (earliest, latest) â same local time, different UTC moments.
Handling Ambiguity: Single, Earliest, Latest
use chrono::{TimeZone, LocalResult, DateTime};
// LocalResult provides convenience methods for disambiguation
fn resolve_ambiguity<Tz: TimeZone>(result: LocalResult<DateTime<Tz>>) -> Option<DateTime<Tz>> {
// .single() returns Some for Single, None for None or Ambiguous
// .earliest() returns Some for Single, earliest for Ambiguous
// .latest() returns Some for Single, latest for Ambiguous
result.single() // Reject ambiguous and non-existent
}
fn prefer_earliest<Tz: TimeZone>(result: LocalResult<DateTime<Tz>>) -> Option<DateTime<Tz>> {
result.earliest() // Accept earliest for ambiguous
}
fn prefer_latest<Tz: TimeZone>(result: LocalResult<DateTime<Tz>>) -> Option<DateTime<Tz>> {
result.latest() // Accept latest for ambiguous
}Convenience methods single(), earliest(), and latest() provide common disambiguation strategies.
Practical Disambiguation Strategies
use chrono::{TimeZone, LocalResult, DateTime, NaiveDateTime};
// Strategy 1: Reject ambiguous/non-existent times
fn strict_conversion<Tz: TimeZone>(
tz: Tz,
naive: NaiveDateTime,
) -> Result<DateTime<Tz>, String> {
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt),
LocalResult::None => Err("Local time does not exist".to_string()),
LocalResult::Ambiguous(_, _) => Err("Local time is ambiguous".to_string()),
}
}
// Strategy 2: Use earliest for ambiguous, error for non-existent
fn earliest_or_fail<Tz: TimeZone>(
tz: Tz,
naive: NaiveDateTime,
) -> Result<DateTime<Tz>, String> {
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt),
LocalResult::None => Err("Local time does not exist".to_string()),
LocalResult::Ambiguous(earliest, _) => Ok(earliest),
}
}
// Strategy 3: Use latest for ambiguous, use single for unambiguous
fn prefer_latest_or_single<Tz: TimeZone>(
tz: Tz,
naive: NaiveDateTime,
) -> Option<DateTime<Tz>> {
tz.from_local_datetime(&naive).latest()
}
// Strategy 4: Lenient - return something for all cases
fn lenient_conversion<Tz: TimeZone>(
tz: Tz,
naive: NaiveDateTime,
) -> Option<DateTime<Tz>> {
tz.from_local_datetime(&naive).earliest()
}Choose disambiguation strategy based on your application's requirements.
FixedOffset vs DST-Aware Timezones
use chrono::{TimeZone, FixedOffset, LocalResult, NaiveDateTime, NaiveDate, NaiveTime};
fn fixed_offset_no_dst() {
// FixedOffset has no DST transitions
// Every local time maps to exactly one UTC time
let offset = FixedOffset::east_opt(5 * 3600).unwrap();
let result = offset.from_local_datetime(&NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
NaiveTime::from_hms_opt(2, 30, 0).unwrap(),
));
// Always Single for FixedOffset - no None or Ambiguous
assert!(matches!(result, LocalResult::Single(_)));
// FixedOffset represents a constant UTC offset
// No daylight saving transitions
}FixedOffset never produces None or Ambiguous; only DST-aware timezones do.
from_local_datetime vs from_utc_datetime
use chrono::{TimeZone, Utc, FixedOffset, NaiveDateTime, NaiveDate, NaiveTime};
fn local_vs_utc() {
let offset = FixedOffset::east_opt(5 * 3600).unwrap();
// from_local_datetime: Local time -> TimeZone-aware DateTime
// May return None or Ambiguous for DST timezones
let local_naive = NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
);
let local_result = offset.from_local_datetime(&local_naive);
// Returns LocalResult::Single, None, or Ambiguous
// from_utc_datetime: UTC time -> TimeZone-aware DateTime
// Always unambiguous - UTC has no DST
let utc_naive = NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
NaiveTime::from_hms_opt(7, 0, 0).unwrap(),
);
let utc_result: chrono::DateTime<FixedOffset> = offset.from_utc_datetime(&utc_naive);
// Returns DateTime directly, never LocalResult
// Key insight: from_utc is always unambiguous
// from_local requires handling edge cases
}from_utc_datetime is always unambiguous; from_local_datetime requires handling edge cases.
Converting User Input
use chrono::{TimeZone, NaiveDateTime, NaiveDate, NaiveTime, LocalResult, DateTime};
fn parse_user_datetime<Tz: TimeZone>(
input: &str,
tz: Tz,
) -> Result<DateTime<Tz>, String> {
// Parse user input as naive datetime
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")
.map_err(|e| format!("Invalid format: {}", e))?;
// Convert to timezone-aware
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt),
LocalResult::None => {
Err(format!(
"Time {} doesn't exist in this timezone (DST spring forward)",
naive
))
}
LocalResult::Ambiguous(earliest, latest) => {
Err(format!(
"Time {} is ambiguous (DST fall back). \
Could be {} or {}",
naive, earliest, latest
))
}
}
}
// Alternative: Auto-resolve with preference
fn parse_user_datetime_lenient<Tz: TimeZone>(
input: &str,
tz: Tz,
) -> Result<DateTime<Tz>, String> {
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")
.map_err(|e| format!("Invalid format: {}", e))?;
// Prefer earliest for ambiguous, reject non-existent
tz.from_local_datetime(&naive)
.earliest()
.ok_or_else(|| format!("Time {} doesn't exist in this timezone", naive))
}When parsing user input, explicitly handle DST edge cases rather than silently picking an interpretation.
Storing vs Displaying Times
use chrono::{TimeZone, Utc, DateTime};
// Best practice: Store UTC, convert to local for display
fn store_as_utc() {
// Store timestamps as UTC
// UTC has no DST, so no ambiguity
let utc_time: DateTime<Utc> = Utc::now();
// When displaying, convert to local
// Going UTC -> local is unambiguous
let local_time = utc_time.with_timezone(&chrono::Local);
// The reverse (local -> UTC for storage) can be ambiguous
// So always store as UTC
}
fn scheduling_example() {
// Problem: User schedules meeting for "1:30 AM on Nov 3"
// In US Eastern, this time occurs twice
// Solution: Store as UTC, or store local time + timezone + DST flag
// Option 1: Ask user to specify "1:30 AM EST" vs "1:30 AM EDT"
// Option 2: Clarify during ambiguous times: "Which 1:30 AM?"
// Option 3: Store UTC and note which occurrence was intended
}Store times as UTC to avoid ambiguity; convert to local only for display.
Real-World Pattern: Logging System
use chrono::{TimeZone, Utc, DateTime, LocalResult, NaiveDateTime};
struct EventLogger<Tz: TimeZone> {
timezone: Tz,
}
impl<Tz: TimeZone> EventLogger<Tz> {
fn log_event(&self, local_time_str: &str, event: &str) -> Result<DateTime<Tz>, String> {
// Parse local time
let naive = NaiveDateTime::parse_from_str(local_time_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| format!("Parse error: {}", e))?;
// Handle ambiguity based on use case
match self.timezone.from_local_datetime(&naive) {
LocalResult::Single(dt) => {
// Normal case
Ok(dt)
}
LocalResult::None => {
// Non-existent time: log warning, use next valid time
// Or reject with error
Err(format!("Non-existent time {}: {}", naive, event))
}
LocalResult::Ambiguous(earliest, latest) => {
// Ambiguous time: use earliest by default
// Or log both possibilities
eprintln!(
"Warning: Ambiguous time {}. Using earliest interpretation",
naive
);
Ok(earliest)
}
}
}
fn log_event_strict(&self, local_time_str: &str, event: &str) -> Result<DateTime<Tz>, String> {
let naive = NaiveDateTime::parse_from_str(local_time_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| format!("Parse error: {}", e))?;
self.timezone
.from_local_datetime(&naive)
.single()
.ok_or_else(|| format!("Time {} is invalid or ambiguous", naive))
}
}Different use cases require different disambiguation strategies.
Synthesis
Quick reference:
use chrono::{TimeZone, LocalResult, DateTime};
// LocalResult has three variants:
// - Single(DateTime): Unambiguous mapping
// - None: Time doesn't exist (spring forward)
// - Ambiguous(earliest, latest): Time occurs twice (fall back)
fn handle_local_result<Tz: TimeZone>(result: LocalResult<DateTime<Tz>>) {
match result {
LocalResult::Single(dt) => {
// Normal case: unique mapping
println!("Unique time: {}", dt);
}
LocalResult::None => {
// Spring forward: 2:00 AM -> 3:00 AM
// 2:30 AM never existed
println!("Time doesn't exist (skipped during DST transition)");
}
LocalResult::Ambiguous(earliest, latest) => {
// Fall back: 2:00 AM EDT -> 1:00 AM EST
// 1:30 AM occurs twice
println!("Ambiguous time:");
println!(" First (earliest): {}", earliest);
println!(" Second (latest): {}", latest);
}
}
}
// Convenience methods:
// result.single() -> Option<DateTime> (None for None/Ambiguous)
// result.earliest() -> Option<DateTime> (earliest for Ambiguous)
// result.latest() -> Option<DateTime> (latest for Ambiguous)
// Best practices:
// 1. Store times as UTC (no ambiguity)
// 2. Use from_utc_datetime when possible (always unambiguous)
// 3. Handle LocalResult explicitly for user input
// 4. Choose disambiguation strategy based on use case
// 5. Use chrono-tz for real DST-aware timezonesKey insight: from_local_datetime returns LocalResult because converting local time to timezone-aware time is not always a one-to-one mapping. During spring forward, some local times don't exist (None). During fall back, some local times occur twice (Ambiguous). This three-valued result forces explicit handling of edge cases rather than silently picking an interpretation. For fixed-offset timezones without DST, LocalResult::Single is always returned. For real-world timezones with DST, use chrono-tz and handle all three cases. The safest approach is storing times as UTC (where from_utc_datetime is always unambiguous) and only converting to local for display.
