Skip to main content

How to Migrate from Moment.js to date-fns

·PkgPulse Team
0

TL;DR

Migrating from Moment.js to date-fns is mostly a find-and-replace. The main mental model shift: Moment.js is OOP with chained methods on a mutable Moment object; date-fns is purely functional with immutable functions that accept native JavaScript Date objects. You'll write format(new Date(), 'yyyy-MM-dd') instead of moment().format('YYYY-MM-DD'). The biggest gotcha is format token casing — yyyy not YYYY, dd not DD — which produces wrong output without an error.

Why Migrate

Moment.js published a deprecation notice in 2020. The project is in maintenance mode — no new features, security fixes only. More importantly, Moment.js's design makes it impossible to tree-shake: importing any part of Moment imports the entire library, including all locale data. The bundle size is 67KB minified (290KB with all locales). A typical date-fns import for common operations is 8–15KB.

Bundle impact comparison:

import moment from 'moment'           → 67KB always (no tree-shaking possible)

import { format, addDays, parseISO }  → ~3KB (only what you import)
  from 'date-fns'

date-fns v3 (released 2024) introduced TypeScript-native types, better tree-shaking, and dropped CommonJS exports in favor of pure ESM. It's actively maintained with regular releases. For any project targeting modern bundlers, date-fns is the clear successor.

The other popular migration target is Day.js — lighter than date-fns, closer to Moment's API. If bundle size is the only concern and you want to minimize code changes, Day.js is a valid choice. For projects that value functional programming patterns, immutability, and TypeScript-first design, date-fns is the stronger option. See the dayjs vs date-fns comparison for a direct breakdown.


The Mental Model Shift

This is the most important thing to understand before starting migration. Moment.js wraps a timestamp in a moment() object that has methods. These methods mutate the object in place:

// Moment.js — OOP, mutable, chained
const date = moment('2026-01-15');
date.add(7, 'days');          // date is now Jan 22 — mutation!
date.startOf('month');        // date is now Jan 1 — mutation again!
console.log(date.format());   // '2026-01-01' — not what you might expect

// Common bug: cloning before mutating
const original = moment();
const copy = original;         // NOT a copy — same reference!
copy.add(1, 'day');            // Also mutates original

date-fns uses plain JavaScript Date objects and pure functions. Every function returns a new Date — the original is never modified:

// date-fns — functional, immutable, explicit
import { addDays, startOfMonth, format } from 'date-fns';

const date = new Date('2026-01-15');
const plusWeek = addDays(date, 7);          // New Date — date unchanged
const monthStart = startOfMonth(plusWeek);   // New Date — plusWeek unchanged
console.log(format(monthStart, 'yyyy-MM-dd')); // '2026-01-01'
console.log(format(date, 'yyyy-MM-dd'));       // '2026-01-15' — still original

This immutability eliminates an entire class of bugs. Any Moment.js codebase long enough will have a bug where a date was accidentally mutated because someone forgot to call .clone(). With date-fns, this bug category simply does not exist.


Format Token Reference

Format token differences are the #1 source of silent bugs during migration. The tokens look similar but behave differently — and wrong tokens produce wrong output without throwing any errors.

⚠️  Moment.js → date-fns token mapping:

Moment    date-fns    Output example        Notes
───────────────────────────────────────────────────────────────────
YYYY      yyyy        2026                  CRITICAL: different case
YY        yy          26                    2-digit year
MM        MM          03                    Month number (same)
M         M           3                    Month, no padding
MMMM      MMMM        March                 Full month (same)
MMM       MMM         Mar                   Short month (same)
DD        dd          08                    CRITICAL: different case
D         d           8                    Day, no padding
dddd      EEEE        Sunday               Full day name — different letter!
ddd       EEE         Sun                  Short day name
HH        HH          14                   24h hour (same)
hh        hh          02                   12h hour (same)
mm        mm          30                   Minutes (same)
ss        ss          45                   Seconds (same)
A         a           AM                   AM/PM
a         aaa         am                   am/pm lowercase
Z         xxx         +05:30               Timezone offset
x         (getTime()) 1709856000000        Use Date.getTime() instead

The YYYYyyyy and DDdd differences catch everyone. YYYY in Moment.js means "4-digit year"; YYYY in date-fns means "ISO week year" — a different concept that produces wrong output around year boundaries. DD in Moment.js means "day of month"; DD in date-fns means "day of year". These are not TypeScript errors; they are silent wrong outputs.


The Complete API Migration Map

Parsing

// Before (Moment.js):
moment('2026-03-08');                         // Parse ISO string — returns moment object
moment('03/08/2026', 'MM/DD/YYYY');           // Parse with custom format
moment(1709856000000);                         // Parse Unix milliseconds
moment.unix(1709856);                          // Parse Unix seconds
moment('not a date').isValid();                // Check if valid

// After (date-fns):
import { parseISO, parse, fromUnixTime, isValid } from 'date-fns';

parseISO('2026-03-08');                        // Parse ISO string → Date
parse('03/08/2026', 'MM/dd/yyyy', new Date()); // Note: dd not DD
new Date(1709856000000);                       // Native Date from milliseconds
fromUnixTime(1709856);                         // Parse Unix seconds → Date
isValid(parseISO('not-a-date'));               // false

Formatting

// Before (Moment.js):
moment().format('YYYY-MM-DD');                 // '2026-03-08'
moment().format('MMMM Do YYYY');               // 'March 8th 2026'
moment('2026-03-08').format('dddd');           // 'Sunday'
moment().format('h:mm A');                     // '2:30 PM'
moment().toISOString();                        // '2026-03-08T14:30:00.000Z'

// After (date-fns):
import { format } from 'date-fns';

format(new Date(), 'yyyy-MM-dd');              // '2026-03-08'
format(new Date(), 'MMMM do yyyy');            // 'March 8th 2026'
format(new Date('2026-03-08'), 'EEEE');       // 'Sunday'
format(new Date(), 'h:mm a');                  // '2:30 pm' (lowercase)
new Date().toISOString();                      // Native method — no import needed

Date Arithmetic

// Before (Moment.js — mutates in place):
moment().add(7, 'days');
moment().add(2, 'weeks');
moment().add(1, 'month');
moment().subtract(3, 'hours');
moment().subtract(30, 'minutes');
moment().startOf('month');
moment().endOf('week');
moment().startOf('year');

// After (date-fns — always returns new Date):
import {
  addDays, addWeeks, addMonths,
  subHours, subMinutes,
  startOfMonth, endOfWeek, startOfYear
} from 'date-fns';

const now = new Date();
addDays(now, 7);
addWeeks(now, 2);
addMonths(now, 1);
subHours(now, 3);
subMinutes(now, 30);
startOfMonth(now);
endOfWeek(now);
startOfYear(now);

Comparison and Diff

// Before (Moment.js):
moment('2026-03-08').isBefore(moment('2026-06-01'));
moment('2026-03-08').isAfter(moment('2026-01-01'));
moment('2026-03-08').isSame(moment('2026-03-08'), 'day');
moment('2026-03-08').isBetween('2026-01-01', '2026-12-31');
moment('2026-03-08').diff(moment('2026-01-01'), 'days');
moment('2026-03-08').diff(moment('2026-01-01'), 'months');

// After (date-fns):
import {
  isBefore, isAfter, isSameDay,
  isWithinInterval,
  differenceInDays, differenceInMonths
} from 'date-fns';

isBefore(new Date('2026-03-08'), new Date('2026-06-01'));   // true
isAfter(new Date('2026-03-08'), new Date('2026-01-01'));    // true
isSameDay(new Date('2026-03-08'), new Date('2026-03-08')); // true
isWithinInterval(new Date('2026-03-08'), {
  start: new Date('2026-01-01'),
  end: new Date('2026-12-31'),
}); // true
differenceInDays(new Date('2026-03-08'), new Date('2026-01-01'));    // 66
differenceInMonths(new Date('2026-03-08'), new Date('2026-01-01')); // 2

Relative Time

// Before (Moment.js):
moment('2026-01-01').fromNow();               // "2 months ago"
moment('2027-01-01').fromNow();               // "in 10 months"
moment().from(moment('2026-03-01'));           // "a week ago"
moment('2026-03-08').calendar();              // "Today at 2:30 PM"

// After (date-fns):
import { formatDistanceToNow, formatDistance, formatRelative } from 'date-fns';

formatDistanceToNow(new Date('2026-01-01'), { addSuffix: true }); // "2 months ago"
formatDistanceToNow(new Date('2027-01-01'), { addSuffix: true }); // "in 10 months"
formatDistance(new Date(), new Date('2026-03-01'));                // "about 1 week"
formatRelative(new Date('2026-03-08'), new Date());               // "today at 2:30 PM"

Timezone Handling

Moment Timezone (moment-timezone) is a separate package from Moment.js. The date-fns equivalent is date-fns-tz.

// Before (moment-timezone):
import moment from 'moment-timezone';

moment.tz('2026-03-08T14:00:00', 'America/New_York').format('YYYY-MM-DD HH:mm z');
// '2026-03-08 14:00 EST'

moment.utc('2026-03-08T19:00:00Z').tz('America/New_York').format();
// Convert UTC to New York time

// After (date-fns-tz):
import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz';

// Format a Date in a specific timezone (most common operation)
formatInTimeZone(
  new Date('2026-03-08T19:00:00Z'),
  'America/New_York',
  'yyyy-MM-dd HH:mm zzz'
); // '2026-03-08 14:00 EST'

// Get a "zoned" Date object (local time numbers match target timezone)
const nyTime = toZonedTime(new Date('2026-03-08T19:00:00Z'), 'America/New_York');
format(nyTime, 'HH:mm'); // '14:00'

// Convert a local time in a timezone back to UTC
const utcDate = fromZonedTime(
  new Date('2026-03-08T14:00:00'),
  'America/New_York'
); // Date object representing 19:00 UTC

The key difference in mental model: date-fns-tz doesn't create "timezone-aware Date objects" — JavaScript Date objects are always UTC internally. Instead, toZonedTime creates a Date whose local-time numbers match the target timezone, and formatInTimeZone formats directly without converting.


Locale (i18n) Migration

// Before (Moment.js — global locale, implicit):
import 'moment/locale/fr';
import 'moment/locale/de';
moment.locale('fr');                              // Sets global locale
moment().format('MMMM');                          // 'mars' (French)
moment('2026-01-01').fromNow();                   // 'il y a 2 mois'

// After (date-fns — per-call, tree-shakeable):
import { format, formatDistanceToNow } from 'date-fns';
import { fr, de } from 'date-fns/locale';

format(new Date(), 'MMMM', { locale: fr });       // 'mars'
formatDistanceToNow(new Date('2026-01-01'), {
  locale: fr,
  addSuffix: true,
}); // 'il y a 2 mois'

// Per-call locale means you can use multiple locales simultaneously
format(date, 'MMMM', { locale: fr });             // French
format(date, 'MMMM', { locale: de });             // German
// No global state — no race conditions

The date-fns locale approach is explicitly better: each locale is a separate import that's only included in your bundle if you actually use it. Moment.js locale imports are global side effects that affect all subsequent formatting calls.


date-fns v3 Gotchas

date-fns v3 (released 2024) has a few breaking changes from v2 worth noting:

Strings are no longer accepted where Dates are expected. In v2, passing a string to format() would work (with a deprecation warning). In v3, it throws a runtime error:

// v2: worked (with warning)
format('2026-03-08', 'yyyy-MM-dd'); // OK in v2

// v3: throws TypeError
format('2026-03-08', 'yyyy-MM-dd'); // Error: string is not a Date

// v3 correct:
format(new Date('2026-03-08'), 'yyyy-MM-dd');
format(parseISO('2026-03-08'), 'yyyy-MM-dd');

ESM-only. date-fns v3 is pure ESM — no CommonJS exports. This matters if you're running Node.js scripts with require(). You'll need either import() or to switch your scripts to ESM (.mjs or "type": "module" in package.json).


Automated Migration with the Codemod

The @date-fns/upgrade codemod handles the mechanical parts of migration — import replacements, method renaming, and some format token conversions.

npx @date-fns/upgrade

# What it handles automatically:
# - moment() → new Date()
# - .format('YYYY-MM-DD') → format(date, 'yyyy-MM-dd') with import
# - .add(7, 'days') → addDays(date, 7) with import
# - .subtract(1, 'month') → subMonths(date, 1) with import
# - .isBefore(other) → isBefore(date, other) with import

# Review every change:
git diff src/

The codemod is not perfect. It won't catch:

  • Format tokens that are context-dependent (the DDdd vs D distinction)
  • Chained method calls that depended on mutation (.add().startOf().format())
  • Any dynamic format strings (string variables passed to .format())
  • Global locale settings that need to be converted to per-call locales

The recommended approach is: run the codemod, review every diff manually, then add eslint-plugin-you-dont-need-momentjs to catch any remaining Moment.js usage in CI.


Migration Checklist

  1. Install date-fns v3: npm install date-fns date-fns-tz
  2. Run the codemod: npx @date-fns/upgrade
  3. Review the diff — check every format string for YYYY/DD tokens
  4. Replace moment-timezone with date-fns-tz
  5. Convert global locale setup to per-call { locale } options
  6. Update any string arguments to new Date() or parseISO() calls
  7. Add eslint-plugin-you-dont-need-momentjs to lint config
  8. Run tests and check date formatting outputs visually
  9. Uninstall moment and moment-timezone

Check the date-fns package page on PkgPulse for current download trends and bundle size comparison. You can also compare the abandoned package on the Moment.js package page. For a deeper comparison of date-fns and its alternatives, see dayjs vs date-fns in 2026 or the dayjs vs date-fns comparison page.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.