Trading calendar in Python: what the NSE holiday list and pandas_market_calendars miss

By Arshad Ansari

A trading calendar in Python looks like a solved problem. Install pandas_market_calendars, ask for the NSE's sessions, done. It answers one question: on which days did the exchange plan to be open?

A backtest asks a different one: on which days did the market trade, and do I have that day's data? Those differ more than you would think. When I tested the libraries against the NSE's own files for 2024 to 2026, I found sessions on Saturdays and a Sunday that no list contains, and three closures one library treats as trading days. My pipeline's worse calendar bugs had nothing to do with holidays at all. They came from code that read "no file" as "holiday". This post covers both, with code I ran.

What a trading calendar has to answer

Four questions, and most bugs come from mixing them up:

  1. Was day D a session? Did the exchange trade?
  2. What is the session before D? The previous close, the last known value.
  3. What are the last N sessions? A 20-day average is 20 sessions, not 20 calendar days.
  4. If I have no data for D, is the market closed or is my data missing?

The first three are about the exchange. The fourth is about your pipeline, and no library can answer it for you.

Three sources for the NSE holiday calendar in Python

NSE's own list. The exchange publishes its holidays as JSON. It wants a session cookie from the home page first, and a Referer:

import requests

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
headers = {"User-Agent": UA, "Accept": "application/json, text/javascript, */*; q=0.01"}

s = requests.Session()
s.get("https://www.nseindia.com", headers=headers, timeout=30)  # cookies first
headers["Referer"] = "https://www.nseindia.com/resources/exchange-communication-holidays"
r = s.get("https://www.nseindia.com/api/holiday-master?type=trading&year=2026",
          headers=headers, timeout=30)
for row in r.json()["CM"]:
    print(row["tradingDate"], row["weekDay"], row["description"])
15-Jan-2026 Thursday Municipal Corporation Election - Maharashtra
26-Jan-2026 Monday Republic Day
15-Feb-2026 Sunday Mahashivratri
03-Mar-2026 Tuesday Holi
...
02-Oct-2026 Friday Mahatma Gandhi Jayanti
20-Oct-2026 Tuesday Dussehra
08-Nov-2026 Sunday Diwali Laxmi Pujan*
...

The response has a key per segment (CM for the cash market, FO for derivatives, and ten more), because segments can close on different days. 2026 has 20 cash-market entries, 16 of them on weekdays.

pandas_market_calendars (version 5.4.0) has an XNSE calendar. exchange_calendars (version 4.13.2) has no NSE calendar but has XBOM for the BSE, which closes on the same days for the usual holidays.

I compared both libraries with NSE's own list, weekday by weekday, for three years:

YearNSE weekday sessionspandas_market_calendars XNSEexchange_calendars XBOM
2024246Open on 22 Jan ("Special Holiday") and 20 Nov (Maharashtra assembly elections); closed on 1 NovClosed on 1 Nov
2025247MatchesMatches
2026245Open on 15 Jan (Maharashtra municipal election)Matches

The comparison code:

import json
from datetime import datetime

import exchange_calendars as xcals
import pandas as pd
import pandas_market_calendars as mcal

nse = json.load(open("holidays_2026_raw.json"))  # the response above
holidays = {datetime.strptime(r["tradingDate"], "%d-%b-%Y").date() for r in nse["CM"]}
weekdays = [d.date() for d in pd.bdate_range("2026-01-01", "2026-12-31")]
truth = {d for d in weekdays if d not in holidays}

pmc = {d.date() for d in mcal.get_calendar("XNSE").valid_days("2026-01-01", "2026-12-31")}
print(len(truth), len(pmc), sorted(pmc - truth))
245 246 [datetime.date(2026, 1, 15)]

The pattern is clear. Planned religious and national holidays are right everywhere. One-off closures announced during the year, such as elections, are where a library falls behind. A library is a snapshot of someone's reading of the exchange's circulars, updated when someone opens a pull request.

Sessions no holiday list contains

Both libraries and NSE's list are lists of closures on weekdays. The NSE also trades on days that are not weekdays, and holds short sessions on days it lists as holidays. The exchange's end-of-day file (the bhavcopy) shows every one of them:

DateDayBhavcopy rowspandas_market_calendarsexchange_calendars XBOM
2024-01-20Saturdayfile dated 20-Jan-2024closedsession
2024-03-02Saturday2,417closedclosed
2024-05-18Saturday2,449closedclosed
2024-11-01Friday (Muhurat)2,671closedclosed
2025-02-01Saturday2,734closedsession
2025-10-21Tuesday (Muhurat)2,953closedclosed
2026-02-01Sunday3,079closedclosed

NSE's 2024 list even carries 2 March as "Special Live Trading", inside the holiday list. The Muhurat rows come from the special session NSE holds on Diwali. NSE's list shows 21 October 2025 as a holiday, yet that day has a full file.

Whether your backtest should trade a Muhurat session is your call. Whether it should trade the Sunday session in February is not really a choice: prices moved, and the next Monday's return includes that move. A calendar that skips the Sunday attributes it to Monday.

The calendar I use: dates that have exchange data

My pipeline does not use any of these lists as its calendar. Its calendar is the price table itself: a date is a trading day if the NSE's bhavcopy for it has rows dated that day. The design calls it the price spine.

I rebuilt that calendar for 2026 from scratch to check it. I downloaded the bhavcopy for every weekday from 1 January to 1 October, 196 files, and counted a date as a session only when the date inside the file matched the date I asked for. That gave 185 weekday sessions. The 11 weekdays without one are exactly the 11 weekday holidays on NSE's list in that range, including 15 January: the request for the election day returned the previous session's file, so the NSE did not trade, whatever pandas_market_calendars says. Add the Sunday session on 1 February and the calendar has 186 days to 1 October.

Every derived table is checked against that spine by a blocking asset check: no derived row may sit on a date the spine does not have. That check has caught a live, half-finished yfinance bar saved as a close on a date the spine had not reached, on its first unattended run.

It also failed for a week on correct data, because a job ran at 02:03 and 02:15 while the spine only extends at 02:30. That is a scheduling bug, not a calendar one, and I wrote it up in Dagster vs Airflow.

One trap with this approach: a request for a non-trading date can return the previous session's file, not an error. Take the date from inside the file, never from the URL. Data quality checks that catch real bugs has the 7,509 rows that skipping this cost one asset.

A 404 is not a holiday

This is the bug that cost me data. My F&O downloader treated any HTTP 404 from NSE as "weekend or holiday". It wrote an empty marker file so later runs would not retry the day, and reported success. Every later run saw the marker and skipped the day as a known holiday.

But NSE sometimes answers 404 at 02:00 IST for a file it publishes later. Checked on 21 September 2026:

Month (2026)Trading daysDays with F&O dataMissing
June212006-19
July2323none
August211908-27, 08-31
September (to the 18th)13809-04, 09-07, 09-09, 09-16, 09-18

Seven trading days lost, all with green runs, and getting worse each month. Fetching the same URLs that day returned the files for six of the seven.

The fix asks the calendar before writing a marker. A 404 on a weekend, or on a day in NSE's own F&O holiday list, gets the marker. A 404 on any other weekday fails the run, so it retries and alerts, and writes nothing permanent. The holiday lookup raises if NSE cannot be reached, so "could not ask" is never read as "not a holiday".

The empty marker file that lies twice

Holiday markers caused two more failures, both silent.

A marker on a trading day. On Tuesday 8 September 2026, one bad NSE response for the index snapshot produced a header-only marker, the same 152-byte file the downloader writes for a Saturday. The run reported success, and every later run skipped the day. The NSE had traded; the price table held 2,565 rows for that date. The index tables simply had no 8 September.

Markers that break the reader. The same downloader had written 148 empty markers among 952 Parquet files, with every column typed as text. After a DuckDB upgrade, the query that read all of them with union_by_name and ran window functions over the result began throwing an internal error. The loader produced zero rows for all 153 indexes from about 13 March to 5 April 2026 before it was caught. The fix reads each file on its own with Polars and skips anything under 5,000 bytes.

If you need to record "nothing to fetch here", a row in a small table of checked dates is safer than a fake data file sitting among real ones.

Count rows, not dates

The check against the spine asks one question: does this table hold a date the exchange did not trade? It cannot ask the opposite. The opposite hid for seven months.

Four trading days in my index table held only the 10 or so indices that a yfinance backfill supplies, on days when the price table had 2,232 to 2,565 equity rows. Every one of those days was present, so a check that asks "is the date there?" passed all four. The mirror check now counts rows per trading day, with a floor of 50: yfinance supplies at most 10 names, and the thinnest healthy day on record has 104.

Before trusting a check like this, run it once with an absurd floor and make sure it returns rows. Mine returned 917, every day in the era. Otherwise "0 short days" looks the same as a query that matches nothing.

Trading days, not calendar days

The last mistake is the subtlest. A "365-day" rolling Sharpe ratio on my crypto backtest read +0.41 while the trailing year was really -0.45, with the portfolio down 8.7%. The window was 365 rows, and weeks spent in cash had no rows, so 365 rows reached back 505 days.

The same applies to any window counted in rows over a calendar with gaps. When a window is counted in rows, check what it spans in days. When it is meant to be N sessions, count sessions from a calendar, not rows from a table that may be missing some.

And when you annualise, use your calendar's count. The NSE had 245 to 249 sessions a year in 2024 to 2026, not the 252 that many formulas assume.

A trading calendar checklist

  1. Use the exchange's own files as the calendar for history: a day with rows is a session.
  2. Use the exchange's holiday list for the future, and to tell "closed" from "late".
  3. If you use a library, compare it with the exchange's list for every year you backtest.
  4. Decide how to treat weekend and Muhurat sessions, and write the decision down.
  5. Never turn a 404 or an empty response into a permanent holiday on a weekday.
  6. Take the date from inside the file.
  7. Check both directions: no rows off the calendar, and enough rows on every calendar day.
  8. Count windows in sessions, and annualise with your own session count.

So: which trading calendar should you use in Python?

For the past, the one the exchange wrote: the dates that have its end-of-day file. For the future, its published holiday list, read fresh each year. exchange_calendars matched the NSE's weekday holidays in all three years I tested and is a fine default for planning. pandas_market_calendars missed three one-off closures. Neither knows about weekend sessions.

And treat the calendar as a check, not just a lookup. My lost days were never a library being wrong. They were code that decided a day was a holiday because data did not arrive.

Local-First Analytics has a chapter on data quality gates, including contracts between pipeline stages such as "the output row count must not drop", which is the general form of the row-count check above. It does not cover trading calendars or exchange data. Chapter 1 is free to read; the rest is on Amazon.

More in this series: NSE bhavcopy in Python, survivorship bias in backtesting, adjusted close vs close and look-ahead bias and point-in-time data.

Common questions

How do I get the NSE trading calendar in Python?
Three ways. NSE publishes its holiday list as JSON at `https://www.nseindia.com/api/holiday-master?type=trading&year=YYYY`; it needs a session cookie from the NSE home page first, and the cash market is under the `CM` key. The `pandas_market_calendars` library has an `XNSE` calendar, and `exchange_calendars` has `XBOM` for the BSE. All three are lists of planned holidays. None includes special weekend sessions, so for a backtest the most reliable calendar is the set of dates that actually have an exchange end-of-day file.
Is pandas_market_calendars accurate for NSE holidays?
Mostly. I compared `pandas_market_calendars` 5.4.0 (`XNSE`) with NSE's own holiday list for 2024 to 2026. It matched in 2025, but marked three one-off closures as trading days: 22 January 2024, 20 November 2024 and 15 January 2026, the last a Maharashtra municipal election. It also has none of the weekend special sessions. `exchange_calendars` 4.13.2 (`XBOM`) matched NSE's list on every weekday in those three years and included two of the weekend sessions.
How many trading days are there in a year on the NSE?
About 245 to 249. From NSE's own holiday lists, there were 246 weekday sessions in 2024, 247 in 2025 and 245 in 2026. Special sessions add to that: the exchange's daily files show sessions on three Saturdays in 2024, one Saturday in 2025 and one Sunday in 2026. The 252 days that many annualisation formulas assume is not the NSE's number, so use your own calendar's count.
Does the NSE holiday list include Muhurat trading?
Not as a session. NSE's 2025 list shows Diwali Laxmi Pujan, 21 October 2025, as a holiday, yet the exchange held its special Muhurat trading session that day, and its end-of-day file for the date has 2,953 rows. `pandas_market_calendars` and `exchange_calendars` both treat those days as closed. Decide explicitly whether your backtest trades them; do not let a library decide for you.
Why does my trading calendar have missing days?
Usually because something treated "no data" as "market closed". A download that returns 404 can mean a holiday or a file the exchange has not published yet, and if your code records it as a holiday, the day is lost for good. Check every gap against the exchange's holiday list, and count rows per trading day, not just which dates are present.

Get new posts by email

Data engineering notes like this one — what breaks and what it costs, in production.

What breaks and what it costs — pipelines, warehouse bills, and the failures that only show up in production. A few a month, never padded to hit a schedule. No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.

Want the whole playbook?

If this was useful, the long version is my book. Local-First Analytics — 313 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, and chapter 1 is free to read here.

Get the book

Not ready to buy? Read chapter 1 free — the whole chapter, no email required.

Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email info@hikmahtech.in.