On ERPNext v16.16.0, saving a Purchase Invoice against a supplier who has a Tax Withholding Category assigned started failing with "No Tax Withholding data found for the current posting date" — even though the Category clearly exists and the supplier is unchanged. The reason is not a bug in ERPNext. It is that the Income Tax Act 2025 renumbered and restructured India's TDS sections, and the rate rows inside your existing Tax Withholding Category documents no longer cover the current posting date. The fix is small, local, and safe — but only if you understand exactly what the match logic looks for.
If your team hit this the day it went live, you are in good company — issue #54772 is the canonical write-up on the ERPNext repo. I am Manoj, ERPNext lead at MithTech in Bengaluru — we run the India-compliance stack across dozens of client sites and have shipped this fix on production twice this quarter.
The exact error you're seeing
The full error text raised on Purchase Invoice save/submit is:
No Tax Withholding data found for the current posting date.
It surfaces in the browser as a red toast and on the server side as a frappe.ValidationError. Documents affected: Purchase Invoice, Payment Entry (advance TDS), and Journal Entry whenever an entry line references a supplier with a tax_withholding_category and the posting date falls outside every rate row's window inside that Category.
Sales Invoice can raise the same error under the equivalent flow — TCS on 206C(1H) — because the same TaxWithholdingCategory.get_applicable_tax_row method backs both directions. The bug is not currency-specific and not supplier-specific. It is date-specific.
What actually broke — Tax Withholding Category internals
The Tax Withholding Category DocType stores period-specific rates in a child table called Tax Withholding Rate. Its JSON at erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json defines the fields that drive the match:
"field_order": [
"from_date",
"to_date",
"tax_withholding_group",
"column_break_3",
"tax_withholding_rate",
"cumulative_threshold",
"single_threshold"
]
Three of those fields are load-bearing at match time: from_date, to_date, and tax_withholding_group. The controller method that reads them lives in erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py:
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
for row in self.rates:
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
row.tax_withholding_group
) == cstr(tax_withholding_group):
return row
frappe.throw(_("No Tax Withholding data found for the current posting date."))
The match is a linear scan with two ANDed conditions: the date must fall inside a row's window, and the row's tax_withholding_group must equal the group the caller passed in. If either check fails on every row, the loop exits and the throw fires.
The caller sits a few lines down in the same file — TaxWithholdingDetails.get() at line 132 iterates over the categories a supplier is subject to and calls doc.get_applicable_tax_row(self.posting_date, self.tax_withholding_group). Nothing catches the throw. It bubbles all the way to the Purchase Invoice submit handler.
Why the failure is silent-until-submit
get_applicable_tax_row is called during the tax computation that runs on Purchase Invoice save (via TaxWithholdingDetails.get(), invoked from the accounts controller). The user typed nothing wrong — they picked a supplier, added items, hit Save. The failure isn't in their input; it's in seed data that quietly went out of date. This is exactly the class of bug a static gate at the Category level cannot catch — the Category validates fine on its own (validate_dates() at line 46 only checks for row overlap, not coverage of any particular future date). It only breaks when combined with a specific posting date.
Before ITA 2025, most sites had a top row in each Category ending 2025-03-31 and no successor row. The moment somebody raised a Purchase Invoice dated 2025-04-01 or later against a TDS supplier — throw.
Pick a TDS section — see the pre-ITA 2025 vs post-ITA 2025 rate and threshold, with a 'verify with your CA' caveat.
Which TDS section broke — and what to compare against
The four-step fix
Fixing this correctly at the Desk level takes about ten minutes per Category. No code changes, no version upgrade, no monkey-patching.
Click through the four steps: identify affected Category → add ITA 2025 rate row → save → test on a Purchase Invoice.
Click a step — see exactly what to do
The critical detail people miss on step 2 is the tax_withholding_group column. If your existing rows use 194C-Individual and 194C-Company as separate groups (a common pattern where different payee types attract different rates), you need one new row per group — not one row for the whole Category. The cstr(row.tax_withholding_group) == cstr(tax_withholding_group) check is strict; None matches "" (both cstr to "") but "" does not match "194C-Individual".
Use the builder below to compose a well-formed row and paste it straight into the Desk grid — or into a fixture.
Compose a Tax Withholding Rate row (from_date, to_date, rate, single & cumulative thresholds) → get a JSON snippet ready to paste into bench console or a fixture.
Compose a Tax Withholding Rate child row
{
"doctype": "Tax Withholding Rate",
"from_date": "2025-04-01",
"to_date": "2026-03-31",
"tax_withholding_rate": 1,
"single_threshold": 30000,
"cumulative_threshold": 100000,
"tax_withholding_group": null
}Seeding all categories at once (bench script)
If your site has three or four Categories, do them by hand. If you have twenty — or you're an implementer with a fleet of client sites hitting the same wall this week — the safer move is a bench console snippet that reads every Category, checks whether any existing row covers today's date, and appends a placeholder ITA 2025 row where none is found.
Two design choices are load-bearing:
- The placeholder rate is
0.00%. It unblocksget_applicable_tax_rowso the error stops. It is also visibly wrong, so any invoice that submits under it will be flagged by your reconciliation review before it goes to the government. Fail loud, not silent. - Nothing is committed until you say so. The script prints its plan and leaves
frappe.db.commit()for you to run separately. If the plan looks wrong,frappe.db.rollback()walks it back cleanly.
Two recipes to seed missing rate rows across every Tax Withholding Category in one shot: a bench console script and a hooks.py fixture pattern for your custom app.
Backfill every Category — bench or fixtures
Interactive backfill. Reads every Tax Withholding Category, checks whether an existing rate row covers today, and appends a placeholder ITA 2025 row where missing. Prints a report — nothing is committed until you confirm.
# bench --site your-site.local console
import frappe
from frappe.utils import getdate
TODAY = getdate()
FY_FROM = "2025-04-01"
FY_TO = "2026-03-31"
# Placeholder rate — every category gets the same 0.00% marker so a human MUST
# review before the row is usable. Filtering by 0.00 later flags what still
# needs a real CA-signed value.
PLACEHOLDER_RATE = 0.00
NOTE = "ITA 2025 — verify rate with CA before submitting invoices"
report = {"seeded": [], "already_ok": [], "skipped": []}
for name in frappe.get_all("Tax Withholding Category", pluck="name"):
doc = frappe.get_doc("Tax Withholding Category", name)
# Group rows by tax_withholding_group; get_applicable_tax_row matches on
# (group, posting_date) together, so a per-group check is the right lens.
from collections import defaultdict
by_group = defaultdict(list)
for r in doc.rates:
by_group[r.tax_withholding_group or ""].append(r)
for group, rows in by_group.items():
covered = any(
getdate(r.from_date) <= TODAY <= getdate(r.to_date) for r in rows
)
if covered:
report["already_ok"].append((name, group))
continue
doc.append("rates", {
"from_date": FY_FROM,
"to_date": FY_TO,
"tax_withholding_rate": PLACEHOLDER_RATE,
"single_threshold": 0,
"cumulative_threshold": 0,
"tax_withholding_group": group or None,
})
report["seeded"].append((name, group))
try:
doc.save(ignore_permissions=True)
except frappe.ValidationError as e:
# validate_dates() rejects overlapping rows — safe: your data is fine.
report["skipped"].append((name, str(e)))
frappe.db.rollback()
print("Seeded:", len(report["seeded"]))
print("Already ok:", len(report["already_ok"]))
print("Skipped:", len(report["skipped"]))
for row in report["seeded"][:20]:
print(" +", row)
# Nothing is committed until you commit explicitly. Review, then:
# frappe.db.commit()
get_applicable_tax_row but is visibly wrong until a human sets the real figure — a safe fail-loud.Automation gate
The seed script unblocks today's invoices. The next step is stopping the same class of error surprising a buyer six months from now, when FY 2026-27 opens and someone forgot to add rows.
Wire a doc_events guard on Purchase Invoice validate that reads every referenced tax_withholding_category and warns — loudly, at save time — if no rate row covers the invoice's posting_date. This runs before get_applicable_tax_row gets a chance to throw, so the message is your polite warning rather than ERPNext's cryptic one.
# custom_ita_2025/hooks.py
doc_events = {
"Purchase Invoice": {
"validate": "custom_ita_2025.tds.guards.warn_if_stale_twc",
}
}
# custom_ita_2025/tds/guards.py
import frappe
from frappe import _
from frappe.utils import getdate
def warn_if_stale_twc(doc, method=None):
if doc.doctype != "Purchase Invoice":
return
categories = {
i.tax_withholding_category
for i in (doc.get("items") or [])
if i.tax_withholding_category
}
supplier_twc = frappe.db.get_value("Supplier", doc.supplier, "tax_withholding_category")
if supplier_twc:
categories.add(supplier_twc)
posting = getdate(doc.posting_date)
for name in categories:
rates = frappe.get_all(
"Tax Withholding Rate",
filters={"parent": name},
fields=["from_date", "to_date", "tax_withholding_group"],
)
covered = any(
getdate(r.from_date) <= posting <= getdate(r.to_date) for r in rates
)
if not covered:
frappe.msgprint(
_(
"Tax Withholding Category <b>{0}</b> has no rate row covering "
"the posting date <b>{1}</b>. Add an ITA 2025 row before submit, "
"or the tax computation will fail with 'No Tax Withholding data found'."
).format(name, doc.posting_date),
title=_("Stale TDS Category"),
indicator="orange",
)
Deploy this as a frappe.msgprint warning first — collect two weeks of feedback from the buying team. Only escalate to frappe.throw once you're confident there are no false positives on advance invoices, foreign-currency invoices, or reverse-charge cases in your specific setup.
If you prefer a client-side variant so buyers see the warning before they even hit Save, the same logic works as a frappe.ui.form.on('Purchase Invoice', { validate: … }) script — call frappe.db.get_list('Tax Withholding Rate', { filters, fields }) and frappe.msgprint() the same message.
What we DON'T recommend
Do not monkey-patch get_applicable_tax_row
The tempting fix, when you're staring at the throw at 6pm on a submit deadline, is to override the method to return the most recent row instead of raising. Please do not. The throw is the entire correctness contract — it forces a conscious human decision about which rate applies to a payment. Silencing it will land your books with rows applied under the wrong rate for a period nobody remembers configuring, and that mistake is much more expensive at assessment time than a missed evening.
Do not commit specific rate percentages into public code
The rate schedule under ITA 2025 will be amended by CBDT notifications and future Finance Acts, as every year's schedule has been. If you ship a custom app that hard-codes 1.0 or 10.0 into a fixture, every downstream site inherits your obsolete number the moment it changes. Commit the shape (Category names, from/to dates, groups). Source the values from your CA every FY and load them via a site-local bench console run, not a code push.
The India-specific TDS handling was moved out of ERPNext core into the separate india_compliance app during v14 (see erpnext/patches/v14_0/remove_india_localisation.py). If you're running v16 and haven't installed india_compliance, your Categories are whatever a previous implementer created by hand — there is no "official" seed to fall back on. That's another reason a per-site, CA-verified seed is the correct pattern rather than a code-pushed fixture.
FAQ
+Does this error only affect v16.16.0, or later patch versions too?
The error string and the throwing code path are unchanged in erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py up to the current v16.28.0 checkout we verified against. It is not a regression that gets patched — it is the correct behaviour when your Category has no matching row. Upgrading ERPNext will not add the rows for you; only your data change will.
+Why can I save the Category successfully but Purchase Invoice still throws?
Because TaxWithholdingCategory.validate_dates() — at line 46 of the same file — only checks that rows don't overlap. It does not check that some row covers today's date, or any future date. A Category with rate rows ending in 2024 is perfectly valid to save; it just cannot answer a lookup for 2025 or later. The coverage check lives on the invoice-save code path, not the Category-save one.
+Do I need india_compliance installed for the fix to work?
No. The Tax Withholding Category and Tax Withholding Rate DocTypes are in ERPNext core (under erpnext/accounts/doctype/). The india_compliance app adds GST-adjacent tooling on top, but the TDS Category structure and the get_applicable_tax_row code path exist without it. The fix is site-local ERPNext data.
+What's the difference between single_threshold and cumulative_threshold?
Both are floats on the Tax Withholding Rate child table. single_threshold is the per-transaction limit above which TDS applies to that single payment; cumulative_threshold is the year-to-date aggregate limit above which TDS applies to every payment once crossed. validate_thresholds() in the controller enforces cumulative_threshold >= single_threshold. Setting one to zero disables that check, which is common for sections like 194Q where only the aggregate matters.
+What if my rate row's tax_withholding_group is blank — does the match still work?
Yes, as long as the caller also passes a blank group. cstr() on the ERPNext side turns both None and "" into "", so a NULL row group matches a NULL caller group. If your supplier's Category has non-empty group values (e.g. 194C-Individual), your caller must pass the same string exactly. This is the second-most-common cause of "why is my row not matching" after the date window.
+Should I edit an existing row's to_date or add a new row?
Add a new row. Editing history rewrites the meaning of past submitted invoices — it makes the row look like it was in effect during a period it wasn't. Auditors reading Tax Withholding Entry records against Category history will notice. A new row keyed to 2025-04-01 → 2026-03-31 preserves the audit trail cleanly.
+Does this affect Lower Deduction Certificate (LDC) supplier flows?
Not directly — LDC has its own matching path in TaxWithholdingDetails.get_valid_ldc_records() using valid_from/valid_upto against Lower Deduction Certificate documents. But the underlying Category is still looked up first, so if your Category has no rate row for the posting date, the throw fires before LDC ever gets a chance to apply. Fix the Category, then LDC works as before.
+Can I roll the placeholder 0.00% row out to production or is it dev-only?
Only if your reconciliation process will catch a 0% TDS deduction before payment. On a well-run site, TDS Computation Summary or a nightly variance report will flag any invoice that computed zero TDS against a supplier who should have been deducted from. If your process is weaker than that, do the CA-verified rate first for the top few supplier volumes and only use 0.00% for the long tail. The placeholder is a safe fail-loud, not a safe production value.
Closing
The "No Tax Withholding data found" throw is one of the few ERPNext error messages that is doing its job perfectly. It is refusing to guess a TDS rate on your behalf when your Category tells it nothing about the current period. The fix — add a rate row for FY 2025-26 per group, then gate future stale-ness with a doc_events warning — is a ten-minute Desk change wrapped in a two-week rollout plan.
Don't monkey-patch. Don't commit CA-sensitive rate values into a public fixture. Do add the rows, do wire the guard, and do get your CA to sign off on the actual percentages under ITA 2025 before you submit real invoices.
Running ERPNext across multiple sites and hitting this on all of them?
We run the bench-console backfill, deploy the doc_events guard, and hand your CA a rate-row sign-off template so this year's ITA 2025 mess and next year's Finance Act update land as scheduled tasks rather than fire drills.