NSE bhavcopy in Python: download it, parse it, and check the date inside
By Arshad Ansari
The NSE bhavcopy is the exchange's own end-of-day file: every security that traded, with its open, high, low, close, volume and delivery numbers. It is free, it goes back years, and downloading it in Python takes about ten lines. So most guides to the NSE bhavcopy in Python stop at the download.
The download was never my problem. My trading pipeline has pulled the bhavcopy every night since its archive starts in January 2023, and every bug it had came from reading the file, not fetching it. A column that was NULL on every row for the pipeline's whole life. A Sunday request that came back with Friday's prices. A filter that quietly dropped 169 companies. This post is the download plus the parsing rules that fixed those, with code I ran on real files.
What the NSE bhavcopy is, and which file to use
NSE publishes more than one end-of-day file for the cash market, and they are not the same. The three my pipeline uses, all on nsearchives.nseindia.com:
| File | Path | What it has | Coverage I measured |
|---|---|---|---|
| Full bhavcopy | products/content/sec_bhavdata_full_DDMMYYYY.csv | Prices, volume, turnover, trades, delivery quantity and % | 200 on 2020-01-02, 404 on 2019-06-03 |
| UDiFF bhavcopy | content/cm/BhavCopy_NSE_CM_0_0_0_YYYYMMDD_F_0000.csv.zip | Prices and the ISIN | 200 from 2024-01-02 |
| Legacy bhavcopy | content/historical/EQUITIES/YYYY/MON/cmDDMONYYYYbhav.csv.zip | Prices and the ISIN | 200 on 2019-06-14, 404 from 2024-07-26 |
The UDiFF and legacy coverage comes from probes recorded in the pipeline's code (September 2026); the full-file dates are mine, from October 2026.
I use the full file for prices, because delivery percentage is a useful signal and only that file has it. It has no ISIN, so the ISIN comes from one of the other two, joined on.
How to download the NSE bhavcopy in Python
The archive does not need a login or a session cookie. It does want a browser User-Agent. A 404 means there is no file for that date.
from datetime import date
from pathlib import Path
import requests
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
def download_bhavcopy(day: date, folder: Path) -> Path | None:
url = ("https://nsearchives.nseindia.com/products/content/"
f"sec_bhavdata_full_{day:%d%m%Y}.csv")
r = requests.get(url, headers={"User-Agent": UA}, timeout=30)
if r.status_code == 404:
return None # weekend, holiday, or not published yet
r.raise_for_status()
path = folder / f"sec_bhavdata_full_{day:%Y%m%d}.csv"
path.write_bytes(r.content)
return path
Two habits from production. First, be slow. My pipeline waits 2 seconds between NSE requests on the nightly run and 12 seconds during a backfill, and retries 403 and 429 with a growing delay. NSE blocks clients that hurry. Second, save the file as delivered before you parse it. Every fix below needed a re-parse of old files, and re-parsing is free when the raw files are on disk.
"404 means holiday" is not quite true, by the way. A 404 also means "not published yet", and treating it as a holiday lost my pipeline seven trading days of F&O data. That belongs to the trading calendar more than to the file format, so I leave it there.
Parse it as text first: the bhavcopy is full of spaces
Here is the first line of the 30 September 2026 file:
SYMBOL, SERIES, DATE1, PREV_CLOSE, OPEN_PRICE, HIGH_PRICE, LOW_PRICE, LAST_PRICE, CLOSE_PRICE, AVG_PRICE, TTL_TRD_QNTY, TURNOVER_LACS, NO_OF_TRADES, DELIV_QTY, DELIV_PER
NSE writes a space after every comma, in the header and in every row. So the column is not SERIES, it is SERIES, and a value is not 2567.50, it is 2567.50. A CSV reader that infers types sees the leading space and reads the numbers as strings:
import polars as pl
raw = pl.read_csv("bhav/sec_bhavdata_full_20260930.csv")
print(raw.columns[:4])
print(raw.select(raw.columns[11:13]).schema)
['SYMBOL', ' SERIES', ' DATE1', ' PREV_CLOSE']
Schema({' TURNOVER_LACS': String, ' NO_OF_TRADES': String})
Now cast that string to a number without stripping it first, with a lenient cast so bad values become NULL instead of raising:
naive = raw.rename({c: c.strip() for c in raw.columns}).select(
pl.col("TURNOVER_LACS").cast(pl.Float64, strict=False).alias("no_strip"),
pl.col("TURNOVER_LACS").str.strip_chars().cast(pl.Float64, strict=False).alias("stripped"),
)
print(naive.null_count())
┌──────────┬──────────┐
│ no_strip ┆ stripped │
│ --- ┆ --- │
│ u32 ┆ u32 │
╞══════════╪══════════╡
│ 3529 ┆ 0 │
└──────────┴──────────┘
All 3,529 rows are NULL without the strip. That is exactly what happened in my pipeline. Every other numeric column was stripped before the cast; turnover was not. So turnover was NULL on every bhavcopy row the pipeline ever parsed, and on every row of the published price table. Nothing broke, because everything that needed traded value computed volume * close instead. Nothing complained either. A column that is empty everywhere passes a NULL check that only looks for some NULLs.
The parser I use now reads everything as text, strips names and values, and only then casts:
NUMERIC = ["PREV_CLOSE", "OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE",
"CLOSE_PRICE", "TURNOVER_LACS", "DELIV_QTY", "DELIV_PER"]
def parse_bhavcopy(path: Path, requested: date) -> pl.DataFrame:
df = pl.read_csv(path, infer_schema=False) # everything as text first
df = df.rename({c: c.strip() for c in df.columns}) # " SERIES" -> "SERIES"
df = df.with_columns(pl.all().str.strip_chars()) # " EQ" -> "EQ"
df = df.with_columns(
pl.col("DATE1").str.strptime(pl.Date, "%d-%b-%Y"),
pl.col(NUMERIC).cast(pl.Float64, strict=False),
pl.col("TTL_TRD_QNTY").cast(pl.Int64, strict=False),
)
if df.height and df["DATE1"][0] != requested:
return df.clear() # NSE served another session's file
return df
strict=False is still there because some series print - where a number would be, such as delivery quantity for non-delivery series. After the strip, a NULL means the file said nothing, not that the parser failed.
The file you asked for is not always the file you get
The last two lines of that parser matter most. NSE's archive answers some non-trading days with the previous session's file, not with a 404.
I requested Sunday 6 September 2026 and Friday 4 September 2026. Both returned HTTP 200, and cmp reports the two files byte-identical; the DATE1 inside the Sunday file reads 04-Sep-2026. Saturday 5 September returned 404. The same happens on weekday holidays: the request for Wednesday 22 October 2025, a holiday, returned the 21 October file, with 2,953 rows dated 21-Oct-2025.
So the date in the URL is a request, not a fact. If you stamp the rows with the date you asked for, you invent a trading day full of plausible prices. In my pipeline, an ETF asset that trusted the requested date stamped 7,509 rows onto 229 dates on which nothing traded; the full story is in data quality checks that catch real bugs. The equity asset never had the problem, because it read DATE1. Read DATE1, and return nothing when it differs from the date you asked for.
Keep every series, not just EQ
Most bhavcopy tutorials filter SERIES == 'EQ' straight away. Here is what one day holds:
df = parse_bhavcopy(download_bhavcopy(date(2026, 9, 30), folder), date(2026, 9, 30))
print(df.height, "rows,", df["SYMBOL"].n_unique(), "symbols")
print(df.group_by("SERIES").len().sort("len", descending=True).head(6))
3529 rows, 3529 symbols
┌────────┬──────┐
│ SERIES ┆ len │
╞════════╪══════╡
│ EQ ┆ 2669 │
│ SM ┆ 391 │
│ BE ┆ 234 │
│ ST ┆ 86 │
│ GS ┆ 55 │
│ GB ┆ 43 │
└────────┴──────┘
EQ is the normal equity series. BE is trade-for-trade, SM and ST are SME segments, GS and GB are government securities and gold bonds. Dropping everything but EQ looks like cleaning. It is not, because NSE moves companies between series. A stock under surveillance can sit in BE for months and come back to EQ.
My raw downloader kept only EQ for its first few years. When I measured it in September 2026, 169 symbols had 51,003 symbol-days with no price at all in the gap where they traded outside EQ: PATANJALI, LLOYDSENGG, GATECH, SELMC among them. The fix kept every series in the raw layer and moved the choice of series into the step that builds the price table, which can see the whole history.
One more rule from that fix: the key is (symbol, series), not symbol. On 30 September every symbol appeared once, but that is not guaranteed. A rights entitlement can trade under its parent's name in BE while the share trades in EQ.
Getting the ISIN from the UDiFF bhavcopy
The full file has prices but no ISIN. The UDiFF file has both, with longer, more formal column names:
TradDt,BizDt,Sgmt,Src,FinInstrmTp,FinInstrmId,ISIN,TckrSymb,SctySrs,XpryDt,...,OpnPric,HghPric,LwPric,ClsPric,LastPric,PrvsClsgPric,...,TtlTradgVol,TtlTrfVal,TtlNbOfTxsExctd,...
My pipeline downloads it as a companion file and left-joins ISIN onto the prices on (symbol, series). A left join, so a missing companion file blanks the ISIN rather than dropping a price row. Before 2024 it falls back to the legacy file, which has SYMBOL, SERIES and ISIN. On the overlap day, 14 June 2024, the two files carried the same 2,764 (symbol, series) rows and the same ISIN on 2,763 of them. The odd one out was a company in the middle of an ISIN change.
Why bother? The ISIN says what an instrument is: an INE prefix is an Indian company's equity, INF is a fund. It also links a renamed ticker to its old self, which is how my pipeline found 23 ticker renames it could not see from prices alone. That work is in survivorship bias in backtesting.
NSE bhavcopy historical data: build an archive, then query it
For history, call the downloader once per day and keep the files. I fetched every weekday from 1 January to 1 October 2026 this way, 196 requests a few seconds apart. Every one returned HTTP 200, holidays included; not one was a 404. Eleven were stale copies of the previous session's file, one for each weekday holiday on NSE's 2026 list in that range, from the 15 January municipal election holiday to Ganesh Chaturthi on 14 September. That left 185 real sessions and 68 MB of CSV.
Once the files are on disk, you do not need to load them one by one. DuckDB reads the whole folder in one query and checks DATE1 against the date in the file name. It strips the spaces from the header names by itself, but not from the values, so trim is still needed:
WITH raw AS (
SELECT strptime(regexp_extract(filename, '(\d{8})', 1), '%Y%m%d')::DATE AS requested,
strptime(trim(DATE1), '%d-%b-%Y')::DATE AS traded,
trim(SERIES) AS series
FROM read_csv('y2026/*.csv', all_varchar = true, filename = true)
),
files AS (
SELECT requested, any_value(traded) AS traded, count(*) AS rows,
count(*) FILTER (series = 'EQ') AS eq_rows
FROM raw GROUP BY requested
)
SELECT count(*) AS files,
count(*) FILTER (traded = requested) AS sessions,
count(*) FILTER (traded <> requested) AS stale_copies,
round(avg(rows) FILTER (traded = requested)) AS avg_rows,
round(avg(eq_rows) FILTER (traded = requested)) AS avg_eq_rows
FROM files;
┌───────┬──────────┬──────────────┬──────────┬─────────────┐
│ files │ sessions │ stale_copies │ avg_rows │ avg_eq_rows │
├───────┼──────────┼──────────────┼──────────┼─────────────┤
│ 196 │ 185 │ 11 │ 3252.0 │ 2465.0 │
└───────┴──────────┴──────────────┴──────────┴─────────────┘
Bhavcopy or yfinance for NSE data?
yfinance is the usual shortcut for Indian prices, and it is fine for a quick look at a stock you already know. For a backtest it failed my pipeline three ways, each covered in its own post. It only answers for tickers listed today, so delisted companies vanish (survivorship bias). It returned about 900 daily "trading days" each for 27 NSE-suspended companies that did not trade. And its adjusted close is recomputed after every corporate action, so it is a different number every time you download it (adjusted close vs close).
The bhavcopy has none of those problems, for one reason: it is a record of what traded that day, written that day. My pipeline now takes every price from it and uses Yahoo only for the years before the archive starts.
A bhavcopy checklist
- Use
sec_bhavdata_fullfor prices and delivery; join the ISIN from UDiFF (2024 on) or the legacy file (before). - Send a browser User-Agent. Wait seconds, not milliseconds, between requests.
- Save the raw file unchanged before parsing it.
- Read every column as text. Strip column names and values. Then cast.
- Check the cast. A column that is NULL on every row is a parsing bug, not missing data.
- Read DATE1 and drop the file when it is not the date you asked for.
- Keep every series in the raw layer; choose series later.
- Key rows on
(symbol, series), and on date.
So: is the NSE bhavcopy in Python worth the trouble?
Yes. It is the one free source of Indian end-of-day prices that records what actually traded, delisted companies included. The trouble is small and it is all in the parse: spaces, a date you must check, and series you must not throw away. Get those three right once, keep the raw files, and the archive becomes something you can rebuild from whenever you find the next bug.
Every fix in this post was a re-parse of files already on disk, which is the habit Local-First Analytics builds its file layout around: a raw zone kept exactly as delivered, and Parquet layers built from it that you can regenerate at any time, with Polars doing the cleaning. The book does not cover NSE data or trading. Chapter 1 is free to read; the rest is on Amazon.
More in this series: survivorship bias in backtesting, adjusted close vs close and look-ahead bias and point-in-time data.
Common questions
- What is the bhavcopy in NSE?
- The bhavcopy is the National Stock Exchange of India's end-of-day file for a trading session. It has one row per security and trading series, with the previous close, open, high, low, close, average price, traded quantity, turnover and number of trades. The `sec_bhavdata_full` version also carries delivery quantity and delivery percentage. NSE publishes it as a CSV in its archive at nsearchives.nseindia.com, and it is free. It is the exchange's own record of what traded that day, which makes it the best free source for Indian end-of-day prices.
- How do I download the NSE bhavcopy in Python?
- Request `https://nsearchives.nseindia.com/products/content/sec_bhavdata_full_DDMMYYYY.csv` with a browser User-Agent header, using the `requests` library. No login or session cookie is needed for the archive. Treat HTTP 404 as "no file for that date", wait a couple of seconds between requests, and save the raw file before parsing it. Then read it with every column as text, strip the spaces from the column names and the values, and only then cast the numbers. Finally, check that the DATE1 column matches the date you asked for, because NSE can answer a holiday with the previous session's file.
- Where can I get NSE bhavcopy historical data?
- From the same archive, one file per day. When I probed it in October 2026, the `sec_bhavdata_full` file answered for 2 January 2020 but returned 404 for 3 June 2019. The older `cm<DDMONYYYY>bhav.csv.zip` archive went back further but stopped at July 2024, and the newer UDiFF archive (`BhavCopy_NSE_CM_0_0_0_<YYYYMMDD>_F_0000.csv.zip`) starts in January 2024. To build history, download each day once, keep the raw files unchanged, and build your tables from them.
- How do I get the NSE bhavcopy with ISIN?
- The `sec_bhavdata_full` file has no ISIN column. The UDiFF bhavcopy does, in its `ISIN` column, next to `TckrSymb` (symbol) and `SctySrs` (series). It is a zipped CSV at `nsearchives.nseindia.com/content/cm/BhavCopy_NSE_CM_0_0_0_<YYYYMMDD>_F_0000.csv.zip` and starts in January 2024; before that, the legacy `cm<DDMONYYYY>bhav.csv.zip` file carries an ISIN. Join it to the prices on symbol and series together, not symbol alone.
- Is yfinance data reliable for NSE stocks?
- For a quick look at a stock you already know, it is fine. For a backtest, it has three problems I measured in my own pipeline: it only answers for tickers listed today, so delisted stocks are missing; it returned years of daily prices for 27 NSE-suspended companies that did not trade; and its adjusted close is recomputed whenever a corporate action happens, so it changes after you download it. The NSE bhavcopy has none of these problems, because it records what actually traded each day.
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 bookNot 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.