---
name: jalali-dates
description: Handle Iranian Solar Hijri (Jalali / Shamsi / Persian calendar) dates correctly in software - store UTC/Gregorian and convert at the edges, display with Intl (fa-IR-u-ca-persian), pick proven conversion libraries for JavaScript, Python, PHP and Go, avoid leap-year bugs (Esfand 30, the broken 2820-year algorithm), use the Asia/Tehran zone (UTC+03:30, no DST since 2022), Saturday-first weeks, Jalali month range queries in SQL, Jalali date pickers, and known test vectors. Use when code parses, formats, stores, compares, groups or queries dates for Iranian users, or when the user mentions تاریخ شمسی, جلالی, خورشیدی, Jalali, Shamsi or Persian calendar. تاریخ شمسی در نرم‌افزار
---

# تاریخ شمسی (Jalali dates in software)

## Core rules

1. **Store Gregorian, never Jalali strings.** Instants go in UTC (`timestamptz`, ISO 8601 with `Z`). Date-only values (birthday, due date) go in a Gregorian `DATE`. A Jalali string such as `1403/01/01` in a VARCHAR is never the source of truth. If a legal document needs the Jalali text exactly as entered, store it *in addition* to the Gregorian value.
2. **Convert at the edges.** Parse Jalali input to Gregorian at the API boundary; format Gregorian to Jalali in the UI or report layer.
3. **Convert in the Asia/Tehran zone.** The Jalali date of an instant depends on the zone: `2025-03-20T21:00:00Z` is 00:30 on 1404/01/01 in Tehran but still 1403/12/30 in UTC. Pass the zone explicitly; never rely on the server's local zone.
4. **Never hand-roll the leap rule.** Use Intl or one of the libraries below.

## Timezone: Asia/Tehran

- UTC+03:30 all year. Iran stopped daylight saving after the clock change on 2022-09-21; tzdata 2022b and later encode this.
- Systems with older tzdata still jump to +04:30 every spring. Update tzdata in the OS image, Docker base image, JVM, and the Python `tzdata` package.
- Check: `zdump -v Asia/Tehran | tail -n 3` should show the last transition on 2022-09-21. In JS:
  `new Intl.DateTimeFormat('en-US', {timeZone: 'Asia/Tehran', timeZoneName: 'longOffset'}).format(new Date('2026-07-01T00:00:00Z'))` gives `7/1/2026, GMT+03:30`.
- Use the zone name, not a hard-coded `+03:30`. Parliament debated bringing DST back as recently as 2025, so the rule may change again.
- Python on Windows: `zoneinfo` needs `pip install tzdata`. MySQL: named zones such as `'Asia/Tehran'` work only after the time zone tables are loaded (`mysql_tzinfo_to_sql`).

## Calendar facts

| # | Month | Days |
|---|---|---|
| 1 | فروردین Farvardin | 31 |
| 2 | اردیبهشت Ordibehesht | 31 |
| 3 | خرداد Khordad | 31 |
| 4 | تیر Tir | 31 |
| 5 | مرداد Mordad | 31 |
| 6 | شهریور Shahrivar | 31 |
| 7 | مهر Mehr | 30 |
| 8 | آبان Aban | 30 |
| 9 | آذر Azar | 30 |
| 10 | دی Dey | 30 |
| 11 | بهمن Bahman | 30 |
| 12 | اسفند Esfand | 29, or 30 in a leap year |

- 1 Farvardin (Nowruz) currently falls on March 20 or 21. Always convert; never compute it.
- The official calendar is astronomical. Recent leap years are 1395, 1399 and 1403; the next one is **1408**. The gap from 1403 to 1408 is five years, so "every 4 years" logic is wrong.
- **The 2820-year (Birashk) algorithm is wrong for current dates.** It makes 1404 the leap year instead of 1403, so software using it was one day off from 30 Esfand 1403 to the end of 1404. If date code contains the number 2820, replace it with one of the libraries below.
- Weekdays: شنبه، یکشنبه، دوشنبه، سه‌شنبه، چهارشنبه، پنجشنبه، جمعه. The week starts on Saturday.

## Display with Intl (no library)

```js
const d = new Date('2024-03-20T12:00:00Z');

new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
  timeZone: 'Asia/Tehran', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d); // '۱۴۰۳/۰۱/۰۱'

new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn', {
  timeZone: 'Asia/Tehran', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d); // '1403/01/01' (Latin digits)

// Numeric parts for logic: read year/month/day from formatToParts
const parts = Object.fromEntries(
  new Intl.DateTimeFormat('en-US-u-ca-persian', {
    timeZone: 'Asia/Tehran', year: 'numeric', month: 'numeric', day: 'numeric',
  }).formatToParts(d).map((p) => [p.type, p.value]),
); // { year: '1403', month: '1', day: '1', ... }
```

- Always pass `timeZone`.
- The word order of long formats (`month: 'long'`, `weekday: 'long'`, `dateStyle: 'full'`) differs between ICU versions and browsers. When the exact layout matters, build the string yourself from `formatToParts`.
- Relative time: `new Intl.RelativeTimeFormat('fa', {numeric: 'auto'}).format(-1, 'day')` gives `دیروز`.
- Intl only formats. For parsing Jalali input and doing date arithmetic, use a library.

## Libraries

| Language | Library | Use for |
|---|---|---|
| JS | `jalaali-js` | Tiny converter, no dependencies: `toJalaali`, `toGregorian`, `isValidJalaaliDate`, `isLeapJalaaliYear`, `jalaaliMonthLength` |
| JS | `date-fns-jalali` | The date-fns API with Jalali semantics (format, addMonths, startOfMonth...) |
| JS | `dayjs` + `jalaliday` | Day.js plugin. v3 is ESM-only: `import jalaliday from 'jalaliday/dayjs'` |
| React UI | `@mui/x-date-pickers` with `AdapterDateFnsJalali`; `react-multi-date-picker` | Jalali date pickers |
| Python | `jdatetime`, `persiantools` | Jalali `date`/`datetime` types |
| PHP | `morilog/jalali` | `Jalalian`, `CalendarUtils`, Carbon interop |
| Go | `github.com/yaa110/go-persian-calendar` (package `ptime`) | Persian `Time` type that works with `time.Time` |

Avoid moment-jalaali in new code: Moment.js is in maintenance mode.

### JavaScript

```js
import { toJalaali, toGregorian, isValidJalaaliDate } from 'jalaali-js';
toJalaali(2025, 3, 20);              // { jy: 1403, jm: 12, jd: 30 }
toGregorian(1404, 1, 1);             // { gy: 2025, gm: 3, gd: 21 }
isValidJalaaliDate(1404, 12, 30);    // false (1404 is not a leap year)

import { format, newDate } from 'date-fns-jalali';
format(new Date(2024, 2, 20), 'yyyy/MM/dd'); // '1403/01/01'
newDate(1403, 0, 1);                 // Date for 2024-03-20 (month is 0-based!)

import dayjs from 'dayjs';
import jalaliday from 'jalaliday/dayjs';
dayjs.extend(jalaliday);
dayjs('2024-03-20').calendar('jalali').format('YYYY/MM/DD'); // '1403/01/01'
dayjs('1403-01-01', { jalali: true }).format('YYYY-MM-DD');  // '2024-03-20'
```

`jalaali-js` takes calendar-date parts (1-based months), not instants: get the Tehran date parts first (formatToParts above), then convert. `date-fns-jalali` and `date-fns` operate on the JS `Date` in the runtime's local zone, so run servers with `TZ=Asia/Tehran` or convert explicitly.

MUI X: `import { AdapterDateFnsJalali } from '@mui/x-date-pickers/AdapterDateFnsJalali'` works with date-fns-jalali v3/v4. For date-fns-jalali v2, import from `@mui/x-date-pickers/AdapterDateFnsJalaliV2`.

### Python

```python
import datetime, jdatetime
from zoneinfo import ZoneInfo

jdatetime.date.fromgregorian(date=datetime.date(2024, 3, 20))  # 1403-01-01
jdatetime.date(1403, 12, 30).togregorian()                    # 2025-03-20
jdatetime.date(1404, 1, 1).isleap()                           # False

now_tehran = datetime.datetime.now(ZoneInfo("Asia/Tehran"))
jdatetime.date.fromgregorian(date=now_tehran.date())

from persiantools.jdatetime import JalaliDate
JalaliDate(datetime.date(2024, 3, 20))      # 1403-01-01
JalaliDate(1403, 12, 30).to_gregorian()     # 2025-03-20
```

### PHP

```php
// composer require morilog/jalali:3.*
use Morilog\Jalali\Jalalian;
use Morilog\Jalali\CalendarUtils;

$dt = new DateTime('2025-03-20 12:00', new DateTimeZone('Asia/Tehran'));
Jalalian::fromDateTime($dt)->format('Y/m/d');   // 1403/12/30
CalendarUtils::toGregorian(1403, 12, 30);       // [2025, 3, 20]
CalendarUtils::checkDate(1404, 12, 30);         // false
(new Jalalian(1405, 7, 1))->toCarbon();         // Carbon 2026-09-23
```

### Go

```go
import ptime "github.com/yaa110/go-persian-calendar"

pt := ptime.New(time.Now().In(ptime.Iran()))
pt.Format("yyyy/MM/dd")
g := ptime.Date(1403, ptime.Esfand, 30, 0, 0, 0, 0, ptime.Iran()).Time() // 2025-03-20
```

## Querying by Jalali month or year

Compute the Gregorian boundaries with a library, interpret them as Tehran midnight, and query a half-open range `[start, next_start)`. Never use SQL `MONTH()` or `date_trunc('month', ...)`: Gregorian months do not line up with Jalali months.

Example, Mehr 1405: `toGregorian(1405, 7, 1)` is 2026-09-23 and `toGregorian(1405, 8, 1)` is 2026-10-23.

```sql
-- PostgreSQL, timestamptz column: let the database apply the zone rules
SELECT * FROM orders
WHERE created_at >= TIMESTAMP '2026-09-23 00:00' AT TIME ZONE 'Asia/Tehran'
  AND created_at <  TIMESTAMP '2026-10-23 00:00' AT TIME ZONE 'Asia/Tehran';
-- Equivalent UTC range: [2026-09-22T20:30:00Z, 2026-10-22T20:30:00Z)

-- DATE column (no time part)
SELECT * FROM invoices WHERE due_date >= '2026-09-23' AND due_date < '2026-10-23';
```

For a Jalali year, use `toGregorian(jy, 1, 1)` and `toGregorian(jy + 1, 1, 1)`.

For reports grouped by Jalali month, either aggregate per day in SQL and bucket in application code, or generate a calendar dimension table once with a library (`gregorian_date, jy, jm, jd, weekday, is_holiday`) and join on it.

## Weeks, weekends and holidays

- Week starts on Saturday. Where supported, `new Intl.Locale('fa-IR').getWeekInfo()` returns `{firstDay: 6, weekend: [5]}` (6 = Saturday, 5 = Friday).
- Friday is the official weekend day. Thursday is a half day or a day off depending on the organization and on government decrees that keep changing; bills to make Thursday (or Saturday) an official day off have been debated for years without a settled outcome. Offices are also often closed by one-off decrees, for example during energy shortages.
- So keep weekend days, business hours and holidays in configuration or data, not in code. Many official holidays follow the lunar Hijri calendar and move every year; load the official yearly holiday list instead of computing it.

## Input and UX

- `<input type="date">` always holds a Gregorian ISO value, and browsers do not offer a Jalali picker for it. Use a Jalali picker component, or three selects (year, month name, day).
- Accept `yyyy/mm/dd` with Persian, Arabic-Indic or Latin digits. Normalize the digits to ASCII first, then validate with `isValidJalaaliDate` / `CalendarUtils::checkDate` (1403/12/30 is valid, 1404/12/30 is not).
- Require four-digit years. Two-digit years are ambiguous.
- When users deal with foreign parties (invoices, contracts, bookings), show both calendars: «۱ مهر ۱۴۰۵ (2026-09-23)».
- Month arithmetic clamps at month end (Shahrivar has 31 days, Mehr 30). Test the behavior of the library you chose.
- Sort and compare Gregorian values, not formatted Jalali strings.

## Test vectors

| Gregorian | Jalali | Why it matters |
|---|---|---|
| 2016-05-07 | 1395/02/18 | morilog/jalali README example |
| 2021-03-20 | 1399/12/30 | Esfand 30 in a leap year |
| 2023-03-21 | 1402/01/01 | Nowruz on March 21 |
| 2024-03-20 | 1403/01/01 | Nowruz on March 20 (a Wednesday) |
| 2025-03-20 | 1403/12/30 | Software using the 2820-year algorithm shows 1404/01/01 here |
| 2025-03-21 | 1404/01/01 | |
| 2026-03-21 | 1405/01/01 | |
| 2026-09-23 | 1405/07/01 | Start of the 30-day months |

- Leap: 1395, 1399, 1403, 1408. Not leap: 1400 to 1402, 1404 to 1407.
- Timezone vector: `2025-03-20T21:00:00Z` gives 1404/01/01 in Asia/Tehran and 1403/12/30 in UTC.
- Round-trip test: for every day of a year range, `toGregorian(toJalaali(g))` must equal `g`.
- jalaali-js, ICU's Persian calendar (Node 24), jdatetime, persiantools, morilog/jalali and go-persian-calendar all agree on the vectors above.
