mrkeyoor.com_
Thu 06 Aug 02:44 UTC
npmUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability5/53.x has been stable since 2022 with only additive changes, and the 2.x to 3.x upgrade was small. A 4.0 is under public discussion rather than in flight, so nothing is going to move under you soon.
Docs5/5moment.github.io/luxon has a guided tour, dedicated pages on zones, formatting, math, and validity, a Moment migration guide, an essay on why the library exists, plus generated API docs. Among the best documentation of any date library.
Maintenance3/5The repo was pushed within the last day and issues get answers, but the published version has been 3.7.2 since September 2025 and 155 open issues remain. Development reads as careful upkeep rather than active feature work.
Ecosystem4/536.7M weekly downloads and 16.4K stars, with @types/luxon on DefinitelyTyped and adapters for chart and calendar libraries. Smaller third-party surface than date-fns, though it needs less because zones and intervals are built in.

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
Skip it if

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').hours

diff 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')   // 30

Durations 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 degrading

Do 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')           // false

Worth 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

PackageRegistryPick it when
date-fnsnpmYou want tree-shakeable functions over native Date objects and can accept the separate date-fns-tz package for zone work
dayjsnpmYou are migrating off Moment, need a near drop-in API, and 3 KB matters more than deep timezone support
@js-temporal/polyfillnpmYou want the standards-track Temporal API today and can absorb a larger polyfill until engines ship it natively
date-fns-tznpmYou already use date-fns and only need to add IANA zone conversion rather than replace the whole library