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 timezones

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