Frappe v16 Standards & Gotchas
Source:
engineering/anshin-orchestrate(GitLab ID: 80) —orchestrate-core/CLAUDE.mdanddocs/frappe-v16-migration/, Rev 2026-02-01
Stack Versions
| Component | Version |
|---|---|
| Frappe | v16 |
| ERPNext | v16 |
| Python | 3.14 |
| MariaDB | 11.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).
| Setting | Value |
|---|---|
| Python target | 3.14 |
| Line length | 120 |
| Rules enabled | E, 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
- Update all
rebuild_tree()calls to single-argument form - Verify all OAuth client document names match
client_idvalues - Update all
redirect_urisfrom newline-separated to space-separated - Add
flags.ignore_validate = Trueto any Email Account saves in automated scripts - Remove
frappe.db.commit()from all document hooks - Make all
has_permissionreturns explicit (True/False) - Add
methods=[...]to all@frappe.whitelistdecorators
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
| Rev | Date | Author | Description |
|---|---|---|---|
| 1.0 | 2026-02-01 | Anshin Engineering | Initial release |