Working with Dates Using date-fns: Arithmetic, Differences, Locales and Time Zones
Key takeaways
date-fns is a set of pure functions over native Date objects: nothing is mutated and only the functions you import are bundled. This guide covers formatting tokens, parsing without the one-day-off bug, month and DST-aware arithmetic, calendar vs elapsed differences, locales, and time zones with date-fns-tz.
Introduction
date-fns provides comprehensive utilities for JavaScript dates. Unlike Moment.js, it’s modular and tree-shakeable, meaning you only import what you need.
Why date-fns?
// Moment.js (legacy, not recommended)
moment().add(7, 'days').format('YYYY-MM-DD');
// The whole library is bundled, whatever you use
// date-fns (modern, recommended)
import { addDays, format } from 'date-fns';
format(addDays(new Date(), 7), 'yyyy-MM-dd');
// Only addDays, format and their helpers are bundled
The design difference is bigger than the syntax. Moment wraps a date in a mutable object with methods: m.add(7, 'days') changes m itself, which is a classic source of bugs when the same moment object is shared between two parts of a UI. date-fns works on plain native Date objects and every function returns a new Date, so nothing you pass in is ever modified. Because each function is a separate ES module, a bundler can drop every function you never import. The Moment team itself put the project into maintenance mode in 2020 and recommends alternatives for new projects.
How it compares
| Library | Data model | Mutable | Tree-shakeable | Time zones |
|---|---|---|---|---|
| Moment.js | Wrapper object | Yes | No | Via moment-timezone |
| date-fns | Native Date + functions | No | Yes | Via date-fns-tz (or @date-fns/tz in v4) |
| Day.js | Wrapper object, Moment-like API | No | Plugins instead | Plugin |
| Luxon | Own DateTime type | No | Mostly no | Built in, via Intl |
The trade-off with date-fns is that it inherits the limitations of Date. A Date is only an instant in time (milliseconds since 1970 UTC); when you read its fields you always see them in the runtime’s local time zone. date-fns cannot represent “3 PM in Tokyo” as a value on its own, which is why time zone support lives in a companion package. If your application is heavily time-zone-centric (scheduling across regions, calendars), Luxon’s zone-aware DateTime, or the upcoming Temporal API once your targets support it, may fit better. For formatting, arithmetic and comparisons in the user’s own zone, date-fns is hard to beat.
Installation
npm install date-fns
Basic Usage
Formatting Dates
import { format } from 'date-fns';
const date = new Date(2024, 0, 15); // January 15, 2024
format(date, 'yyyy-MM-dd'); // "2024-01-15"
format(date, 'MMMM do, yyyy'); // "January 15th, 2024"
format(date, 'h:mm a'); // "12:00 AM"
format(date, "EEEE 'at' h:mm a"); // "Monday at 12:00 AM"
date-fns uses Unicode (CLDR) format tokens, not Moment’s. The two differences that catch everyone migrating from Moment are yyyy vs YYYY and dd vs DD. In Unicode tokens, YYYY is the week-numbering year and DD is the day of the year, so format(new Date(2024, 11, 30), 'YYYY-MM-DD') would give a nonsensical 2025-12-365. To prevent this, date-fns throws a RangeError for YYYY and DD telling you to use yyyy and dd instead (you can opt in with useAdditionalWeekYearTokens and useAdditionalDayOfYearTokens if you really mean them). Literal text goes in single quotes, as in 'at' above.
new Date(2024, 0, 15) uses a zero-based month: 0 is January. That is a Date quirk, not a date-fns one, but every example below inherits it.
Parsing Dates
import { parse, parseISO } from 'date-fns';
// Parse ISO string
parseISO('2024-01-15'); // Date object
// Parse custom format
parse('15/01/2024', 'dd/MM/yyyy', new Date());
parse('Jan 15, 2024', 'MMM dd, yyyy', new Date());
Prefer parseISO over new Date(string) for date-only strings. The native constructor treats '2024-01-15' as UTC midnight, so in New York it becomes January 14 at 7 PM local time and format prints the previous day. parseISO('2024-01-15') treats it as local midnight, which is what people usually mean. This one-day-off bug is the date problem I have seen most often in real frontends, and it tends to be reported only by users west of UTC.
The third argument of parse is the reference date: fields missing from the format are taken from it. parse('14:30', 'HH:mm', new Date()) gives today at 14:30. When a string does not match the format, parse does not throw; it returns an Invalid Date, which is why validation (section 9) matters.
Date Arithmetic
import { addDays, addMonths, addYears, subDays, subMonths } from 'date-fns';
const date = new Date(2024, 0, 15);
// Addition
addDays(date, 7); // January 22, 2024
addMonths(date, 3); // April 15, 2024
addYears(date, 1); // January 15, 2025
// Subtraction
subDays(date, 7); // January 8, 2024
subMonths(date, 1); // December 15, 2023
// All functions return NEW date (immutable)
const original = new Date(2024, 0, 15);
const modified = addDays(original, 7);
console.log(original); // Still January 15, 2024
console.log(modified); // January 22, 2024
Month arithmetic clamps to the end of the month: addMonths(new Date(2024, 0, 31), 1) is February 29, 2024, not March 2. That is usually what billing and subscription logic wants, but it means addMonths is not reversible: adding one month to January 31 and then subtracting one gives January 29.
addDays works in calendar days, not 24-hour blocks. Across a daylight-saving change, addDays(date, 1) keeps the same wall-clock time and the underlying gap is 23 or 25 hours. If you really need a fixed duration, use addHours(date, 24).
Comparison
import {
isAfter,
isBefore,
isEqual,
isFuture,
isPast,
isToday,
isYesterday,
isTomorrow,
isWeekend,
} from 'date-fns';
const date1 = new Date(2024, 0, 15);
const date2 = new Date(2024, 0, 20);
isAfter(date2, date1); // true
isBefore(date1, date2); // true
isEqual(date1, date1); // true
isFuture(new Date(2099, 0, 1)); // true
isPast(new Date(2020, 0, 1)); // true
isToday(new Date()); // true
isWeekend(new Date(2024, 0, 13)); // true (Saturday)
isEqual compares instants to the millisecond. Two dates on the same day but at different times are not equal; for “same calendar day” use isSameDay (and isSameMonth, isSameYear and so on). Never compare dates with ===: two separate Date objects are different objects even when they hold the same time. isToday and friends use the runtime’s local time zone, so on a server running in UTC they can disagree with what the user sees.
Difference Calculations
import {
differenceInDays,
differenceInMonths,
differenceInYears,
differenceInHours,
differenceInMinutes,
} from 'date-fns';
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 15);
differenceInDays(end, start); // 14
differenceInHours(end, start); // 336
differenceInMinutes(end, start); // 20160
const birthDate = new Date(1990, 0, 1);
differenceInYears(new Date(), birthDate); // full years elapsed as of today
The differenceIn* functions count complete units and truncate the rest: differenceInDays between January 1 at 23:00 and January 2 at 01:00 is 0, because only two hours passed. When you want “how many calendar days apart are these dates”, use differenceInCalendarDays, which gives 1 for the same pair. Mixing the two up is behind many off-by-one bugs in “due in N days” labels. Argument order is (later, earlier); reversing it returns a negative number rather than an error.
Start/End of Period
import {
startOfDay,
endOfDay,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
startOfYear,
endOfYear,
} from 'date-fns';
const date = new Date(2024, 0, 15, 14, 30); // Jan 15, 2:30 PM
startOfDay(date); // Jan 15, 12:00:00 AM
endOfDay(date); // Jan 15, 11:59:59.999 PM
startOfWeek(date); // Jan 14 (Sunday)
endOfWeek(date); // Jan 20 (Saturday)
startOfWeek(date, { weekStartsOn: 1 }); // Jan 15 (Monday)
startOfMonth(date); // Jan 1
endOfMonth(date); // Jan 31
startOfYear(date); // Jan 1
endOfYear(date); // Dec 31
Weeks start on Sunday by default because the default locale is en-US. Most of Europe and many other regions start the week on Monday; pass weekStartsOn: 1, or pass a locale option, whose week settings are used automatically. endOfDay returns 23:59:59.999, which is convenient for inclusive range checks, but for database queries a half-open range (>= startOfDay(d) and < startOfDay(addDays(d, 1))) is more robust, because it does not depend on the storage precision of the timestamp column.
Internationalization
npm install date-fns
import { format } from 'date-fns';
import { ko, ja, es, fr } from 'date-fns/locale';
const date = new Date(2024, 0, 15);
// English (default)
format(date, 'PPPP');
// "Monday, January 15th, 2024"
// Korean
format(date, 'PPPP', { locale: ko });
// "2024년 1월 15일 월요일"
// Japanese
format(date, 'PPPP', { locale: ja });
// "2024年1月15日月曜日"
// Spanish
format(date, 'PPPP', { locale: es });
// "lunes, 15 de enero de 2024"
P, PP, PPP and PPPP are localized long-date formats: each locale decides the order and wording, which is much better than hard-coding 'MMMM do, yyyy' and translating the month names. Locales are ordinary imports, so only the ones you import end up in the bundle. If your app supports many languages, load the locale dynamically (await import('date-fns/locale/ja')) instead of importing all of them up front. Passing a locale to format does not change the time zone; that is a separate concern.
Time Zones
npm install date-fns date-fns-tz
import { format } from 'date-fns';
import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz'; // date-fns-tz v3
const date = new Date('2024-01-15T12:00:00Z');
// Format in specific timezone
formatInTimeZone(date, 'America/New_York', 'yyyy-MM-dd HH:mm:ss zzz');
// "2024-01-15 07:00:00 EST"
formatInTimeZone(date, 'Asia/Seoul', 'yyyy-MM-dd HH:mm:ss zzz');
// "2024-01-15 21:00:00 GMT+9" (short names depend on the locale's data)
// Convert UTC to timezone (v2 name: utcToZonedTime)
const newYorkDate = toZonedTime(date, 'America/New_York');
// Convert timezone to UTC (v2 name: zonedTimeToUtc)
const utcDate = fromZonedTime('2024-01-15 12:00:00', 'America/New_York');
formatInTimeZone is the function you want most of the time: it takes a real instant and prints it as it appears in a given zone, without changing the instant. toZonedTime is subtler. It returns a different Date whose local-time fields happen to show New York’s wall-clock time; the instant inside is shifted. That is useful for feeding date pickers or format, but if you send that Date to a server or call toISOString() on it, you get a wrong time. Keep real instants in your data and use zoned dates only at the display edge.
fromZonedTime answers the reverse question, “what instant is 12:00 on January 15 in New York?”, which is what you need when a user picks a time in a form. Around daylight-saving transitions some wall-clock times do not exist (2:30 AM on the spring-forward day) or happen twice, and the library has to pick one; if your domain cares, test those dates explicitly. date-fns v4 also introduced first-party time zone support through the separate @date-fns/tz package with a TZDate class, which avoids the shifted-instant trick.
The short name zzz depends on the locale data: English locales know “EST” but typically print GMT+9 for Seoul. Use xxx (+09:00) when you need something unambiguous.
Validation
import { isValid, isDate, isMatch } from 'date-fns';
isValid(new Date()); // true
isValid(new Date('invalid')); // false
isDate(new Date()); // true
isDate('2024-01-15'); // false
isMatch('2024-01-15', 'yyyy-MM-dd'); // true
isMatch('15/01/2024', 'yyyy-MM-dd'); // false
isValid checks that a value is a usable date, not that a string was well-formed: isValid(new Date(2024, 1, 30)) is true, because the native constructor silently rolls February 30 over to March 1. parse is stricter and returns Invalid Date for '2024-02-30' with 'yyyy-MM-dd', so for user input, parse with an explicit format and then call isValid on the result. Formatting an invalid date throws RangeError: Invalid time value, which is often how these bugs surface first.
Real-World Examples
Relative Time Display
import { formatDistanceToNow } from 'date-fns';
const postDate = subDays(new Date(), 1);
formatDistanceToNow(postDate, { addSuffix: true });
// "1 day ago"
const futureDate = addDays(new Date(), 5);
formatDistanceToNow(futureDate, { addSuffix: true });
// "in 5 days"
(subDays and addDays come from the arithmetic section.) formatDistanceToNow rounds generously: anything from about 45 minutes to 90 minutes is “about 1 hour”. That fits a “posted 3 hours ago” label but not a precise countdown. For server-rendered pages, remember the text is computed at render time; a cached page will keep saying “2 minutes ago” for hours, so relative labels are usually rendered on the client or refreshed periodically.
Date Range Filtering
import { isWithinInterval } from 'date-fns';
const rangeStart = new Date(2024, 0, 1);
const rangeEnd = new Date(2024, 0, 31);
const testDate = new Date(2024, 0, 15);
isWithinInterval(testDate, { start: rangeStart, end: rangeEnd });
// true
Both ends are inclusive. Note that rangeEnd here is January 31 at 00:00, so a date at noon on January 31 is outside the interval. Use endOfDay(rangeEnd) or endOfMonth(rangeStart) when you mean “through the end of the 31st”.
Business Days
import { addBusinessDays, isWeekend, differenceInBusinessDays } from 'date-fns';
const date = new Date(2024, 0, 15); // Monday
addBusinessDays(date, 5); // Next Monday (skips weekend)
differenceInBusinessDays(
new Date(2024, 0, 22),
new Date(2024, 0, 15)
); // 5 business days
“Business day” here means Monday to Friday only. Public holidays are not considered, and date-fns has no holiday calendar, so real SLA or delivery-date calculations need your own list of holidays on top of these functions.
Age Calculator
import { differenceInYears, differenceInMonths, differenceInDays } from 'date-fns';
function calculateAge(birthDate) {
const now = new Date();
const years = differenceInYears(now, birthDate);
const months = differenceInMonths(now, birthDate) % 12;
return { years, months };
}
calculateAge(new Date(1990, 0, 15));
// { years: <full years>, months: <remaining months> }, relative to today
intervalToDuration({ start: birthDate, end: new Date() }) returns years, months and days in one call and handles month lengths for you, which is simpler than combining several differenceIn* results.
Event Countdown
import { intervalToDuration, formatDuration } from 'date-fns';
function countdown(eventDate) {
const duration = intervalToDuration({
start: new Date(),
end: eventDate,
});
return formatDuration(duration);
}
countdown(new Date(2099, 11, 25));
// e.g. "73 years 2 months 30 days 3 hours 25 minutes 12 seconds" (depends on today)
formatDuration omits zero-valued units and, by default, prints every unit down to seconds; pass { format: ['days', 'hours'] } to limit it. Check isPast(eventDate) first so a finished event does not produce a confusing duration.
Imports, storage format and invalid dates
Import specific functions
// Good: tree-shakeable
import { format, addDays } from 'date-fns';
// Risky: may import everything, depending on your bundler
import * as dateFns from 'date-fns';
Modern bundlers can often tree-shake a namespace import as long as you only access its members statically, but it is easy to defeat (passing dateFns around, or dynamic property access), and older setups do not handle it at all. Named imports make the bundle size obvious.
Store ISO strings, and know which offset you get
import { formatISO, parseISO } from 'date-fns';
// Store dates as ISO strings
const dateString = formatISO(new Date());
// "2024-01-15T21:00:00+09:00" (local offset, no milliseconds)
// Parse back to Date
const date = parseISO(dateString);
formatISO writes the local offset, not Z, and drops milliseconds. Both are valid ISO 8601 and round-trip correctly through parseISO, but if your API or database expects UTC with a Z suffix, use the native date.toISOString(), which always gives 2024-01-15T12:00:00.000Z. For pure dates without a time (birthdays, due dates), store 'yyyy-MM-dd' strings instead of timestamps, so no time zone conversion can move them to another day.
Validate at the boundary
import { isValid, parse } from 'date-fns';
function parseDate(dateString, formatString) {
const parsed = parse(dateString, formatString, new Date());
if (!isValid(parsed)) {
throw new Error('Invalid date');
}
return parsed;
}
The reason for this wrapper is that date-fns never throws while parsing. When the input does not match the pattern, parse and parseISO return an Invalid Date object, which flows through addDays, comparisons and state updates without complaint. The error only surfaces later, when format is called on it and throws RangeError: Invalid time value, often in a render function far from the form field that produced the bad string. Checking isValid right where user input or API data becomes a Date keeps that failure next to its cause.
Frequently Asked Questions (FAQ)
Q. Why can’t I import utcToZonedTime or zonedTimeToUtc from date-fns-tz anymore?
A. date-fns-tz v3 renamed those functions: utcToZonedTime became toZonedTime and zonedTimeToUtc became fromZonedTime, while formatInTimeZone kept its name. The time zone examples in this article use the new names; if you are on date-fns-tz v2, use the old ones. The behavior is the same: one shifts a UTC instant into a zone’s wall-clock time, the other interprets a wall-clock time in a zone and returns the UTC instant.
Q. Why does my date show up one day earlier than the string I parsed?
A. You probably used new Date('2024-01-15'), which the JavaScript spec treats as UTC midnight. In any time zone west of UTC that instant is still the previous day locally. Use parseISO('2024-01-15') or parse(str, 'yyyy-MM-dd', new Date()), which interpret the date in local time.
Q. Why does format throw a RangeError about YYYY?
A. In date-fns tokens, YYYY means the week-numbering year and DD the day of the year, which are almost never what you want. date-fns refuses them by default and asks you to use yyyy and dd.