How to Migrate from Moment.js to date-fns 2026
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
Day.js vs date-fns: Choosing Your Migration Target
The two main Moment.js migration targets have different design philosophies, and the choice matters more than people initially think.
Day.js was designed as a Moment.js drop-in. The API is intentionally similar: dayjs().add(7, 'day').format('YYYY-MM-DD') instead of moment().add(7, 'day').format('YYYY-MM-DD'). If your primary goal is to reduce bundle size with minimal code changes, Day.js is the easier path. The codemod surface is smaller, and your team's existing Moment.js knowledge transfers almost directly. Day.js is 2.7KB gzipped — a 97% reduction from Moment's 72KB.
date-fns has a different philosophy: pure functions, immutable inputs, explicit imports. It is not trying to be Moment.js. The migration requires more conceptual adjustment because format(new Date(), 'yyyy-MM-dd') looks different from moment().format('YYYY-MM-DD'), even though it does the same thing. The payoff is a codebase where date operations are explicit, composable, and tree-shakeable at the function level. date-fns is also TypeScript-first in v3, with return types and parameter types that catch errors at compile time that Moment.js and Day.js would let through silently.
The decision rule: if you're migrating a large existing codebase and want to minimize churn, choose Day.js. If you're starting a new project or doing a significant refactor anyway, choose date-fns. Either is a good choice; neither is Moment.js, which is the important thing.
One practical difference that affects the decision: Day.js uses plugins for features like timezone support, relative time, and custom parsing. The plugin model keeps the core small but means you need to discover, import, and register the right plugin for each feature. date-fns ships everything as individual named exports — no plugin registration, just import the function you need. For teams that want to minimize API surface area and configuration, date-fns's single-package model is simpler to reason about. For teams that want to configure exactly which capabilities they include and keep the total bundle minimal, Day.js's plugin model gives finer control.
TypeScript support also differs meaningfully. date-fns v3 was rewritten with TypeScript-native types — no separate @types/date-fns package, and the types accurately reflect the function signatures including edge cases. Day.js ships types as a separate declaration file that can lag behind the implementation. For TypeScript-first teams, date-fns is the stronger choice on type quality alone. The format function's return type in date-fns is string, with the input parameters typed precisely enough to catch common mistakes at compile time rather than producing wrong output at runtime.
Testing Code That Manipulates Dates
One challenge during migration that the codemod doesn't address: tests that use moment() without arguments to get the current time. These tests are usually brittle because they depend on real wall clock time and can produce different results depending on when they run.
The standard fix is to mock the system clock in tests. Both Jest and Vitest provide useFakeTimers() for this. With date-fns, you pass Date objects explicitly to most functions, which makes mocking straightforward:
// Vitest / Jest — control the system clock in tests
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
test('formats the current date correctly', () => {
// date-fns uses new Date() internally when called without args
expect(format(new Date(), 'yyyy-MM-dd')).toBe('2026-01-15');
});
test('computes days until end of month', () => {
const today = new Date();
const endOfMonth = endOfMonth_fn(today);
const days = differenceInDays(endOfMonth, today);
expect(days).toBe(16); // Jan 15 → Jan 31 = 16 days
});
For testing timezone-sensitive code with date-fns-tz, mock the system clock and pass explicit timezone strings. Avoid tests that depend on the machine's local timezone being a specific value — CI servers often run in UTC while developer machines are in local timezones, causing tests to pass locally and fail in CI.
If you have utility functions that take a date parameter, design them to accept a Date argument rather than calling new Date() internally. This makes them pure functions that are trivially testable without any mocking at all, which is a better long-term design pattern than relying on useFakeTimers.
Incremental Migration for Large Codebases
For codebases with hundreds of Moment.js usages, a complete migration in one PR is too risky. The recommended approach is gradual replacement over several weeks.
Start by installing date-fns alongside Moment.js — both can coexist, and the project will build and run fine with both present. Add eslint-plugin-you-dont-need-momentjs to your ESLint configuration immediately, but set all its rules to warn rather than error. This marks Moment.js usages as warnings without breaking CI, making them visible in editor gutter marks and lint output without requiring immediate fixes.
Migrate by module rather than by usage. Pick a directory or feature area, migrate all date handling in that area, and review the diff. This gives you bounded, reviewable PRs rather than a massive one-time change. Engineers working in a module that's been migrated learn the date-fns API organically through code review rather than from documentation alone.
Once a module is fully migrated, change its ESLint rules from warn to error at the directory level using an .eslintrc override. This prevents Moment.js from being re-introduced into migrated code.
The final step is upgrading the ESLint rules to error globally and removing Moment.js from package.json. At that point, any remaining Moment.js imports will break CI, forcing any remaining usages to be addressed.
The Temporal API: What's Coming
The TC39 Temporal proposal is the future of date handling in JavaScript — a first-class date API in the language itself that addresses all of Moment.js's shortcomings: immutability, timezone correctness, precise calendar arithmetic, and a clear distinction between "instant" (a point in time) and "plain date" (a calendar date without timezone).
Temporal is Stage 3 as of 2026 and available in some runtimes behind flags. It is not yet safe for production use without a polyfill, and the polyfill adds ~150KB. For production code today, date-fns remains the correct choice.
The reason to be aware of Temporal now is that date-fns's functional style and explicit Date objects will map more cleanly to Temporal than Moment.js's chained OOP style would. Code written with date-fns today — pure functions taking and returning values — will be easier to migrate to Temporal when it ships broadly in 2027-2028 than code written with Moment.js's mutable object model.
Package Health
| Package | Weekly Downloads | Bundle Size | Status |
|---|---|---|---|
moment | ~12M | 72KB | Maintenance only |
date-fns | ~35M | ~3KB (typical import) | Active |
dayjs | ~20M | 2.7KB | Active |
date-fns-tz | ~6M | ~3KB | Active |
luxon | ~8M | 23KB | Active |
Moment.js download numbers remain high because of existing projects that haven't migrated. New projects overwhelmingly choose date-fns or Day.js.
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 →