Every year around ITR season, people search for a "residential status calculator for AY 2026-27". Most of what they find is a form: you enter the days you spent in India this year and in a few earlier years, and it prints Resident, RNOR or Non-resident. That's useful for filing. It's much less useful for the question people moving countries actually have, which is "what will my status be over the next few years if I land on date X?"
I wanted to understand the rules properly, so I wrote them out as code. This post walks through that model: the data structure, the two-stage test, the edge cases, and why projecting forward changes the design. It isn't tax advice. The rules below are a simplified reading of section 6 of India's Income-tax Act, 1961 (carried into the Income-tax Act, 2025 for tax years from 2026-27), and real cases need a Chartered Accountant.
First, decode "AY 2026-27"
India's old law used two labels for each tax period:
- Previous year (PY) / financial year (FY): the year you earn income, 1 April to 31 March.
- Assessment year (AY): the following year, when that income is assessed.
So AY 2026-27 refers to income earned in FY 2025-26 (1 April 2025 to 31 March 2026). The new Act replaces both labels with a single "tax year", so FY 2026-27 is "tax year 2026-27". Your first bug is almost always an off-by-one between AY and FY. Normalise everything to the year of presence and convert labels at the edges.
The data model
The core input is just days present in India, per financial year:
from dataclasses import dataclass
@dataclass
class Profile:
days: dict[int, int] # FY start year -> days in India, e.g. {2025: 40}
citizen_or_pio: bool = True
indian_income_over_15l: bool = False
liable_to_tax_elsewhere: bool = True
left_for_employment: dict[int, bool] = None # FY -> left India that year for a job abroad
on_visit: dict[int, bool] = None # FY -> came to India on a visit
days is keyed by the starting calendar year of the FY (2025 means FY 2025-26). Ten years of history plus the year being tested is the minimum the rules need.
Stage 1: resident or not?
An individual is resident in a year if either:
- days in India in that year ≥ 182, or
- days ≥ 60 in that year and ≥ 365 days in total across the 4 preceding years.
The 60 in condition 2 is swapped for a larger number in some situations:
- 182 for an Indian citizen who leaves India in that year for employment abroad (or as crew of an Indian ship), and for a citizen or PIO who is outside India and comes on a visit;
- 120 for that visiting citizen/PIO if their total income other than foreign-source income exceeds ₹15 lakh.
def lookback(p: Profile, fy: int, n: int) -> int:
return sum(p.days.get(fy - k, 0) for k in range(1, n + 1))
def short_stay_threshold(p: Profile, fy: int) -> int:
if (p.left_for_employment or {}).get(fy):
return 182
if p.citizen_or_pio and (p.on_visit or {}).get(fy):
return 120 if p.indian_income_over_15l else 182
return 60
def is_resident(p: Profile, fy: int) -> tuple[bool, str]:
d = p.days.get(fy, 0)
if d >= 182:
return True, "182-day"
t = short_stay_threshold(p, fy)
if t < 182 and d >= t and lookback(p, fy, 4) >= 365:
return True, f"{t}+365"
if p.citizen_or_pio and p.indian_income_over_15l and not p.liable_to_tax_elsewhere:
return True, "deemed"
return False, ""
Note the last branch: deemed residency. An Indian citizen whose Indian income exceeds ₹15 lakh and who isn't liable to tax in any country because of residence or domicile is treated as resident even with zero days in India. The classic example is a professional in a no-income-tax jurisdiction. The rule applies to citizens only, so strictly the citizen_or_pio flag should be split into two for this branch. I kept one flag here to keep the snippet short.
Stage 2: ordinarily resident or not?
A resident is RNOR if any of these hold:
- non-resident in 9 of the 10 preceding years;
- 729 days or fewer in India during the 7 preceding years;
- resident only via the 120-day rule;
- resident only via deemed residency.
def status(p: Profile, fy: int, memo: dict) -> str:
if fy in memo:
return memo[fy]
res, basis = is_resident(p, fy)
if not res:
memo[fy] = "NR"
return "NR"
nr_years = sum(1 for k in range(1, 11)
if status(p, fy - k, memo) == "NR")
rnor = (nr_years >= 9 or lookback(p, fy, 7) <= 729
or basis in ("120+365", "deemed"))
memo[fy] = "RNOR" if rnor else "ROR"
return memo[fy]
This is where the rules get interesting computationally: the "9 out of 10" test needs the status of each of the previous ten years, not just the days, so status is recursive. Memoise it, and seed years before your data with a sensible default ("NR" for someone who has lived abroad for decades, or ask the user).
Why one-year calculators fall short
A one-year calculator answers status(p, fy) for a single fy. People planning a move need:
def project(p: Profile, landing: date, years: int = 4) -> list[tuple[int, str]]:
q = with_planned_presence(p, landing) # fill future days: partial year, then 365s
memo = {}
first = fy_of(landing)
return [(fy, status(q, fy, memo)) for fy in range(first, first + years)]
Run that for different landing dates and a pattern appears. For a long-term NRI with light visit history:
- landing in mid-October gives a first year below 182 days. If the 4-year lookback is under 365, that year is NR, followed by two RNOR years.
- landing in April makes the move year resident (RNOR), the next year RNOR, then ROR.
Same person, one fewer advantaged year. You only see that by projecting. The inverse question, "what's the latest landing date that still keeps year one NR?", is a search over dates using the same function.
Edge cases worth unit tests
- Day counting. Practice generally counts any part-day of presence as a day in India. Model arrival/departure dates, not just totals, if you can.
- Leap years. FYs containing 29 February have 366 days. Matters for "full year" fills.
- The 120-day path only applies to a visiting citizen/PIO with high Indian income. Someone relocating isn't "visiting", which changes the threshold back to 60.
- Seeding history. Missing years silently treated as 0 days can flip the 9-of-10 test. Make missing data explicit.
- AY/FY/tax-year labels. Accept any of the three and normalise.
A reference implementation to compare against
If you want to sanity-check your own implementation, Desi Return publishes a free multi-year RNOR calculator that does the forward projection (move year plus three), shows the 10-year day table it used, and includes the deemed-residency check. I found it handy for cross-checking my test fixtures. The official Income Tax Department utility is the other reference, but it evaluates one year at a time.
Wrapping up
The residential status rules look like a short list of if-statements, but the recursive 9-of-10 test, the moving lookback windows and the special thresholds make them a nice small modelling exercise. Projecting forward turns them from a filing check into a planning tool.
Disclaimer: this is an engineering write-up, not tax advice. Thresholds and conditions can change through Finance Acts and the new Income-tax Act 2025. Check the current rules and consult a Chartered Accountant for any real decision.













