Skip to content

Humanize a timestamp as "3 hours ago" snippet

Humans read "3 hours ago", not timestamps.

Humans read "3 hours ago", not timestamps. The trap is units: a month is not 30 days and a year is not 365 — across boundaries the calendar disagrees with arithmetic, so every correct implementation switches units at thresholds (59s → "a minute ago", not 0.98 minutes). The second trap is clock mixing: compute the delta from two instants in the SAME zone or DST transitions produce negative ages. JavaScript and Swift ship a formatter; everywhere else it is a thresholds table — and every language's table looks the same.

Runnable recipe · 12 languagesOpen the timestamp tool →
Dates & Timerelative-timehumanizedatetimeformatting

Every language

12 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
function relativeTime(date, now = new Date()) {
  const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  const UNITS = [
    ['year',   60 * 60 * 24 * 365],
    ['month',  60 * 60 * 24 * 30],
    ['day',    60 * 60 * 24],
    ['hour',   60 * 60],
    ['minute', 60],
    ['second', 1],
  ];
  const delta = (date.getTime() - now.getTime()) / 1000; // seconds, past is negative
  for (const [unit, secs] of UNITS) {
    if (Math.abs(delta) >= secs || unit === 'second') {
      return rtf.format(Math.round(delta / secs), unit);
    }
  }
}

relativeTime(new Date(Date.now() - 3 * 60 * 60 * 1000)); // '3 hours ago'

Intl.RelativeTimeFormat formats a (value, unit) pair — it does not CHOOSE the unit. Walking the table and picking is your job; numeric: 'auto' turns 1 into 'a minute' and 0 into 'now' ('always' is the mechanical '1 minute ago').

TSTypeScript
export function relativeTime(
  delta: number, // epoch milliseconds of the instant to describe
  now: Date = new Date(),
): string {
  const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  const UNITS: ReadonlyArray<[Intl.RelativeTimeFormatUnit, number]> = [
    ['year', 31_536_000],
    ['month', 2_592_000],
    ['day', 86_400],
    ['hour', 3_600],
    ['minute', 60],
    ['second', 1],
  ];
  const secs = (delta - now.getTime()) / 1000;
  for (const [unit, size] of UNITS) {
    if (Math.abs(secs) >= size || unit === 'second') {
      return rtf.format(Math.round(secs / size), unit);
    }
  }
}

Same API as the JS version with the unit table typed as Intl.RelativeTimeFormatUnit and an injectable now for tests. The numeric option is a site-wide contract: 'auto' reads human, 'always' reads mechanical — pick once.

GoGo
import (
	"fmt"
	"time"
)

// RelativeTime renders t as "3 hours ago" relative to now (same time zone!).
func RelativeTime(t, now time.Time) string {
	d := now.Sub(t) // time.Duration — int64 nanoseconds
	switch {
	case d < time.Minute:
		return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
	case d < time.Hour:
		return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%d hours ago", int(d.Hours()))
	case d < 30*24*time.Hour:
		return fmt.Sprintf("%d days ago", int(d.Hours())/24)
	case d < 365*24*time.Hour:
		return fmt.Sprintf("%d months ago", int(d.Hours())/24/30)
	default:
		return fmt.Sprintf("%d years ago", int(d.Hours())/24/365)
	}
}

time.Duration is int64 NANOSECONDS: `d < 86400` compiles — and compares against 86.4 microseconds. Spell every threshold in time.Minute / 24*time.Hour products. No stdlib relative formatter exists; the switch is the idiom.

RsRust
use chrono::{DateTime, Utc};

fn relative_time(t: DateTime<Utc>, now: DateTime<Utc>) -> String {
    // TimeDelta (now - t) flattened to whole seconds for the table
    let s = (now - t).num_seconds().max(0);
    match s {
        0..=44 => "a few seconds ago".into(),
        45..=89 => "a minute ago".into(),
        90..=2_699 => format!("{} minutes ago", (s + 30) / 60),
        2_700..=5_399 => "an hour ago".into(),
        5_400..=86_399 => format!("{} hours ago", (s + 1_800) / 3_600),
        86_400..=2_591_999 => format!("{} days ago", s / 86_400),
        2_592_000..=31_535_999 => format!("about {} months ago", s / 2_592_000),
        _ => format!("about {} years ago", s / 31_536_000),
    }
}

chrono::TimeDelta is (days, secs) — num_seconds() flattens it for the table. Months and years need calendar math: these fall back to 'about N' over flat 30-day months; chrono's Months arithmetic is the calendar-exact route.

PHPPHP
function relative_time(int $epochThen, ?int $now = null): string
{
    $now ??= time();
    $delta = $epochThen - $now;  // < 0 means the past
    $abs = abs($delta);         // threshold on the magnitude…

    foreach (
        [31536000 => 'year', 2592000 => 'month', 86400 => 'day',
         3600 => 'hour', 60 => 'minute', 1 => 'second'] as $size => $unit
    ) {
        if ($abs >= $size) {
            $n = intdiv($abs, $size);
            $phrase = $n === 1 ? "a $unit" : "$n {$unit}s";
            return $delta < 0 ? "$phrase ago" : "in $phrase"; // …sign re-attached here
        }
    }
}

abs() the delta, run the thresholds on the magnitude, re-attach the sign only when phrasing — formatting the signed value directly prints 'in -3 hours'. time() is epoch seconds from one clock; feed both operands from it.

PyPython
from datetime import datetime, timezone

def relative_time(dt: datetime, now: datetime | None = None) -> str:
    now = now or datetime.now(timezone.utc)
    secs = (now - dt).total_seconds()  # aware − aware, same zone
    age = abs(secs)
    for unit, size in (('year', 31_536_000), ('month', 2_592_000),
                       ('day', 86_400), ('hour', 3_600),
                       ('minute', 60), ('second', 1)):
        if age >= size or unit == 'second':
            n = int(age // size)
            plural = '' if n == 1 else 's'
            return f'{n} {unit}{plural} ago' if secs > 0 else f'in {n} {unit}{plural}'

The stdlib has NO humanizer — humanize/arrow are the third-party answers. Subtract two tz-aware datetimes in the same zone (datetime.now(timezone.utc)): aware minus naive raises, and mixing local clocks across a DST fold produces negative ages.

C#C#
using System;

static string RelativeTime(DateTimeOffset then, DateTimeOffset now)
{
    TimeSpan d = now - then;             // positive = the past
    bool past = d >= TimeSpan.Zero;
    TimeSpan a = d.Duration();           // TimeSpan's abs()

    (int n, string unit) = a switch
    {
        _ when a >= TimeSpan.FromDays(365)  => (a.Days / 365, "year"),
        _ when a >= TimeSpan.FromDays(30)   => (a.Days / 30, "month"),
        _ when a >= TimeSpan.FromDays(1)    => (a.Days, "day"),
        _ when a >= TimeSpan.FromHours(1)   => (a.Hours, "hour"),
        _ when a >= TimeSpan.FromMinutes(1) => (a.Minutes, "minute"),
        _ => (a.Seconds, "second"),
    };

    string phrase = $"{n} {unit}{(n == 1 ? "" : "s")}";
    return past ? $"{phrase} ago" : $"in {phrase}";
}

DateTimeOffset over DateTime — the offset travels with the value, so the subtraction can never mix zones. For testability take a TimeProvider (.NET 8+) and read provider.GetUtcNow() instead of calling the wall clock directly.

JvJava
import java.time.Duration;
import java.time.Instant;
import java.time.Period;
import java.time.ZoneOffset;

static String relativeTime(Instant then, Instant now) {
    long secs = Duration.between(then, now).abs().getSeconds();
    long n;
    String unit;
    if (secs < 60)           { n = secs;            unit = "second"; }
    else if (secs < 3600)    { n = secs / 60;       unit = "minute"; }
    else if (secs < 86400)   { n = secs / 3600;     unit = "hour";   }
    else if (secs < 2592000) { n = secs / 86400;    unit = "day";    }
    else {
        // Duration tops out at days — months/years are calendar math
        long months = Period.between(
                then.atZone(ZoneOffset.UTC).toLocalDate(),
                now.atZone(ZoneOffset.UTC).toLocalDate()).toTotalMonths();
        if (months < 12) return months + (months == 1 ? " month ago" : " months ago");
        long years = months / 12;
        return years + (years == 1 ? " year ago" : " years ago");
    }
    return n + " " + unit + (n == 1 ? " ago" : "s ago");
}

Duration.toDays() is where Duration STOPS — there is no toMonths(). Calendar-exact months and years come from Period.between on the LocalDate side; Duration only ever handles the sub-month part.

SwSwift
import Foundation

func relativeTime(_ date: Date, relativeTo now: Date = Date()) -> String {
    let formatter = RelativeDateTimeFormatter()
    formatter.unitsStyle = .abbreviated  // '3h ago'; .full → '3 hours ago'
    formatter.dateTimeStyle = .named     // 'yesterday' when it lands exactly
    formatter.locale = Locale(identifier: "en_US")
    return formatter.localizedString(for: date, relativeTo: now)
}

print(relativeTime(Date(timeIntervalSinceNow: -3 * 3600)))  // '3h ago'

Foundation ships the whole feature — RelativeDateTimeFormatter picks the unit AND localizes. unitsStyle: .abbreviated gives '3h ago', .full gives '3 hours ago'; dateTimeStyle .named yields 'yesterday'. Formatters are expensive — reuse one when rendering a list.

KtKotlin
import java.time.Duration
import java.time.Instant
import java.time.Period
import java.time.ZoneOffset

fun relativeTime(then: Instant, now: Instant = Instant.now()): String {
    val secs = Duration.between(then, now).abs().seconds
    val (n, unit) = when {
        secs < 60 -> secs to "second"
        secs < 3_600 -> secs / 60 to "minute"
        secs < 86_400 -> secs / 3_600 to "hour"
        secs < 2_592_000 -> secs / 86_400 to "day"
        else -> {
            // Duration has no months — that half lives in Period
            val months = Period.between(
                then.atZone(ZoneOffset.UTC).toLocalDate(),
                now.atZone(ZoneOffset.UTC).toLocalDate()
            ).toTotalMonths()
            if (months < 12) return "$months month${if (months == 1L) "" else "s"} ago"
            val years = months / 12
            return "$years year${if (years == 1L) "" else "s"} ago"
        }
    }
    return "$n $unit${if (n == 1L) "" else "s"} ago"
}

Same JVM split as Java — Period vs Duration is the line: Duration below a month, Period.between(...).toTotalMonths() above it. The default-arg now keeps the function pure and coroutine-clean (no clock capture mid-suspension); inject a Clock for virtual time.

RbRuby
def relative_time(t, now = Time.now)
  secs = (now - t).to_i.abs   # Time#− is Float seconds — to_i it
  table = { 'year' => 31_536_000, 'month' => 2_592_000, 'day' => 86_400,
            'hour' => 3_600, 'minute' => 60 }
  unit, size = table.find { |_, s| secs >= s } || ['second', 1]
  n = secs / size
  phrase = "#{n} #{unit}#{'s' unless n == 1}"
  now < t ? "in #{phrase}" : "#{phrase} ago"
end

Time#− returns Float seconds — to_i before dividing or '59.999 seconds ago' leaks out. The stdlib has no formatter at all; ActiveSupport's time_ago_in_words (Rails only) is the drop-in when you are inside Rails.

ZigZig
const std = @import("std");

/// `then` / `now` are epoch SECONDS (std.time.timestamp()).
fn relativeTime(buf: []u8, then: i64, now: i64) ![]u8 {
    const delta = now - then;
    const past = delta >= 0;
    const secs: u64 = @abs(delta);

    const Unit = struct { name: []const u8, size: u64 };
    const table = [_]Unit{
        .{ .name = "year", .size = 365 * 86400 }, // flat 365 — no calendar
        .{ .name = "month", .size = 30 * 86400 }, // flat 30 — 'about a month'
        .{ .name = "day", .size = 86400 },
        .{ .name = "hour", .size = 3600 },
        .{ .name = "minute", .size = 60 },
        .{ .name = "second", .size = 1 },
    };

    for (table) |u| {
        if (secs >= u.size) {
            const n = secs / u.size;
            const plural: []const u8 = if (n == 1) "" else "s";
            return if (past)
                std.fmt.bufPrint(buf, "{d} {s}{s} ago", .{ n, u.name, plural })
            else
                std.fmt.bufPrint(buf, "in {d} {s}{s}", .{ n, u.name, plural });
        }
    }
    return std.fmt.bufPrint(buf, "now", .{});
}

std.time.timestamp() returns epoch SECONDS — JS/Java sources in milliseconds must be divided by 1000 first. std has no civil calendar at all, so months are flat 30 days and years flat 365; a day of drift at boundaries is the accepted cost.

Keep going

Try the interactive timestamp tool →