If you build payroll for Indian employees, three statutory deductions show up on almost every payslip: the Employees' Provident Fund (EPF), Employees' State Insurance (ESI) and state Professional Tax (PT). Each looks like a one-line percentage, and each hides edge cases: a ceiling that changed mid-month, slab boundaries worded differently by state, and scheme-specific rounding.
This post covers the rules as of October 2026 and builds a small config-driven Python calculator, with tests that reproduce EPFO's own worked examples.
1. Start with "wages", not "gross"
The four Labour Codes came into force on 21 November 2025. The Code on Wages, 2019 defines "wages" in section 2(y), and the Code on Social Security, 2020 defines it in section 2(88). Wages include basic pay, dearness allowance and retaining allowance. They exclude items such as HRA, conveyance, overtime, commission and the employer's PF contribution.
The part that matters for engineers is a proviso, usually called the 50% rule. If the excluded payments exceed one-half of total remuneration, the amount above that half is added back into wages. For example, with a gross of ₹50,000 and basic plus DA of ₹20,000, the exclusions are ₹30,000. That is ₹5,000 more than half of the gross, so statutory wages become ₹25,000.
EPFO's FAQs on the new ceiling confirm that coverage is tested against section 2(88) wages, not gross salary or CTC. Your engine needs an include/exclude flag per pay component; the function below simplifies this by treating everything except basic, DA and retaining allowance as excluded.
2. EPF: rates, the new ₹25,000 ceiling and the September split
Rates.
- The employee contributes 12% of PF wages.
- The employer also contributes 12%. Of that, 8.33% goes to the pension scheme (EPS, A/c 10) and the balance, 3.67%, goes to EPF (A/c 1).
- EPFO's FAQ illustrations also show two employer charges: EDLI at 0.5% (A/c 21) and administrative charges at 0.5% (A/c 2).
- The FAQs set a minimum administrative charge of ₹500 per month per establishment with at least one contributing member (an establishment-level rule, not per employee).
The ceiling. Notification S.O. 5109(E), dated 17 September 2026, set the wage ceiling for Chapter III of the Code (EPF, EPS and EDLI) at ₹25,000 per month. It replaced ₹15,000, the figure in place since September 2014, and took effect on the date of publication, 17 September 2026. PIB has confirmed the change, and EPFO has said it will not be pushed back to 1 October. As a result, the maximum EPS contribution rises from ₹1,250 to ₹2,083 a month.
The ceiling caps more than EPS. Where wages exceed the ceiling, both the employee and employer contributions are generally limited to the ceiling. The exception is an employee who has been permitted to contribute on higher wages. EPS membership is open only to employees whose wages did not exceed the ceiling when they joined or on the date the new ceiling took effect.
September 2026 is a split month. According to EPFO's FAQs, September is reported in a single ECR, but contributions are worked out for two periods:
- 1–16 September: the ₹15,000 ceiling applies, with wages apportioned at 16/30.
- 17–30 September: the ₹25,000 ceiling applies, with wages apportioned at 14/30.
Take an existing member earning ₹20,000. The EPF wage for September is 15,000 × 16/30 + 20,000 × 14/30 = ₹17,333.33. Employees who were previously excluded join EPF, EPS and EDLI from 17 September. If the employee share could not be deducted in September, it may be recovered in the next payroll. Both shares still have to be remitted by 15 October.
Rounding. The EPF Scheme rounds each contribution to the nearest rupee: 50 paise or more goes up, and anything less is dropped. The employer's EPF share is calculated as the difference between the total employer share and the EPS amount. That is why ₹25,000 gives an EPS amount of ₹2,083 (from 2,082.50) and an EPF amount of ₹917. EPFO's September illustration shows amounts in paise, so the engine below follows the scheme text and rounds once, at the end.
3. ESI: 0.75% + 3.25%, tested per contribution period
- Rates: The employee pays 0.75% and the employer pays 3.25%. These rates have applied since 1 July 2019.
- Eligibility: The limit is ₹21,000 per month, or ₹25,000 for persons with disability. It was last revised from 1 January 2017. The September 2026 EPF notification covers Chapter III only, so do not copy the EPF change into your ESI config.
- Contribution periods: There are two, 1 April–30 September and 1 October–31 March. S.O. 2351(E), dated 8 May 2026, provides that an employee whose wages go above the ceiling after a period has started stays covered for the rest of that period. In code, coverage should be decided at the start of the period, or on joining, and not recalculated every month.
- Low-wage exemption: According to ESIC, employees with an average daily wage of up to ₹176 pay no employee share, but the employer still pays its share.
- Rounding: Engines commonly round ESI amounts up to the next rupee, and the calculator below does the same. We couldn't find a current official text confirming this under the Code, so keep it configurable.
4. Professional Tax: slabs as data
PT is set by each state, so model it as data. Two examples follow.
Maharashtra (Schedule I, from 1 April 2023):
| Monthly salary | Men | Women |
|---|---|---|
| Up to ₹7,500 | Nil | Nil |
| ₹7,501–₹10,000 | ₹175/month | Nil |
| ₹10,001–₹25,000 | ₹200/month, ₹300 in February | Nil |
| Above ₹25,000 | ₹200/month, ₹300 in February | ₹200/month, ₹300 in February |
Karnataka (from 1 April 2025): if salary is ₹25,000 and above, PT is ₹200 a month and ₹300 in February, for ₹2,500 a year. Below ₹25,000 it is nil.
Pay attention to the wording. Maharashtra says "exceeds ₹10,000", so a salary of exactly ₹10,000 pays ₹175. Karnataka says "₹25,000 and above", so a salary of exactly ₹25,000 pays ₹200. Explicit inclusive bounds turn that difference into data instead of an if statement. Confirm the wage base each state expects with your compliance team.
5. The calculator
The calculator below handles the cases discussed above:
- Mid-month joiners: wages are pro-rated by paid days.
-
Split months: the month is cut at every rule change, and each segment is capped at
ceiling × segment_days / days_in_month. For a normal month with one segment, this is the full ceiling, so a joiner's earned wage is still capped at ₹25,000 rather than a pro-rated ceiling. For September 2026, it gives EPFO's 16/30 and 14/30 apportionment. - Arrears: the original wage month is recalculated under that month's rules, and the difference is taken.
- Voluntary PF (VPF): this raises only the employee share. The employer has no obligation to match it.
# statutory.py
"""Rules-driven PF / ESI / PT calculator (illustrative, not compliance-certified)."""
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal as D, ROUND_HALF_UP, ROUND_CEILING
import calendar
def to_rupee(x: D) -> D: # EPF: >= 50 paise up, < 50 paise dropped
return x.quantize(D("1"), rounding=ROUND_HALF_UP)
def up_to_rupee(x: D) -> D: # ESI: common practice, round up (verify!)
return x.quantize(D("1"), rounding=ROUND_CEILING)
# ---------------- rules as data, effective-dated ----------------
@dataclass(frozen=True)
class EpfRule:
effective_from: date
wage_ceiling: D
ee_rate: D = D("0.12")
eps_rate: D = D("0.0833")
edli_rate: D = D("0.005")
admin_rate: D = D("0.005")
EPF_RULES = [
EpfRule(date(2014, 9, 1), D("15000")),
EpfRule(date(2026, 9, 17), D("25000")), # S.O. 5109(E), 17 Sep 2026
]
@dataclass(frozen=True)
class EsiRule:
effective_from: date
ceiling: D = D("21000")
ee_rate: D = D("0.0075")
er_rate: D = D("0.0325")
ESI_RULES = [EsiRule(date(2019, 7, 1))]
@dataclass(frozen=True)
class PtSlab:
min_wage: D # inclusive
max_wage: D | None # inclusive; None = open-ended
monthly: D
february: D | None = None
PT_RULES = { # (state, gender) -> slabs; "*" = any gender
("MH", "M"): [PtSlab(D("0"), D("7500"), D("0")),
PtSlab(D("7500.01"), D("10000"), D("175")),
PtSlab(D("10000.01"), None, D("200"), D("300"))],
("MH", "F"): [PtSlab(D("0"), D("25000"), D("0")),
PtSlab(D("25000.01"), None, D("200"), D("300"))],
("KA", "*"): [PtSlab(D("0"), D("24999.99"), D("0")),
PtSlab(D("25000"), None, D("200"), D("300"))],
}
def rule_on(rules, day: date):
return max((r for r in rules if r.effective_from <= day),
key=lambda r: r.effective_from)
# ---------------- wages ----------------
def code_wages(basic_da_ra: D, gross: D) -> D:
"""Code on Wages s.2(y) 50% proviso, simplified: if excluded items exceed
half of total remuneration, the excess is added back to wages."""
excluded = gross - basic_da_ra
return basic_da_ra + max(D("0"), excluded - gross / 2)
# ---------------- EPF ----------------
@dataclass
class EpfResult:
epf_wage: D
eps_wage: D
ee: D
eps: D
er_epf: D
edli: D
admin: D
audit: list = field(default_factory=list)
def epf_month(year, month, monthly_wage: D, paid_from=1, paid_to=None,
member_from: date | None = None, eps_from: date | None = None,
higher_wages=False, vpf_rate=D("0")) -> EpfResult:
"""Split the month at every rule change (and membership date), cap each
segment at ceiling x segment_days / days_in_month, then round once."""
dim = calendar.monthrange(year, month)[1]
paid_to = paid_to or dim
first, last = date(year, month, 1), date(year, month, dim)
cuts = {first}
cuts |= {r.effective_from for r in EPF_RULES if first < r.effective_from <= last}
cuts |= {d for d in (member_from, eps_from) if d and first < d <= last}
cuts = sorted(cuts) + [last + timedelta(days=1)]
epf_wage = eps_wage = D("0")
audit = []
for start, nxt in zip(cuts, cuts[1:]):
seg_days = (nxt - start).days
paid = len([d for d in range(start.day, nxt.day if nxt <= last else dim + 1)
if paid_from <= d <= paid_to])
if member_from and start < member_from:
continue
rule = rule_on(EPF_RULES, start)
earned = monthly_wage * paid / dim
cap = rule.wage_ceiling * seg_days / dim
seg_epf = earned if higher_wages else min(earned, cap)
seg_eps = min(earned, cap) if (eps_from and start >= eps_from) else D("0")
epf_wage += seg_epf
eps_wage += seg_eps
audit.append(f"{start}..{nxt - timedelta(days=1)} ceiling={rule.wage_ceiling} "
f"epf={seg_epf:.2f} eps={seg_eps:.2f}")
r = rule_on(EPF_RULES, last)
ee = to_rupee(epf_wage * (r.ee_rate + vpf_rate)) # VPF: employee only
er_total = to_rupee(epf_wage * r.ee_rate)
eps = to_rupee(eps_wage * r.eps_rate)
return EpfResult(epf_wage.quantize(D("0.01")), eps_wage.quantize(D("0.01")),
ee, eps, er_total - eps,
to_rupee(epf_wage * r.edli_rate), to_rupee(epf_wage * r.admin_rate),
audit)
# ---------------- ESI ----------------
def esi_month(day: date, esi_wage: D, wage_at_period_start: D):
"""Eligibility is tested at the start of the contribution period
(1 Apr / 1 Oct, or on joining); a mid-period raise doesn't end coverage."""
r = rule_on(ESI_RULES, day)
if wage_at_period_start > r.ceiling:
return D("0"), D("0")
return up_to_rupee(esi_wage * r.ee_rate), up_to_rupee(esi_wage * r.er_rate)
# ---------------- Professional Tax ----------------
def pt_month(state: str, gender: str, month: int, wage: D) -> D:
slabs = PT_RULES.get((state, gender)) or PT_RULES.get((state, "*"), [])
for s in slabs:
if wage >= s.min_wage and (s.max_wage is None or wage <= s.max_wage):
return s.february if (month == 2 and s.february is not None) else s.monthly
return D("0")
def epf_arrears(year, month, old_wage: D, new_wage: D, **kw) -> dict:
"""Re-run the ORIGINAL wage month under the rules of that month."""
a, b = epf_month(year, month, old_wage, **kw), epf_month(year, month, new_wage, **kw)
return {k: getattr(b, k) - getattr(a, k) for k in ("ee", "eps", "er_epf")}
if __name__ == "__main__":
always = date(2000, 1, 1) # long-standing EPS member
sep = epf_month(2026, 9, D("20000"), eps_from=always)
print("Sep 2026, wage 20,000:", sep.epf_wage, sep.ee, sep.eps, sep.er_epf, sep.edli)
for line in sep.audit:
print(" ", line)
oct_ = epf_month(2026, 10, D("25000"), eps_from=always)
print("Oct 2026, wage 25,000:", oct_.ee, oct_.eps, oct_.er_epf, oct_.edli, oct_.admin)
j = epf_month(2026, 10, D("24000"), paid_from=20, member_from=date(2026, 10, 20),
eps_from=date(2026, 10, 20))
print("Joined 20 Oct, wage 24,000:", j.epf_wage, j.ee, j.eps, j.er_epf)
print("Arrears 14k->18k for Sep:", epf_arrears(2026, 9, D("14000"), D("18000"), eps_from=always))
print("ESI on 18,450:", esi_month(date(2026, 10, 31), D("18450"), D("18450")))
print("PT MH(M) 12,000 Feb:", pt_month("MH", "M", 2, D("12000")),
"| KA 25,000 Oct:", pt_month("KA", "*", 10, D("25000")))
Running python3 statutory.py:
Sep 2026, wage 20,000: 17333.33 2080 1444 636 87
2026-09-01..2026-09-16 ceiling=15000 epf=8000.00 eps=8000.00
2026-09-17..2026-09-30 ceiling=25000 epf=9333.33 eps=9333.33
Oct 2026, wage 25,000: 3000 2083 917 125 125
Joined 20 Oct, wage 24,000: 9290.32 1115 774 341
Arrears 14k->18k for Sep: {'ee': Decimal('288'), 'eps': Decimal('200'), 'er_epf': Decimal('88')}
ESI on 18,450: (Decimal('139'), Decimal('600'))
PT MH(M) 12,000 Feb: 300 | KA 25,000 Oct: 200
The September result (₹2,080 employee share, ₹1,444 EPS and ₹636 EPF) matches EPFO's Scenario C (₹2,080.00, ₹1,443.87 and ₹636.13) once it is rounded to rupees. The October row matches EPFO's full-month table exactly.
Tests that pin you to the regulator's examples
# test_statutory.py
from datetime import date
from decimal import Decimal as D
from statutory import epf_month, esi_month, pt_month, code_wages
OLD = date(2000, 1, 1) # "has been an EPS member for ages"
NEW = date(2026, 9, 17) # revised ceiling effective date
def test_sep_2026_scenario_c_capped_member(): # Scenario C
r = epf_month(2026, 9, D("20000"), eps_from=OLD)
assert r.epf_wage == D("17333.33")
assert (r.ee, r.eps, r.er_epf, r.edli) == (2080, 1444, 636, 87)
def test_october_full_month_table(): # EPFO FAQ Q13
assert [(r.ee, r.eps, r.er_epf, r.edli, r.admin) for r in (
epf_month(2026, 10, D("20000"), eps_from=OLD),
epf_month(2026, 10, D("25000"), eps_from=OLD),
epf_month(2026, 10, D("35000")), # not an EPS member
)] == [(2400, 1666, 734, 100, 100), (3000, 2083, 917, 125, 125),
(3000, 0, 3000, 125, 125)]
def test_pt_slabs():
assert pt_month("MH", "M", 10, D("10000")) == 175 # MH: only wages that "exceed" 10,000 pay 200
assert pt_month("MH", "M", 2, D("10001")) == 300 # February
assert pt_month("MH", "F", 10, D("25000")) == 0
assert pt_month("KA", "*", 10, D("25000")) == 200 # "25,000 and above"
assert pt_month("KA", "*", 10, D("24999")) == 0
$ pytest -q test_statutory.py
3 passed in 0.01s
The regulator's worked examples make cheap, trustworthy fixtures, and old tests should keep passing for old dates after a rule changes.
6. Design tips
-
Treat rules as data. Rates, ceilings and PT slabs belong in versioned configuration, not in
ifbranches. A rule change should be a data migration with a review, not a code deploy. -
Give every rule a date range. Each rule needs an
effective_fromdate, and lookups should be based on the wage period, not the date the payroll runs. September 2026 shows why: the same month had two ceilings, and October arrears for August need August's rules. Payroll platforms like TankhaPay handle these rules as effective-dated configuration. -
Keep an audit trail. Store the rule version, segment breakdown, unrounded intermediate values and rounding mode alongside each result, as the
auditlist does above. EPFO's own checklist for the ceiling change tells employers to keep a clear audit trail of the calculations. -
Round once, at the edge. Use
Decimaland keep paise throughout the calculation. Then apply each scheme's rounding rule only to the final contribution. - Keep eligibility separate from calculation. ESI coverage is a per-period state and EPS membership is a per-employee state. Store both as data instead of inferring them from this month's salary.
Things this example does not cover
- Exempted PF trusts.
- International workers.
- Contract labour handled through the principal employer.
- PT for states other than Maharashtra and Karnataka.
- Gender-specific or other state-specific PT exemptions beyond those shown above.
- A joiner whose start date falls inside September 2026.
Disclaimer: This article is general information for software developers and is not legal, tax or compliance advice. Statutory rates, ceilings and slabs change, so verify against the official notifications and your compliance advisers before using any figure in production payroll.
References
- Gazette of India, S.O. 5109(E), 17 Sept 2026 (₹25,000 wage ceiling): https://cdnbbsr.s3waas.gov.in/s36547884cea64550284728eb26b0947ef/uploads/2026/09/202609241085718651.pdf
- PIB, Cabinet approves enhancement of EPFO wage ceiling (16 Sept 2026): https://www.pib.gov.in/PressReleasePage.aspx?PRID=2310811&lang=2®=48
- Ministry of Labour & Employment / PIB press release on the 17 Sept 2026 effective date: https://www.labour.gov.in/static/uploads/2026/09/4f607a88c5342aeb980c6b999997caab.pdf
- PIB Kolkata, EPFO raises wage ceiling; EPS maximum rises to ₹2,083 (23 Sept 2026): https://www.pib.gov.in/PressReleasePage.aspx?PRID=2313829&lang=2®=48
- EPFO, FAQs: Revision of EPFO Statutory Wage Ceiling from ₹15,000 to ₹25,000 (Sept 2026), published by EPFO
- PIB, Four Labour Codes effective 21 Nov 2025: https://www.pib.gov.in/PressReleasePage.aspx?PRID=2192463
- The Code on Wages, 2019 (section 2(y)): https://www.labour.gov.in/static/uploads/2025/06/c328da14bbb15fc4ad571dc33e7a4ab3.pdf
- ESIC, Contribution rates and contribution periods: https://esic.gov.in/contribution
- ESIC, Coverage and wage limit: https://esic.gov.in/coverage
- Gazette of India, S.O. 2351(E), 8 May 2026 (ESI continuation within a contribution period): https://www.labour.gov.in/static/uploads/2026/05/e10c5bf2f9ba640316b5190a6e7d9300.pdf
- Lok Sabha Unstarred Question 1345 (27 July 2026), ESI wage ceiling: https://sansad.in/getFile/lsapps/loksabhaquestions/annex/188/AU1345_TBAsOH.pdf
- Maharashtra GST Department, Profession Tax rate schedule: https://www.mahagst.gov.in/en/profession-tax-and-other-rate-schedule
- Karnataka Commercial Taxes Department, Professional Tax portal (February ₹300 notice): https://ptax.karnataka.gov.in/ptemployer
Written by the team at TankhaPay, a global HRMS and AI recruitment platform.
Top comments (0)