
Frappe Syntax Serverscripts
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Write Frappe Server Scripts for doc events, APIs, and scheduler tasks within the sandbox, avoiding blocked imports and using correct v14-v16 syntax.
About
Guides writing Python Server Scripts in Frappe for document events, API endpoints, and scheduler events within the sandbox. A developer uses it when adding low-code Python logic without touching app files.
- Write Frappe Server Scripts for doc events, APIs, and scheduler
- Prevents the sandbox import mistake (all imports blocked)
Frappe Syntax Serverscripts by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,830 of 4,347 Backend & APIs 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-syntax-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
Write Frappe Server Scripts for doc events, APIs, and scheduler tasks within the sandbox, avoiding blocked imports and using correct v14-v16 syntax.
Files
Frappe Server Scripts — Complete Reference
Server Scripts are Python scripts managed via Setup > Server Script in the Frappe/ERPNext UI. They run inside a RestrictedPython sandbox.
CRITICAL: The Sandbox Rule
┌──────────────────────────────────────────────────────────────────┐
│ ALL import STATEMENTS ARE BLOCKED │
│ │
│ import json → ImportError: __import__ not found │
│ from datetime import * → ImportError: __import__ not found │
│ import frappe → ImportError (even frappe itself!) │
│ │
│ EVERYTHING you need is pre-loaded in the frappe namespace. │
│ NEVER write an import line. ALWAYS use frappe.utils.*, etc. │
└──────────────────────────────────────────────────────────────────┘ALWAYS use the pre-loaded namespace instead of imports:
| Blocked import | Use instead |
|---|---|
import json | frappe.parse_json() / frappe.as_json() |
from datetime import date | frappe.utils.today() / frappe.utils.now_datetime() |
from frappe.utils import cint | frappe.utils.cint() (already loaded) |
import requests | frappe.make_get_request() / frappe.make_post_request() |
import re | Not available — restructure logic without regex |
import os / import sys | Not available — use a custom app instead |
Enabling Server Scripts
# v14: enabled by default
# v15+: DISABLED by default — you MUST enable explicitly:
bench set-config -g server_script_enabled 1
# Or set server_script_enabled: true in site_config.jsonNEVER expect Server Scripts to work on Frappe Cloud shared benches — they require a private bench.
Script Types
| Type | Trigger | Key Variable |
|---|---|---|
| Document Event | Document lifecycle (save, submit, cancel) | doc |
| API | HTTP request to /api/method/{name} | frappe.form_dict |
| Scheduler Event | Cron schedule | (none) |
| Permission Query | Document list filtering | user, conditions |
Event Name Mapping (Document Events)
CRITICAL: The UI names differ from internal hook names:
| Server Script UI | Internal Hook | Fires When |
|---|---|---|
| Before Insert | before_insert | Before new doc saved to DB |
| After Insert | after_insert | After first DB insert |
| Before Validate | before_validate | Before framework validation |
| Before Save | `validate` | Before save (new + update) |
| After Save | on_update | After successful save |
| Before Submit | before_submit | Before submit (docstatus 0→1) |
| After Submit | on_submit | After submit completes |
| Before Cancel | before_cancel | Before cancel (docstatus 1→2) |
| After Cancel | on_cancel | After cancel completes |
| Before Delete | on_trash | Before permanent delete |
| After Delete | after_delete | After permanent delete |
NEVER confuse "Before Save" with before_save — the UI label "Before Save" maps to the validate hook. The actual before_save hook runs AFTER validate.
Decision Tree: Server Script vs Document Controller
Need custom Python logic for a DocType?
│
├─► Can you install a custom Frappe app?
│ ├─► YES: Use a Document Controller when you need:
│ │ • import statements (any Python library)
│ │ • File system access
│ │ • Complex class inheritance
│ │ • autoname / before_naming hooks
│ │ • Unit-testable code
│ │
│ └─► NO: Use a Server Script when:
│ • You only have UI access (no bench CLI)
│ • Logic is simple validation / field calculation
│ • You need a quick API endpoint
│ • You need dynamic permission filtering
│
└─► Is logic > 50 lines or needs external libraries?
├─► YES → Document Controller in a custom app
└─► NO → Server Script is fineQuick Reference: Available in Sandbox
Pre-loaded Objects
doc # Current document (Document Event only)
frappe # Core namespace — ALWAYS available
frappe.db # Database operations
frappe.utils # Date, number, string utilities
frappe.session # Current session (user, csrf_token)
frappe.form_dict # Request parameters (API scripts)
frappe.response # Response object (API scripts)
frappe.request # Werkzeug request object
frappe.qb # Query Builder (v14+)
json # Python json module (pre-loaded)Core Methods
# Documents
frappe.get_doc(doctype, name) # Fetch document
frappe.new_doc(doctype) # Create new document
frappe.get_cached_doc(doctype, name) # Cached fetch (read-only)
frappe.get_last_doc(doctype) # Most recent document
frappe.get_mapped_doc(...) # Map fields between DocTypes
frappe.delete_doc(doctype, name) # Delete document
frappe.rename_doc(doctype, old, new) # Rename document
# Querying
frappe.get_all(doctype, filters, fields, order_by, limit) # No permission check
frappe.get_list(doctype, filters, fields, order_by, limit) # With permission check
frappe.db.get_value(doctype, name, fieldname)
frappe.db.get_single_value(doctype, fieldname)
frappe.db.set_value(doctype, name, fieldname, value)
frappe.db.exists(doctype, name_or_filters)
frappe.db.count(doctype, filters)
frappe.db.sql(query, values, as_dict) # ALWAYS parameterize!
frappe.db.escape(value) # SQL escape
frappe.db.commit() # ONLY in Scheduler scripts
frappe.db.rollback() # ONLY in Scheduler scripts
# Messaging
frappe.throw(msg, exc, title) # Stop execution + show error
frappe.msgprint(msg, title, indicator) # User notification
frappe.log_error(message, title) # Error Log entry
# HTTP (yes, these work in sandbox!)
frappe.make_get_request(url, params, headers)
frappe.make_post_request(url, data, headers)
frappe.make_put_request(url, data, headers)
# Email
frappe.sendmail(recipients, sender, subject, message)
# Utilities
frappe.utils.today() # "2024-01-15"
frappe.utils.now() # "2024-01-15 10:30:00"
frappe.utils.now_datetime() # datetime object
frappe.utils.add_days(date, n) # Date arithmetic
frappe.utils.add_months(date, n)
frappe.utils.date_diff(d1, d2) # Days between dates
frappe.utils.flt(val) # Safe float (None → 0.0)
frappe.utils.cint(val) # Safe int (None → 0)
frappe.utils.cstr(val) # Safe string (None → "")
frappe.parse_json(string) # JSON string → dict/list
frappe.as_json(obj) # dict/list → JSON string
frappe.render_template(template, ctx) # Jinja rendering
frappe.get_url() # Site URL
frappe.get_hooks(hook) # Read app hooks
run_script(script_name, **kwargs) # Call another Server Script
# Session / Permissions
frappe.session.user # Current user email
frappe.get_roles(user) # User's roles list
frappe.has_permission(doctype, ptype, doc)
frappe.get_fullname(user) # User's display name
_("translatable string") # Translation functionPython Builtins Available
str, int, float, bool, list, dict, tuple, set # Types
range, enumerate, zip, map, filter # Iteration
sum, min, max, len, sorted, reversed # Aggregation
isinstance, type, hasattr, getattr # Introspection
all, any, abs, round, divmod # Math/logic
print # → server log
True, False, None # ConstantsPython Builtins BLOCKED
open, file # No file I/O
eval, exec, compile # No dynamic code execution
__import__ # No imports (this is the root cause)
globals, locals # No scope introspectionSyntax Per Script Type
Document Event
# Config: Reference DocType = Sales Invoice, Event = Before Save
if doc.grand_total < 0:
frappe.throw("Total MUST NOT be negative")
doc.requires_approval = 1 if doc.grand_total > 10000 else 0API
# Config: API Method = get_customer_orders, Allow Guest = No
# Endpoint: /api/method/get_customer_orders
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("Parameter 'customer' is required")
orders = frappe.get_all("Sales Order",
filters={"customer": customer, "docstatus": 1},
fields=["name", "grand_total", "status"],
order_by="creation desc",
limit=20
)
frappe.response["message"] = {"orders": orders, "count": len(orders)}Scheduler Event
# Config: Event Frequency = Cron, Cron Format = 0 9 * * *
overdue = frappe.get_all("Sales Invoice",
filters={"status": "Unpaid", "due_date": ["<", frappe.utils.today()], "docstatus": 1},
fields=["name", "customer", "grand_total"]
)
for inv in overdue:
frappe.log_error(f"Overdue: {inv.name} ({inv.customer})", "Invoice Reminder")
frappe.db.commit() # ALWAYS commit in Scheduler scriptsPermission Query
# Config: Reference DocType = Sales Invoice
# Variables available: user, conditions
roles = frappe.get_roles(user)
if "System Manager" in roles:
conditions = ""
elif "Sales User" in roles:
conditions = f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
else:
conditions = "1=0"Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Server Scripts enabled | By default | Disabled by default | Disabled by default |
| Enable command | Not needed | bench set-config -g server_script_enabled 1 | Same as v15 |
frappe.qb (Query Builder) | Available | Available | Available |
run_script() for libraries | v13+ | Available | Available |
frappe.make_get_request() | Available | Available | Available |
| Frappe Cloud shared bench | Supported | NOT supported | NOT supported |
Top 5 Rules
1. NEVER write import — everything is in the frappe namespace 2. NEVER call doc.save() inside a Before Save script — causes infinite loop 3. NEVER call frappe.db.commit() in Document Event scripts — framework handles it 4. ALWAYS call frappe.db.commit() at the end of Scheduler scripts 5. ALWAYS use parameterized queries: %(var)s with dict, NEVER f-strings in SQL
References
- [references/methods.md](references/methods.md) — Complete sandbox API reference
- [references/events.md](references/events.md) — Document lifecycle and execution order
- [references/examples.md](references/examples.md) — Working examples per script type
- [references/anti-patterns.md](references/anti-patterns.md) — Sandbox violations and common mistakes
- [references/syntax.md](references/syntax.md) — Quick syntax cheat sheet
- [references/patterns.md](references/patterns.md) — Common patterns (validation, auto-fill, API)
- [references/hooks.md](references/hooks.md) — Server Scripts vs hooks.py interaction
Cross-References
- frappe-syntax-api — Frappe REST API and whitelisted methods
- frappe-syntax-doctype — DocType field types and schema
- frappe-core-database — frappe.db deep dive
- frappe-core-permissions — Permission system architecture
- frappe-errors-common — Error handling patterns
Server Script Anti-Patterns and Common Mistakes
Rule #1: NO Imports
The RestrictedPython sandbox blocks __import__. Every import statement fails.
# ALL of these produce: ImportError: __import__ not found
import json # Use frappe.parse_json() / frappe.as_json()
import re # Not available — restructure logic
import math # Use sum(), min(), max(), round() builtins
import os # Not available
import requests # Use frappe.make_get_request()
import frappe # Already loaded — NEVER import it
from datetime import date # Use frappe.utils.today()
from frappe.utils import cint # Use frappe.utils.cint() directly
from collections import defaultdict # Use dict with .get(key, default)ALWAYS delete every import line. The frappe namespace is pre-loaded.
---
Sandbox Violations
File System Access — BLOCKED
# WRONG:
open("/tmp/data.txt", "r") # NameError: name 'open' is not defined
file = open("export.csv", "w") # Not available
# CORRECT alternative:
frappe.log_error(data, "Export Data") # Log to Error LogDynamic Code Execution — BLOCKED
# WRONG:
eval("1 + 1") # Blocked
exec("print('hello')") # Blocked
compile("code", "", "exec") # Blocked
# CORRECT: Write the logic directly — no dynamic evaluationOS / System Commands — BLOCKED
# WRONG:
os.system("ls") # Not available
subprocess.run(["echo", "hi"]) # Not available
# CORRECT: Use a custom Frappe app for system-level operations---
Database Anti-Patterns
SQL Injection
# NEVER do this:
frappe.db.sql(f"SELECT * FROM tabUser WHERE name = '{user_input}'")
frappe.db.sql("SELECT * FROM tabUser WHERE name = '" + user_input + "'")
# ALWAYS use parameterized queries:
frappe.db.sql("""
SELECT * FROM `tabUser`
WHERE name = %(user)s
""", {"user": user_input}, as_dict=True)
# Or use ORM methods (automatically safe):
frappe.get_all("User", filters={"name": user_input})N+1 Query Problem
# WRONG — N queries for N items:
for item in doc.items:
name = frappe.db.get_value("Item", item.item_code, "item_name")
# CORRECT — 1 batch query:
codes = [item.item_code for item in doc.items]
items_map = {d.name: d.item_name for d in frappe.get_all(
"Item",
filters={"name": ["in", codes]},
fields=["name", "item_name"]
)}
for item in doc.items:
name = items_map.get(item.item_code)Unnecessary Commit in Document Events
# WRONG in Document Event:
doc.total = 100
frappe.db.commit() # Framework handles commit — NEVER do this
# CORRECT in Document Event:
doc.total = 100 # Framework commits after the event chain
# EXCEPTION — Scheduler scripts ALWAYS need commit:
frappe.db.set_value("Task", task.name, "status", "Done")
frappe.db.commit() # Required in Schedulerset_value for Complex Updates
# RISKY — bypasses all validation hooks:
frappe.db.set_value("Sales Invoice", "SINV-001", "grand_total", 1000)
# SAFER — triggers validate, permissions, linked doc updates:
inv = frappe.get_doc("Sales Invoice", "SINV-001")
inv.grand_total = 1000
inv.save()---
Performance Anti-Patterns
Fetching Full Document for One Field
# WRONG — loads ALL fields into memory:
customer = frappe.get_doc("Customer", doc.customer)
email = customer.email_id
# CORRECT — fetches only what you need:
email = frappe.db.get_value("Customer", doc.customer, "email_id")No Limit on Queries
# WRONG — could return thousands of records:
all_invoices = frappe.get_all("Sales Invoice", filters={"docstatus": 1})
# CORRECT — ALWAYS set a limit:
invoices = frappe.get_all("Sales Invoice",
filters={"docstatus": 1},
limit=100,
order_by="creation desc"
)Selecting All Fields
# WRONG:
orders = frappe.get_all("Sales Order", filters={...}, fields=["*"])
# CORRECT — only needed fields:
orders = frappe.get_all("Sales Order",
filters={...},
fields=["name", "grand_total", "status"]
)Heavy Computation in Before Save
# WRONG — slows down EVERY save:
total = frappe.db.sql("""
SELECT SUM(grand_total) FROM `tabSales Invoice`
WHERE customer = %(c)s
""", {"c": doc.customer})[0][0]
doc.lifetime_value = total
# CORRECT — use a Scheduler Event for heavy aggregations---
Logic Anti-Patterns
Infinite Loop from Recursive Save
# WRONG in Before Save — triggers Before Save again:
doc.total = calculate_total()
doc.save() # INFINITE LOOP
# CORRECT in Before Save — framework saves after event:
doc.total = calculate_total()
# NO doc.save() call — framework handles thisThrow After Database Changes
# WRONG — side effects happen even when save fails:
frappe.get_doc({"doctype": "Log", ...}).insert()
if doc.total < 0:
frappe.throw("Invalid total")
# The Log record exists even though save was blocked!
# CORRECT — validate FIRST, then side effects:
if doc.total < 0:
frappe.throw("Invalid total")
frappe.get_doc({"doctype": "Log", ...}).insert()Relying on Script Execution Order
# WRONG — execution order of multiple Server Scripts is undefined:
# Script A (Before Save): doc.calc_value = complex_calc()
# Script B (Before Save): doc.derived = doc.calc_value * 2
# CORRECT — combine dependent logic in ONE script:
doc.calc_value = complex_calc()
doc.derived = doc.calc_value * 2Modifying doc in After Save Without Persisting
# WRONG in After Save — field change is lost:
doc.note = "Updated"
# Not saved — doc was already written to DB
# CORRECT in After Save:
doc.db_set("note", "Updated", update_modified=False)
# Or:
frappe.db.set_value(doc.doctype, doc.name, "note", "Updated")---
Security Anti-Patterns
No Permission Check in API Scripts
# WRONG — any authenticated user can query:
data = frappe.get_doc("Customer", frappe.form_dict.get("customer"))
frappe.response["message"] = data.as_dict()
# CORRECT:
customer = frappe.form_dict.get("customer")
if not frappe.has_permission("Customer", "read", customer):
frappe.throw("Access denied", frappe.PermissionError)
data = frappe.get_doc("Customer", customer)
frappe.response["message"] = data.as_dict()ignore_permissions Overuse
# WRONG — bypasses all security:
doc.save(ignore_permissions=True) # Why?
frappe.delete_doc("X", name, ignore_permissions=True) # Dangerous
# CORRECT — only for system-generated records with explicit justification:
# Creating a system ToDo after verifying parent permission
if frappe.has_permission("Sales Order", "write", doc.name):
frappe.get_doc({"doctype": "ToDo", ...}).insert(ignore_permissions=True)Sensitive Data in Error Logs
# WRONG:
frappe.log_error(f"Auth failed: user={user}, password={pw}")
# CORRECT:
frappe.log_error(f"Auth failed for user: {user}")Guest Endpoint Exposing Internal Data
# WRONG — Allow Guest = Yes with sensitive data:
frappe.response["message"] = frappe.get_all("Customer",
fields=["name", "email_id", "tax_id", "outstanding_amount"])
# CORRECT — only expose non-sensitive fields:
frappe.response["message"] = frappe.get_all("Item",
filters={"show_on_website": 1},
fields=["item_name", "stock_uom"]
)---
Common Mistakes Summary
| Mistake | Fix |
|---|---|
Any import statement | Remove it — use frappe.* namespace |
doc.save() in Before Save | Remove it — framework saves automatically |
frappe.db.commit() in Document Event | Remove it — framework commits automatically |
Missing frappe.db.commit() in Scheduler | Add it — scheduler does NOT auto-commit |
doc.name in Before Insert | Use After Insert — name may not exist yet |
| Modifying doc in After Save | Use doc.db_set() or frappe.db.set_value() |
f"WHERE x = '{var}'" in SQL | Use %(var)s with parameters dict |
fields=["*"] in get_all | Specify only needed fields |
No limit in get_all | ALWAYS set a limit |
| No permission check in API script | ALWAYS check frappe.has_permission() |
| Permission Query without admin bypass | ALWAYS check for System Manager role first |
Server Script Events — Complete Reference
Event Name Mapping
CRITICAL: UI Names vs Internal Hooks
The Server Script UI displays different labels than the internal Frappe hook names. ALWAYS use the UI label when configuring; the framework maps it internally.
| Server Script UI | Internal Hook | Controller Method |
|---|---|---|
| Before Insert | before_insert | before_insert() |
| After Insert | after_insert | after_insert() |
| Before Validate | before_validate | before_validate() |
| Before Save | `validate` | validate() |
| After Save | on_update | on_update() |
| Before Submit | before_submit | before_submit() |
| After Submit | on_submit | on_submit() |
| Before Cancel | before_cancel | before_cancel() |
| After Cancel | on_cancel | on_cancel() |
| Before Delete | on_trash | on_trash() |
| After Delete | after_delete | after_delete() |
Why "Before Save" Maps to validate
In Frappe's architecture:
validateis the primary hook for pre-save validation and calculationsbefore_saveis a separate hook that runs AFTERvalidate- The Server Script UI uses "Before Save" as a user-friendly label for
validate - NEVER confuse the UI label with the actual
before_savehook
---
Document Lifecycle — Execution Order
New Document Insert
1. before_insert ← Server Script: "Before Insert"
2. before_naming ← NOT available in Server Scripts
3. autoname ← NOT available in Server Scripts
4. before_validate ← Server Script: "Before Validate"
5. validate ← Server Script: "Before Save"
6. before_save ← NOT available in Server Scripts
7. [DB INSERT]
8. after_insert ← Server Script: "After Insert"
9. on_update ← Server Script: "After Save"
10. on_change ← NOT available in Server ScriptsExisting Document Update
1. before_validate ← Server Script: "Before Validate"
2. validate ← Server Script: "Before Save"
3. before_save ← NOT available in Server Scripts
4. [DB UPDATE]
5. on_update ← Server Script: "After Save"
6. on_change ← NOT available in Server ScriptsDocument Submit (docstatus 0 → 1)
1. before_validate ← Server Script: "Before Validate"
2. validate ← Server Script: "Before Save"
3. before_submit ← Server Script: "Before Submit"
4. [DB UPDATE: docstatus = 1]
5. on_update ← Server Script: "After Save"
6. on_submit ← Server Script: "After Submit"
7. on_change ← NOT available in Server ScriptsDocument Cancel (docstatus 1 → 2)
1. before_cancel ← Server Script: "Before Cancel"
2. [DB UPDATE: docstatus = 2]
3. on_cancel ← Server Script: "After Cancel"
4. on_change ← NOT available in Server ScriptsDocument Delete
1. on_trash ← Server Script: "Before Delete"
2. [DB DELETE]
3. after_delete ← Server Script: "After Delete"---
Event Details
Before Insert
- Fires: Only for NEW documents, before DB insert
- doc.name: NOT yet available (unless manually set or autoname is simple)
- Use for: Setting default values, pre-insert validation
- Can throw: Yes — prevents insert
if not doc.priority:
doc.priority = "Medium"
doc.created_via_script = 1After Insert
- Fires: Immediately after first DB insert
- doc.name: Now available
- Use for: Creating related records, sending notifications
- Can throw: Yes, but document is already inserted
frappe.get_doc({
"doctype": "ToDo",
"reference_type": doc.doctype,
"reference_name": doc.name,
"description": f"Review new {doc.doctype}: {doc.name}"
}).insert(ignore_permissions=True)Before Validate
- Fires: Before framework validation (mandatory checks, link validation)
- Use for: Setting fields that must pass validation
- Can throw: Yes — prevents save
# Set a mandatory field before framework checks it
if not doc.naming_series:
doc.naming_series = "INV-.YYYY.-"Before Save (= validate hook)
- Fires: Before every save (insert and update)
- Use for: Custom validation, calculations, auto-fill fields
- Can throw: Yes — prevents save
- MOST COMMONLY USED event
if doc.discount_percentage > 50:
frappe.throw("Discount cannot exceed 50%")
doc.total_qty = sum(frappe.utils.flt(item.qty) for item in doc.items)After Save (= on_update hook)
- Fires: After successful save to database
- Changes to doc fields are NOT automatically saved
- Use for: Side effects, syncing external systems, updating related docs
- ALWAYS use
doc.db_set()orfrappe.db.set_value()to persist changes
if doc.status == "Approved":
frappe.db.set_value("Project", doc.project,
"approval_date", frappe.utils.today())Before Submit
- Fires: Only for submittable DocTypes (with is_submittable = 1)
- Use for: Final validation before document becomes immutable
- Can throw: Yes — prevents submit
if doc.grand_total > 100000 and not doc.manager_approval:
frappe.throw("Manager approval required for amounts over 100,000")After Submit
- Fires: After document is submitted (docstatus = 1)
- Document is now immutable (except via Amend)
- Use for: Sending notifications, creating downstream documents
frappe.sendmail(
recipients=[doc.owner],
subject=f"{doc.name} submitted",
message=f"Document {doc.name} has been submitted successfully."
)Before Cancel
- Fires: Before cancel operation
- Use for: Checking if cancel is allowed (linked documents, etc.)
- Can throw: Yes — prevents cancel
payments = frappe.get_all("Payment Entry Reference",
filters={"reference_name": doc.name, "docstatus": 1},
fields=["parent"]
)
if payments:
frappe.throw("Cancel linked payments first")After Cancel
- Fires: After document is cancelled (docstatus = 2)
- Use for: Cleanup, reversing side effects
doc.add_comment("Info", f"Cancelled by {frappe.session.user}")Before Delete (= on_trash hook)
- Fires: Before permanent deletion
- Can throw: Yes — prevents delete
After Delete
- Fires: After permanent deletion
- doc.name: Still available in the script context
- Use for: Cleaning up external references, audit logging
---
Hooks NOT Available in Server Scripts
These hooks exist in Document Controllers but CANNOT be triggered via Server Scripts:
| Hook | Purpose | Alternative |
|---|---|---|
autoname | Custom naming logic | Use Naming Rule in DocType settings |
before_naming | Pre-naming hook | Not available |
before_save | Runs after validate | Use "Before Save" (= validate) |
db_insert / db_update | After DB operation | Use "After Save" |
on_change | After any state change | Use "After Save" + "After Submit" |
get_feed | Activity feed | Not available |
before_rename / after_rename | Rename hooks | Not available |
---
Multiple Server Scripts on Same Event
- Multiple Server Scripts CAN target the same DocType + Event
- Execution order is NOT guaranteed
- NEVER rely on one script's output being available in another
- If scripts must share data, use
frappe.flags(transient, same request only)
# Script A (Before Save on Sales Order):
frappe.flags.custom_total_calculated = True
doc.custom_total = sum(item.amount for item in doc.items)
# Script B (Before Save on Sales Order):
# WARNING: This may run before Script A — order is undefined
if frappe.flags.get("custom_total_calculated"):
doc.custom_status = "Calculated"Best practice: ALWAYS combine dependent logic into a single Server Script.
Server Script Examples — All Script Types
Every example uses ONLY the pre-loaded sandbox namespace. No import statements.
---
Document Event Examples
1. Field Validation (Before Save)
# Config: DocType = Sales Invoice, Event = Before Save
if doc.grand_total < 0:
frappe.throw("Grand total MUST NOT be negative")
if doc.discount_percentage and doc.discount_percentage > 50:
frappe.throw("Discount cannot exceed 50%", title="Validation Error")2. Auto-Calculate Fields (Before Save)
# Config: DocType = Sales Order, Event = Before Save
doc.total_qty = sum(frappe.utils.flt(item.qty) for item in doc.items)
doc.total_weight = sum(
frappe.utils.flt(item.qty) * frappe.utils.flt(item.weight_per_unit)
for item in doc.items
)
if doc.grand_total > 10000:
doc.priority = "High"
doc.requires_approval = 13. Auto-Fill from Linked Document (Before Save)
# Config: DocType = Sales Invoice, Event = Before Save
if doc.customer and not doc.customer_name:
doc.customer_name = frappe.db.get_value(
"Customer", doc.customer, "customer_name")
if doc.customer and not doc.territory:
doc.territory = frappe.db.get_value(
"Customer", doc.customer, "territory")4. Create Related Document (After Insert)
# Config: DocType = Sales Order, Event = After Insert
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": doc.owner,
"reference_type": "Sales Order",
"reference_name": doc.name,
"description": f"New order {doc.name} — follow up with customer",
"date": frappe.utils.add_days(frappe.utils.today(), 1)
}).insert(ignore_permissions=True)5. Pre-Submit Validation (Before Submit)
# Config: DocType = Purchase Order, Event = Before Submit
if doc.grand_total > 50000:
if not doc.budget_approval:
frappe.throw("Budget approval required for orders over 50,000")
if doc.approved_by == doc.owner:
frappe.throw("Order MUST NOT be approved by its creator")6. Post-Submit Side Effects (After Submit)
# Config: DocType = Sales Invoice, Event = After Submit
total_invoices = frappe.db.count("Sales Invoice",
filters={"customer": doc.customer, "docstatus": 1})
frappe.db.set_value("Customer", doc.customer,
"total_invoices", total_invoices)
if doc.grand_total > 10000:
frappe.sendmail(
recipients=[doc.owner],
subject=f"High-value invoice {doc.name}",
message=f"Invoice {doc.name} for {doc.grand_total} has been submitted."
)7. Cancel Guard (Before Cancel)
# Config: DocType = Sales Invoice, Event = Before Cancel
payments = frappe.get_all("Payment Entry Reference",
filters={
"reference_doctype": "Sales Invoice",
"reference_name": doc.name,
"docstatus": 1
},
fields=["parent"]
)
if payments:
frappe.throw(
f"Cannot cancel: {len(payments)} linked payment(s) exist. "
"Cancel the payments first.",
title="Cancellation Blocked"
)8. Set Default Values (Before Insert)
# Config: DocType = Sales Order, Event = Before Insert
if not doc.delivery_date:
doc.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
if not doc.currency:
doc.currency = frappe.db.get_single_value(
"Global Defaults", "default_currency") or "USD"9. Prevent Infinite Loop with Flags
# Config: DocType = Sales Order, Event = After Save
# Updating a related doc that might trigger this script again
if not doc.flags.get("skip_sync"):
linked = frappe.get_doc("Project", doc.project)
linked.flags.skip_sync = True
linked.total_orders = frappe.db.count("Sales Order",
filters={"project": doc.project, "docstatus": 1})
linked.save(ignore_permissions=True)10. Child Table Validation (Before Save)
# Config: DocType = Sales Order, Event = Before Save
seen_items = []
for item in doc.items:
if item.item_code in seen_items:
frappe.throw(f"Duplicate item {item.item_code} in row {item.idx}")
seen_items.append(item.item_code)
if frappe.utils.flt(item.qty) <= 0:
frappe.throw(f"Quantity must be > 0 in row {item.idx}")
if frappe.utils.flt(item.rate) <= 0:
frappe.throw(f"Rate must be > 0 in row {item.idx}")---
API Examples
11. GET Endpoint with Permission Check
# Config: Script Type = API, Method = get_customer_orders, Allow Guest = No
# Endpoint: GET /api/method/get_customer_orders?customer=CUST-001
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("Parameter 'customer' is required")
if not frappe.has_permission("Sales Order", "read"):
frappe.throw("Access denied", frappe.PermissionError)
orders = frappe.get_all("Sales Order",
filters={"customer": customer, "docstatus": 1},
fields=["name", "transaction_date", "grand_total", "status"],
order_by="transaction_date desc",
limit=20
)
frappe.response["message"] = {
"customer": customer,
"orders": orders,
"count": len(orders)
}12. POST Endpoint with Input Validation
# Config: Script Type = API, Method = update_order_status, Allow Guest = No
# Endpoint: POST /api/method/update_order_status
order_name = frappe.form_dict.get("order")
new_status = frappe.form_dict.get("status")
if not order_name or not new_status:
frappe.throw("Parameters 'order' and 'status' are required")
valid_statuses = ["Open", "Completed", "On Hold"]
if new_status not in valid_statuses:
frappe.throw(f"Invalid status. Valid: {', '.join(valid_statuses)}")
if not frappe.has_permission("Sales Order", "write", order_name):
frappe.throw("No write permission", frappe.PermissionError)
frappe.db.set_value("Sales Order", order_name, "status", new_status)
frappe.response["message"] = {"success": True, "new_status": new_status}13. Dashboard Data Endpoint
# Config: Script Type = API, Method = get_sales_dashboard, Allow Guest = No
today = frappe.utils.today()
month_start = frappe.utils.get_first_day(today)
orders_today = frappe.db.count("Sales Order",
filters={"transaction_date": today, "docstatus": 1})
month_revenue = frappe.db.sql("""
SELECT COALESCE(SUM(grand_total), 0) as total
FROM `tabSales Invoice`
WHERE posting_date >= %(start)s AND docstatus = 1
""", {"start": month_start}, as_dict=True)
top_customers = frappe.get_all("Sales Invoice",
filters={"posting_date": [">=", month_start], "docstatus": 1},
fields=["customer", "sum(grand_total) as total"],
group_by="customer",
order_by="total desc",
limit=5
)
frappe.response["message"] = {
"orders_today": orders_today,
"month_revenue": month_revenue[0].total if month_revenue else 0,
"top_customers": top_customers
}14. Public Guest Endpoint
# Config: Script Type = API, Method = check_availability, Allow Guest = Yes
# NEVER expose sensitive data in guest endpoints
item_code = frappe.form_dict.get("item")
if not item_code:
frappe.throw("Parameter 'item' is required")
item = frappe.db.get_value("Item", item_code,
["item_name", "stock_uom", "disabled"], as_dict=True)
if not item or item.disabled:
frappe.response["message"] = {"available": False}
else:
stock = frappe.db.get_value("Bin",
{"item_code": item_code}, "sum(actual_qty)") or 0
frappe.response["message"] = {
"available": frappe.utils.flt(stock) > 0,
"item_name": item.item_name,
"uom": item.stock_uom
}15. External API Integration
# Config: Script Type = API, Method = sync_external_data, Allow Guest = No
api_key = frappe.db.get_single_value("My Settings", "api_key")
if not api_key:
frappe.throw("API key not configured")
response = frappe.make_get_request(
"https://api.example.com/data",
headers={"Authorization": f"Bearer {api_key}"}
)
frappe.response["message"] = {
"synced": True,
"records": len(response.get("data", []))
}---
Scheduler Event Examples
16. Daily Overdue Invoice Reminders
# Config: Script Type = Scheduler Event, Cron = 0 9 * * *
today = frappe.utils.today()
overdue = frappe.get_all("Sales Invoice",
filters={
"status": "Unpaid",
"due_date": ["<", today],
"docstatus": 1
},
fields=["name", "customer", "grand_total", "due_date", "owner"],
limit=200
)
for inv in overdue:
days_overdue = frappe.utils.date_diff(today, inv.due_date)
if not frappe.db.exists("ToDo", {
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"status": "Open"
}):
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": inv.owner,
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"description": f"Invoice {inv.name} is {days_overdue} days overdue"
}).insert(ignore_permissions=True)
frappe.db.commit() # ALWAYS commit in scheduler scripts17. Weekly Draft Cleanup
# Config: Script Type = Scheduler Event, Cron = 0 2 * * 0
cutoff = frappe.utils.add_days(frappe.utils.today(), -30)
old_drafts = frappe.get_all("Sales Order",
filters={"docstatus": 0, "modified": ["<", cutoff]},
fields=["name"],
limit=100
)
deleted = 0
for draft in old_drafts:
try:
frappe.delete_doc("Sales Order", draft.name, force=True)
deleted += 1
except Exception:
frappe.log_error(
f"Could not delete draft {draft.name}",
"Cleanup Error"
)
frappe.db.commit()
if deleted > 0:
frappe.log_error(f"Deleted {deleted} old draft Sales Orders", "Weekly Cleanup")18. Monthly Summary Report
# Config: Script Type = Scheduler Event, Cron = 0 6 1 * *
prev_start = frappe.utils.add_months(
frappe.utils.get_first_day(frappe.utils.today()), -1)
prev_end = frappe.utils.get_last_day(prev_start)
summary = frappe.db.sql("""
SELECT
COUNT(*) as count,
COALESCE(SUM(grand_total), 0) as revenue,
COUNT(DISTINCT customer) as customers
FROM `tabSales Invoice`
WHERE posting_date BETWEEN %(start)s AND %(end)s
AND docstatus = 1
""", {"start": prev_start, "end": prev_end}, as_dict=True)[0]
frappe.log_error(
f"Monthly Report ({prev_start} to {prev_end})\n"
f"Invoices: {summary.count}\n"
f"Revenue: {summary.revenue}\n"
f"Customers: {summary.customers}",
"Monthly Sales Report"
)
frappe.db.commit()---
Permission Query Examples
19. Role-Based Filtering
# Config: Script Type = Permission Query, DocType = Sales Invoice
# Variables available: user, conditions
roles = frappe.get_roles(user)
if "System Manager" in roles or "Accounts Manager" in roles:
conditions = ""
elif "Sales User" in roles:
conditions = f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
else:
conditions = "1=0"20. Territory-Based Filtering
# Config: Script Type = Permission Query, DocType = Customer
user_territory = frappe.db.get_value("User", user, "territory")
if "Sales Manager" in frappe.get_roles(user):
conditions = ""
elif user_territory:
conditions = f"`tabCustomer`.territory = {frappe.db.escape(user_territory)}"
else:
conditions = "1=0"21. Company-Based Filtering
# Config: Script Type = Permission Query, DocType = Sales Order
allowed = frappe.get_all("User Permission",
filters={"user": user, "allow": "Company"},
pluck="for_value"
)
if not allowed:
conditions = ""
elif len(allowed) == 1:
conditions = f"`tabSales Order`.company = {frappe.db.escape(allowed[0])}"
else:
escaped = ", ".join(frappe.db.escape(c) for c in allowed)
conditions = f"`tabSales Order`.company IN ({escaped})"Server Scripts vs hooks.py — Interaction and Differences
How Server Scripts Relate to hooks.py
Server Scripts and hooks.py doc_events target the SAME document lifecycle hooks. They run in a defined order:
Document Lifecycle Event (e.g., validate)
│
├─► 1. Controller method (e.g., SalesInvoice.validate())
├─► 2. hooks.py handler (e.g., doc_events["Sales Invoice"]["validate"])
└─► 3. Server Script (e.g., Before Save on Sales Invoice)ALWAYS be aware that Server Scripts run AFTER controller methods and hooks.py handlers. If a controller throws an error, the Server Script NEVER executes.
---
When to Use Which
| Criterion | Server Script | hooks.py / Controller |
|---|---|---|
| Deployment | UI only (no bench access) | Requires custom Frappe app |
| Python imports | BLOCKED (sandbox) | Full Python available |
| External libraries | Only frappe.make_*_request() | Any pip package |
| File system access | BLOCKED | Full access |
| Unit testing | Not testable | Fully testable with pytest |
| Version control | Stored in DB | Stored in code (Git) |
| Performance | Slightly slower (sandbox overhead) | Native Python speed |
| Complexity limit | Simple logic (<50 lines ideal) | Unlimited |
| Available hooks | 11 document events + API/Scheduler/Permission | All hooks including autoname, on_change, etc. |
---
hooks.py doc_events Format
For comparison — this is how the same logic looks in hooks.py:
# In your_app/hooks.py
doc_events = {
"Sales Invoice": {
"validate": "your_app.overrides.sales_invoice.validate",
"on_submit": "your_app.overrides.sales_invoice.on_submit",
}
}# In your_app/overrides/sales_invoice.py
import frappe # Imports WORK in controllers
from datetime import date # Any Python module available
def validate(doc, method):
if doc.grand_total < 0:
frappe.throw("Total cannot be negative")
def on_submit(doc, method):
# Full Python available here
passKey Difference: Method Signature
| Context | Signature |
|---|---|
| Server Script | No function definition — doc is a global variable |
| hooks.py handler | def handler(doc, method): — doc is a parameter |
| Controller method | def validate(self): — doc is self |
---
Coexistence Rules
1. Multiple handlers on same event: Controller + hooks.py + Server Script ALL run for the same event. They do NOT cancel each other.
2. Execution order is fixed: Controller → hooks.py → Server Script. NEVER rely on a Server Script running before a controller method.
3. If any handler throws: Subsequent handlers do NOT run. The entire save/submit/cancel operation is rolled back.
4. Multiple Server Scripts: If two Server Scripts target the same DocType + Event, execution order between them is UNDEFINED.
5. Data visibility: All handlers see the same doc object. Changes made by the controller are visible to hooks.py and Server Scripts.
---
Migrating Between Server Scripts and hooks.py
Server Script → hooks.py
When a Server Script becomes too complex:
1. Create a custom Frappe app: bench new-app my_customizations 2. Move logic to a Python file with proper imports 3. Register in hooks.py doc_events 4. Delete the Server Script from the UI 5. Run bench migrate to apply hooks
hooks.py → Server Script
When you need to let non-developers manage logic:
1. ONLY migrate if the logic fits sandbox restrictions 2. Remove import statements — use frappe.* namespace 3. Remove function definition — doc becomes a global 4. Remove self references — use doc directly 5. Create Server Script in UI with appropriate event 6. Remove the hooks.py entry
---
Server Scripts and Custom Apps Together
A common architecture:
Custom App (hooks.py) Server Scripts (UI)
├── Complex validation ├── Simple field validation
├── External API integration ├── Quick API endpoints
├── Background jobs ├── Permission queries
├── Custom report logic └── Scheduled reminders
└── Unit-tested business rulesBest practice: Use Server Scripts for logic that business users may need to adjust without deploying code. Use custom apps for core business logic that must be version-controlled and tested.
---
Version Differences
| Feature | v14 | v15+ |
|---|---|---|
| Server Scripts enabled | By default | Must enable explicitly |
| hooks.py doc_events | Fully supported | Fully supported |
| Execution order | Controller → hooks → Script | Same |
run_script() | Available (v13+) | Available |
| Server Script export/import | Via fixtures | Via fixtures |
---
Exporting Server Scripts (Version Control)
Server Scripts live in the database by default. To version-control them:
# In hooks.py — export Server Scripts as fixtures
fixtures = [
{"dt": "Server Script", "filters": [["module", "=", "My Module"]]}
]Then: bench export-fixtures creates JSON files in your app that can be committed to Git. bench migrate re-imports them.
ALWAYS export Server Scripts as fixtures if you need reproducible deployments across environments.
Server Script Sandbox — Complete Method Reference
All methods available inside the RestrictedPython sandbox. NEVER use import statements — everything listed here is pre-loaded.
---
doc Object (Document Event Scripts Only)
Properties
doc.name # str — Document ID (NOT available in Before Insert)
doc.doctype # str — DocType name
doc.docstatus # int — 0=Draft, 1=Submitted, 2=Cancelled
doc.owner # str — Creator email
doc.modified_by # str — Last modifier email
doc.creation # datetime — Creation timestamp
doc.modified # datetime — Last modification timestamp
doc.flags # _dict — Transient flags (not persisted)
# Every DocType field is a direct attribute:
doc.customer # Link field value
doc.grand_total # Currency field value
doc.items # Child table (list of child doc objects)Methods
doc.get("fieldname") # Safe access — returns None if missing
doc.get("fieldname", "default") # With default value
doc.update({"field1": val1, ...}) # Set multiple fields at once
doc.append("child_table", {...}) # Add row to child table
doc.as_dict() # Convert to dictionary
doc.db_set("field", value) # Direct DB update (After Save only)
doc.add_comment("Info", "text") # Add comment to document
doc.add_tag("tag_name") # Add tag
doc.get_tags() # Get tags list
# Child table iteration
for item in doc.items:
item.item_code # Child field
item.qty # Child field
item.idx # Row number (1-based)
item.parent # Parent document name
item.parenttype # Parent DocType name---
frappe.db — Database Operations
Single Value
# Get one field from one document
val = frappe.db.get_value("Customer", "CUST-001", "customer_name")
# Get multiple fields as dict
vals = frappe.db.get_value("Customer", "CUST-001",
["customer_name", "territory"], as_dict=True)
# Get value with filters (returns first match)
email = frappe.db.get_value("User", {"first_name": "John"}, "email")
# Get value from Singles DocType
company = frappe.db.get_single_value("Global Defaults", "default_company")
# Get default value
default = frappe.db.get_default("currency")Set Value
# Single field
frappe.db.set_value("Customer", "CUST-001", "status", "Active")
# Multiple fields
frappe.db.set_value("Customer", "CUST-001", {
"status": "Active",
"last_contact": frappe.utils.today()
})
# WARNING: set_value bypasses validate hooks — use only for simple updatesMultiple Records
# get_all — NO permission filtering
orders = frappe.get_all("Sales Order",
filters={"customer": "CUST-001", "docstatus": 1},
fields=["name", "grand_total", "status"],
order_by="creation desc",
limit=20
)
# Returns: [{"name": "SO-001", "grand_total": 5000, "status": "Submitted"}, ...]
# get_list — WITH permission filtering (respects user permissions)
orders = frappe.db.get_list("Sales Order",
filters={"docstatus": 1},
fields=["name", "grand_total"],
limit=20
)
# Filter operators
filters = {
"grand_total": [">", 1000],
"status": ["in", ["Open", "Active"]],
"due_date": ["<", frappe.utils.today()],
"name": ["like", "SO-%"],
"customer": ["is", "set"], # IS NOT NULL
"note": ["is", "not set"], # IS NULL
"creation": ["between", [start, end]]
}
# pluck — returns flat list of one field
names = frappe.get_all("Customer", filters={...}, pluck="name")
# Returns: ["CUST-001", "CUST-002", ...]Count / Exists
count = frappe.db.count("Sales Invoice",
filters={"status": "Unpaid", "docstatus": 1})
if frappe.db.exists("Customer", "CUST-001"):
pass # Document exists
if frappe.db.exists("Sales Order", {"customer": doc.customer, "docstatus": 0}):
pass # Matching document existsRaw SQL
# ALWAYS use parameterized queries
results = frappe.db.sql("""
SELECT name, grand_total
FROM `tabSales Invoice`
WHERE customer = %(customer)s AND docstatus = 1
""", {"customer": doc.customer}, as_dict=True)
# NEVER use f-strings or string concatenation in SQL:
# frappe.db.sql(f"... WHERE name = '{user_input}'") ← SQL INJECTIONQuery Builder (frappe.qb)
SI = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(SI)
.select(SI.name, SI.grand_total)
.where(SI.customer == "CUST-001")
.where(SI.docstatus == 1)
.orderby(SI.creation, order=frappe.qb.desc)
.limit(10)
)
results = query.run(as_dict=True)Transaction Control
frappe.db.commit() # ONLY in Scheduler scripts — NEVER in Document Events
frappe.db.rollback() # ONLY in Scheduler scripts — NEVER in Document Events
frappe.db.escape(val) # Escape value for SQL WHERE clause---
frappe Document Methods
# Fetch existing document
customer = frappe.get_doc("Customer", "CUST-001")
customer.customer_name # Read field
customer.save() # Save changes (triggers hooks)
# Cached fetch (faster, read-only — NEVER modify and save)
customer = frappe.get_cached_doc("Customer", "CUST-001")
# Create new document (method 1)
todo = frappe.get_doc({
"doctype": "ToDo",
"description": "Follow up",
"reference_type": doc.doctype,
"reference_name": doc.name
})
todo.insert(ignore_permissions=True)
# Create new document (method 2)
todo = frappe.new_doc("ToDo")
todo.description = "Follow up"
todo.insert()
# Get most recent document
last = frappe.get_last_doc("Sales Order",
filters={"customer": doc.customer})
# Delete document
frappe.delete_doc("ToDo", "TODO-001")
# Rename document
frappe.rename_doc("Customer", "Old Name", "New Name")
# Get DocType metadata
meta = frappe.get_meta("Sales Invoice")
meta.get_field("grand_total") # Field metadata---
frappe.utils — Utilities
Date / Time
frappe.utils.today() # "2024-01-15"
frappe.utils.nowdate() # Same as today()
frappe.utils.now() # "2024-01-15 10:30:00"
frappe.utils.now_datetime() # datetime object
frappe.utils.nowtime() # "10:30:00"
frappe.utils.add_days(date, 7) # +7 days
frappe.utils.add_months(date, 1) # +1 month
frappe.utils.add_years(date, 1) # +1 year
frappe.utils.date_diff(date1, date2) # Days between (int)
frappe.utils.get_first_day(date) # First day of month
frappe.utils.get_last_day(date) # Last day of month
frappe.utils.getdate(string) # String → date object
frappe.utils.get_datetime(string) # String → datetime object
frappe.utils.formatdate(date, "dd-MM-yyyy")
frappe.utils.format_datetime(datetime)
frappe.format_date(date) # Human-readable date
frappe.date_format # System date format stringNumber / String
frappe.utils.flt(value) # → float (None → 0.0)
frappe.utils.flt(value, precision=2) # With decimal precision
frappe.utils.cint(value) # → int (None → 0)
frappe.utils.cstr(value) # → str (None → "")
frappe.utils.rounded(123.456, 2) # → 123.46
frappe.utils.fmt_money(1234.56, currency="EUR")
frappe.utils.strip_html(html) # Remove HTML tags
frappe.utils.escape_html(text) # Escape HTML entities
frappe.utils.random_string(8) # Random alphanumeric string
frappe.utils.get_url() # Site base URL
frappe.utils.get_fullname(user) # User's full nameJSON (Instead of import json)
data = frappe.parse_json(json_string) # JSON string → Python dict/list
text = frappe.as_json(python_obj) # Python dict/list → JSON string---
Messaging / Errors
# Stop execution and show error to user
frappe.throw("Amount cannot be negative")
frappe.throw("Access denied", frappe.PermissionError)
frappe.throw("Invalid amount", title="Validation Error")
# Show notification (does NOT stop execution)
frappe.msgprint("Record updated successfully")
frappe.msgprint(msg="Created", title="Success", indicator="green")
# Log to Error Log list (background — no user notification)
frappe.log_error(message="Details here", title="Sync Failed")
frappe.log_error(frappe.get_traceback(), "Unhandled Error")---
HTTP Requests (Available in Sandbox)
# GET request
response = frappe.make_get_request(
"https://api.example.com/data",
params={"key": "value"},
headers={"Authorization": "Bearer token"}
)
# POST request
response = frappe.make_post_request(
"https://api.example.com/submit",
data={"field": "value"},
headers={"Content-Type": "application/json"}
)
# PUT request
response = frappe.make_put_request(
"https://api.example.com/update/1",
data={"field": "new_value"}
)---
frappe.sendmail(
recipients=["user@example.com"],
sender="noreply@example.com",
subject="Invoice Overdue",
message="Your invoice SI-001 is overdue."
)---
Session / Permissions
frappe.session.user # "user@example.com" or "Guest"
frappe.session.csrf_token # CSRF token
frappe.user # Same as frappe.session.user
frappe.full_name # Current user's full name
frappe.get_roles() # Current user's roles
frappe.get_roles("user@email.com") # Specific user's roles
frappe.get_fullname() # Current user's full name
frappe.get_fullname("user@email.com") # Specific user's full name
frappe.get_gravatar() # User avatar URL
frappe.has_permission("Sales Invoice", "read")
frappe.has_permission("Sales Invoice", "write", "SINV-001")
# Permission types: read, write, create, delete, submit, cancel, amend---
Miscellaneous
# Translation
_("Translatable string")
# Template rendering
html = frappe.render_template("Hello {{ name }}", {"name": "World"})
# Call another Server Script as a library (v13+)
result = run_script("My Library Script", arg1="value1")
# Read hooks from apps
hooks = frappe.get_hooks("doc_events")
# Format value by field type
frappe.format_value(1234.5, {"fieldtype": "Currency"})
# System settings
settings = frappe.get_system_settings()---
What is BLOCKED
| Category | Examples | Error |
|---|---|---|
| All imports | import json, from datetime import date | ImportError: __import__ not found |
| File I/O | open(), file() | NameError |
| Code execution | eval(), exec(), compile() | Blocked by sandbox |
| OS access | os.system(), subprocess.run() | Not available |
| Scope introspection | globals(), locals(), vars() | Blocked |
| Module access | __import__, importlib | Blocked |
Server Script Common Patterns
Reusable patterns for the most frequent Server Script use cases. Every example uses ONLY the pre-loaded sandbox namespace — no import statements.
---
Pattern 1: Field Validation with Custom Error
# Before Save — validate business rules
errors = []
if not doc.customer:
errors.append("Customer is required")
if frappe.utils.flt(doc.grand_total) <= 0:
errors.append("Grand total must be greater than zero")
if doc.delivery_date and doc.delivery_date < frappe.utils.today():
errors.append("Delivery date cannot be in the past")
if errors:
frappe.throw("<br>".join(errors), title="Validation Errors")---
Pattern 2: Auto-Fill from Master Data
# Before Save — fetch related data from linked documents
if doc.customer:
customer_data = frappe.db.get_value("Customer", doc.customer,
["customer_name", "territory", "customer_group", "default_currency"],
as_dict=True
)
if customer_data:
if not doc.customer_name:
doc.customer_name = customer_data.customer_name
if not doc.territory:
doc.territory = customer_data.territory
if not doc.customer_group:
doc.customer_group = customer_data.customer_group---
Pattern 3: Child Table Aggregation
# Before Save — recalculate totals from child items
doc.total_qty = 0
doc.total_amount = 0
for item in doc.items:
qty = frappe.utils.flt(item.qty)
rate = frappe.utils.flt(item.rate)
item.amount = qty * rate
doc.total_qty += qty
doc.total_amount += item.amount
doc.grand_total = doc.total_amount - frappe.utils.flt(doc.discount_amount)---
Pattern 4: Conditional Logic Based on Roles
# Before Save — restrict actions by role
roles = frappe.get_roles()
if doc.discount_percentage > 20 and "Sales Manager" not in roles:
frappe.throw("Only Sales Managers can give discounts above 20%")
if doc.is_priority and "System Manager" not in roles:
frappe.throw("Only System Managers can set priority flag")---
Pattern 5: Create Downstream Document
# After Submit — create a follow-up document
if doc.requires_delivery:
delivery = frappe.new_doc("Delivery Note")
delivery.customer = doc.customer
delivery.company = doc.company
for item in doc.items:
delivery.append("items", {
"item_code": item.item_code,
"qty": item.qty,
"rate": item.rate,
"against_sales_order": doc.name,
"so_detail": item.name
})
delivery.insert(ignore_permissions=True)
frappe.msgprint(f"Delivery Note {delivery.name} created", indicator="green")---
Pattern 6: Duplicate Detection
# Before Insert — prevent duplicate entries
existing = frappe.db.exists("Sales Order", {
"customer": doc.customer,
"po_no": doc.po_no,
"docstatus": ["<", 2]
})
if existing:
frappe.throw(
f"A Sales Order ({existing}) already exists for this customer "
f"with PO number {doc.po_no}",
title="Duplicate Detected"
)---
Pattern 7: Batch Update in Scheduler
# Scheduler Event — process records in batches with commit
batch_size = 100
offset = 0
total_processed = 0
while True:
records = frappe.get_all("Task",
filters={"status": "Open", "exp_end_date": ["<", frappe.utils.today()]},
fields=["name"],
limit=batch_size,
start=offset
)
if not records:
break
for record in records:
frappe.db.set_value("Task", record.name, "status", "Overdue")
total_processed += 1
frappe.db.commit() # Commit per batch to avoid long transactions
offset += batch_size
if total_processed > 0:
frappe.log_error(
f"Marked {total_processed} tasks as Overdue",
"Task Overdue Scheduler"
)---
Pattern 8: API Endpoint with Pagination
# API Script — paginated list endpoint
page = frappe.utils.cint(frappe.form_dict.get("page", 1))
page_size = frappe.utils.cint(frappe.form_dict.get("page_size", 20))
if page_size > 100:
page_size = 100 # ALWAYS cap page size
start = (page - 1) * page_size
total = frappe.db.count("Sales Order", filters={"docstatus": 1})
orders = frappe.get_all("Sales Order",
filters={"docstatus": 1},
fields=["name", "customer", "grand_total", "status"],
order_by="creation desc",
limit=page_size,
start=start
)
frappe.response["message"] = {
"data": orders,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": -(-total // page_size) # Ceiling division
}---
Pattern 9: Status Transition Guard
# Before Save — enforce valid status transitions
VALID_TRANSITIONS = {
"Draft": ["Open", "Cancelled"],
"Open": ["In Progress", "On Hold", "Cancelled"],
"In Progress": ["Completed", "On Hold"],
"On Hold": ["Open", "Cancelled"],
"Completed": [],
"Cancelled": []
}
if doc.status and doc.get("_doc_before_save"):
old_status = doc._doc_before_save.status
if old_status and doc.status != old_status:
allowed = VALID_TRANSITIONS.get(old_status, [])
if doc.status not in allowed:
frappe.throw(
f"Cannot change status from '{old_status}' to '{doc.status}'. "
f"Allowed: {', '.join(allowed) or 'none'}",
title="Invalid Status Transition"
)---
Pattern 10: External API Sync
# Scheduler Event — sync data from external API
api_key = frappe.db.get_single_value("Integration Settings", "api_key")
if not api_key:
frappe.log_error("API key not configured", "Sync Error")
return
try:
response = frappe.make_get_request(
"https://api.example.com/products",
headers={"Authorization": f"Bearer {api_key}"}
)
except Exception:
frappe.log_error(frappe.get_traceback(), "External API Error")
return
products = response.get("data", [])
synced = 0
for product in products[:100]: # ALWAYS limit batch size
if not frappe.db.exists("Item", product.get("sku")):
item = frappe.new_doc("Item")
item.item_code = product.get("sku")
item.item_name = product.get("name")
item.item_group = "Products"
try:
item.insert(ignore_permissions=True)
synced += 1
except Exception:
frappe.log_error(
f"Failed to sync: {product.get('sku')}",
"Sync Error"
)
frappe.db.commit()
if synced > 0:
frappe.log_error(f"Synced {synced} new products", "External Sync")---
Pattern 11: Calling Another Server Script as Library
# v13+ — reuse logic across scripts using run_script()
# Library Server Script named "Calculate Tax":
# tax_rate = frappe.db.get_value("Tax Rule", {"region": kwargs.get("region")}, "rate")
# frappe.flags.result = frappe.utils.flt(kwargs.get("amount")) * frappe.utils.flt(tax_rate) / 100
# Calling script:
tax = run_script("Calculate Tax", region=doc.territory, amount=doc.net_total)
doc.tax_amount = frappe.flags.result---
Pattern 12: Permission Query with Multiple Conditions
# Permission Query — combine role, territory, and company filtering
roles = frappe.get_roles(user)
if "System Manager" in roles:
conditions = ""
else:
clauses = []
# Owner condition for basic users
if "Sales User" in roles and "Sales Manager" not in roles:
clauses.append(f"`tabSales Order`.owner = {frappe.db.escape(user)}")
# Company filter
companies = frappe.get_all("User Permission",
filters={"user": user, "allow": "Company"},
pluck="for_value"
)
if companies:
escaped = ", ".join(frappe.db.escape(c) for c in companies)
clauses.append(f"`tabSales Order`.company IN ({escaped})")
conditions = " AND ".join(clauses) if clauses else ""Server Script Syntax — Quick Cheat Sheet
Script Configuration Fields
| Field | Document Event | API | Scheduler | Permission Query |
|---|---|---|---|---|
| Script Type | Document Event | API | Scheduler Event | Permission Query |
| Reference DocType | Required | - | - | Required |
| DocType Event | Required | - | - | - |
| API Method | - | Required | - | - |
| Allow Guest | - | Optional | - | - |
| Event Frequency | - | - | Required | - |
| Cron Format | - | - | If Cron | - |
---
Available Variables Per Script Type
Document Event
doc # The current document object (frappe.model.Document)
frappe # Core namespaceAPI
frappe.form_dict # Request parameters (GET query + POST body)
frappe.request # Werkzeug request object
frappe.response # Response dict — set frappe.response["message"] for output
frappe # Core namespaceScheduler Event
frappe # Core namespace
# No doc, no form_dict — you query what you needPermission Query
user # str — email of the user being checked
conditions # str — set this to a SQL WHERE fragment
frappe # Core namespace---
Cron Format Reference
┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *| Schedule | Cron Expression |
|---|---|
| Every minute | * * * * * |
| Every 15 minutes | */15 * * * * |
| Every hour | 0 * * * * |
| Daily at 9:00 | 0 9 * * * |
| Daily at midnight | 0 0 * * * |
| Weekdays at 8:00 | 0 8 * * 1-5 |
| Sunday at 02:00 | 0 2 * * 0 |
| 1st of month at 06:00 | 0 6 1 * * |
| Every 6 hours | 0 */6 * * * |
---
Common Patterns — One-Liners
Validation
if not doc.customer:
frappe.throw("Customer is required")
if doc.grand_total < 0:
frappe.throw("Total MUST NOT be negative")
if doc.discount_percentage > 50:
frappe.throw("Max discount is 50%", title="Validation Error")Safe Type Conversion
qty = frappe.utils.flt(doc.qty) # None/str → float, default 0.0
count = frappe.utils.cint(doc.count) # None/str → int, default 0
name = frappe.utils.cstr(doc.name) # None → ""Date Operations
today = frappe.utils.today() # "2024-01-15"
tomorrow = frappe.utils.add_days(today, 1) # "2024-01-16"
next_month = frappe.utils.add_months(today, 1) # "2024-02-15"
days_ago = frappe.utils.date_diff(today, some_date) # int
month_start = frappe.utils.get_first_day(today) # "2024-01-01"
month_end = frappe.utils.get_last_day(today) # "2024-01-31"JSON Operations
data = frappe.parse_json(json_string) # str → dict/list
text = frappe.as_json(python_obj) # dict/list → strDatabase Shortcuts
# Check existence
if frappe.db.exists("Customer", "CUST-001"):
pass
# Get one value
name = frappe.db.get_value("Customer", "CUST-001", "customer_name")
# Get multiple fields
vals = frappe.db.get_value("Customer", "CUST-001",
["customer_name", "territory"], as_dict=True)
# Count
n = frappe.db.count("Sales Invoice", filters={"status": "Unpaid"})
# Flat list of names
names = frappe.get_all("Customer", filters={...}, pluck="name")API Response
frappe.response["message"] = {"status": "ok", "data": result}Permission Query Output
# Full access
conditions = ""
# Owner only
conditions = f"`tabDocType`.owner = {frappe.db.escape(user)}"
# No access
conditions = "1=0"---
Filter Operators
| Operator | Example | Meaning |
|---|---|---|
= (default) | {"status": "Open"} | Equals |
!= | {"status": ["!=", "Closed"]} | Not equals |
> | {"amount": [">", 1000]} | Greater than |
< | {"date": ["<", today]} | Less than |
>= | {"amount": [">=", 100]} | Greater or equal |
<= | {"amount": ["<=", 9999]} | Less or equal |
like | {"name": ["like", "SO-%"]} | SQL LIKE |
not like | {"name": ["not like", "TEST%"]} | SQL NOT LIKE |
in | {"status": ["in", ["A", "B"]]} | In list |
not in | {"status": ["not in", ["X"]]} | Not in list |
is | {"field": ["is", "set"]} | IS NOT NULL |
is | {"field": ["is", "not set"]} | IS NULL |
between | {"date": ["between", [d1, d2]]} | Between two values |