How to Migrate from Moment.js to date-fns
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 YYYY → yyyy and DD → dd 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
DD→ddvsDdistinction) - 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
- Install date-fns v3:
npm install date-fns date-fns-tz - Run the codemod:
npx @date-fns/upgrade - Review the diff — check every format string for
YYYY/DDtokens - Replace
moment-timezonewithdate-fns-tz - Convert global locale setup to per-call
{ locale }options - Update any string arguments to
new Date()orparseISO()calls - Add
eslint-plugin-you-dont-need-momentjsto lint config - Run tests and check date formatting outputs visually
- Uninstall
momentandmoment-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.
See the live comparison
View dayjs vs. date fns on PkgPulse →