luxon
Luxon is a date and time library written by one of Moment.js's maintainers as its intended successor. It gives you three immutable types: DateTime for an instant plus a time zone and locale, Duration for a length of time, and Interval for a span between two DateTimes. Every operation returns a new object, so nothing you pass around can be mutated behind your back. Time zones and localized formatting come from the runtime's built-in Intl API rather than bundled data files, which is why the whole thing is about 21 KB gzipped while still handling every IANA zone. Invalid dates are represented as invalid DateTime objects rather than exceptions, which is a design choice you have to plan for.
The strongest choice today when time zones, durations, and intervals are real requirements rather than incidental. Check isValid on anything parsed from user input, and keep an eye on Temporal, which is where this problem space is headed.
Use it if
- Time zones are central to your product: setZone, keepLocalTime, zone-aware parsing, and DST-correct arithmetic are first-class here rather than plugins bolted on afterwards
- You need Interval and Duration as real types: overlaps, splitBy, shiftTo, and ISO 8601 duration parsing are built in and tedious to hand-roll
- You want localized output without shipping locale files: toLocaleString and setLocale delegate to Intl, so all locales the runtime knows are available at no bundle cost
- You are coming from Moment and want the same mental model with immutability, explicit zones, and no global mutation of instances
- Bundle size is your binding constraint: Luxon is a class-based API that does not tree-shake, so importing DateTime pulls essentially the whole 21 KB gzipped, while date-fns lets you import only the functions you call
- You are starting fresh and can target Temporal: it is the standards-track replacement, and Luxon's own README points at an open discussion about what a 4.0 should even be in that world
- Your runtime has no full ICU: zone conversion and localized formatting rely on Intl, so a Node build without full-icu or an older React Native runtime silently degrades to English and UTC-ish behavior
- You format or convert thousands of dates in a loop: every Intl call is comparatively expensive, and heavy zone conversion shows up in profiles in a way that plain Date arithmetic does not
- You want failures to be loud: DateTime.fromISO('garbage') returns an invalid DateTime, and every downstream call on it yields the literal string 'Invalid DateTime' instead of throwing, so bad input reaches your UI or database unless you check isValid or opt into Settings.throwOnInvalid
- You need frequent releases: 3.7.2 has been the published version since September 2025, with 155 open issues (179 including PRs) against it
Setup reality
npm install luxon with zero dependencies, and TypeScript users need the separate @types/luxon package because none are bundled. The gotchas are runtime, not install. Zones and locales come from Intl, so Node needs full ICU (bundled since Node 13, but small-icu builds and some Docker base images still exist) and older mobile JS engines can lack it entirely. Import style matters: import { DateTime } from 'luxon' is a named import from a CommonJS build, so bundler and Jest configs occasionally need transform tweaks. Most teams set Settings.defaultZone and Settings.defaultLocale once at startup, otherwise output changes with the machine's system zone. Turning on Settings.throwOnInvalid gives you exceptions instead of invalid objects, but it is global and third-party code in your process gets the same behavior.
Patterns
Get the current time and format itnow-and-format
import { DateTime } from 'luxon'
const now = DateTime.now()
now.toISO() // '2026-08-06T10:15:30.000+05:30'
now.toFormat('dd LLL yyyy HH:mm') // '06 Aug 2026 10:15'
now.toLocaleString(DateTime.DATETIME_MED) // 'Aug 6, 2026, 10:15 AM'Prefer toLocaleString with a preset over toFormat: presets follow the user's locale conventions, while toFormat hard-codes one arrangement for everyone.
Parse input and check that it workedparse-and-validate
const dt = DateTime.fromISO('2026-13-45')
if (!dt.isValid) {
console.error(dt.invalidReason) // 'unit out of range'
console.error(dt.invalidExplanation) // 'you specified 13 (of type number) as a month...'
}
dt.toFormat('yyyy') // 'Invalid DateTime' (a string, not an error)This is the single biggest Luxon footgun. Invalid DateTimes propagate happily through plus, setZone, and formatting, producing the string 'Invalid DateTime' wherever they land.
Parse a non-ISO stringparse-custom-format
DateTime.fromFormat('06/08/2026 14:30', 'dd/MM/yyyy HH:mm')
DateTime.fromFormat('6 Aug 2026', 'd LLL yyyy', { locale: 'en', zone: 'Europe/Paris' })
DateTime.fromSQL('2026-08-06 14:30:00')
DateTime.fromRFC2822('Thu, 06 Aug 2026 14:30:00 +0530')Luxon format tokens are not Moment's: yyyy not YYYY, dd not DD, and LLL for a standalone month name. Getting this wrong yields an invalid DateTime rather than a warning.
Convert between time zonesconvert-timezone
const utc = DateTime.fromISO('2026-08-06T09:00:00Z', { zone: 'utc' })
utc.setZone('America/New_York').toFormat('HH:mm') // '05:00' (same instant)
utc.setZone('America/New_York', { keepLocalTime: true }) // 09:00 in New York
utc.toLocal() // system zone
utc.zoneName // 'UTC'{ zone } on the parser says how to interpret the input; setZone afterwards changes how it is displayed. keepLocalTime moves the instant instead of the label, which is what calendar events usually want.
Add and subtract time safely across DSTdate-arithmetic
const d = DateTime.fromISO('2026-03-08T12:00', { zone: 'America/New_York' })
d.plus({ days: 1 }) // next day at 12:00 local, DST handled
d.plus({ hours: 24 }) // 24 real hours later: 13:00 local on a spring-forward day
d.minus({ months: 1, weeks: 2 })
d.startOf('month').endOf('day')Calendar units (days, months) keep the wall-clock time across a DST shift; exact units (hours, minutes) do not. Picking the wrong one is how scheduled jobs drift by an hour twice a year.
Measure the gap between two datesdifference-between-dates
const a = DateTime.fromISO('2026-01-01')
const b = DateTime.fromISO('2026-08-06')
b.diff(a).as('days') // 217
b.diff(a, ['months', 'days']).toObject() // { months: 7, days: 5 }
b.diffNow('hours').hoursdiff returns a Duration, not a number. Without a units argument it holds milliseconds only, so calling .days on it gives 0 until you ask for that unit explicitly.
Build and reshape durationsdurations
import { Duration } from 'luxon'
const d = Duration.fromObject({ hours: 3, minutes: 95 })
d.shiftTo('hours', 'minutes').toObject() // { hours: 4, minutes: 35 }
d.toHuman() // '3 hours, 95 minutes'
d.toISO() // 'PT3H95M'
Duration.fromISO('P1DT6H').as('hours') // 30Durations do not normalize themselves. toHuman prints exactly the units you put in, so call normalize() or shiftTo() first if 95 minutes should read as 1 hour 35 minutes.
Work with a span of timeintervals
import { DateTime, Interval } from 'luxon'
const shift = Interval.fromDateTimes(
DateTime.fromISO('2026-08-06T09:00'),
DateTime.fromISO('2026-08-06T17:00'),
)
shift.contains(DateTime.fromISO('2026-08-06T12:00')) // true
shift.length('hours') // 8
shift.splitBy({ hours: 1 }).length // 8 one-hour slots
shift.overlaps(otherInterval)Intervals are half-open: the start is included and the end is not, so back-to-back intervals never report as overlapping and contains() is false at the exact end instant.
Render 'x days ago' style textrelative-time
DateTime.fromISO('2026-08-04').toRelative() // '2 days ago'
DateTime.now().plus({ hours: 5 }).toRelative() // 'in 5 hours'
DateTime.fromISO('2026-08-05').toRelativeCalendar() // 'yesterday'
DateTime.fromISO('2026-08-04').toRelative({ locale: 'fr' }) // 'il y a 2 jours'toRelative gives an elapsed-time phrase; toRelativeCalendar gives a calendar-day phrase. Both are computed against the current clock, so they go stale in a server-rendered page.
Move between Date, epoch numbers, and DateTimenative-date-interop
DateTime.fromJSDate(new Date())
DateTime.fromMillis(1785000000000)
DateTime.fromSeconds(1785000000)
const dt = DateTime.now()
dt.toJSDate() // Date
dt.toMillis() // 1785000000000
dt.toUnixInteger() // 1785000000 (seconds, truncated)A JS Date has no zone, so fromJSDate lands in the system zone unless you pass { zone }. toUnixInteger truncates rather than rounds, which matters when you compare against another system's timestamps.
Set zone, locale, and strictness onceglobal-defaults
import { Settings } from 'luxon'
Settings.defaultZone = 'Asia/Kolkata'
Settings.defaultLocale = 'en-IN'
Settings.throwOnInvalid = true // invalid dates now raise instead of degradingDo this at process startup, before any DateTime is created. throwOnInvalid is process-global and also applies to any library in your dependency tree that uses Luxon.
Detect whether the runtime supports zones and relative timecheck-runtime-support
import { Info } from 'luxon'
Info.features() // { relative: true, localeWeek: true }
Info.isValidIANAZone('Asia/Kolkata') // true
Info.hasDST('Asia/Kolkata') // falseWorth asserting at boot in React Native or a slim Node image: without full ICU, zone conversion and toRelative quietly return wrong or English-only output rather than failing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| date-fns | npm | You want tree-shakeable functions over native Date objects and can accept the separate date-fns-tz package for zone work |
| dayjs | npm | You are migrating off Moment, need a near drop-in API, and 3 KB matters more than deep timezone support |
| @js-temporal/polyfill | npm | You want the standards-track Temporal API today and can absorb a larger polyfill until engines ship it natively |
| date-fns-tz | npm | You already use date-fns and only need to add IANA zone conversion rather than replace the whole library |