Skip to main content

GST & compliance

ERPNext v16 TDS and the Income-tax Act 2025: Fixing 'No Tax Withholding data found'

From 1 April 2026, Purchase Invoices on ERPNext v16 can fail with 'No Tax Withholding data found'. Why the Category's rate rows miss the date, and the fix.

Updated 12 Oct 20269 min readBy ManojFirst published 24 Jul 2026

On ERPNext v16, saving a Purchase Invoice against a supplier with a Tax Withholding Category can fail with "No Tax Withholding data found for the current posting date", even though the Category exists and the supplier hasn't changed. It is not a bug. The rate rows inside your Tax Withholding Category don't cover the invoice's posting date. Many sites hit it on invoices dated from 1 April 2026, the first tax year under the Income-tax Act, 2025, when last year's rows ran out and teams were rebuilding categories for the new Act. The fix is small and local, once you know exactly what the match logic looks for.

The canonical write-up is issue #54772 on the ERPNext repo (May 2026, ERPNext 16.16.0). I lead ERPNext and Frappe programmes at MithTech, a team of Frappe specialists in Bengaluru. This post walks the code path, the Desk fix and a guard so the error doesn't come back next April.

The exact error you're seeing

The error raised on Purchase Invoice save or submit is:

No Tax Withholding data found for the current posting date.

It shows in the browser as a red message and on the server as a frappe.ValidationError. It can hit Purchase Invoice, Payment Entry (TDS on advances) and Journal Entry whenever a line references a supplier with a tax_withholding_category and the posting date falls outside every rate row in that Category.

The same get_applicable_tax_row method backs TCS categories on the sales side, so a TCS category with stale rows fails the same way. The error is not about currency or supplier. It is about the date.

What actually broke: Tax Withholding Category internals

The Tax Withholding Category DocType stores period-specific rates in a child table, Tax Withholding Rate. Its JSON at erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json defines the fields that drive the match:

JSON
"field_order": [
  "from_date",
  "to_date",
  "tax_withholding_group",
  "column_break_3",
  "tax_withholding_rate",
  "cumulative_threshold",
  "single_threshold"
]

Three fields decide the match: from_date, to_date and tax_withholding_group. The method that reads them is in erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py:

Python
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."))

It is a linear scan with two conditions, both required: the date must fall inside a row's window, and the row's tax_withholding_group must equal the group the caller passes. If no row passes both, the loop ends and the throw fires.

The caller is in the same file: TaxWithholdingDetails.get() (line 132) loops over the supplier's categories and calls doc.get_applicable_tax_row(self.posting_date, self.tax_withholding_group). Nothing catches the throw, so it reaches the Purchase Invoice save.

A common setup has a top row in each Category ending 2026-03-31 and no row after it. The first Purchase Invoice dated 2026-04-01 or later against a TDS supplier throws. The same thing happens at any tax-year boundary where nobody added the next row.

CalculatorTDS Category Inspector

Pick a TDS section and see what to check before you add the new row, with a 'verify with your CA' caveat.

Section inspector

Which TDS section broke — and what to compare against

Pre-ITA 2025 (what your Category holds today)

1% (individual) / 2% (others)

Threshold: ₹30,000 single / ₹1,00,000 aggregate

Post-ITA 2025 (row you must add)

Restated under ITA 2025 — verify

Threshold: Threshold slabs restated — verify

Scope of 194C

Payments to contractors / sub-contractors

Section 194C exists in the new Act under a renumbered clause; slabs and rates were restated. Confirm the exact code before setting up the Category.

The four-step fix

At the Desk this takes a few minutes per Category. No code changes, no version upgrade, no patching.

InteractiveFour-Step Fix Walkthrough

Click through the four steps: identify affected Category → add the new rate row → save → test on a Purchase Invoice.

The 4-step fix

Click a step — see exactly what to do

Step 1 of 4
Identify the affected Category

Open Tax Withholding Category list. Filter by the section (194C, 194J, …) your Purchase Invoice referenced. Open its Rates table and note the highest to_date — anything ending before your invoice's posting_date is why get_applicable_tax_row raised.

5 min · Desk UI
1 / 4

The detail people miss in step 2 is the tax_withholding_group column. If your rows use 194C-Individual and 194C-Company as separate groups (a common pattern where 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 become ""), but "" does not match "194C-Individual".

The builder below composes a well-formed row to paste into the Desk grid or a fixture. Set its dates to the tax year you are fixing: for the first year under the new Act, 2026-04-01 to 2027-03-31.

CalculatorRate Row Builder

Compose a Tax Withholding Rate row (from_date, to_date, rate, single and cumulative thresholds) and get a JSON snippet for bench console or a fixture.

Rate row builder

Compose a Tax Withholding Rate child row

tax_withholding_rate.json
{
  "doctype": "Tax Withholding Rate",
  "from_date": "2026-04-01",
  "to_date": "2027-03-31",
  "tax_withholding_rate": 1,
  "single_threshold": 30000,
  "cumulative_threshold": 100000,
  "tax_withholding_group": null
}

How do you seed all categories at once?

With three or four Categories, do them by hand. With twenty, or across several client sites, a bench console snippet is safer: it reads every Category, checks whether a row covers today's date, and appends a placeholder row where none does.

Two design choices matter:

  1. The placeholder rate is 0.00%. It stops the throw. It is also visibly wrong, so any invoice submitted under it shows up in your TDS review before anything is deposited or filed.
  2. Nothing is committed until you say so. The script prints its plan and leaves frappe.db.commit() for you to run. If the plan looks wrong, frappe.db.rollback() undoes it.
Code recipeBench + Fixtures Seed Recipes

Two recipes to seed missing rate rows across every Tax Withholding Category in one go: a bench console script and a hooks.py fixture pattern for your custom app.

Seed recipes

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.

seed_ita_2025.py
# bench --site your-site.local console
import frappe
from frappe.utils import getdate

TODAY = getdate()
FY_FROM = "2026-04-01"
FY_TO   = "2027-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()

Placeholder rate is 0.00% on purpose. It unblocks get_applicable_tax_row but is visibly wrong until a human sets the real figure — a safe fail-loud.

The guard for next year

The seed script unblocks today's invoices. The guard stops the same error at the next tax-year boundary, when someone forgets the rows again.

Wire a doc_events hook on Purchase Invoice validate that reads every referenced tax_withholding_category and warns at save time if no rate row covers the invoice's posting_date. It runs before get_applicable_tax_row throws, so users see a clear message instead of ERPNext's terse one.

Python
# 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 a row for this tax year 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",
            )

Run it as a frappe.msgprint warning first and collect two weeks of feedback from the buying team. Switch to frappe.throw only once you're sure there are no false positives on advance invoices, foreign-currency invoices or reverse-charge cases in your setup.

For a client-side variant, so buyers see the warning before they press 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

India-specific GST handling left ERPNext core for the separate India Compliance app in v14 (see erpnext/patches/v14_0/remove_india_localisation.py). The Tax Withholding Category and Rate DocTypes stay in ERPNext core, so your Categories are whatever was created on your site, by India Compliance's setup or by a previous implementer. That is another reason to keep a per-site, CA-checked seed rather than a code-pushed fixture.

At a glance

AspectWhat to know
Symptom"No Tax Withholding data found for the current posting date" on save
CauseNo rate row covers the posting date for the supplier's group
FixAdd one row per group for the new tax year; update India Compliance
Guardvalidate hook that warns when no row covers the posting date

Common questions from the buying team

+Does this error only affect v16.16.0, or later patch versions too?

The error string and the code path are unchanged in tax_withholding_category.py up to the v16.28.0 checkout we verified. It is not a regression that a patch will fix: it is the correct behaviour when your Category has no matching row. Upgrading ERPNext won't add the rows for you; only a data change will.

+Why can I save the Category but the Purchase Invoice still throws?

Because validate_dates() (line 46 of the same file) only checks that rows don't overlap. It does not check that a row covers today or any future date. A Category whose rows end in March 2026 saves fine; it just can't answer a lookup for April 2026. The coverage check runs when the invoice is saved, not when the Category is.

+Do I need India Compliance installed for the fix to work?

The Tax Withholding Category and Tax Withholding Rate DocTypes and get_applicable_tax_row are in ERPNext core, so the date fix works without it. But India Compliance has added fields for the new income-tax rules, and Frappe's answer on issue #54772 was to update it. If you run Indian TDS, keep India Compliance current as well.

+What's the difference between single_threshold and cumulative_threshold?

Both are fields on the Tax Withholding Rate row. single_threshold is the per-transaction amount above which TDS applies to that payment; cumulative_threshold is the year-to-date amount above which TDS applies once crossed. validate_thresholds() enforces cumulative_threshold >= single_threshold. Setting one to zero disables that check, which suits sections where only the yearly total matters, such as TDS on purchase of goods.

+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() turns both None and "" into "", so a blank row group matches a blank caller group. If the Category uses group values such as 194C-Individual, the caller must pass the same string exactly. After the date window, this is the most common reason a row doesn't match.

+Should I edit an existing row's to_date or add a new row?

Add a new row. Editing an old row rewrites what applied to past invoices and makes it look as if a rate was in effect for a period it wasn't. Auditors comparing Tax Withholding Entry records with the Category history will notice. A new row from 2026-04-01 to 2027-03-31 keeps the trail clean.

+Does this affect Lower Deduction Certificate (LDC) suppliers?

Not directly. LDC has its own match in TaxWithholdingDetails.get_valid_ldc_records(), using valid_from and valid_upto on the Lower Deduction Certificate. But the Category is looked up first, so if it has no row for the posting date, the throw fires before the LDC applies. Fix the Category and the LDC works as before.

+Can the 0.00% placeholder row go to production?

Only if your review will catch a 0% deduction before payment. A TDS Computation Summary check or a nightly variance report should flag any invoice that computed zero TDS for a supplier who should have been deducted. If your process is weaker, enter the CA-confirmed rate first for your largest suppliers and use 0.00% only for the long tail. The placeholder fails loudly; it is not a safe production value.

What is the bottom line?

"No Tax Withholding data found" is an error doing its job. It refuses to guess a TDS rate when your Category says nothing about the current period. Add a rate row per group for the new tax year, keep India Compliance current, and add a validate warning so the next boundary doesn't surprise anyone.

Don't monkey-patch. Don't commit rate values into shared fixtures. Add the rows, wire the guard, and have your CA confirm the rates under the Income-tax Act, 2025 before you submit real invoices.

Your next step

Hitting this across several ERPNext sites?

Next step · GST & compliance

Now see GST compliance

GST compliance

Comments & ratings

No comments yet. Start a new discussion.

0

Manoj · Published on 24 July 2026

Worked with us? Leave a Google review

Questions about this? Talk to the team.

Book a 30-minute Google Meet, WhatsApp us, or send a message. The engineer who works on this replies.

Send us a message
Your details go to our team's CRM and are used only to reply.