The cache that never expires
You're building a cache layer for your API. Every item has an expiration time stored as a number. When a request comes in, you need to check if the current moment is past that number. You reach for the time function, get a result, and suddenly you're staring at a SystemTime struct that refuses to subtract from another SystemTime without panicking, or you're trying to serialize a timestamp and realizing the standard library gives you a duration, not a number you can just drop into JSON.
Rust handles time with intentional friction. The language separates wall-clock time from elapsed time, wraps measurements in types that prevent overflow, and forces you to acknowledge that the system clock can jump around. This design stops entire classes of bugs where code assumes time flows smoothly or that timestamps fit in a simple integer.
Time as a measurement, not a number
The Unix epoch is a single point in time: January 1, 1970, at midnight UTC. A timestamp is just a measurement of how much time has passed since that moment. Think of it like a stopwatch that started running on that date and never stopped. A timestamp in seconds is the number on the display right now. A timestamp in milliseconds is the same display, but with three extra digits for precision.
Rust treats this measurement as a Duration, which is a span of time, not a raw number. This distinction matters because a duration can be converted to seconds, milliseconds, or nanoseconds, and the compiler keeps you from mixing units accidentally. A Duration is a struct holding seconds and nanoseconds. When you extract a number from it, you get a typed integer that matches the precision you asked for. This prevents silent truncation and makes the precision of your time logic explicit in the type system.
Getting the timestamp
The standard library provides std::time::SystemTime for wall-clock time and UNIX_EPOCH as the reference point. You measure the time since the epoch using duration_since.
use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
// Query the OS for the current wall-clock time.
let now = SystemTime::now();
// Calculate the span from the epoch to now.
// Returns a Result because the clock could be before 1970.
let duration = now.duration_since(UNIX_EPOCH).expect("System time is before 1970");
// Extract seconds as a u64.
let seconds = duration.as_secs();
// Extract milliseconds as a u128 to prevent overflow.
let millis = duration.as_millis();
println!("Epoch seconds: {seconds}");
println!("Epoch millis: {millis}");
}
The compiler forces you to handle the past. Respect the Result.
What happens under the hood
SystemTime::now() makes a system call to the operating system to read the hardware clock. This value represents wall-clock time, which is subject to adjustments. Network Time Protocol (NTP) clients can step the clock forward or backward. A user can change the time manually. Virtual machines might have their clocks desynchronized during migration.
UNIX_EPOCH is a constant representing the 1970 start point. Calling duration_since computes the difference between the current time and that constant. The method returns a Result<Duration, SystemTimeError> because the current time could theoretically be before the epoch. This is rare on modern general-purpose systems, but it happens on embedded devices, during boot sequences, or if the clock is misconfigured. The Result type forces you to decide how to handle that case. You can panic with a clear message, or you can implement a fallback strategy.
When the calculation succeeds, you get a Duration. Calling as_secs() returns the number of whole seconds as a u64. Calling as_millis() returns the total milliseconds as a u128. Note the type change. The number of milliseconds since 1970 exceeds the range of a u64 around the year 584 billion. Rust uses u128 for milliseconds to prevent overflow on any realistic timeline. This choice protects you from assuming milliseconds fit in 64 bits. If you need milliseconds in a u64, you must explicitly truncate, which signals to readers that you're aware of the precision loss.
Why Rust wraps time in types
Many languages represent timestamps as raw i64 integers. Rust avoids this pattern for several reasons. A raw integer carries no semantic meaning. A u64 could be seconds, milliseconds, nanoseconds, or a random ID. By wrapping time in Duration and requiring an explicit conversion to a number, the code documents its own precision. A variable named timestamp_secs with type u64 is clear. A variable named ts with type i64 requires a comment to explain the epoch and the unit.
Wrapping time also prevents accidental negative values. Duration is unsigned. If you try to measure a time before the epoch, duration_since returns an error rather than a negative number. This forces you to handle the edge case explicitly. If you need to support timestamps before 1970, you can use UNIX_EPOCH.duration_since(now) to get a duration in the past, but you still have to make that choice consciously. The type system guides you toward correct logic instead of letting you hide bugs in arithmetic.
A u64 timestamp tells the whole story. An i64 requires a comment.
Real-world usage: checking expiration
In practice, you often store timestamps as numbers in a database or a cache, then compare them against the current time. Here's a realistic pattern for checking if a cache entry has expired.
use std::time::{SystemTime, UNIX_EPOCH};
struct CacheEntry {
data: String,
// Store expiration as seconds since epoch.
expires_at: u64,
}
fn is_expired(entry: &CacheEntry) -> bool {
// Get current time as seconds since epoch.
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time is before 1970")
.as_secs();
// Compare the stored expiration time with the current time.
now_secs >= entry.expires_at
}
fn main() {
// Simulate creating an entry that expires in 5 seconds.
let current = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let entry = CacheEntry {
data: "user_profile_123".to_string(),
expires_at: current + 5,
};
println!("Expired immediately: {}", is_expired(&entry));
}
This pattern keeps the comparison simple. You convert the current time to the same unit as your stored timestamp, then do a standard integer comparison. The expect call documents that a pre-1970 clock is a fatal error for this application. If you're building a system that must tolerate clock errors, you would handle the Result differently, perhaps by falling back to a monotonic timer or rejecting the request.
Store numbers, not structs. Keep your cache logic simple.
Pitfalls and compiler errors
The biggest trap is confusing wall-clock time with elapsed time. SystemTime is not monotonic. If the OS adjusts the clock, SystemTime::now() can jump backward or forward. Never use SystemTime for measuring elapsed time, calculating latency, or implementing timeouts. Use std::time::Instant for those cases. Instant is guaranteed to move forward and is immune to clock adjustments. It measures time relative to some unspecified point in the past, which is perfect for duration calculations.
Another common issue is trying to subtract two SystemTime values directly. The compiler rejects this with E0369 (binary operation not supported). You cannot write now - other. The language forces you to choose a direction for the subtraction using duration_since or checked_duration_since. This prevents sign errors where you accidentally swap the operands and get a negative duration. checked_duration_since returns Option<Duration> instead of a Result, which is useful when you want to handle the "time went backwards" case without panicking.
Serialization is another friction point. The standard library does not implement Serialize for SystemTime. If you're using serde, you'll need to convert the time to a number or a string first. The convention is to serialize the epoch seconds as a u64 for JSON APIs. You can use a helper crate like serde_with to handle the conversion automatically, or write a custom serializer that extracts the seconds. This explicit step reminds you that timestamps are just numbers when they leave your process.
Finally, be careful with as_nanos(). It returns a u128. Nanosecond precision is rarely needed for application logic. Milliseconds are usually sufficient. Seconds are enough for most database storage. Using nanoseconds adds complexity and can lead to u128 arithmetic, which is slightly slower on 64-bit CPUs. Only use nanoseconds when you actually need that precision, such as in high-frequency trading or scientific instrumentation.
SystemTime lies. Instant tells the truth. Pick accordingly.
Choosing the right tool
Rust provides multiple time types because different problems require different guarantees. Pick the type that matches your use case.
Use SystemTime::now().duration_since(UNIX_EPOCH) when you need a timestamp for storage, serialization, or comparison with external systems like databases and APIs. Use std::time::Instant::now() when you need to measure elapsed time, calculate latency, or implement timeouts, because Instant is monotonic and immune to clock adjustments. Use chrono or time crates when you need to parse human-readable date strings, handle time zones, or perform calendar arithmetic, since the standard library focuses on raw time measurements rather than calendar logic. Use u64 for seconds and u128 for milliseconds when storing timestamps, matching the return types of as_secs() and as_millis() to avoid conversion errors.
Match the tool to the job. Timestamps for the world, Instants for the machine.