Skip to main content

Frappe v16 Standards & Gotchas

Source: engineering/anshin-orchestrate (GitLab ID: 80) — orchestrate-core/CLAUDE.md and docs/frappe-v16-migration/, Rev 2026-02-01

Stack Versions

ComponentVersion
Frappev16
ERPNextv16
Python3.14
MariaDB11.8
Node (bench build only)24

Breaking Changes from v15

rebuild_tree() — 1 Argument Only

In Frappe v15, rebuild_tree() accepted two arguments: (doctype, parent_field). In Frappe v16, it takes exactly 1 argument: the doctype name only.

# ✅ v16 CORRECT
frappe.utils.nestedset.rebuild_tree("Item Group")

# ❌ v15 SYNTAX — will fail in v16
frappe.utils.nestedset.rebuild_tree("Item Group", "parent_item_group")

This affects post_install_config.py and any migration scripts that rebuild NestedSet trees.

OAuth Client Name Must Equal client_id

In Frappe v16, validate_client_id() looks up OAuth clients by document name, not by the client_id field.

# ✅ v16 CORRECT — document name MUST equal the client_id value
oauth_client = frappe.new_doc("OAuth Client")
oauth_client.name = "my-spa-client" # document name
oauth_client.client_id = "my-spa-client" # MUST match name exactly
oauth_client.app_name = "My SPA"

If the document name does not match client_id, OAuth token validation will fail even though the record exists.

Redirect URI Splitting — Space Delimiter

In Frappe v16, validate_redirect_uri() splits the redirect_uris field by space (not newline as in v15).

# ✅ v16 CORRECT — space-separated
oauth_client.redirect_uris = "https://app.example.com/callback https://localhost:3000/callback"

# ❌ v15 SYNTAX — newline-separated, fails in v16
oauth_client.redirect_uris = "https://app.example.com/callback\nhttps://localhost:3000/callback"

Email Account — flags.ignore_validate

In Frappe v16, saving an Email Account triggers SMTP validation. Inside K8s pods that cannot reach external SMTP servers, this causes saves to fail.

# ✅ v16 Pattern for Email Account in K8s pods
email_account = frappe.new_doc("Email Account")
email_account.email_account_name = "Gmail"
email_account.smtp_server = "smtp.gmail.com"
# ... configure fields ...
email_account.flags.ignore_validate = True # Skip SMTP connection test
email_account.save(ignore_permissions=True)

No frappe.db.commit() in Document Hooks

Frappe v16 manages transaction boundaries internally. Calling frappe.db.commit() inside document hooks (before_save, on_submit, etc.) is not allowed and may cause transaction conflicts.

# ✅ v16 CORRECT — no manual commit in hooks
def before_save(self):
self.computed_field = calculate_value(self)
# no frappe.db.commit() here

# ❌ WRONG — do not call commit in hooks
def before_save(self):
frappe.db.commit() # causes transaction conflict

has_permission Must Return Explicit Boolean

In Frappe v16, has_permission functions must return True or False explicitly. Returning None (implicit falsy) is not accepted.

# ✅ v16 CORRECT
def has_permission(doc, ptype, user):
if user == "Administrator":
return True
return False # must be explicit False, not None

# ❌ v15 pattern — implicit None treated as denied but not compliant
def has_permission(doc, ptype, user):
if user == "Administrator":
return True
# implicit return None — fails in v16

Explicit HTTP Methods on Whitelisted Functions

Frappe v16 requires whitelisted API functions to declare allowed HTTP methods explicitly.

# ✅ v16 CORRECT
@frappe.whitelist(methods=["GET"])
def get_booking(booking_id):
return frappe.get_doc("Booking", booking_id).as_dict()

@frappe.whitelist(methods=["POST"])
def create_booking(booking_data):
doc = frappe.new_doc("Booking")
doc.update(frappe.parse_json(booking_data))
doc.save()
return doc.as_dict()

Query Builder Preferred Over Raw SQL

Frappe v16 strongly prefers the Query Builder API over raw SQL strings. Raw SQL continues to work but bypasses Frappe's RBAC and tenant-isolation hooks.

# ✅ PREFERRED — Query Builder
bookings = frappe.get_all(
"Booking",
filters={"company": company, "status": "Confirmed"},
fields=["name", "customer", "total_amount"],
order_by="creation desc",
limit=50,
)

# ✅ ACCEPTABLE with saas_guard guardrails
result = frappe.db.sql("""
SELECT name, customer
FROM `tabBooking`
WHERE company = %(company)s
""", values={"company": company}, as_dict=True)

Linting and Formatting

All Python in orchestrate-core uses Ruff (not Black or Flake8).

SettingValue
Python target3.14
Line length120
Rules enabledE, F, B, S, I, UP
ruff check . # Lint
ruff check . --fix # Lint + auto-fix
ruff format . # Format (replaces Black)

Migration Path: v15 → v16

Strategy: Stage-first — v16 deploys to stage for validation before touching dev or prod.

Key Migration Steps

  1. Update all rebuild_tree() calls to single-argument form
  2. Verify all OAuth client document names match client_id values
  3. Update all redirect_uris from newline-separated to space-separated
  4. Add flags.ignore_validate = True to any Email Account saves in automated scripts
  5. Remove frappe.db.commit() from all document hooks
  6. Make all has_permission returns explicit (True/False)
  7. Add methods=[...] to all @frappe.whitelist decorators

Migration Docs

Full migration details: orchestrate-core/docs/frappe-v16-migration/


bench Commands (On Server / Pod)

bench build --production # Build frontend assets
bench migrate # Run database migrations
bench clear-cache # Clear Redis/Valkey cache
bench restart # Restart all bench workers

These commands must be run inside the pod (or on the server), not locally.


Testing

pytest # Run all tests
pytest -k "test_booking" # Run specific tests
pytest --tb=short # Short traceback format

Document Control

RevDateAuthorDescription
1.02026-02-01Anshin EngineeringInitial release