ERP

ERPNext v16 Custom Report Filter Bugs: The Shape Assumption That Breaks Them

Three real Frappe/ERPNext filter-shape bugs, each with its exact version boundary. One shipped in v16.14.0 and was fixed in v16.21.0. One is open but…

MManojAugust 5, 202614 min read
ERPNext#\"erpnext\#\"frappe\#\"upgrade\#\"custom-apps\
Share

This guide on erpnext v16 custom report filter bugs is written for Indian SMEs, with code samples, ERPNext / Medusa recipes, and step-by-step fixes you can copy into a real project. A filter value arrives in a shape your code did not expect — a bare string where a list was assumed, a plain dict where a frappe._dict was assumed, an operator filter like ["Between", [...]] where a plain value was assumed — and the failure surfaces four frames deep inside pypika, pydantic or MariaDB, naming none of the code responsible. AttributeError: 'str' object has no attribute 'nodes_'. pymysql.err.ProgrammingError: (1064, ...). FrappeTypeError: Argument 'ctx' ... should be of type 'frappe._dict | str' but got 'dict' instead. Three different messages, one bug class. This post takes three real Frappe/ERPNext issues from that class, states exactly which releases each one affects — the answers are genuinely different,. Two of the three are narrower than the internet suggests — and turns them into an audit you can run on your own apps before you upgrade. This guide on erpnext v16 custom report filter bugs is written for Indian SMEs, with code samples, ERPNext / Medusa recipes,. Step-by-step fixes you can copy into a real project.

Planning the v15 to v16 jump?

This post is about what breaks in your code during the upgrade. For the upgrade mechanics themselves, see the v15 to v16 upgrade guide; for whether to move at all, see ERPNext v15 vs v16.

I am Manoj, ERP implementation lead at Mith Tech in Bengaluru. Most of the "v16 broke our reports" tickets we see are not v16 breaking anything — they are a custom app that got away with a shape assumption for two years. Finally met a caller that did not honour it. The useful work is telling those two situations apart quickly, which means knowing exactly which release fixed what.

How do you use Start with the version question, because the answers differ?

The single most common mistake in the community threads on this topic is treating the three failures as one story with one fix. They are not. One is a shipped regression with a clean before-and-after release boundary. One is an open report that a maintainer disputes and that no one has reproduced on stock code. One never touched a v16 release at all.

InteractiveAm I Affected?link

Enter your ERPNext version and say whether you call reports from code and whether you override report filters in JS. Get a separate verdict for each of the three cases, including the cases that do not apply to you.

Cases needing action
1 of 3
Case 1 fixed in
v16.21.0
Cases with no upstream fix
1
Delivery Note selector — operator filterserpnext#54805Affected — upgrade to v16.21.0 or later

v16.14.0 through v16.20.1 inclusive ship the broken Delivery Note selector. Any operator filter (Between, >, in, like) sent from the Get Items From dialog compiles to invalid SQL.

Stock Balance — scalar item_codeerpnext#57212Not exposed

Upstream stock_balance.js declares item_code as a MultiSelectList on both develop and version-16, so the stock report UI cannot emit a bare string. If you only ever run Stock Balance from the desk, this cannot reach you.

apply_price_list — FrappeTypeErrorerpnext#56657Does not apply to your release

apply_price_list on version-16 has no argument annotations, so typing_validations has nothing to enforce and FrappeTypeError cannot fire there. This is a develop/v17 story, not a v16 one.

Version windows come from reading erpnext/controllers/queries.py at each release tag plus the GitHub compare API. Case 2 is version-independent: it is open on every branch and only reachable from custom or scripted callers. Case 3 fires on no shipped v16 release.

Case 1 — the Delivery Note selector, and the only genuine v16 regression here

Issue #54805: using a filter in Sales Invoice's "Get Items From → Delivery Note" dialog throws a MariaDB 1064 syntax error, with a bracketed Python list visible in the error text.

It was introduced by PR #52594, commit ef454822d7bc. Read that PR's title and you will see the trap: it is about toggling a button based on is_return and POS view. The Query Builder rewrite of get_delivery_notes_to_be_billed rode along inside it. Grepping release notes for "query builder" would never have found this.

What the rewrite removed was the delegation to Frappe's own filter compiler:

fcond=get_filters_cond(doctype, filters, []),
mcond=get_match_cond(doctype),

What replaced it was a loop that assumes every filter is an equality:

if filters and isinstance(filters, dict):
    for key, value in filters.items():
        query = query.where(DeliveryNote[key] == value)

get_filters_cond understands the Frappe filter grammar — ["Between", [...]], [">", 1000], ["in", [...]], ["like", "%x%"]. The loop understands none of it. When the dialog sends {"posting_date": ["Between", null]}, that compiles to `posting_date` = ['Between', NULL] and MariaDB rejects it. get_match_cond was dropped in the same change, which is a permissions concern on top of a syntax one.

The fix is PR #55443, commit 4ef17c9, which deletes the loop and seeds the query from frappe.qb.get_query. We read erpnext/controllers/queries.py at each release tag to find the boundary: v16.14.0 through v16.20.1 carry the bug; v16.21.0 is the first fixed v16 release. On the v15 line, v15.110.0 is the first fixed release; we confirmed v15.109.3 still carries it.

One oddity worth knowing if you go read the issue: it was closed on 2026-05-28, three days before its fix merged on 2026-05-31. The visible context is a need-more-info label and a maintainer who could not reproduce it. Why it was closed early is not recorded anywhere in the timeline, and we are not going to guess.

Case 2 — the Stock Balance crash that your Stock Balance report almost certainly does not have

Issue #57212 reports AttributeError: 'str' object has no attribute 'nodes_' from Stock Balance when item_code is a single string. It is open, and it is disputed — and the dispute is the interesting part.

The unguarded line is real. erpnext/stock/report/stock_balance/stock_balance.py line 451, identical on develop and version-16 today:

if item_codes := self.filters.get("item_code"):
    query = query.where(item_table.name.isin(item_codes))

pypika's ContainsCriterion.nodes_() does yield from self.container.nodes_(). Hand it a str and you get an AttributeError whose traceback mentions pypika, your report, and nothing about the filter that caused it.

But a maintainer's rebuttal on the issue is correct, and we verified it independently: upstream stock_balance.js declares item_code as a MultiSelectList on both develop and version-16. The stock report UI cannot emit a bare string. So the crash is reachable only when the report is invoked programmatically (frappe.desk.query_report.run with filters={"item_code": "Melamine"}), when a Client Script or app override has downgraded the filter to a Link, or when a custom app constructs the report class directly with a scalar.

If you only ever run Stock Balance from the desk, this cannot reach you. If your app, your scheduled job, or your integration calls it, normalise on your side — there is no fix PR,. No one has reproduced it on stock code, us included.

What makes it worth studying anyway is what sits one directory away. erpnext/stock/report/stock_ledger/stock_ledger.py guards the same class of value in four separate places, including the exact branch that stock_balance.py is missing:

if isinstance(value, list | tuple):
    query = query.where(table[field].isin(value))
else:
    query = query.where(table[field] == value)

and a scalar-to-list normalisation at the top of the function:

item_codes = filters.item_code
if isinstance(item_codes, str):
    item_codes = [item_codes]

Same app, same project, same conventions. One file survives a scalar; the other does not.

InteractiveFilter Shape Inspectorlink

Pick a filter value — a scalar string, a list, an operator filter, or a plain dict — and see what each code path does with it: the unguarded .isin() from stock_balance.py, the shape-checked branch from stock_ledger.py, the naive equality loop that shipped in v16.14.0, and frappe.qb.get_query.

filters payload · str
{"item_code": "Melamine"}

The shape the Stock Balance UI cannot produce, and the shape a scripted caller produces by default.

Unguarded .isin()stock_balance.py:451
query = query.where(item_table.name.isin(item_codes))

Crashes inside pypika. Nothing in the traceback names your filter or your caller.

AttributeError: 'str' object has no attribute 'nodes_'
Shape-checked branchstock_ledger.py:670-674
if isinstance(value, list | tuple):
    query = query.where(table[field].isin(value))
else:
    query = query.where(table[field] == value)

Falls to the equality branch and compiles to `item_code` = 'Melamine'.

Scalar normalisationstock_ledger.py:699-700
item_codes = filters.item_code
if isinstance(item_codes, str):
    item_codes = [item_codes]

Wraps the string once, at the top of the function, so every later call site sees a list.

Line numbers are from the files as they stand on develop and version-16 today. Note what the guarded and unguarded lines have in common: they sit in two report modules in the same app, written by the same project, one file apart.

Case 3 — the Python 3.14 annotation trap, which is the shape of things coming in v17

Issue #56657: adding an item to a Sales Invoice raised FrappeTypeError: Argument 'ctx' in 'erpnext.stock.get_item_details.apply_price_list' should be of type 'frappe.types.frappedict._dict | str' but got 'dict' instead.

The root cause is genuinely a Python 3.14 language change, and ERPNext's own commit message states it precisely. PEP 649/749 replaced __annotations__ with __annotate__ in functools.WRAPPER_ASSIGNMENTS. ERPNext's normalize_ctx_input decorator excluded only __annotations__ when wrapping:

@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
def wrapper(ctx: T | Document | dict | str, *args, **kwargs): ...

So functools.wraps copied the wrapped function's __annotate__, and the wrapper's permissive _dict | Document | dict | str annotation was silently replaced by the narrow ItemDetailsCtx | str. The decorator's runtime body already handled a plain dict correctly — but frappe.utils.typing_validations reads the annotation before the body runs. Pydantic validates frappe._dict as an arbitrary type, meaning isinstance with no coercion, and a plain dict is not an instance of the frappe._dict subclass. The call never reached the code that would have normalised it.

The fix adds __annotate__ to the exclusion list. It landed on develop only, via commits 6eeadbd, f26cb79, 9406ec4 and 5956d3e — there is no linked PR; the issue was closed with a comment and we located the commits by scanning the file's history in the closing window.

Now the scope, which is the part that gets misreported: apply_price_list on version-16 is def apply_price_list(ctx, as_doc=False, doc=None) — no argument annotations at all. typing_validations only checks parameters that carry annotations, so there is nothing to enforce and this error cannot fire there. develop is v17, not v16. No shipped v16 release is affected by this issue.

Treat it as a preview. The __annotate__ trap itself is a plain Python 3.14 behaviour, so if your app wraps annotated functions with functools.wraps, you can reproduce the same silent annotation swap in your own code today. And the follow-up commits read like a checklist of the same mistake: restoring | dict on helper signatures plus the frappe._dict(pctx) coercion inside the body, reserving the narrow type for the normalisation boundary only, and fixing a cts=args keyword typo that had sat in transaction_base.py unnoticed until type checking made it visible.

What we verified, and what we did not

We read the source at release tags and used the GitHub compare API to confirm ancestry — that is where every version boundary in this post comes from. We read the issue timelines, the PR diffs, the commit messages, and the current state of stock_balance.py, stock_ledger.py, queries.py and stock_balance.js on both develop and version-16.

We did not reproduce any of these on a live bench. We did not reproduce #57212 on stock code — nobody has. Two of the three issues were reported from Frappe Cloud benches, and Frappe Cloud's patch state relative to public tags is opaque to us, so every version window here is for the public GitHub release tags only. Where the record is silent — the early closure of #54805, the missing PR link on #56657 — we have said so rather than filled the gap.

The one sentence that unifies all three

Each of these is code that assumed a filter or argument value would arrive in one particular shape, at a boundary where the framework stopped guaranteeing that shape.

  • #54805 assumed filter values are scalars. They can be ["Between", [...]].
  • #57212 assumed item_code is a sequence. It can be a str.
  • #56657 assumed ctx is a frappe._dict. It can be a plain dict.

Those assumptions used to be absorbed by something. Raw SQL had get_filters_cond doing the parsing. Form-encoded request bodies stringified everything, so a dict and a JSON string of a dict were the same thing on arrival. Unannotated signatures meant no validator ever looked. The migration from frappe.db.sql to frappe.qb removes the first cushion. The v17 move to JSON request bodies and enforced annotations removes the other two. v16 is the release where the cushions are thinning but the enforcement has not arrived — which makes it the cheapest possible time to fix your shape assumptions.

How do you audit your own instance?

Code recipeAudit, Normalise, Verifylink

Three tabs with copy buttons: the grep patterns to run against your custom apps before upgrading, the normalisation helpers lifted from what upstream already does, and the scratch-site test plan that reproduces each case deliberately.

Run these against apps/ before you plan the upgrade. Ordered by expected yield. Every pattern traces to one of the three cases — nothing here is generic hygiene.

bash
# Pre-upgrade audit for filter-shape assumptions
# Run from your bench root, against apps/

# 1. .isin() on a value that is not obviously a literal list
#    -> the erpnext#57212 class (stock_balance.py:451)
grep -rn "\.isin(" apps/ --include=*.py | grep -v "isin(\["

# 2. Hand-rolled filter loops over frappe.qb
#    -> the erpnext#54805 class (the v16.14.0-v16.20.1 regression)
grep -rn -A3 "for key, value in filters.items()" apps/ --include=*.py
grep -rn -B2 -A6 "frappe.qb.from_" apps/ --include=*.py | grep -n "filters"

# 3. Raw SQL still using the old filter compilers. These are the call
#    sites that WILL break if someone "modernises" them to qb.
grep -rn "get_filters_cond\|get_match_cond" apps/ --include=*.py

# 4. Attribute access on a filters/args dict
#    -> the commit c286a73e0b88 class (frappe._dict vs plain dict)
grep -rnE "\bfilters\.[a-z_]+\b" apps/ --include=*.py \
  | grep -v "filters\.get\|filters\.items\|filters\.keys\|filters\.values\|filters\.update\|filters\.pop"
grep -rnE "\bargs\.[a-z_]+\b" apps/ --include=*.py \
  | grep -v "args\.get\|args\.items\|args\.update\|args\.pop"

# 5. functools.wraps on Python 3.14
#    -> the PEP 649 / __annotate__ class behind erpnext#56657
grep -rn "functools.wraps\|from functools import wraps" apps/ --include=*.py
grep -rn "WRAPPER_ASSIGNMENTS" apps/ --include=*.py   # any hit needs __annotate__

# 6. Report .js filters whose fieldtype you changed
#    -> the erpnext#57212 dispute: MultiSelectList and Link are not
#       interchangeable, because the Python side does .isin()
grep -rn "fieldtype: \"MultiSelectList\"\|fieldtype: \"Link\"" apps/ --include=*.js \
  | grep -i "report"

Fix your version first, because it is free

If you are on v16.14.0 through v16.20.1, move to v16.21.0 or later. If you are on v15, move to v15.110.0 or later. That closes case 1 entirely with no code change on your side. Nothing else in this post is solved by upgrading.

Grep for .isin() without a literal list

grep -rn "\.isin(" apps/ --include=*.py | grep -v "isin(\[". Every hit is a place a scalar can reach pypika and produce a traceback that names neither your filter nor your caller. Fix each one with the isinstance(value, list | tuple) branch, or normalise the value earlier.

Grep for hand-rolled filter loops and for the old compilers

for key, value in filters.items() over a frappe.qb query is the #54805 pattern in your own code. Separately, get_filters_cond and get_match_cond call sites are the raw-SQL functions that will break the day someone modernises them to frappe.qb — annotate them now so that person is warned.

Grep for attribute access on filters and args

filters.posting_date works on a frappe._dict and raises on a plain dict. The report runner hands execute() a frappe._dict, so report bodies are safe — but every helper they call, and every hook, background job or cross-app caller, is not. The upstream fix for this class was literally the substitution to filters.get("posting_date") in two places.

Normalise once, at the top, then verify on a scratch site

Restore a backup to a scratch site, switch branches, migrate, then hit every custom report with both {"item_code": "ABC"} and {"item_code": ["ABC"]}, plus one operator filter such as ["Between", ["2026-05-01", "2026-05-31"]]. Call every whitelisted endpoint with a real JSON body so dicts arrive as dicts. Do this deliberately on your schedule rather than during month-end.

InteractivePre-upgrade Checklistlink

Nine items for maintainers of custom ERPNext apps and script reports, each tagged with the specific case it derives from — version, grep patterns, normalisation, annotations, and the scratch-site rehearsal.

Nine items, each traced to a specific case. Nothing is saved.
0 / 9

What are the three cases side by side?

CaseStatusAffects which releasesFixWho is actually exposed
#54805 — Delivery Note selector, MariaDB 1064Closed, fixedv16.14.0 – v16.20.1; v15 up to v15.109.3v16.21.0 / v15.110.0 (PR #55443)Anyone using Get Items From with a filter on an affected release
#57212 — Stock Balance, 'str' has no attribute 'nodes_'Open, disputedUnguarded line present on develop and version-16None — no PR existsOnly custom or scripted callers passing a scalar item_code; not the stock UI
#56657 — apply_price_list FrappeTypeErrorClosed, fixed on developNo shipped v16 release; develop/v17 onlydevelop commits 6eeadbd / f26cb79 / 9406ec4 / 5956d3e (no PR)v17 users, plus any app wrapping annotated functions with functools.wraps
Three bugs in the same class, with three genuinely different version answers

How do you At a glance — quick reference?

AspectWhat to know
When to use erpnext v16 custom report filter bugsStandard fit for the common case; review edge cases against the table.
Typical effort1–4 hours for a small team; longer with custom data or multi-entity setups.
Main riskSkipping reconciliation or running before the data is clean.
What to do nextRun the steps below, then verify against the checklist.

Frequently asked questions

+Which ERPNext version fixes the Delivery Note selector filter bug?

ERPNext v16.21.0 and v15.110.0, both released 2026-06-02. The affected v16 range is v16.14.0 through v16.20.1 inclusive. We verified this by reading erpnext/controllers/queries.py at each release tag and cross-checking ancestry with the GitHub compare API, not from release notes — the fix was PR #55443, backported to version-16 as commit 277a0723ef39.

+Why does ERPNext say 'str' object has no attribute 'nodes_'?

You passed a bare string to a pypika .isin() call, which expects a sequence. Wrap the value in a list, or add if isinstance(value, list | tuple): query.where(table[field].isin(value)) else: query.where(table[field] == value). It is tracked as erpnext#57212 and is still open. Note that the stock Stock Balance report cannot produce this from the desk, because upstream declares item_code as a MultiSelectList — if you are seeing it, a script, an API call, or a customised report filter is passing the scalar.

+Does ERPNext version 16 require Python 3.14?

Yes. Frappe's pyproject.toml on the version-16 branch declares requires-python = ">=3.14,<3.15", and ERPNext version-16 declares >=3.14. It is not a default you can opt out of — 3.13 and 3.15 are both outside the accepted range. The v15 line still supports 3.10 through 3.14. That does not mean Python 3.14 breaks v16; it means you should run your own app's test suite on 3.14 before you touch Frappe at all.

+Hey Google, why did my custom ERPNext report stop working after upgrading to version sixteen?

Most likely one of three shape assumptions in your own code. You call .isin() on a value that can be a single string. You read filters with attribute access such as filters.posting_date instead of filters.get("posting_date"). Or you hand-rolled a for key, value in filters.items(): query.where(T[key] == value) loop, which cannot parse operator filters like ["Between", [...]]. Replace the third with frappe.qb.get_query(doctype, fields=..., filters=..., ignore_permissions=False).

+What does FrappeTypeError argument ctx should be of type frappe._dict but got dict mean?

Frappe validates annotated arguments with pydantic, and frappe._dict is validated as an arbitrary type — an isinstance check with no coercion. A plain dict is not an instance of the frappe._dict subclass, so the call is rejected before your function body runs. Broaden the annotation to include | dict and coerce with frappe._dict(...) inside the body. Both halves are load-bearing. In ERPNext this was erpnext#56657, which affects the develop (v17) line and no shipped v16 release.

+Do I need to add type hints to my custom app's whitelisted methods for ERPNext v16?

Not for v16. The require_type_annotated_api_methods hook is not set in ERPNext's hooks.py on version-16, and unannotated parameters are skipped by the validator entirely. It is set on develop alongside use_json_request_body, so annotate now and the v17 upgrade becomes boring rather than eventful.

Is it safe to replace frappe.db.sql with frappe.qb in my custom app?. Only if you replace the filter compilation too. frappe.db.sql calls typically pass filters through get_filters_cond and permissions through get_match_cond. Writing frappe.qb.from_(X).where(X[k] == v) in a loop reproduces neither — that is exactly what shipped in v16.14.0 and broke the Delivery Note selector. Use frappe.qb.get_query(doctype, fields=..., filters=..., ignore_permissions=False), which parses the filter grammar and re-applies user permissions, then add your own hard conditions on top.

Hey Google, what happens if we do not fix filter shape assumptions before upgrading ERPNext?. Nothing, until a caller changes. Then a scheduled job fails at 2am with an AttributeError from inside pypika, or a link-field dialog throws a MariaDB 1064 with a Python list visible in the SQL, and the traceback names none of your code. The failures are intermittent because they depend on what shape a particular caller happened to send, which makes them expensive to diagnose under pressure. The audit takes an afternoon; the same bug found during month-end close takes a day and costs you trust.

None of this is exotic. It is the ordinary cost of a framework tightening its contracts while a decade of application code carries assumptions those contracts used to hide. The version work is an afternoon and closes exactly one of the three cases. The rest is a grep, a normalisation helper, and one rehearsal on a scratch site. Do it while nobody is waiting on the answer. If you would rather not do it yourself, that is roughly what our ERPNext implementation and support work looks like on an upgrade.

Upgrading ERPNext with a custom app you cannot afford to break?

We audit custom apps and script reports against the target release before the upgrade, so the failures show up on a scratch site instead of in production. Send us your app list and your current version.

Now work out what this costs for your business

Answer six questions about headcount, modules and locations, and get a three-year cost breakdown — implementation, hosting and support, with no licence fees in the total.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

Already a Mith Tech client?

Help the next operator choose.

Most teams evaluating ERPNext have no way to tell who actually delivers. If we’ve run an implementation for you, two lines on Google count for more than anything we can write about ourselves.

Leave a Google review

Only if we’ve actually worked together — Google filters reviews from non-customers, so an honest one is worth more than ten polite ones.

Keep reading

See what this looks like for your business

A 30-minute working session with a principal consultant. We pressure-test the architecture and outline the engagement model that fits your governance and procurement posture. You leave with a written brief.

0
Published on 5 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.