-
Notifications
You must be signed in to change notification settings - Fork 264
v16 Migration Checklist
This guide documents breaking changes and migration steps required when upgrading from v15 to v16.
| Application | Migration Wiki |
|---|---|
| Frappe | Migrating to Version 16 |
| ERPNext | Migration Guide to Version 16 |
The ORM methods frappe.db.get_value(), get_list(), and get_all() now use the Query Builder internally. This changes how SQL functions like Sum, Count, etc., must be used.
Old Syntax (v15):
frappe.qb.get_query("GP Task", fields=["sum(name) as count"])New Syntax (v16):
frappe.qb.get_query("GP Task", fields=[{"SUM": "name", "as": "count"}])| Type | Link |
|---|---|
| PR | frappe#32381 |
| Documentation | Query Builder Migration Guide |
Number Card aggregate function tuples no longer accept a fifth boolean element. This was previously allowed but now causes breaking errors with the Query Builder.
Old Syntax (v15):
# Fifth boolean element was allowed
["Sales Invoice","docstatus","=","2",false]New Syntax (v16):
# Remove the fifth element
["Sales Invoice","docstatus","=","2"]The URL prefix has changed from /app to /desk. All hardcoded URLs must be updated.
Old (v15):
url = "/app/sales-invoice/" + doc.nameNew (v16):
# Option 1: Use utility functions (recommended)
from frappe.utils import get_link_to_form
url = get_link_to_form("Sales Invoice", doc.name)
# Option 2: Direct replacement
url = "/desk/sales-invoice/" + doc.name| Function | Purpose |
|---|---|
frappe.utils.get_link_to_form(doctype, name) |
Generate DocType form links |
frappe.utils.get_url_to_form(doctype, name) |
Generate full URL to form |
| Type | Link |
|---|---|
| PR | frappe#34565 |
Apps can now add custom sidebar items and desktop icons for better navigation.
| Type | Link |
|---|---|
| PR | frappe#34565 |
When overriding a class, the overriding class must explicitly inherit from a base class. Classes without inheritance will now throw an error.
Old (v15) - Worked but incorrect:
class CustomController: # No inheritance - this will break!
def validate(self):
passNew (v16) - Correct:
from erpnext.controllers.base_controller import BaseController
class CustomController(BaseController): # Must inherit
def validate(self):
super().validate()
# your code| Type | Link |
|---|---|
| PR | frappe#26152 |
frappe.db.set_value() no longer accepts None as the document name for Single DocTypes. You must use frappe.db.set_single_value() instead.
Old (v15):
frappe.db.set_value("Accounts Settings", None, "determine_address_tax_category_from", "Shipping Address")
frappe.db.set_value("Accounts Settings", "Accounts Settings", "determine_address_tax_category_from", "Shipping Address")New (v16):
frappe.db.set_single_value("Accounts Settings", "determine_address_tax_category_from", "Shipping Address")| Type | Link |
|---|---|
| PR | frappe#19481 |
A new frappe.get_lazy_doc() function has been introduced that loads documents lazily - child tables are only fetched from the database when accessed. This significantly improves performance when you only need to access parent document fields.
Use get_lazy_doc() when:
- You only need parent document fields (not child tables)
- Only need controller methods
# Old: Loads entire document including all child tables
doc = frappe.get_doc("Sales Invoice", invoice_name)
# New: Lazy loading - child tables loaded only when accessed
doc = frappe.get_lazy_doc("Sales Invoice", invoice_name)
doc.update_status()
# Child tables are loaded on-demand
items = doc.items # Fetched from DB only now- Does not support Virtual DocTypes
- Does not support
DocTypedoctype itself
| Type | Link |
|---|---|
| PR | frappe#32877 |
ERPNext has migrated from a legacy JSON field (item_wise_tax_detail) to a fully relational child table (Item Wise Tax Detail) for tracking tax breakdowns at the item level.
This affects:
- Custom tax calculation logic
- Reports that read tax breakdowns
- Integrations that manipulate tax data
# JSON field on each tax row
tax_row.item_wise_tax_detail = json.dumps({
"ITEM-001": {
"tax_rate": 10.0,
"tax_amount": 100.0,
"net_amount": 1000.0
},
"ITEM-002": {
"tax_rate": 10.0,
"tax_amount": 50.0,
"net_amount": 500.0
}
})# Child table: Item Wise Tax Detail
doc.item_wise_tax_details = [
{
"item_row": "abc123", # Reference to item row name
"tax_row": "def456", # Reference to tax row name
"rate": 10.0,
"amount": 100.0,
"taxable_amount": 1000.0
},
# ... more rows
]During tax calculation (before save), the system uses a temporary in-memory list called _item_wise_tax_details:
# Internal structure during calculation
doc._item_wise_tax_details = [
frappe._dict(
item=<Item Row Object>, # Actual item row object
tax=<Tax Row Object>, # Actual tax row object
rate=10.0,
amount=100.0,
taxable_amount=1000.0
),
# ... more entries
]Note: The
on_update()hook automatically converts this temporary list to child table records.
Old (v15):
import json
tax_row["item_wise_tax_detail"] = json.dumps({
"ITEM-001": {"tax_rate": 10, "tax_amount": 100, "net_amount": 1000}
})New (v16):
# Don't set item_wise_tax_detail on tax rows
# The system handles this automatically via _item_wise_tax_detailsOld (v15):
# Keyed by item_code string
item_wise_tax_detail[item_code] = [rate, amount]New (v16):
# Store actual item and tax row objects
doc._item_wise_tax_details.append(
frappe._dict(
item=item_row, # Row object, not item_code string
tax=tax_row, # Row object, not tax name string
rate=rate,
amount=amount,
taxable_amount=taxable_amount
)
)When manually setting taxes, prevent ERPNext from recalculating:
tax_row = {
"charge_type": "Actual",
"account_head": account_head,
"tax_amount": 100.0,
"dont_recompute_tax": 1, # Required to prevent recalculation
}If you need to manually create item-wise tax breakdowns (e.g., for custom tax logic or integrations), you must:
-
Populate the temporary
_item_wise_tax_detailslist with row objects -
Set
dont_recompute_tax = 1on tax rows to prevent ERPNext from overwriting your values
# Step 1: Initialize the temporary list if not exists
if not hasattr(doc, "_item_wise_tax_details"):
doc._item_wise_tax_details = []
# Step 2: Add your custom tax details
for item_row in doc.items:
for tax_row in doc.taxes:
# Set flag to prevent automatic recalculation
tax_row.dont_recompute_tax = 1
# Calculate your custom tax values
taxable_amount = item_row.net_amount
rate = 18.0 # Your custom rate
amount = taxable_amount * rate / 100
# Append to temporary list (uses row objects, not strings)
doc._item_wise_tax_details.append(
frappe._dict(
item=item_row, # Row object reference
tax=tax_row, # Row object reference
rate=rate,
amount=amount,
taxable_amount=taxable_amount
)
)
# Step 3: The system will automatically convert _item_wise_tax_details
# to the child table on save via on_update() hook
doc.save()Important: Always set
dont_recompute_tax = 1on each tax row when manually populating tax details, otherwise ERPNext will recalculate and overwrite your values duringcalculate_taxes_and_totals().
Reports that previously parsed the JSON field must now query the child table:
Old (v15):
import json
tax_detail = json.loads(tax_row.item_wise_tax_detail or "{}")
for item_code, values in tax_detail.items():
rate = values.get("tax_rate")New (v16):
tax_details = frappe.get_all(
"Item Wise Tax Detail",
filters={"parent": doc.name},
fields=["item_row", "tax_row", "rate", "amount", "taxable_amount"]
)
for detail in tax_details:
rate = detail.rate| Type | Link |
|---|---|
| PR | erpnext#48692 |
This example demonstrates migrating a Shopify order sync that creates Sales Invoices with tax data.
def get_order_taxes(shopify_order, setting, items, doc=None):
tax_account_wise_data = {}
for line_item in shopify_order.get("line_items"):
item_code = get_item_code(line_item)
for tax in line_item.get("tax_lines"):
account_head = get_tax_account_head(tax)
# Old: Initialize JSON structure
tax_account_wise_data.setdefault(
account_head,
{
"charge_type": "Actual",
"description": tax.get("title"),
"cost_center": setting.cost_center,
"tax_amount": 0,
"item_wise_tax_detail": {}, # JSON field (deprecated)
},
)
# Old: Update JSON field with item_code as key
tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("price"))
tax_account_wise_data[account_head]["item_wise_tax_detail"].setdefault(
item_code, [flt(tax.get("rate"), 4) * 100, 0]
)
tax_account_wise_data[account_head]["item_wise_tax_detail"][item_code][1] += flt(
tax.get("price")
)
# Old: Convert to JSON string
taxes = []
for account, tax_row in tax_account_wise_data.items():
row = {"account_head": account, **tax_row}
row["item_wise_tax_detail"] = json.dumps(row.get("item_wise_tax_detail", {}))
taxes.append(row)
doc.update({"items": items, "taxes": taxes})def get_order_taxes(shopify_order, setting, items, doc=None):
tax_account_wise_data = {}
item_code_to_row_map = {} # Map item_code to item row objects
# Build mapping from item_code to item row object
for item_row in items:
item_code = item_row.get("item_code")
item_code_to_row_map[item_code] = item_row
for line_item in shopify_order.get("line_items"):
item_code = get_item_code(line_item)
item_row = item_code_to_row_map.get(item_code) # Get item row object
if not item_row:
continue
for tax in line_item.get("tax_lines"):
account_head = get_tax_account_head(tax)
# New: Initialize without item_wise_tax_detail JSON
tax_account_wise_data.setdefault(
account_head,
{
"charge_type": "Actual",
"description": tax.get("title"),
"cost_center": setting.cost_center,
"dont_recompute_tax": 1, # Important: prevents recalculation
"tax_amount": 0,
},
)
tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("price"))
# New: Create tax rows first
taxes = []
for account, tax_data in tax_account_wise_data.items():
tax_row = {"account_head": account, **tax_data}
taxes.append(tax_row)
doc.update({"items": items, "taxes": taxes})
# New: Populate _item_wise_tax_details after items and taxes are set
doc._item_wise_tax_details = []
# Build tax row mapping
tax_row_map = {}
for tax_row in doc.get("taxes"):
tax_row_map[tax_row.account_head] = tax_row
# Populate item wise tax details
for line_item in shopify_order.get("line_items"):
item_code = get_item_code(line_item)
item_row = item_code_to_row_map.get(item_code)
if not item_row:
continue
for tax in line_item.get("tax_lines"):
account_head = get_tax_account_head(tax)
tax_row = tax_row_map.get(account_head)
if not tax_row:
continue
# Append to temporary list with row objects
doc._item_wise_tax_details.append(
frappe._dict(
item=item_row, # Item row object
tax=tax_row, # Tax row object
rate=flt(tax.get("rate"), 4) * 100,
amount=flt(tax.get("price")),
taxable_amount=_get_item_price(line_item, shopify_order.get("taxes_included"))
)
)Last updated: 5 December 2025