How does chrono::TimeZone::from_local_datetime handle ambiguous or non-existent local times?

from_local_datetime returns a LocalResult enum that explicitly represents the three possible outcomes when converting a local datetime to a timezone-aware datetime: Single for unambiguous times, Ambiguous for times that occur twice during a backward timezone transition, and None for times that don't exist during a forward timezone transition. This design forces callers to handle edge cases rather than silently losing information or panicking.

Basic Usage with Unambiguous Times

use chrono::{TimeZone, NaiveDateTime, Utc, LocalResult};
 
fn unambiguous_time() {
    // Most times are unambiguous - they map to exactly one UTC time
    let naive = NaiveDateTime::parse_from_str("2024-06-15 14:30:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    let result = Utc.from_local_datetime(&naive);
    
    match result {
        LocalResult::Single(datetime) => {
            println!("Unambiguous mapping: {}", datetime);
            // datetime is a DateTime<Utc>
        }
        LocalResult::Ambiguous(earliest, latest) => {
            // Won't happen for most times
            println!("Ambiguous: {} or {}", earliest, latest);
        }
        LocalResult::None => {
            // Won't happen for most times
            println!("No valid mapping");
        }
    }
}

The common case produces LocalResult::Single with exactly one valid mapping.

Understanding Ambiguous Times

use chrono::{TimeZone, NaiveDateTime, FixedOffset, LocalResult};
 
fn ambiguous_time_fall_back() {
    // During "fall back" (end of DST), a local time occurs twice:
    // Example: US Eastern "fall back" from EDT to EST
    // 1:30 AM EDT becomes 1:30 AM EST (same local time twice)
    
    // Create a timezone that has DST transitions
    let eastern = FixedOffset::west_opt(5 * 3600).unwrap(); // Simplified: EST
    
    // In practice, use chrono-tz for real timezone with DST:
    // use chrono_tz::US::Eastern;
    // In November, 1:30 AM local time occurs twice
    
    // A time like "2024-11-03 01:30:00" in US Eastern is ambiguous
    // It could be:
    // - 2024-11-03 01:30:00 EDT (UTC-4)
    // - 2024-11-03 01:30:00 EST (UTC-5)
    
    // The result is LocalResult::Ambiguous with both possibilities
    // earliest = the time in the earlier timezone (EDT)
    // latest = the time in the later timezone (EST)
}
 
fn handle_ambiguous() {
    // When from_local_datetime returns Ambiguous:
    // earliest: the time expressed in the earlier offset (before transition)
    // latest: the time expressed in the later offset (after transition)
    
    // Example: 1:30 AM on fall-back day
    // earliest = 1:30 AM EDT = 05:30 UTC
    // latest = 1:30 AM EST = 06:30 UTC
    
    // Applications must decide which one to use:
    // - Use earliest: assume time was before transition
    // - Use latest: assume time was after transition
    // - Ask user for clarification
    // - Use some business logic to decide
}

Ambiguous times occur during "fall back" when clocks are set backward, causing some local times to repeat.

Understanding Non-Existent Times

use chrono::{TimeZone, NaiveDateTime, LocalResult};
 
fn non_existent_time_spring_forward() {
    // During "spring forward" (start of DST), some local times don't exist:
    // Example: US Eastern "spring forward" from EST to EDT
    // 2:00 AM EST becomes 3:00 AM EDT
    // Times from 2:00 AM to 2:59 AM don't exist!
    
    // A time like "2024-03-10 02:30:00" in US Eastern doesn't exist
    // Clocks jump from 1:59 AM EST directly to 3:00 AM EDT
    
    // The result is LocalResult::None
    
    // This is why from_local_datetime can't just return DateTime:
    // there's no valid UTC time for this local time
}
 
fn handle_non_existent() {
    // When from_local_datetime returns None:
    // The local time simply doesn't exist in that timezone
    
    // Applications can handle this by:
    // - Using the next valid time (forward adjustment)
    // - Using the previous valid time (backward adjustment)
    // - Returning an error to the user
    // - Using a default behavior for missing times
}

Non-existent times occur during "spring forward" when clocks are set forward, skipping some local times.

The LocalResult Enum

use chrono::{DateTime, TimeZone, NaiveDateTime};
 
pub enum LocalResult<T> {
    /// Given local time is unique in the timezone
    Single(T),
    
    /// Given local time occurs twice (e.g., during fall-back)
    /// Contains (earliest, latest) where earliest < latest in UTC
    Ambiguous(T, T),
    
    /// Given local time does not exist (e.g., during spring-forward)
    None,
}
 
// The type parameter T is typically DateTime<TimeZone>
fn result_types() {
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    let result: LocalResult<DateTime<chrono::Utc>> = 
        chrono::Utc.from_local_datetime(&naive);
    
    // For most timezones and times, result is Single
}

LocalResult forces explicit handling of all three cases.

Practical Handling Patterns

use chrono::{TimeZone, NaiveDateTime, DateTime, Utc, LocalResult};
 
// Pattern 1: Assume earliest for ambiguous, skip for non-existent
fn assume_earliest_or_fail<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>> {
    match tz.from_local_datetime(naive) {
        LocalResult::Single(dt) => Some(dt),
        LocalResult::Ambiguous(earliest, _latest) => Some(earliest),
        LocalResult::None => None,
    }
}
 
// Pattern 2: Assume latest for ambiguous, skip for non-existent
fn assume_latest_or_fail<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>> {
    match tz.from_local_datetime(naive) {
        LocalResult::Single(dt) => Some(dt),
        LocalResult::Ambiguous(_earliest, latest) => Some(latest),
        LocalResult::None => None,
    }
}
 
// Pattern 3: Forward adjustment for non-existent times
fn forward_adjust<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>> 
where Tz::Offset: Copy {
    match tz.from_local_datetime(naive) {
        LocalResult::Single(dt) => Some(dt),
        LocalResult::Ambiguous(earliest, _) => Some(earliest),
        LocalResult::None => {
            // Find the next valid time by adding offset
            // This is approximate - real implementation needs offset info
            None
        }
    }
}
 
// Pattern 4: Unwrap with a default
fn unwrap_with_default<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime, default: DateTime<Tz>) -> DateTime<Tz> {
    match tz.from_local_datetime(naive) {
        LocalResult::Single(dt) => dt,
        LocalResult::Ambiguous(earliest, _) => earliest,
        LocalResult::None => default,
    }
}

Different applications need different strategies for handling edge cases.

Using chrono-tz for Real Timezone Transitions

// Requires chrono-tz crate for timezone-aware DST transitions
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::US::Eastern;
 
fn real_timezone_example() {
    // November 5, 2023 1:30 AM Eastern is ambiguous
    // (clocks fall back from EDT to EST)
    let naive = NaiveDateTime::parse_from_str("2023-11-05 01:30:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    match Eastern.from_local_datetime(&naive) {
        LocalResult::Ambiguous(earliest, latest) => {
            println!("Ambiguous time:");
            println!("  Earlier (EDT): {}", earliest);
            println!("  Later (EST): {}", latest);
            
            // The difference is 1 hour in UTC time
            let diff = latest.timestamp() - earliest.timestamp();
            println!("  UTC difference: {} seconds (1 hour)", diff);
        }
        LocalResult::Single(dt) => {
            println!("Single: {}", dt);
        }
        LocalResult::None => {
            println!("Non-existent");
        }
    }
    
    // March 12, 2023 2:30 AM Eastern doesn't exist
    // (clocks spring forward from EST to EDT)
    let naive = NaiveDateTime::parse_from_str("2023-03-12 02:30:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    match Eastern.from_local_datetime(&naive) {
        LocalResult::None => {
            println!("Time doesn't exist due to DST spring-forward");
        }
        _ => unreachable!(),
    }
}

Real timezone handling requires chrono-tz for accurate DST transitions.

Conversion Methods Comparison

use chrono::{TimeZone, NaiveDateTime, Utc, LocalResult};
 
fn conversion_methods() {
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    // from_local_datetime: Returns LocalResult (handles edge cases)
    let result: LocalResult<DateTime<Utc>> = Utc.from_local_datetime(&naive);
    // Must handle all three cases
    
    // single: Unwrap, assuming no ambiguity
    // Panics on Ambiguous or None
    let dt: DateTime<Utc> = Utc.from_local_datetime(&naive).single().unwrap();
    // Use only when you're certain the time is unambiguous
    
    // earliest: For ambiguous times, take the earlier occurrence
    // Returns None for non-existent times
    let maybe_dt: Option<DateTime<Utc>> = Utc.from_local_datetime(&naive).earliest();
    
    // latest: For ambiguous times, take the later occurrence
    // Returns None for non-existent times
    let maybe_dt: Option<DateTime<Utc>> = Utc.from_local_datetime(&naive).latest();
    
    // For fixed-offset timezones (no DST), from_local_datetime always returns Single
    let fixed = chrono::FixedOffset::east_opt(5 * 3600).unwrap();
    let result = fixed.from_local_datetime(&naive);
    assert!(matches!(result, LocalResult::Single(_)));
}

LocalResult provides methods for common unwrapping patterns.

User Input Handling

use chrono::{NaiveDateTime, TimeZone, LocalResult};
use chrono_tz::Tz;
 
#[derive(Debug)]
pub enum TimeConversionError {
    AmbiguousTime { earliest: String, latest: String },
    NonExistentTime,
}
 
fn parse_user_time(input: &str, timezone: Tz) -> Result<String, TimeConversionError> {
    let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")
        .map_err(|_| TimeConversionError::NonExistentTime)?;
    
    match timezone.from_local_datetime(&naive) {
        LocalResult::Single(dt) => {
            Ok(format!("Time: {} (unambiguous)", dt))
        }
        LocalResult::Ambiguous(earliest, latest) => {
            Err(TimeConversionError::AmbiguousTime {
                earliest: format!("{}", earliest),
                latest: format!("{}", latest),
            })
        }
        LocalResult::None => {
            Err(TimeConversionError::NonExistentTime)
        }
    }
}
 
fn handle_user_input() {
    // In a real application, you might:
    // 1. Show user the ambiguous options and let them choose
    // 2. For non-existent time, suggest valid alternatives
    // 3. Store the chosen interpretation
    
    // For scheduled events, always store UTC to avoid ambiguity
    // Record the timezone for display purposes
}

When accepting user input, provide clear feedback about ambiguous or non-existent times.

Scheduling Systems

use chrono::{DateTime, NaiveDateTime, TimeZone, Utc, LocalResult};
use chrono_tz::Tz;
 
// For scheduling, always convert to and store UTC
fn schedule_event(local_time: &str, timezone: Tz) -> Result<DateTime<Utc>, String> {
    let naive = NaiveDateTime::parse_from_str(local_time, "%Y-%m-%d %H:%M:%S")
        .map_err(|e| format!("Invalid datetime: {}", e))?;
    
    match timezone.from_local_datetime(&naive) {
        LocalResult::Single(aware) => {
            // Unambiguous - convert to UTC for storage
            Ok(aware.with_timezone(&Utc))
        }
        LocalResult::Ambiguous(earliest, latest) => {
            // Ambiguous - must choose one
            // Common choice: earliest (assume DST hasn't ended yet)
            // Or: reject and ask user
            Ok(earliest.with_timezone(&Utc))
        }
        LocalResult::None => {
            // Non-existent - adjust forward
            // Find the next valid time after the gap
            Err("Time doesn't exist due to DST transition".to_string())
        }
    }
}
 
// For recurring events, consider DST implications
fn recurring_event(start: DateTime<Utc>, interval_hours: u64, timezone: Tz) {
    // "9 AM every day" in local time is tricky with DST
    // - Some days are 23 or 25 hours
    // - The UTC offset changes
    
    // Better: Store "9 AM local time" and convert each occurrence
    // Or: Store UTC and accept that local time shifts with DST
}

Scheduling systems must handle DST transitions explicitly.

Database Storage Patterns

use chrono::{DateTime, NaiveDateTime, TimeZone, Utc, LocalResult};
 
// Pattern 1: Store as UTC, display in local timezone
fn store_as_utc<Tz: TimeZone>(naive: &NaiveDateTime, tz: &Tz) -> Option<DateTime<Utc>>
where Tz::Offset: Copy {
    match tz.from_local_datetime(naive) {
        LocalResult::Single(aware) => Some(aware.with_timezone(&Utc)),
        LocalResult::Ambiguous(earliest, _) => Some(earliest.with_timezone(&Utc)),
        LocalResult::None => None,
    }
}
 
// Pattern 2: Store offset information
struct StoredDateTime {
    utc_time: DateTime<Utc>,
    offset_seconds: i32,  // Store the actual offset used
    timezone_name: String,
}
 
fn store_with_offset<Tz: TimeZone>(naive: &NaiveDateTime, tz: &Tz, name: &str) 
    -> Option<StoredDateTime> 
{
    match tz.from_local_datetime(naive) {
        LocalResult::Single(aware) => {
            let offset = aware.offset().clone();
            Some(StoredDateTime {
                utc_time: aware.with_timezone(&Utc),
                offset_seconds: offset.local_minus_utc(),
                timezone_name: name.to_string(),
            })
        }
        LocalResult::Ambiguous(earliest, _latest) => {
            let offset = earliest.offset().clone();
            Some(StoredDateTime {
                utc_time: earliest.with_timezone(&Utc),
                offset_seconds: offset.local_minus_utc(),
                timezone_name: name.to_string(),
            })
        }
        LocalResult::None => None,
    }
}
 
// Pattern 3: Store local time plus timezone, convert on read
struct StoredLocalTime {
    local_time: NaiveDateTime,  // Store what user entered
    timezone: String,
    // Re-interpret on read, handle ambiguity then
}

Store UTC for correctness; store local representation for user display.

Comparison with from_utc_datetime

use chrono::{TimeZone, NaiveDateTime, Utc};
 
fn comparison() {
    let naive_utc = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    // from_utc_datetime: Never ambiguous or non-existent
    // A UTC time maps to exactly one local time
    let local = Utc.from_utc_datetime(&naive_utc);
    // Always returns DateTime directly, no LocalResult
    // No ambiguity because UTC has no DST transitions
    
    // from_local_datetime: Can be ambiguous or non-existent
    // A local time might map to 0, 1, or 2 UTC times
    let naive_local = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    // Returns LocalResult because of potential ambiguity
    let result = Utc.from_local_datetime(&naive_local);
}
 
// Key insight:
// - UTC -> Local: Always unambiguous (from_utc_datetime)
// - Local -> UTC: Can be ambiguous/non-existent (from_local_datetime)

Converting from UTC is always unambiguous; converting to UTC requires handling edge cases.

Fixed Offset Timezones

use chrono::{FixedOffset, TimeZone, NaiveDateTime, LocalResult};
 
fn fixed_offset() {
    // Fixed-offset timezones have no DST transitions
    // Therefore, from_local_datetime always returns Single
    
    let est = FixedOffset::west_opt(5 * 3600).unwrap();  // UTC-5
    let ist = FixedOffset::east_opt(5 * 3600 + 1800).unwrap();  // UTC+5:30
    
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
        .unwrap();
    
    // Always Single for fixed offsets
    match est.from_local_datetime(&naive) {
        LocalResult::Single(dt) => {
            println!("Fixed offset is always unambiguous: {}", dt);
        }
        _ => unreachable!("Fixed offsets never have ambiguity"),
    }
    
    // This is why you can use .single().unwrap() safely for fixed offsets
}

Fixed-offset timezones (no DST) never produce ambiguous or non-existent times.

Synthesis

Quick reference:

Scenario LocalResult Cause Handling
Normal time Single Unambiguous mapping Use directly
Fall-back (DST end) Ambiguous Local time occurs twice Choose earliest or latest
Spring-forward (DST start) None Local time skipped Adjust or error

Ambiguous time example:

use chrono::{TimeZone, NaiveDateTime, LocalResult};
 
fn handle_ambiguous_time<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) {
    match tz.from_local_datetime(naive) {
        LocalResult::Ambiguous(earliest, latest) => {
            // Example: 1:30 AM on fall-back day
            // earliest = 1:30 AM before transition (e.g., 05:30 UTC)
            // latest = 1:30 AM after transition (e.g., 06:30 UTC)
            
            // Decision strategies:
            // 1. Use earliest: assume time was in summer time
            // 2. Use latest: assume time was in standard time
            // 3. Ask user: present both options
            // 4. Reject: return error and ask for clarification
        }
        _ => {}
    }
}

Key insight: The LocalResult enum represents the fundamental challenge of converting local times to UTC: not all local times exist in all timezones, and some exist twice. During DST "spring forward," a gap is created where local times don't exist (e.g., 2:00 AM to 2:59 AM might be skipped). During DST "fall back," some local times occur twice (e.g., 1:00 AM to 1:59 AM happens in both daylight time and standard time). The from_local_datetime method returns LocalResult::None for times that don't exist, LocalResult::Ambiguous for times that occur twice, and LocalResult::Single for unambiguous times. This forces explicit handling at the call site rather than silently picking an interpretation. For applications that need a specific policy, LocalResult provides .earliest(), .latest(), and .single() convenience methods that return Option<DateTime>. Fixed-offset timezones never produce ambiguous or non-existent times since they have no DST transitions—from_local_datetime always returns Single. For real-world timezone handling with accurate DST transitions, use the chrono-tz crate which includes the IANA timezone database.