
Frappe Errors Serverscripts
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Diagnoses Frappe Server Script errors including ImportError, NameError, sandbox violations, doc_events not firing, and API scripts not returning JSON.
About
A troubleshooting skill for diagnosing errors in Frappe Server Scripts, including sandbox and import restrictions. A developer uses it when a server script hits ImportError, sandbox violations, or fails to fire on doc events.
- Diagnoses ImportError, NameError, and sandbox violations in Server Scripts
- Covers doc_events not firing, wrong script type, and API scripts not returning JSON
Frappe Errors Serverscripts by the numbers
- 1 all-time installs (skills.sh)
- Ranked #489 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-errors-serverscriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Diagnoses Frappe Server Script errors including ImportError, NameError, sandbox violations, doc_events not firing, and API scripts not returning JSON.
Files
Server Script Errors — Diagnosis and Resolution
Cross-refs: frappe-syntax-serverscripts (syntax), frappe-impl-serverscripts (workflows), frappe-errors-clientscripts (client-side).
---
CRITICAL: Server Scripts Disabled by Default [v15+]
Starting from Frappe v15, Server Scripts are disabled by default. You MUST enable them:
# In site_config.json
{ "server_script_enabled": 1 }On Frappe Cloud: Server Scripts are ONLY available on private benches, NOT on shared benches.
---
Error Diagnosis Flowchart
ERROR IN SERVER SCRIPT
│
├─► ImportError / NameError
│ ├─► "import json" → BLOCKED. Use frappe.parse_json()
│ ├─► "import datetime" → BLOCKED. Use frappe.utils
│ ├─► "import os/sys/subprocess" → BLOCKED. Security restriction
│ └─► "NameError: name 'dict' is not defined" → Some builtins restricted
│
├─► SyntaxError: not allowed
│ ├─► "try/except" → BLOCKED by RestrictedPython [v14-v15]
│ ├─► "raise ValueError" → BLOCKED. Use frappe.throw()
│ └─► "exec/eval" → BLOCKED. Security restriction
│
├─► Script runs but nothing happens
│ ├─► Wrong Script Type selected → Check Document Event vs API vs Scheduler
│ ├─► Wrong DocType selected → Verify exact DocType name
│ ├─► Wrong Event selected → Before Save ≠ After Save
│ └─► Script disabled → Check "Enabled" checkbox
│
├─► 403 Permission Denied
│ ├─► Scheduler script → Runs as Administrator, check role permissions
│ ├─► API script → Check Allow Guest setting
│ └─► doc_event → User lacks DocType permission
│
├─► Data not saved in Scheduler
│ └─► Missing frappe.db.commit() → REQUIRED in scheduler scripts
│
└─► API script returns empty/wrong response
└─► Not setting frappe.response["message"] → ALWAYS set response---
Error Message → Cause → Fix Table
| Error Message | Cause | Fix |
|---|---|---|
ImportError: import not allowed | Any import statement in sandbox | Use frappe.utils, frappe.parse_json(), etc. |
NameError: name 'dict' is not defined | Some Python builtins blocked by RestrictedPython | Use frappe._dict() or literal {} |
SyntaxError: try/except not allowed | RestrictedPython blocks exception handling [v14-v15] | Use conditional checks (if/else) instead |
SyntaxError: raise not allowed | RestrictedPython blocks raise | Use frappe.throw() |
Script not executing | Wrong Script Type or Event selected | Verify type matches: Document Event, API, or Scheduler |
doc is not defined | Using doc in API or Scheduler script (no document context) | doc is only available in Document Event scripts |
PermissionError in Scheduler | Scheduler runs as Administrator but script accesses restricted resource | Use ignore_permissions=True where appropriate |
Changes not saved in Scheduler | Missing frappe.db.commit() | ALWAYS call frappe.db.commit() in Scheduler scripts |
API returns empty response | Forgot to set frappe.response["message"] | ALWAYS set frappe.response["message"] = result |
Timeout / killed | Infinite loop or processing too many records | ALWAYS add limit to queries, ALWAYS use batch processing |
ValidationError: qty is required | doc.save() called in Before Save (recursion) | NEVER call doc.save() in Before Save; just set values |
SQL injection via string format | User input in SQL without escaping | ALWAYS use frappe.db.escape() or parameterized queries |
---
The #1 Error: ImportError
Every beginner hits this. The Server Script sandbox blocks ALL imports except json.
# ❌ BLOCKED — These ALL fail with ImportError
import json # Use frappe.parse_json() / frappe.as_json()
from datetime import datetime # Use frappe.utils.now(), frappe.utils.today()
import re # Not available in sandbox
import os # Security: blocked
import requests # Use frappe.make_get_request(), frappe.make_post_request()
# ✅ CORRECT — Sandbox equivalents
data = frappe.parse_json(doc.json_field) # Instead of json.loads()
today = frappe.utils.today() # Instead of datetime.date.today()
now = frappe.utils.now() # Instead of datetime.now()
diff = frappe.utils.date_diff(date1, date2) # Instead of timedelta
resp = frappe.make_get_request("https://api.com") # Instead of requests.get()
resp = frappe.make_post_request("https://api.com", data=payload)Available Sandbox API (Complete Reference)
| Category | Available Methods |
|---|---|
| Document | frappe.get_doc(), frappe.new_doc(), frappe.get_last_doc(), frappe.get_cached_doc(), frappe.get_mapped_doc(), frappe.rename_doc(), frappe.delete_doc() |
| Database | frappe.db.get_list(), frappe.db.get_all(), frappe.db.get_value(), frappe.db.get_single_value(), frappe.db.set_value(), frappe.db.exists(), frappe.db.sql(), frappe.db.commit(), frappe.db.rollback(), frappe.db.escape() |
| Query Builder | frappe.qb (full query builder) |
| HTTP | frappe.make_get_request(), frappe.make_post_request(), frappe.make_put_request() |
| Utility | frappe.utils.* (all utility functions), frappe.parse_json(), frappe.as_json() |
| User/Session | frappe.session.user, frappe.get_roles(), frappe.has_permission() |
| Messages | frappe.throw(), frappe.msgprint(), frappe.log_error(), frappe.sendmail() |
| Module | json (the ONLY importable module) |
---
Script Type Selection Errors
ALWAYS verify you selected the correct Script Type:
| Script Type | Trigger | Has doc? | Has frappe.form_dict? | Auto-commit? |
|---|---|---|---|---|
| Document Event | DocType lifecycle (Before Save, After Save, etc.) | YES | NO | YES |
| API | HTTP request to /api/method/{method_name} | NO | YES | YES |
| Scheduler Event | Cron schedule | NO | NO | NO — MUST call frappe.db.commit() |
| Permission Query | Every list query on the DocType | NO | NO (has user) | N/A |
Common Mistake: Wrong Event
# ❌ WRONG — "After Save" cannot prevent save
# Script Type: Document Event, Event: After Save
if not doc.customer:
frappe.throw("Customer is required") # Document already saved!
# ✅ CORRECT — Use "Before Save" or "Before Validate"
# Script Type: Document Event, Event: Before Save
if not doc.customer:
frappe.throw("Customer is required") # Prevents save---
Sandbox Workarounds
try/except Is Blocked: Use Conditional Checks
# ❌ BLOCKED in sandbox
try:
customer = frappe.get_doc("Customer", doc.customer)
except Exception:
frappe.throw("Customer not found")
# ✅ CORRECT — Check first, then access
if not frappe.db.exists("Customer", doc.customer):
frappe.throw(f"Customer '{doc.customer}' not found")
customer = frappe.get_doc("Customer", doc.customer)raise Is Blocked: Use frappe.throw()
# ❌ BLOCKED
if amount < 0:
raise ValueError("Amount cannot be negative")
# ✅ CORRECT
if amount < 0:
frappe.throw("Amount cannot be negative")frappe.throw() Exception Types for API Scripts
| Exception | HTTP Code | Use When |
|---|---|---|
frappe.ValidationError | 417 | Input validation failure |
frappe.PermissionError | 403 | Access denied |
frappe.DoesNotExistError | 404 | Record not found |
frappe.AuthenticationError | 401 | Not logged in |
| (default, no exc) | 417 | General validation error |
# API Script — Correct exception types
if not customer:
frappe.throw("Customer param required", exc=frappe.ValidationError) # 417
if not frappe.db.exists("Customer", customer):
frappe.throw("Customer not found", exc=frappe.DoesNotExistError) # 404
if not frappe.has_permission("Customer", "read", customer):
frappe.throw("Access denied", exc=frappe.PermissionError) # 403---
Scheduler Script: Critical Mistakes
# ❌ WRONG — No limit, no commit, no error logging
invoices = frappe.get_all("Sales Invoice", filters={"status": "Unpaid"})
for inv in invoices:
frappe.db.set_value("Sales Invoice", inv.name, "reminder_sent", 1)
# ✅ CORRECT — Limit, batch commit, error logging
BATCH_SIZE = 50
invoices = frappe.get_all(
"Sales Invoice",
filters={"status": "Unpaid", "docstatus": 1},
fields=["name", "customer"],
limit=500 # ALWAYS limit
)
errors = []
for i in range(0, len(invoices), BATCH_SIZE):
batch = invoices[i:i + BATCH_SIZE]
for inv in batch:
if not frappe.db.exists("Customer", inv.customer):
errors.append(f"{inv.name}: Customer not found")
continue
frappe.db.set_value("Sales Invoice", inv.name, "reminder_sent", 1)
frappe.db.commit() # REQUIRED
if errors:
frappe.log_error("\n".join(errors), "Reminder Errors")
frappe.db.commit()---
SQL Injection Prevention
# ❌ VULNERABLE — String interpolation with user input
territory = frappe.form_dict.get("territory")
conditions = f"`tabCustomer`.territory = '{territory}'" # SQL INJECTION!
# ✅ SAFE — Use frappe.db.escape()
territory = frappe.form_dict.get("territory")
conditions = f"`tabCustomer`.territory = {frappe.db.escape(territory)}"
# ✅ SAFEST — Use parameterized query or Query Builder
results = frappe.db.get_all("Customer", filters={"territory": territory})---
ALWAYS / NEVER Rules
ALWAYS
1. *Use `frappe.utils. instead of Python imports** — Only json module is importable 2. **Use frappe.throw() instead of raise** — raise is blocked by sandbox 3. **Use conditional checks instead of try/except** — Exception handling is blocked [v14-v15] 4. **Call frappe.db.commit() in Scheduler scripts** — Changes are NOT auto-committed 5. **Add limit to ALL queries in Scheduler scripts** — Prevent memory exhaustion 6. **Set frappe.response["message"] in API scripts** — Otherwise response is empty 7. **Use frappe.db.escape() for user input in SQL** — Prevent SQL injection 8. **Log errors in Scheduler scripts** with frappe.log_error()` — No user to see errors 9. Verify Script Type matches your intent — Document Event vs API vs Scheduler
NEVER
1. NEVER use `import` statements (except json) — Blocked by RestrictedPython 2. NEVER use `try/except` or `raise` — Blocked by sandbox [v14-v15] 3. NEVER call `doc.save()` in Before Save — Causes infinite recursion 4. NEVER use string formatting for SQL with user input — SQL injection risk 5. NEVER process unlimited records in Scheduler — Always use limit 6. NEVER assume `doc` exists in API/Scheduler scripts — Only available in Document Events 7. NEVER forget `frappe.db.commit()` in Scheduler — All changes will be lost
---
Reference Files
| File | Contents |
|---|---|
references/examples.md | Real error scenarios with diagnosis |
references/anti-patterns.md | Common sandbox mistakes with fixes |
references/patterns.md | Defensive error handling patterns by script type |
Server Script Anti-Patterns — Error Prevention
Each anti-pattern shows the mistake, why it fails, and the correct approach.
---
1. Using import Statements
# ❌ BLOCKED — ImportError: __import__ not found
import json
import datetime
from collections import defaultdict
# ✅ CORRECT — Use frappe namespace
data = frappe.parse_json(doc.json_field)
today = frappe.utils.today()
result = frappe._dict()Why: RestrictedPython blocks all imports. Only json module is pre-loaded. Use frappe.utils, frappe.parse_json(), frappe.make_get_request().
---
2. Using try/except Blocks
# ❌ BLOCKED — SyntaxError: Try/Except not allowed
try:
customer = frappe.get_doc("Customer", doc.customer)
except Exception:
frappe.throw("Not found")
# ✅ CORRECT — Conditional check first
if not frappe.db.exists("Customer", doc.customer):
frappe.throw(f"Customer '{doc.customer}' not found")
customer = frappe.get_doc("Customer", doc.customer)Why: RestrictedPython blocks exception handling [v14-v15]. Use if/else with existence checks.
---
3. Using raise Statement
# ❌ BLOCKED — SyntaxError: raise not allowed
if amount < 0:
raise ValueError("Negative amount")
# ✅ CORRECT
if amount < 0:
frappe.throw("Amount cannot be negative")Why: raise is blocked. Use frappe.throw() which raises frappe.ValidationError internally.
---
4. Calling doc.save() in Before Save
# ❌ WRONG — Infinite recursion
# Event: Before Save
doc.status = "Validated"
doc.save() # Triggers Before Save again!
# ✅ CORRECT — Just set the value
# Event: Before Save
doc.status = "Validated"
# Framework saves automatically after Before SaveWhy: doc.save() in Before Save triggers the event again, causing infinite recursion.
---
5. Forgetting frappe.db.commit() in Scheduler
# ❌ WRONG — All changes lost!
# Type: Scheduler Event
for item in frappe.get_all("Item", filters={"sync_pending": 1}, limit=100):
frappe.db.set_value("Item", item.name, "sync_pending", 0)
# ✅ CORRECT
for item in frappe.get_all("Item", filters={"sync_pending": 1}, limit=100):
frappe.db.set_value("Item", item.name, "sync_pending", 0)
frappe.db.commit() # REQUIREDWhy: Scheduler scripts do NOT auto-commit. Without explicit commit, all changes are rolled back.
---
6. No Query Limit in Scheduler
# ❌ WRONG — May load millions of records
# Type: Scheduler Event
all_records = frappe.get_all("Sales Invoice", fields=["*"])
# ✅ CORRECT — Always limit
records = frappe.get_all("Sales Invoice",
filters={"status": "Unpaid", "docstatus": 1},
fields=["name", "customer"],
limit=500
)Why: Unlimited queries exhaust memory and crash the worker process.
---
7. Not Escaping User Input in SQL
# ❌ VULNERABLE — SQL injection
territory = frappe.form_dict.get("territory")
conditions = f"`tabCustomer`.territory = '{territory}'"
# ✅ SAFE
territory = frappe.form_dict.get("territory")
conditions = f"`tabCustomer`.territory = {frappe.db.escape(territory)}"Why: Unescaped user input allows SQL injection attacks.
---
8. Not Checking Record Existence Before get_doc
# ❌ WRONG — Crashes with DoesNotExistError
customer = frappe.get_doc("Customer", doc.customer)
# ✅ CORRECT — Check first
if not frappe.db.exists("Customer", doc.customer):
frappe.throw(f"Customer '{doc.customer}' not found")
customer = frappe.get_doc("Customer", doc.customer)Why: frappe.get_doc() raises an exception if record doesn't exist. Without try/except in sandbox, this crashes the script.
---
9. Throwing on First Error
# ❌ WRONG — User fixes one error, hits the next
if not doc.customer: frappe.throw("Customer required")
if not doc.delivery_date: frappe.throw("Date required")
if not doc.items: frappe.throw("Items required")
# ✅ CORRECT — Collect all errors
errors = []
if not doc.customer: errors.append("Customer is required")
if not doc.delivery_date: errors.append("Delivery Date is required")
if not doc.items: errors.append("At least one item is required")
if errors:
frappe.throw("<br>".join(errors), title="Please fix these errors")Why: Users should see ALL errors at once, not one at a time.
---
10. Exposing Technical Errors
# ❌ WRONG — Confusing for users
if not customer_data:
frappe.throw(f"KeyError: 'credit_limit' not found in dict")
# ✅ CORRECT — Actionable message
if not customer_data:
frappe.throw(f"Customer '{doc.customer}' not found. Please select a valid customer.")Why: Technical messages confuse users. Provide clear, actionable instructions.
---
11. Silent Failures in Scheduler
# ❌ WRONG — No logging, debugging impossible
# Type: Scheduler Event
for inv in invoices:
if not inv.customer:
continue # Silent skip
# ✅ CORRECT — Log all skips and errors
errors = []
for inv in invoices:
if not inv.customer:
errors.append(f"{inv.name}: Missing customer")
continue
process(inv)
if errors:
frappe.log_error("\n".join(errors), "Processing Errors")
frappe.db.commit()Why: Scheduler has no user to see errors. ALWAYS log for debugging.
---
12. Wrong Exception Type in API Scripts
# ❌ WRONG — Returns 417 for "not found" (should be 404)
# Type: API
if not frappe.db.exists("Customer", customer):
frappe.throw("Not found") # Default: ValidationError → 417
# ✅ CORRECT — Use proper exception type
if not frappe.db.exists("Customer", customer):
frappe.throw("Not found", exc=frappe.DoesNotExistError) # → 404Why: Correct HTTP status codes help API consumers handle errors properly.
---
13. Modifying doc After on_update Event
# ❌ WRONG — Changes NOT saved
# Event: After Save
doc.sync_status = "Synced" # Lost!
# ✅ CORRECT — Use frappe.db.set_value
# Event: After Save
frappe.db.set_value(doc.doctype, doc.name, "sync_status", "Synced")Why: After save, changes to doc object are not persisted. Use frappe.db.set_value().
---
14. Assuming doc Exists in API/Scheduler Scripts
# ❌ WRONG — doc is undefined in API scripts!
# Type: API
frappe.response["message"] = doc.customer # NameError: doc not defined
# ✅ CORRECT — Use frappe.form_dict for API parameters
# Type: API
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("'customer' parameter required", exc=frappe.ValidationError)
frappe.response["message"] = customerWhy: doc is only available in Document Event scripts, not in API or Scheduler scripts.
---
15. Assuming Child Table Values Are Not None
# ❌ WRONG — Crashes if qty or rate is None
total = sum(item.qty * item.rate for item in doc.items)
# ✅ CORRECT — Default to 0
total = sum((item.qty or 0) * (item.rate or 0) for item in (doc.items or []))Why: Child table fields can be None. Always provide default values.
---
Pre-Deploy Checklist
- [ ] No
importstatements (exceptjson) - [ ] No
try/exceptorraisestatements - [ ] No
doc.save()in Before Save events - [ ] All database lookups have existence checks
- [ ] Multiple errors collected before
frappe.throw() - [ ] Scheduler scripts have
frappe.db.commit() - [ ] Scheduler scripts have query
limit - [ ] All user input escaped in SQL conditions
- [ ] API scripts use correct exception types
- [ ] Scheduler errors logged with
frappe.log_error() - [ ] API scripts set
frappe.response["message"] - [ ] Child table values have defaults (
or 0,or [])
Server Script Error Examples — Real Scenarios
Complete diagnosis-oriented examples showing actual errors, root cause, and fix.
---
Scenario 1: ImportError — The #1 Server Script Error
Error:
ImportError: __import__ not foundThe broken code:
# Type: Document Event, Event: Before Save
import json
import datetime
data = json.loads(doc.custom_json)
if datetime.date.today() > doc.due_date:
frappe.throw("Overdue!")Root cause: ALL import statements are blocked by the RestrictedPython sandbox (except json which is pre-loaded).
The fix:
# Type: Document Event, Event: Before Save
data = frappe.parse_json(doc.custom_json)
if frappe.utils.today() > str(doc.due_date):
frappe.throw("Overdue!")Complete import replacement table:
| Blocked Import | Sandbox Equivalent |
|---|---|
import json / json.loads() | frappe.parse_json() |
import json / json.dumps() | frappe.as_json() |
from datetime import datetime | frappe.utils.now(), frappe.utils.today() |
from datetime import timedelta | frappe.utils.add_days(), frappe.utils.date_diff() |
import requests | frappe.make_get_request(), frappe.make_post_request() |
import re | Not available — use Python string methods |
import os / import sys | Not available — security restriction |
import math | Use Python arithmetic operators |
---
Scenario 2: NameError — Restricted Builtins
Error:
NameError: name 'dict' is not definedThe broken code:
result = dict(name=doc.name, status=doc.status)
items = list(doc.items)
total = sum(item.qty for item in doc.items)Root cause: Some Python builtins are restricted in the sandbox. dict(), list(), and sum() may not be available depending on Frappe version.
The fix:
result = frappe._dict({"name": doc.name, "status": doc.status})
items = [item for item in doc.items] # List comprehension works
total = 0
for item in doc.items:
total += item.qty or 0---
Scenario 3: try/except Blocked
Error:
SyntaxError: Line 3: Try/Except not allowedThe broken code:
# Type: Document Event, Event: Before Save
try:
customer = frappe.get_doc("Customer", doc.customer)
doc.territory = customer.territory
except Exception:
doc.territory = "Default"The fix:
# Type: Document Event, Event: Before Save
if frappe.db.exists("Customer", doc.customer):
territory = frappe.db.get_value("Customer", doc.customer, "territory")
doc.territory = territory or "Default"
else:
doc.territory = "Default"---
Scenario 4: Script Not Executing — Wrong Script Type
Symptom: Script saved and enabled, but nothing happens when document is saved.
The broken configuration:
Script Type: API
DocType: Sales Order
Script:
if not doc.customer:
frappe.throw("Customer required")Root cause: API scripts are triggered by HTTP requests, NOT by document events. The script type should be "Document Event" with event "Before Save".
The fix:
Script Type: Document Event
DocType: Sales Order
Event: Before Save
Script:
if not doc.customer:
frappe.throw("Customer required")---
Scenario 5: Scheduler Changes Not Saved
Symptom: Scheduler runs without errors, but database values remain unchanged.
The broken code:
# Type: Scheduler Event, Cron: 0 9 * * *
items = frappe.get_all("Item", filters={"sync_pending": 1}, limit=100)
for item in items:
frappe.db.set_value("Item", item.name, "sync_pending", 0)
# Missing frappe.db.commit()!Root cause: Scheduler scripts do NOT auto-commit. All changes are rolled back when script ends.
The fix:
# Type: Scheduler Event, Cron: 0 9 * * *
items = frappe.get_all("Item", filters={"sync_pending": 1}, limit=100)
for item in items:
frappe.db.set_value("Item", item.name, "sync_pending", 0)
frappe.db.commit() # REQUIRED — without this, all changes are lost---
Scenario 6: API Script Returns Empty Response
Symptom: Client calls API script but r.message is undefined.
The broken code:
# Type: API, Method: get_customer_info
customer = frappe.form_dict.get("customer")
data = frappe.db.get_value("Customer", customer, ["name", "credit_limit"], as_dict=True)
# Forgot to set response!The fix:
# Type: API, Method: get_customer_info
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("Parameter 'customer' is required", exc=frappe.ValidationError)
if not frappe.db.exists("Customer", customer):
frappe.throw(f"Customer '{customer}' not found", exc=frappe.DoesNotExistError)
data = frappe.db.get_value("Customer", customer, ["name", "credit_limit"], as_dict=True)
frappe.response["message"] = data # REQUIRED — this is what the client receives---
Scenario 7: doc.save() in Before Save — Infinite Recursion
Error:
RecursionError: maximum recursion depth exceededThe broken code:
# Type: Document Event, Event: Before Save
doc.custom_total = sum((item.qty or 0) * (item.rate or 0) for item in (doc.items or []))
doc.save() # Triggers Before Save again → infinite loop!The fix:
# Type: Document Event, Event: Before Save
doc.custom_total = 0
for item in (doc.items or []):
doc.custom_total += (item.qty or 0) * (item.rate or 0)
# No doc.save() — framework saves automatically after Before Save completes---
Scenario 8: SQL Injection in Permission Query
Vulnerability demonstrated:
# Type: Permission Query, DocType: Project
# User submits territory = "'; DROP TABLE tabProject; --"
territory = frappe.db.get_value("User", user, "territory")
conditions = f"`tabProject`.territory = '{territory}'"
# If territory contains SQL, this is an injection!The fix:
# Type: Permission Query, DocType: Project
territory = frappe.db.get_value("User", user, "territory")
if territory:
conditions = f"`tabProject`.territory = {frappe.db.escape(territory)}"
else:
conditions = f"`tabProject`.owner = {frappe.db.escape(user)}"---
Scenario 9: Wrong Exception Type in API Script
Symptom: Client receives HTTP 417 (default) instead of expected 404.
The broken code:
# Type: API
if not frappe.db.exists("Customer", customer):
frappe.throw("Customer not found") # Returns 417 Expectation FailedThe fix:
# Type: API
if not frappe.db.exists("Customer", customer):
frappe.throw("Customer not found", exc=frappe.DoesNotExistError) # Returns 404---
Scenario 10: Scheduler Without Query Limit — Memory Exhaustion
Error: Worker process killed (OOM) or script timeout.
The broken code:
# Type: Scheduler Event
all_invoices = frappe.get_all("Sales Invoice", fields=["name", "customer", "grand_total"])
# On a system with 500,000+ invoices, this loads everything into memory!The fix:
# Type: Scheduler Event
BATCH_SIZE = 50
invoices = frappe.get_all(
"Sales Invoice",
filters={"status": "Unpaid", "docstatus": 1},
fields=["name", "customer"],
limit=500 # ALWAYS limit
)
for i in range(0, len(invoices), BATCH_SIZE):
batch = invoices[i:i + BATCH_SIZE]
for inv in batch:
process_invoice(inv)
frappe.db.commit() # Commit per batchServer Script Error Handling Patterns
Reusable patterns for defensive error handling in Frappe Server Scripts, organized by script type.
---
Document Event Patterns
Pattern 1: Comprehensive Validation with Error Collection
# Type: Document Event, Event: Before Save, DocType: Sales Order
errors = []
warnings = []
# Required fields
if not doc.customer:
errors.append("Customer is required")
if not doc.delivery_date:
errors.append("Delivery Date is required")
elif str(doc.delivery_date) < frappe.utils.today():
warnings.append("Delivery Date is in the past")
# Customer validation
if doc.customer:
customer = frappe.db.get_value("Customer", doc.customer,
["disabled", "credit_limit"], as_dict=True)
if not customer:
errors.append(f"Customer '{doc.customer}' not found")
elif customer.disabled:
errors.append(f"Customer '{doc.customer}' is disabled")
elif customer.credit_limit and doc.grand_total > customer.credit_limit:
warnings.append(f"Total ({doc.grand_total}) exceeds credit limit ({customer.credit_limit})")
# Child table validation
if not doc.items:
errors.append("At least one item is required")
else:
for idx, item in enumerate(doc.items, 1):
if not item.item_code:
errors.append(f"Row {idx}: Item Code is required")
if (item.qty or 0) <= 0:
errors.append(f"Row {idx}: Quantity must be positive")
# Show warnings (non-blocking)
if warnings:
frappe.msgprint("<br>".join(warnings), title="Warnings", indicator="orange")
# Throw errors (blocking)
if errors:
frappe.throw("<br>".join(errors), title="Please fix these errors")Pattern 2: Safe Database Lookup with Fallback
# Type: Document Event, Event: Before Save
# ALWAYS check existence before get_doc (no try/except in sandbox)
if doc.customer:
if not frappe.db.exists("Customer", doc.customer):
frappe.throw(f"Customer '{doc.customer}' not found")
# Safe multi-field lookup with defaults
data = frappe.db.get_value("Customer", doc.customer,
["territory", "customer_group", "credit_limit"], as_dict=True)
if data:
doc.territory = data.get("territory") or "Default"
doc.customer_group = data.get("customer_group") or ""
else:
doc.territory = "Default"Pattern 3: Cross-Document Validation
# Type: Document Event, Event: Before Submit, DocType: Sales Invoice
# Check for already-invoiced items
for item in (doc.items or []):
if item.sales_order and item.so_detail:
existing = frappe.db.get_value("Sales Invoice Item", {
"sales_order": item.sales_order,
"so_detail": item.so_detail,
"docstatus": 1,
"parent": ["!=", doc.name]
}, "parent")
if existing:
frappe.throw(
f"Row {item.idx}: SO item {item.so_detail} already invoiced in {existing}"
)---
API Script Patterns
Pattern 4: Full API with Input Validation
# Type: API, Method: create_order
# Extract parameters
customer = frappe.form_dict.get("customer")
items = frappe.form_dict.get("items")
# Validate required params
if not customer:
frappe.throw("'customer' is required", exc=frappe.ValidationError)
if not items:
frappe.throw("'items' is required", exc=frappe.ValidationError)
# Parse JSON if needed
if isinstance(items, str):
items = frappe.parse_json(items)
# Validate entities exist
if not frappe.db.exists("Customer", customer):
frappe.throw(f"Customer '{customer}' not found", exc=frappe.DoesNotExistError)
# Check permissions
if not frappe.has_permission("Sales Order", "create"):
frappe.throw("No permission to create orders", exc=frappe.PermissionError)
# Validate items
for idx, item in enumerate(items, 1):
code = item.get("item_code") if isinstance(item, dict) else None
if not code:
frappe.throw(f"Item {idx}: 'item_code' required", exc=frappe.ValidationError)
if not frappe.db.exists("Item", code):
frappe.throw(f"Item '{code}' not found", exc=frappe.DoesNotExistError)
# Create document
so = frappe.get_doc({
"doctype": "Sales Order",
"customer": customer,
"items": [{"item_code": i.get("item_code"), "qty": i.get("qty", 1)} for i in items]
})
so.insert()
# REQUIRED: Set response
frappe.response["message"] = {"success": True, "name": so.name}Pattern 5: API with Safe Error Responses
# Type: API, Method: get_report_data
report_type = frappe.form_dict.get("type")
date_from = frappe.form_dict.get("from")
date_to = frappe.form_dict.get("to")
# Validate parameters
if not report_type:
frappe.throw("'type' parameter is required", exc=frappe.ValidationError)
valid_types = ["sales", "purchase", "stock"]
if report_type not in valid_types:
frappe.throw(
f"Invalid type '{report_type}'. Must be: {', '.join(valid_types)}",
exc=frappe.ValidationError
)
# Safe date parsing
if date_from and not frappe.utils.validate_date_format(date_from):
frappe.throw("Invalid 'from' date format. Use YYYY-MM-DD", exc=frappe.ValidationError)
# Build query safely
filters = {"docstatus": 1}
if date_from:
filters["posting_date"] = [">=", date_from]
if date_to:
filters["posting_date"] = ["<=", date_to]
data = frappe.get_all("Sales Invoice",
filters=filters,
fields=["name", "customer", "grand_total", "posting_date"],
limit=1000
)
frappe.response["message"] = {"data": data, "count": len(data)}---
Scheduler Patterns
Pattern 6: Batch Processing with Error Isolation
# Type: Scheduler Event, Cron: 0 8 * * *
BATCH_SIZE = 50
MAX_ERRORS = 20
stats = {"processed": 0, "errors": []}
records = frappe.get_all("Sales Invoice",
filters={"status": "Unpaid", "docstatus": 1, "due_date": ["<", frappe.utils.today()]},
fields=["name", "customer", "owner", "outstanding_amount"],
limit=500
)
for i in range(0, len(records), BATCH_SIZE):
if len(stats["errors"]) >= MAX_ERRORS:
stats["errors"].append("--- STOPPED: Too many errors ---")
break
batch = records[i:i + BATCH_SIZE]
for rec in batch:
result = process_record(rec)
if result.get("success"):
stats["processed"] += 1
else:
stats["errors"].append(f"{rec.name}: {result.get('error')}")
frappe.db.commit()
# Log summary
summary = f"Processed: {stats['processed']}, Errors: {len(stats['errors'])}"
if stats["errors"]:
summary += "\n" + "\n".join(stats["errors"][:20])
frappe.log_error(summary, "Scheduler Summary")
frappe.db.commit()
def process_record(rec):
if not frappe.db.exists("Customer", rec.customer):
return {"success": False, "error": "Customer not found"}
# Process logic...
frappe.db.set_value("Sales Invoice", rec.name, "reminder_sent", 1)
return {"success": True}Pattern 7: Idempotent Scheduler with Lock
# Type: Scheduler Event, Cron: */15 * * * *
LOCK_KEY = "inventory_sync_lock"
LOCK_TIMEOUT = 600
# Check if already running
lock_time = frappe.cache().get_value(LOCK_KEY)
if lock_time:
elapsed = frappe.utils.time_diff_in_seconds(frappe.utils.now(), lock_time)
if elapsed < LOCK_TIMEOUT:
return # Another instance running
# Set lock
frappe.cache().set_value(LOCK_KEY, frappe.utils.now())
items = frappe.get_all("Item",
filters={"sync_status": "Pending", "disabled": 0},
fields=["name"],
limit=100
)
for item in items:
frappe.db.set_value("Item", item.name, "sync_status", "Processing")
frappe.db.commit()
# Process...
frappe.db.set_value("Item", item.name, {
"sync_status": "Synced",
"last_synced": frappe.utils.now()
})
frappe.db.commit()
# Release lock
frappe.cache().delete_value(LOCK_KEY)
frappe.db.commit()---
Permission Query Pattern
Pattern 8: Safe Permission Query
# Type: Permission Query, DocType: Project
user_roles = frappe.get_roles(user) or []
# Admin — full access
if "System Manager" in user_roles:
conditions = ""
# Manager — department access
elif "Projects Manager" in user_roles:
dept = frappe.db.get_value("User", user, "department")
if dept:
conditions = f"`tabProject`.department = {frappe.db.escape(dept)}"
else:
frappe.log_error(f"Manager {user} has no department", "Permission Warning")
conditions = f"`tabProject`.owner = {frappe.db.escape(user)}"
# User — own records only
elif "Projects User" in user_roles:
conditions = f"`tabProject`.owner = {frappe.db.escape(user)}"
# No role — no access
else:
conditions = "1=0"---
Quick Reference: Error Handling Cheat Sheet
# Stop execution with user message
frappe.throw("Error message")
frappe.throw("Not found", exc=frappe.DoesNotExistError) # 404
frappe.throw("No access", exc=frappe.PermissionError) # 403
# Warning (continues execution)
frappe.msgprint("Warning text", indicator="orange")
# Log silently
frappe.log_error("Details", "Title")
# Safe database access
value = frappe.db.get_value("DocType", name, "field") or 0
exists = frappe.db.exists("DocType", name)
data = frappe.db.get_value("DocType", name, ["f1", "f2"], as_dict=True) or {}
# Scheduler requirements
frappe.db.commit() # REQUIRED
limit=500 # ALWAYS limit queries